feat: add version updater and statistics enhancements (fixes #29)
- Implemented version checking system with update notifications - Integrated Git for fetching and downloading the latest version - Added statistics updates - Removed unused code across the project - Condensed project structure - Updated README - Cleaned up UI
This commit is contained in:
@@ -0,0 +1,567 @@
|
||||
# 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, 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))
|
||||
Reference in New Issue
Block a user