Files
AirlockTools/TUI/Widgets/settingswidget.py
2025-12-24 12:26:34 -05:00

743 lines
26 KiB
Python

# 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 <https://www.gnu.org/licenses/>.
"""
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, Input, Rule, Static, Switch
from utils.configmanager import get_user_value, save_user_config
from utils.setup import get_base_directory
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;
}
/* Telemetry section */
#telemetry_row {
height: auto;
margin: 1 0;
}
#telemetry_label {
width: auto;
margin-right: 1;
}
#telemetry_url_container {
height: auto;
margin: 1 0;
}
#telemetry_url_label {
width: auto;
margin-right: 1;
}
#telemetry_url_input {
width: 1fr;
}
.settings_description {
color: $text-muted;
margin-bottom: 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()
# Telemetry Section
with Vertical(id="telemetry_section") as telemetry:
telemetry.styles.height = "auto"
yield Static(
"📊 Telemetry Settings",
id="telemetry_title",
classes="settings_section_title",
)
telemetry_enabled = get_user_value("TELEMETRY", bool, False)
with Horizontal(id="telemetry_row"):
yield Static("Enable Telemetry: ", id="telemetry_label")
yield Switch(value=telemetry_enabled, id="telemetry_switch")
# URL input container - shown only when telemetry is enabled
if telemetry_enabled:
telemetry_url = get_user_value("TELEM_URL", str, "")
with Horizontal(id="telemetry_url_container"):
yield Static("Telemetry URL: ", id="telemetry_url_label")
yield Input(
value=telemetry_url,
placeholder="https://your-telemetry-endpoint.com",
id="telemetry_url_input",
)
yield Static(
"Help improve Loxide by sending anonymous usage data",
id="telemetry_description",
classes="settings_description",
)
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 on_switch_changed(self, event: Switch.Changed) -> None:
"""Handle telemetry switch toggle."""
if event.switch.id == "telemetry_switch":
enabled = event.value
self._persist_telemetry_setting(enabled)
if enabled:
self._mount_telemetry_url_input()
else:
self._unmount_telemetry_url_input()
def on_input_changed(self, event: Input.Changed) -> None:
"""Handle telemetry URL input changes."""
if event.input.id == "telemetry_url_input":
self._persist_telemetry_url(event.value)
def _persist_telemetry_setting(self, enabled: bool) -> None:
"""Store the telemetry opt-in/out setting in the user's config."""
base_dir = get_base_directory()
config_dir = base_dir / "config"
try:
save_user_config(config_dir, {"TELEMETRY": enabled})
logger.debug("Updated user config with TELEMETRY=%s", enabled)
status = "enabled" if enabled else "disabled"
self.app.notify(f"📊 Telemetry {status}", timeout=2)
except Exception as exc:
logger.error("Failed to save TELEMETRY setting: %s", exc)
self.app.notify(f"⚠️ Failed to save setting: {exc}", severity="warning")
def _persist_telemetry_url(self, url: str) -> None:
"""Store the telemetry URL in the user's config."""
base_dir = get_base_directory()
config_dir = base_dir / "config"
try:
save_user_config(config_dir, {"TELEM_URL": url})
logger.debug("Updated user config with TELEM_URL=%s", url)
except Exception as exc:
logger.error("Failed to save TELEM_URL setting: %s", exc)
def _mount_telemetry_url_input(self) -> None:
"""Mount the telemetry URL input container."""
try:
# Check if already mounted
self.query_one("#telemetry_url_container")
return
except Exception:
pass
telemetry_url = get_user_value("TELEM_URL", str, "")
# Create container with children
container = Horizontal(
Static("Telemetry URL: ", id="telemetry_url_label"),
Input(
value=telemetry_url,
placeholder="https://your-telemetry-endpoint.com",
id="telemetry_url_input",
),
id="telemetry_url_container",
)
# Mount after the telemetry_row within telemetry_section
try:
telemetry_section = self.query_one("#telemetry_section")
telemetry_row = self.query_one("#telemetry_row")
telemetry_section.mount(container, after=telemetry_row)
except Exception as exc:
logger.error("Failed to mount telemetry URL input: %s", exc)
def _unmount_telemetry_url_input(self) -> None:
"""Unmount the telemetry URL input container."""
try:
container = self.query_one("#telemetry_url_container")
container.remove()
except Exception:
pass
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;
}
/* Telemetry section */
#telemetry_row {
height: auto;
margin: 1 0;
}
#telemetry_label {
width: auto;
margin-right: 1;
}
#telemetry_url_container {
height: auto;
margin: 1 0;
}
#telemetry_url_label {
width: auto;
margin-right: 1;
}
#telemetry_url_input {
width: 1fr;
}
.settings_description {
color: $text-muted;
margin-bottom: 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))