301 lines
12 KiB
Python
301 lines
12 KiB
Python
import os
|
|
import subprocess
|
|
import threading
|
|
import webbrowser
|
|
import requests
|
|
from functools import partial
|
|
from pathlib import Path
|
|
from tkinter import ttk
|
|
import typing
|
|
from typing import Any, Callable, Optional
|
|
|
|
if typing.TYPE_CHECKING:
|
|
from tkinter import BooleanVar
|
|
|
|
from .logger import app_logger
|
|
from .common_tools import ConfigManager, Tooltip, message_box_animation
|
|
from .gitea import (
|
|
GiteaUpdater,
|
|
GiteaApiUrlError,
|
|
GiteaVersionParseError,
|
|
GiteaApiResponseError,
|
|
)
|
|
from .animated_icon import AnimatedIcon, PIL_AVAILABLE
|
|
from .message import MessageDialog
|
|
|
|
|
|
class MenuBar(ttk.Frame):
|
|
"""A reusable menu bar widget for tkinter applications."""
|
|
|
|
def __init__(
|
|
self,
|
|
container: ttk.Frame,
|
|
image_manager: 'Any',
|
|
tooltip_state: 'BooleanVar',
|
|
on_theme_toggle: 'Callable[[], None]',
|
|
toggle_log_window: 'Callable[[], None]',
|
|
app_version: str, # Replaces app_config.VERSION
|
|
msg_config: 'Any', # Contains .STR and .TTIP
|
|
about_icon_path: str,
|
|
about_url: str,
|
|
gitea_api_url: str,
|
|
**kwargs: 'Any',
|
|
) -> None:
|
|
"""
|
|
Initializes the MenuBar.
|
|
|
|
Args:
|
|
container: The parent widget.
|
|
image_manager: An object with a `get_icon` method to retrieve icons.
|
|
tooltip_state: A tkinter BooleanVar to control tooltip visibility.
|
|
on_theme_toggle: Callback function to toggle the application's theme.
|
|
toggle_log_window: Callback function to show/hide the log window.
|
|
app_version: The current version string of the application.
|
|
msg_config: Project-specific messages and tooltips. Must have STR and TTIP dicts.
|
|
about_icon_path: Filesystem path to the icon for the 'About' dialog.
|
|
about_url: URL for the project's repository or website.
|
|
gitea_api_url: The Gitea API URL for update checks.
|
|
**kwargs: Additional keyword arguments for the ttk.Frame.
|
|
"""
|
|
super().__init__(container, **kwargs)
|
|
self.image_manager = image_manager
|
|
self.tooltip_state = tooltip_state
|
|
self.on_theme_toggle_callback = on_theme_toggle
|
|
self.app_version = app_version # Store the application version
|
|
self.msg_config = msg_config # Store the messages and tooltips object
|
|
self.about_icon_path = about_icon_path
|
|
self.about_url = about_url
|
|
self.gitea_api_url = gitea_api_url
|
|
self.update_status: str = ""
|
|
|
|
# --- Horizontal button frame for settings ---
|
|
actions_frame = ttk.Frame(self)
|
|
actions_frame.grid(column=0, row=0, padx=(5, 10), sticky="w")
|
|
|
|
# --- Theme Button ---
|
|
self.theme_btn = ttk.Button(
|
|
actions_frame, command=self.theme_toggle, style="TButton.Borderless.Round"
|
|
)
|
|
self.theme_btn.grid(column=0, row=0, padx=(0, 2))
|
|
self.update_theme_icon()
|
|
Tooltip(self.theme_btn, self.msg_config.TTIP["theme_toggle"],
|
|
state_var=self.tooltip_state)
|
|
|
|
# --- Tooltip Button ---
|
|
self.tooltip_btn = ttk.Button(
|
|
actions_frame, command=self.tooltips_toggle, style="TButton.Borderless.Round"
|
|
)
|
|
self.tooltip_btn.grid(column=1, row=0, padx=(0, 2))
|
|
self.update_tooltip_icon()
|
|
Tooltip(self.tooltip_btn, self.msg_config.TTIP["tooltips_toggle"],
|
|
state_var=self.tooltip_state)
|
|
|
|
# --- Update Button ---
|
|
self.update_btn = ttk.Button(
|
|
actions_frame,
|
|
command=self.toggle_update_setting,
|
|
style="TButton.Borderless.Round",
|
|
)
|
|
self.update_btn.grid(column=2, row=0)
|
|
self.update_update_icon()
|
|
Tooltip(self.update_btn, self.msg_config.TTIP["updates_toggle"],
|
|
state_var=self.tooltip_state)
|
|
|
|
# --- Animated Icon for Updates ---
|
|
self.animated_icon_frame = ttk.Frame(actions_frame)
|
|
self.animated_icon_frame.grid(column=3, row=0, padx=(5, 0))
|
|
|
|
current_theme = ConfigManager.get("theme")
|
|
bg_color = "#ffffff" if current_theme == "light" else "#333333"
|
|
|
|
self.animated_icon = AnimatedIcon(
|
|
self.animated_icon_frame,
|
|
animation_type="blink",
|
|
use_pillow=PIL_AVAILABLE,
|
|
bg=bg_color,
|
|
)
|
|
self.animated_icon.pack()
|
|
self.animated_icon_frame.bind("<Button-1>", lambda e: self.updater())
|
|
self.animated_icon.bind("<Button-1>", lambda e: self.updater())
|
|
self.animated_icon_frame.grid_remove() # Initially hidden
|
|
|
|
# Add a spacer column with weight to push subsequent buttons to the right
|
|
self.columnconfigure(1, weight=1)
|
|
|
|
# --- Log Button ---
|
|
self.log_btn = ttk.Button(
|
|
self,
|
|
image=self.image_manager.get_icon("log_blue_small"),
|
|
style="TButton.Borderless.Round",
|
|
command=toggle_log_window,
|
|
)
|
|
self.log_btn.grid(column=2, row=0, sticky="e")
|
|
Tooltip(self.log_btn,
|
|
self.msg_config.TTIP["show_log"], state_var=self.tooltip_state)
|
|
|
|
# --- About Button ---
|
|
self.about_btn = ttk.Button(
|
|
self,
|
|
image=self.image_manager.get_icon("about"),
|
|
style="TButton.Borderless.Round",
|
|
command=self.about,
|
|
)
|
|
self.about_btn.grid(column=3, row=0)
|
|
Tooltip(self.about_btn,
|
|
self.msg_config.TTIP["about_app"], state_var=self.tooltip_state)
|
|
|
|
# --- Start background update check ---
|
|
self.update_thread = threading.Thread(
|
|
target=self.check_for_updates, daemon=True)
|
|
self.update_thread.start()
|
|
|
|
def update_theme_icon(self) -> None:
|
|
"""Sets the theme button icon based on the current theme."""
|
|
current_theme = ConfigManager.get("theme")
|
|
icon_name = "dark_small" if current_theme == "light" else "light_small"
|
|
self.theme_btn.configure(image=self.image_manager.get_icon(icon_name))
|
|
|
|
def update_tooltip_icon(self) -> None:
|
|
"""Sets the tooltip button icon based on the tooltip state."""
|
|
icon_name = "tooltip_small" if self.tooltip_state.get() else "no_tooltip_small"
|
|
self.tooltip_btn.configure(
|
|
image=self.image_manager.get_icon(icon_name))
|
|
|
|
def update_update_icon(self) -> None:
|
|
"""Sets the update button icon based on the update setting."""
|
|
updates_on = ConfigManager.get("updates") == "on"
|
|
icon_name = "update_small" if updates_on else "no_update_small"
|
|
self.update_btn.configure(image=self.image_manager.get_icon(icon_name))
|
|
|
|
def theme_toggle(self) -> None:
|
|
"""Invokes the theme toggle callback."""
|
|
self.on_theme_toggle_callback()
|
|
|
|
def update_theme(self) -> None:
|
|
"""Updates theme-dependent widgets, like icon backgrounds."""
|
|
self.update_theme_icon()
|
|
current_theme = ConfigManager.get("theme")
|
|
bg_color = "#ffffff" if current_theme == "light" else "#333333"
|
|
self.animated_icon.configure(bg=bg_color)
|
|
|
|
def tooltips_toggle(self) -> None:
|
|
"""Toggles the tooltip state and updates the icon."""
|
|
new_bool_state = not self.tooltip_state.get()
|
|
ConfigManager.set("tooltips", str(new_bool_state))
|
|
self.tooltip_state.set(new_bool_state)
|
|
self.update_tooltip_icon()
|
|
|
|
def toggle_update_setting(self) -> None:
|
|
"""Toggles the automatic update setting and re-checks for updates."""
|
|
updates_on = ConfigManager.get("updates") == "on"
|
|
ConfigManager.set("updates", "off" if updates_on else "on")
|
|
self.update_update_icon()
|
|
# After changing the setting, re-run the check to update status
|
|
threading.Thread(target=self.check_for_updates, daemon=True).start()
|
|
|
|
def check_for_updates(self) -> None:
|
|
"""Checks for updates via the Gitea API in a background thread."""
|
|
if ConfigManager.get("updates") == "off":
|
|
self.after(0, self.update_ui_for_update, "DISABLED")
|
|
return
|
|
|
|
try:
|
|
new_version = GiteaUpdater.check_for_update(
|
|
self.gitea_api_url,
|
|
self.app_version,
|
|
)
|
|
self.after(0, self.update_ui_for_update, new_version)
|
|
|
|
except requests.exceptions.RequestException as e:
|
|
# Covers connection errors, timeouts, DNS errors, etc.
|
|
# Good indicator for "no internet" or "server unreachable"
|
|
app_logger.log(f"Network error during update check: {e}")
|
|
self.after(0, self.update_ui_for_update, "ERROR")
|
|
|
|
except (GiteaApiUrlError, GiteaVersionParseError, GiteaApiResponseError) as e:
|
|
# Covers bad configuration or unexpected API changes
|
|
app_logger.log(f"Gitea API or version error: {e}")
|
|
self.after(0, self.update_ui_for_update, "ERROR")
|
|
|
|
except Exception as e:
|
|
# Catch any other unexpected errors
|
|
app_logger.log(f"Unexpected error during update check: {e}", level="error")
|
|
self.after(0, self.update_ui_for_update, "ERROR")
|
|
|
|
def update_ui_for_update(self, new_version: Optional[str]) -> None:
|
|
"""
|
|
Updates the UI based on the result of the update check.
|
|
|
|
Args:
|
|
new_version: The new version string if an update is available,
|
|
"DISABLED" if updates are off, "ERROR" if an error occurred,
|
|
or None if no update is available.
|
|
This string also serves as the update status.
|
|
"""
|
|
self.update_status = new_version
|
|
self.animated_icon_frame.grid_remove()
|
|
self.animated_icon.hide()
|
|
|
|
self.animated_icon_frame.grid()
|
|
tooltip_msg = ""
|
|
animated_icon_frame_state = "normal" # Default to normal
|
|
|
|
if new_version == "DISABLED":
|
|
tooltip_msg = self.msg_config.TTIP["updates_disabled"]
|
|
self.animated_icon.stop()
|
|
animated_icon_frame_state = "disabled"
|
|
elif new_version == "ERROR":
|
|
tooltip_msg = self.msg_config.TTIP["no_server_conn_tt"]
|
|
self.animated_icon.stop(status="DISABLE")
|
|
animated_icon_frame_state = "disabled"
|
|
elif new_version is None:
|
|
tooltip_msg = self.msg_config.TTIP["up_to_date"]
|
|
self.animated_icon.stop()
|
|
animated_icon_frame_state = "disabled"
|
|
else: # A new version string is returned, meaning an update is available
|
|
tooltip_msg = self.msg_config.TTIP["install_new_version"].format(version=new_version)
|
|
self.animated_icon.start()
|
|
animated_icon_frame_state = "normal"
|
|
|
|
# The update_btn (toggle updates on/off) should always be active
|
|
self.update_btn.config(state="normal")
|
|
|
|
if animated_icon_frame_state == "disabled":
|
|
self.animated_icon_frame.unbind("<Button-1>")
|
|
self.animated_icon.unbind("<Button-1>")
|
|
self.animated_icon_frame.config(cursor="arrow")
|
|
else:
|
|
self.animated_icon_frame.bind("<Button-1>", lambda e: self.updater())
|
|
self.animated_icon.bind("<Button-1>", lambda e: self.updater())
|
|
self.animated_icon_frame.config(cursor="hand2")
|
|
|
|
Tooltip(self.animated_icon_frame, tooltip_msg,
|
|
state_var=self.tooltip_state)
|
|
|
|
def updater(self) -> None:
|
|
"""Runs the external installer script for updating the application."""
|
|
tmp_dir = Path("/tmp/lxtools")
|
|
Path.mkdir(tmp_dir, exist_ok=True)
|
|
os.chdir(tmp_dir)
|
|
with message_box_animation(self.animated_icon):
|
|
result = subprocess.run(
|
|
["/usr/local/bin/lxtools_installer"], check=False, capture_output=True, text=True
|
|
)
|
|
if result.returncode != 0:
|
|
MessageDialog("error", result.stderr or result.stdout).show()
|
|
|
|
def about(self) -> None:
|
|
"""Displays the application's About dialog."""
|
|
with message_box_animation(self.animated_icon):
|
|
MessageDialog(
|
|
"info",
|
|
self.msg_config.STR["about_msg"],
|
|
buttons=["OK", self.msg_config.STR["goto_git"]],
|
|
title=self.msg_config.STR["info"],
|
|
commands=[
|
|
None,
|
|
partial(webbrowser.open, self.about_url),
|
|
],
|
|
icon=self.about_icon,
|
|
wraplength=420,
|
|
).show() |