Files
shared_libs/menu_bar.py

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()