# Copyright (C) 2025 James Brotosky, Brandon Wickline # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published # by the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """ Settings widget combining theme selection and update checking. """ import logging import webbrowser from textual.containers import Horizontal, Vertical, VerticalScroll from textual.message import Message from textual.widget import Widget from textual.widgets import Button, Rule, Static from utils.versionchecker import ( RELEASES_PAGE_URL, UpdateCheckResult, check_for_updates, get_current_version, get_version_checker, ) logger = logging.getLogger(__name__) class SettingsWidget(Widget): """Widget for application settings including themes and updates.""" DEFAULT_CSS = """ SettingsWidget { height: 1fr; } /* Update section buttons - add margin between them */ #update_buttons Button { margin-right: 1; } /* Theme buttons - consistent width within columns, slightly smaller */ .theme_btn { width: 100%; margin-bottom: 1; } /* Column headers */ .theme_column_header { text-align: center; text-style: bold; margin-bottom: 1; } /* Section titles */ .settings_section_title { text-style: bold; margin-bottom: 1; } /* Theme columns - reduce overall width */ #theme_columns { width: 80%; } /* Theme columns spacing */ #dark_themes_col1, #dark_themes_col2 { margin-right: 1; } #light_themes_col { margin-left: 1; } """ class ThemeSelected(Message): """Message posted when a theme is selected.""" def __init__(self, theme_name: str): super().__init__() self.theme_name = theme_name # Dark themes - Column 1 DARK_THEMES_COL1 = [ ("Textual Dark", "textual-dark"), ("Nord", "nord"), ("Gruvbox", "gruvbox"), ("Dracula", "dracula"), ] # Dark themes - Column 2 DARK_THEMES_COL2 = [ ("Catppuccin Mocha", "catppuccin-mocha"), ("Tokyo Night", "tokyo-night"), ("Monokai", "monokai"), ] # Light themes (third column) LIGHT_THEMES = [ ("Textual Light", "textual-light"), ("Flexoki", "flexoki"), ("Catppuccin Latte", "catppuccin-latte"), ("Solarized Light", "solarized-light"), ] # Combined for backward compatibility DARK_THEMES = DARK_THEMES_COL1 + DARK_THEMES_COL2 AVAILABLE_THEMES = DARK_THEMES + LIGHT_THEMES def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._update_result: UpdateCheckResult | None = None self._checking = False def compose(self): # Wrap everything in a scrollable container with auto height children with VerticalScroll(id="settings_scroll"): # Version & Updates Section with Vertical(id="updates_section") as updates: updates.styles.height = "auto" yield Static( "šŸ“¦ Version & Updates", id="updates_title", classes="settings_section_title", ) version_text = f"Current Version: v{get_current_version()}" yield Static(version_text, id="current_version") with Horizontal(id="update_buttons") as btn_row: btn_row.styles.height = "auto" yield Button("šŸ” Check for Updates", id="check_updates_btn") yield Button("šŸ“„ View Releases", id="view_releases_btn") yield Static("", id="update_status") yield Rule() # Theme Section - Three columns: Dark 1, Dark 2, Light with Vertical(id="themes_section") as themes: themes.styles.height = "auto" yield Static( "šŸŽØ Theme Options", id="theme_title", classes="settings_section_title", ) with Horizontal(id="theme_columns") as cols: cols.styles.height = "auto" # Dark themes section (2 columns under one header) with Vertical(id="dark_themes_section") as dark_section: dark_section.styles.width = "2fr" dark_section.styles.height = "auto" yield Static( "šŸŒ™ Dark Themes", classes="theme_column_header", id="dark_header", ) with Horizontal(id="dark_columns") as dark_cols: dark_cols.styles.height = "auto" # Dark themes column 1 with Vertical(id="dark_themes_col1") as dark_col1: dark_col1.styles.width = "1fr" dark_col1.styles.height = "auto" for label, btn_id in self.DARK_THEMES_COL1: yield Button( label, id=f"set_theme_{btn_id}", classes="theme_btn", ) # Dark themes column 2 with Vertical(id="dark_themes_col2") as dark_col2: dark_col2.styles.width = "1fr" dark_col2.styles.height = "auto" for label, btn_id in self.DARK_THEMES_COL2: yield Button( label, id=f"set_theme_{btn_id}", classes="theme_btn", ) # Light themes column with Vertical(id="light_themes_col") as light_col: light_col.styles.width = "1fr" light_col.styles.height = "auto" yield Static("ā˜€ļø Light Themes", classes="theme_column_header") for label, btn_id in self.LIGHT_THEMES: yield Button( label, id=f"set_theme_{btn_id}", classes="theme_btn" ) def on_mount(self) -> None: """Check for cached update result on mount.""" checker = get_version_checker() cached_result = checker.get_last_result() if cached_result and cached_result.update_available: self._update_result = cached_result self._show_update_available(cached_result) def on_button_pressed(self, event: Button.Pressed) -> None: button_id = event.button.id if button_id == "check_updates_btn": self._check_for_updates() event.stop() elif button_id == "view_releases_btn": self._open_releases_page() event.stop() elif button_id == "download_update_btn": self._download_update() event.stop() elif button_id == "dismiss_update_btn": self._dismiss_update() event.stop() elif button_id and button_id.startswith("set_theme_"): theme_name = button_id.replace("set_theme_", "") self.post_message(self.ThemeSelected(theme_name)) event.stop() def _check_for_updates(self) -> None: """Check for updates and update UI.""" if self._checking: return self._checking = True status = self.query_one("#update_status", Static) check_btn = self.query_one("#check_updates_btn", Button) # Show checking status check_btn.disabled = True check_btn.label = "ā³ Checking..." status.update("šŸ”„ Checking for updates...") # Run check in worker to avoid blocking UI self.run_worker(self._do_update_check, exclusive=True) async def _do_update_check(self) -> None: """Worker to perform update check.""" try: result = check_for_updates() self._update_result = result # Since we're in an async worker (not a thread), we can call directly self._update_check_complete(result) except Exception as e: logger.error(f"Update check failed: {e}") self._update_check_failed(str(e)) finally: self._checking = False def _update_check_complete(self, result: UpdateCheckResult) -> None: """Handle completed update check.""" check_btn = self.query_one("#check_updates_btn", Button) check_btn.disabled = False check_btn.label = "šŸ” Check for Updates" if result.error: self._update_check_failed(result.error) return if result.update_available: self._show_update_available(result) self.app.notify( f"šŸ†• Update available: {result.latest_version}", title="Update Available", severity="information", timeout=8, ) else: status = self.query_one("#update_status", Static) status.update(f"āœ… Loxide is up to date (v{result.current_version})") self.app.notify( "āœ… Loxide is up to date!", severity="information", timeout=5, ) def _update_check_failed(self, error: str) -> None: """Handle failed update check.""" check_btn = self.query_one("#check_updates_btn", Button) check_btn.disabled = False check_btn.label = "šŸ” Check for Updates" status = self.query_one("#update_status", Static) status.update(f"āš ļø Could not check for updates: {error}") def _show_update_available(self, result: UpdateCheckResult) -> None: """Show update available UI with release notes.""" status = self.query_one("#update_status", Static) msg = f"šŸ†• New version available: {result.latest_version}\n" msg += f" Current: v{result.current_version}" if result.release_info and result.release_info.body: # Show release notes (truncate if very long) notes = result.release_info.body.strip() # Limit to ~500 chars to avoid overwhelming the UI if len(notes) > 500: notes = notes[:500] + "\n..." msg += f"\n\nšŸ“‹ Release Notes:\n{notes}" status.update(msg) # Add download/dismiss buttons if not already there try: self.query_one("#download_update_btn") except Exception: # Buttons don't exist, add them button_container = self.query_one("#update_buttons", Horizontal) download_btn = Button( "šŸ“„ Download Update", id="download_update_btn", variant="success" ) dismiss_btn = Button( "āœ– Dismiss", id="dismiss_update_btn", variant="default" ) button_container.mount(download_btn) button_container.mount(dismiss_btn) def _open_releases_page(self) -> None: """Open the releases page in browser.""" try: webbrowser.open(RELEASES_PAGE_URL) self.app.notify("šŸ“‚ Opened releases page in browser", timeout=3) except Exception as e: logger.error(f"Could not open browser: {e}") self.app.notify(f"āš ļø Could not open browser: {e}", severity="warning") def _download_update(self) -> None: """Download the update exe file.""" import os from pathlib import Path if not self._update_result or not self._update_result.release_info: self.app.notify("āš ļø No update information available", severity="warning") return download_url = self._update_result.release_info.download_url if not download_url: # Fall back to opening the release page url = self._update_result.release_info.html_url try: webbrowser.open(url) self.app.notify( "šŸ“„ Opened download page in browser (no direct download available)", timeout=5, ) except Exception as e: logger.error(f"Could not open browser: {e}") self.app.notify(f"āš ļø Could not open browser: {e}", severity="warning") return # Determine destination path if os.name == "nt": # Windows downloads_dir = Path.home() / "Downloads" else: downloads_dir = Path.home() / "Downloads" if not downloads_dir.exists(): downloads_dir = Path.home() # Extract filename from URL filename = download_url.split("/")[-1] if not filename.endswith(".exe"): filename = f"Loxide_{self._update_result.latest_version}.exe" dest_path = downloads_dir / filename # Disable the button while downloading try: btn = self.query_one("#download_update_btn", Button) btn.disabled = True btn.label = "ā³ Downloading..." except Exception: pass self.app.notify( f"šŸ“„ Downloading to:\n{dest_path}", title="Download Starting", timeout=5 ) # Small delay so user sees the "downloading to" toast before download completes self.set_timer( 0.5, lambda: self.run_worker( self._do_download(download_url, dest_path), exclusive=True ), ) async def _do_download(self, download_url: str, dest_path) -> None: """Worker to download the update file.""" try: # Download the file import requests response = requests.get(download_url, stream=True, timeout=60) response.raise_for_status() with open(dest_path, "wb") as f: for chunk in response.iter_content(chunk_size=8192): if chunk: f.write(chunk) # Success self.app.notify( f"āœ… Downloaded to:\n{dest_path}", title="Download Complete", severity="information", timeout=10, ) logger.info(f"Update downloaded to {dest_path}") # Re-enable button try: btn = self.query_one("#download_update_btn", Button) btn.disabled = False btn.label = "šŸ“„ Download Again" except Exception: pass except Exception as e: logger.error(f"Download failed: {e}") self.app.notify(f"āŒ Download failed: {e}", severity="error", timeout=10) # Re-enable button try: btn = self.query_one("#download_update_btn", Button) btn.disabled = False btn.label = "šŸ“„ Download Update" except Exception: pass def _dismiss_update(self) -> None: """Dismiss the current update notification.""" if self._update_result and self._update_result.latest_version: checker = get_version_checker() checker.dismiss_update(self._update_result.latest_version) # Remove the extra buttons try: self.query_one("#download_update_btn").remove() self.query_one("#dismiss_update_btn").remove() except Exception: pass status = self.query_one("#update_status", Static) status.update(f"āœ“ Dismissed update {self._update_result.latest_version}") self._update_result = None # Keep ThemeSelector as a standalone for backward compatibility class ThemeSelector(Widget): """Widget for selecting and applying Textual themes. DEPRECATED: Use SettingsWidget instead for combined settings UI. """ DEFAULT_CSS = """ ThemeSelector { height: 1fr; } /* Theme buttons - consistent width within columns */ .theme_btn { width: 100%; margin-bottom: 1; } /* Column headers */ .theme_column_header { text-align: center; text-style: bold; margin-bottom: 1; } /* Theme columns - reduce overall width */ #theme_columns { width: 80%; } /* Theme columns spacing */ #dark_themes_col1, #dark_themes_col2 { margin-right: 1; } #light_themes_col { margin-left: 1; } """ class ThemeSelected(Message): """Message posted when a theme is selected.""" def __init__(self, theme_name: str): super().__init__() self.theme_name = theme_name DARK_THEMES_COL1 = SettingsWidget.DARK_THEMES_COL1 DARK_THEMES_COL2 = SettingsWidget.DARK_THEMES_COL2 DARK_THEMES = SettingsWidget.DARK_THEMES LIGHT_THEMES = SettingsWidget.LIGHT_THEMES AVAILABLE_THEMES = SettingsWidget.AVAILABLE_THEMES def compose(self): with VerticalScroll(id="theme_scroll"): yield Static("Theme Options", id="theme_title") with Horizontal(id="theme_columns") as cols: cols.styles.height = "auto" # Dark themes section (2 columns under one header) with Vertical(id="dark_themes_section") as dark_section: dark_section.styles.width = "2fr" dark_section.styles.height = "auto" yield Static( "šŸŒ™ Dark Themes", classes="theme_column_header", id="dark_header", ) with Horizontal(id="dark_columns") as dark_cols: dark_cols.styles.height = "auto" # Dark themes column 1 with Vertical(id="dark_themes_col1") as dark_col1: dark_col1.styles.width = "1fr" dark_col1.styles.height = "auto" for label, btn_id in self.DARK_THEMES_COL1: yield Button( label, id=f"set_theme_{btn_id}", classes="theme_btn" ) # Dark themes column 2 with Vertical(id="dark_themes_col2") as dark_col2: dark_col2.styles.width = "1fr" dark_col2.styles.height = "auto" for label, btn_id in self.DARK_THEMES_COL2: yield Button( label, id=f"set_theme_{btn_id}", classes="theme_btn" ) # Light themes column with Vertical(id="light_themes_col") as light_col: light_col.styles.width = "1fr" light_col.styles.height = "auto" yield Static("ā˜€ļø Light Themes", classes="theme_column_header") for label, btn_id in self.LIGHT_THEMES: yield Button( label, id=f"set_theme_{btn_id}", classes="theme_btn" ) def on_button_pressed(self, event: Button.Pressed) -> None: button_id = event.button.id if button_id and button_id.startswith("set_theme_"): theme_name = button_id.replace("set_theme_", "") self.post_message(self.ThemeSelected(theme_name))