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("", lambda e: self.updater()) self.animated_icon.bind("", 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("") self.animated_icon.unbind("") self.animated_icon_frame.config(cursor="arrow") else: self.animated_icon_frame.bind("", lambda e: self.updater()) self.animated_icon.bind("", 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()