743 lines
26 KiB
Python
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))
|