From 3465ee557e4cfc854e98eb1847c0a4c4a1c8db8c Mon Sep 17 00:00:00 2001 From: Orinks <38449772+Orinks@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:38:06 +0200 Subject: [PATCH] fix: make tray window controls reachable from the keyboard Users who minimize AccessiWeather to the tray need a keyboard path to restore or hide the window and hear the current tray information without using the mouse. This adds configurable shortcut preferences, validates them in Settings, and registers tray/window hotkeys when the platform supports them while preserving in-window accelerators. Constraint: Show-window behavior must still work after the frame is hidden to the tray, so the new shortcuts register as hotkeys when wx exposes RegisterHotKey Rejected: Add fixed in-window accelerators only | they cannot restore a hidden tray window Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep reserved shortcut validation aligned with existing menu and section accelerators before adding more configurable shortcuts Tested: ./.venv/bin/ruff check src/accessiweather/app_shortcuts.py src/accessiweather/shortcut_preferences.py src/accessiweather/models/config_settings.py src/accessiweather/models/config_constants.py src/accessiweather/models/config_serialization.py src/accessiweather/models/config_validation.py src/accessiweather/ui/system_tray.py src/accessiweather/ui/dialogs/settings_tabs/advanced.py src/accessiweather/ui/dialogs/settings_dialog_handlers.py tests/gui/test_main_window_minimize.py tests/test_settings_dialog_shortcuts.py tests/test_shortcut_preferences.py tests/test_system_tray.py Tested: ./.venv/bin/pytest -q tests/gui/test_main_window_minimize.py tests/test_settings_dialog_shortcuts.py tests/test_shortcut_preferences.py tests/test_system_tray.py Tested: ./.venv/bin/pytest -q tests/test_settings_dialog_tray_text.py tests/test_config_manager.py Not-tested: Real desktop global-hotkey registration on Windows/Linux/macOS outside the wx stub environment Related: #731 --- CHANGELOG.md | 1 + src/accessiweather/app_lifecycle.py | 3 + src/accessiweather/app_shortcuts.py | 127 +++++++++++ src/accessiweather/models/config_constants.py | 3 + .../models/config_serialization.py | 9 + src/accessiweather/models/config_settings.py | 3 + .../models/config_validation.py | 12 ++ src/accessiweather/shortcut_preferences.py | 202 ++++++++++++++++++ .../ui/dialogs/settings_dialog_handlers.py | 41 ++++ .../ui/dialogs/settings_tabs/advanced.py | 49 +++++ src/accessiweather/ui/system_tray.py | 31 ++- tests/gui/test_main_window_minimize.py | 61 ++++++ tests/test_settings_dialog_shortcuts.py | 93 ++++++++ tests/test_shortcut_preferences.py | 38 ++++ tests/test_system_tray.py | 27 +++ 15 files changed, 697 insertions(+), 3 deletions(-) create mode 100644 src/accessiweather/shortcut_preferences.py create mode 100644 tests/test_settings_dialog_shortcuts.py create mode 100644 tests/test_shortcut_preferences.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d3c8be2d..6e0e101f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. ## [Unreleased] ### Changed +- AccessiWeather now includes default shortcuts for showing the main window, hiding it to the tray, and reading the current tray information, and you can change or disable those shortcuts in Settings > Advanced. - Forecaster Notes can now show official NWS Surf Zone Forecasts for regional beaches where available, with surf/beach condition alternatives from supported global sources when an official SRF product is not available. - AccessiWeather can now automatically tune NOAA Weather Radio for qualifying alerts that use Specific Area Message Encoding (SAME) when you turn on the opt-in setting on the Alerts tab, then stop after the duration you choose when a reliable station match is available. - NOAA Weather Radio now opens independently of saved weather locations and keeps playing after you close the player until you press Stop or exit the app. diff --git a/src/accessiweather/app_lifecycle.py b/src/accessiweather/app_lifecycle.py index 30e972cb1..f437e5abd 100644 --- a/src/accessiweather/app_lifecycle.py +++ b/src/accessiweather/app_lifecycle.py @@ -431,6 +431,9 @@ def refresh_runtime_settings(self) -> None: verbosity_level=getattr(settings, "verbosity_level", "standard"), ) + if self.main_window: + self._setup_accelerators() + self._start_auto_update_checks() self._start_background_updates() diff --git a/src/accessiweather/app_shortcuts.py b/src/accessiweather/app_shortcuts.py index bb15c27fa..4da27a677 100644 --- a/src/accessiweather/app_shortcuts.py +++ b/src/accessiweather/app_shortcuts.py @@ -2,16 +2,32 @@ from __future__ import annotations +import contextlib import logging import wx from .native_shortcuts import install_accelerator_table_preserving_native_close +from .shortcut_preferences import ( + WINDOW_TRAY_SHORTCUTS, + resolve_shortcut_binding, +) logger = logging.getLogger(__name__) class AppShortcutsMixin: + def _get_shortcut_settings(self): + """Return live settings when available, else a default placeholder.""" + config_manager = getattr(self, "config_manager", None) + if config_manager is None: + return object() + try: + return config_manager.get_settings() + except Exception: + logger.debug("Falling back to default shortcut settings", exc_info=True) + return object() + def _setup_accelerators(self) -> None: """Set up keyboard accelerators (shortcuts).""" if not self.main_window: @@ -33,6 +49,26 @@ def _setup_accelerators(self) -> None: (wx.ACCEL_NORMAL, wx.WXK_F5, self._on_refresh_shortcut), (wx.ACCEL_NORMAL, getattr(wx, "WXK_F6", wx.WXK_F5), self._on_cycle_sections_shortcut), ] + settings = self._get_shortcut_settings() + tray_shortcut_bindings: list[tuple[int, int, object, str]] = [] + for preference in WINDOW_TRAY_SHORTCUTS: + binding = resolve_shortcut_binding( + getattr(settings, preference.setting_name, preference.default), + default=preference.default, + ) + if binding is None: + continue + tray_shortcut_bindings.append( + ( + binding.accelerator_flags(wx), + binding.key_code(wx), + getattr(self, preference.handler_name), + preference.label, + ) + ) + accelerators.extend( + (flags, key, handler) for flags, key, handler, _label in tray_shortcut_bindings + ) # Create accelerator table # Access the frame directly (MainWindow is now a SizedFrame) @@ -44,8 +80,70 @@ def _setup_accelerators(self) -> None: accel_entries.append((flags, key, cmd_id)) install_accelerator_table_preserving_native_close(frame, accel_entries) + self._register_global_hotkeys(settings=settings) logger.info("Keyboard accelerators set up successfully") + def _register_global_hotkeys(self, *, settings) -> None: + """Register configurable tray/window shortcuts as global hotkeys when supported.""" + frame = self.main_window + self._unregister_global_hotkeys() + if frame is None or not hasattr(frame, "RegisterHotKey"): + return + + hotkey_event = getattr(wx, "EVT_HOTKEY", None) + if hotkey_event is None: + return + + registered_ids: list[int] = [] + for preference in WINDOW_TRAY_SHORTCUTS: + binding = resolve_shortcut_binding( + getattr(settings, preference.setting_name, preference.default), + default=preference.default, + ) + if binding is None: + continue + + hotkey_id = int(wx.NewIdRef()) + try: + registered = frame.RegisterHotKey( + hotkey_id, + binding.hotkey_modifiers(wx), + binding.key_code(wx), + ) + except Exception: + logger.debug( + "Failed to register global hotkey %s for %s", + binding.normalized, + preference.label, + exc_info=True, + ) + continue + if not registered: + logger.warning( + "Global hotkey %s for %s could not be registered", + binding.normalized, + preference.label, + ) + continue + + frame.Bind(hotkey_event, getattr(self, preference.handler_name), id=hotkey_id) + registered_ids.append(hotkey_id) + + self._registered_hotkey_ids = registered_ids + + def _unregister_global_hotkeys(self) -> None: + """Remove previously-registered global hotkeys.""" + frame = self.main_window + hotkey_event = getattr(wx, "EVT_HOTKEY", None) + for hotkey_id in getattr(self, "_registered_hotkey_ids", []): + if frame is not None and hasattr(frame, "UnregisterHotKey"): + with contextlib.suppress(Exception): + frame.UnregisterHotKey(hotkey_id) + if frame is not None and hotkey_event is not None and hasattr(frame, "Unbind"): + with contextlib.suppress(Exception): + frame.Unbind(hotkey_event, id=hotkey_id) + self._registered_hotkey_ids = [] + def _on_refresh_shortcut(self, event) -> None: """Handle Ctrl+R / F5 shortcut.""" if self.main_window: @@ -104,3 +202,32 @@ def _on_settings_shortcut(self, event) -> None: def _on_exit_shortcut(self, event) -> None: """Handle Ctrl+Q shortcut.""" self.request_exit() + + def _on_show_main_window_shortcut(self, event) -> None: + """Show or restore the main window, including from the tray.""" + if getattr(self, "tray_icon", None) is not None: + self.tray_icon.show_main_window() + return + if self.main_window: + self.main_window.Show(True) + self.main_window.Iconize(False) + self.main_window.Raise() + self.main_window.SetFocus() + + def _on_hide_main_window_shortcut(self, event) -> None: + """Hide the main window to the tray when available.""" + tray_icon = getattr(self, "tray_icon", None) + if tray_icon is not None: + tray_icon.hide_main_window() + return + if self.main_window and hasattr(self.main_window, "set_status"): + self.main_window.set_status("Tray icon unavailable, so the window cannot be hidden.") + + def _on_read_tray_info_shortcut(self, event) -> None: + """Speak the current tray tooltip text through the app's announcer path.""" + tray_icon = getattr(self, "tray_icon", None) + if tray_icon is not None: + tray_icon.announce_tooltip() + return + if self.main_window and hasattr(self.main_window, "set_status"): + self.main_window.set_status("Tray information is unavailable.") diff --git a/src/accessiweather/models/config_constants.py b/src/accessiweather/models/config_constants.py index 5cd4251fe..6ed92c7ba 100644 --- a/src/accessiweather/models/config_constants.py +++ b/src/accessiweather/models/config_constants.py @@ -67,6 +67,9 @@ "enable_alerts", "minimize_to_tray", "minimize_on_startup", + "shortcut_show_main_window", + "shortcut_hide_main_window", + "shortcut_read_tray_info", "startup_enabled", "auto_update_enabled", "update_channel", diff --git a/src/accessiweather/models/config_serialization.py b/src/accessiweather/models/config_serialization.py index 5d521b748..c43701c3a 100644 --- a/src/accessiweather/models/config_serialization.py +++ b/src/accessiweather/models/config_serialization.py @@ -22,6 +22,9 @@ def to_dict(self) -> dict: "enable_alerts": settings.enable_alerts, "minimize_to_tray": settings.minimize_to_tray, "minimize_on_startup": settings.minimize_on_startup, + "shortcut_show_main_window": settings.shortcut_show_main_window, + "shortcut_hide_main_window": settings.shortcut_hide_main_window, + "shortcut_read_tray_info": settings.shortcut_read_tray_info, "startup_enabled": settings.startup_enabled, "data_source": settings.data_source, # weather provider API keys and github_app_* are stored in secure keyring, not JSON @@ -129,6 +132,9 @@ def from_dict(cls, data: dict) -> AppSettings: enable_alerts=settings_cls._as_bool(data.get("enable_alerts"), True), minimize_to_tray=settings_cls._as_bool(data.get("minimize_to_tray"), False), minimize_on_startup=settings_cls._as_bool(data.get("minimize_on_startup"), False), + shortcut_show_main_window=data.get("shortcut_show_main_window", "Ctrl+Shift+W"), + shortcut_hide_main_window=data.get("shortcut_hide_main_window", "Ctrl+Shift+M"), + shortcut_read_tray_info=data.get("shortcut_read_tray_info", "Ctrl+Shift+I"), startup_enabled=settings_cls._as_bool(data.get("startup_enabled"), False), data_source=data.get("data_source", "auto"), pirate_weather_api_key=data.get("pirate_weather_api_key", ""), @@ -286,6 +292,9 @@ def from_dict(cls, data: dict) -> AppSettings: settings.validate_on_access("parallel_fetch_timeout") settings.validate_on_access("specific_alert_sound_packs") settings.validate_on_access("auto_tune_weather_radio_duration_minutes") + settings.validate_on_access("shortcut_show_main_window") + settings.validate_on_access("shortcut_hide_main_window") + settings.validate_on_access("shortcut_read_tray_info") if settings.data_source not in {"auto", "nws", "openmeteo", "pirateweather"}: settings.data_source = "auto" return settings diff --git a/src/accessiweather/models/config_settings.py b/src/accessiweather/models/config_settings.py index 3419679db..f0ed199fb 100644 --- a/src/accessiweather/models/config_settings.py +++ b/src/accessiweather/models/config_settings.py @@ -18,6 +18,9 @@ class AppSettings(AppSettingsValidationMixin, AppSettingsSerializationMixin): enable_alerts: bool = True minimize_to_tray: bool = False minimize_on_startup: bool = False + shortcut_show_main_window: str = "Ctrl+Shift+W" + shortcut_hide_main_window: str = "Ctrl+Shift+M" + shortcut_read_tray_info: str = "Ctrl+Shift+I" startup_enabled: bool = False data_source: str = "auto" pirate_weather_api_key: str = "" diff --git a/src/accessiweather/models/config_validation.py b/src/accessiweather/models/config_validation.py index dfcfd34ac..c28d24438 100644 --- a/src/accessiweather/models/config_validation.py +++ b/src/accessiweather/models/config_validation.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING, cast +from ..shortcut_preferences import WINDOW_TRAY_SHORTCUT_DEFAULTS, normalize_shortcut_text from ..sound_events import DEFAULT_MUTED_SOUND_EVENTS, normalize_known_muted_sound_events from .config_constants import NON_CRITICAL_SETTINGS @@ -127,6 +128,17 @@ def validate_on_access(self, setting_name: str) -> bool: if not isinstance(value, str) or not value.strip(): setattr(settings, setting_name, "{temp} {condition}") + elif setting_name in WINDOW_TRAY_SHORTCUT_DEFAULTS: + default_value = WINDOW_TRAY_SHORTCUT_DEFAULTS[setting_name] + if not isinstance(value, str): + setattr(settings, setting_name, default_value) + else: + try: + normalized_value = normalize_shortcut_text(value, allow_empty=True) + except ValueError: + normalized_value = default_value + setattr(settings, setting_name, normalized_value) + elif setting_name in { "alert_global_cooldown_minutes", "alert_per_alert_cooldown_minutes", diff --git a/src/accessiweather/shortcut_preferences.py b/src/accessiweather/shortcut_preferences.py new file mode 100644 index 000000000..9696a68d6 --- /dev/null +++ b/src/accessiweather/shortcut_preferences.py @@ -0,0 +1,202 @@ +"""Helpers for configurable window and tray shortcuts.""" + +from __future__ import annotations + +from dataclasses import dataclass + +DEFAULT_SHOW_MAIN_WINDOW_SHORTCUT = "Ctrl+Shift+W" +DEFAULT_HIDE_MAIN_WINDOW_SHORTCUT = "Ctrl+Shift+M" +DEFAULT_READ_TRAY_INFO_SHORTCUT = "Ctrl+Shift+I" + +_MODIFIER_ALIASES = { + "ALT": "Alt", + "CTRL": "Ctrl", + "CONTROL": "Ctrl", + "CMD": "Ctrl", + "COMMAND": "Ctrl", + "OPTION": "Alt", + "SHIFT": "Shift", +} +_SPECIAL_KEYS = { + "ESC": "Escape", + "ESCAPE": "Escape", + "SPACE": "Space", + "TAB": "Tab", +} +_VISIBLE_MODIFIER_ORDER = ("Ctrl", "Alt", "Shift") +_ACCELERATOR_MODIFIER_NAMES = { + "Ctrl": "ACCEL_CTRL", + "Alt": "ACCEL_ALT", + "Shift": "ACCEL_SHIFT", +} +_HOTKEY_MODIFIER_NAMES = { + "Ctrl": ("MOD_CONTROL", "ACCEL_CTRL"), + "Alt": ("MOD_ALT", "ACCEL_ALT"), + "Shift": ("MOD_SHIFT", "ACCEL_SHIFT"), +} +_SPECIAL_KEY_CODE_NAMES = { + "Escape": ("WXK_ESCAPE", 27), + "Space": ("WXK_SPACE", 32), + "Tab": ("WXK_TAB", 9), +} + + +@dataclass(frozen=True) +class ShortcutPreference: + setting_name: str + label: str + default: str + handler_name: str + + +@dataclass(frozen=True) +class ShortcutBinding: + modifiers: tuple[str, ...] + key: str + + @property + def normalized(self) -> str: + parts = [*self.modifiers, self.key] + return "+".join(parts) + + def accelerator_flags(self, wx_module) -> int: + flags = getattr(wx_module, "ACCEL_NORMAL", 0) + for modifier in self.modifiers: + flags |= getattr(wx_module, _ACCELERATOR_MODIFIER_NAMES[modifier], 0) + return flags + + def hotkey_modifiers(self, wx_module) -> int: + flags = 0 + for modifier in self.modifiers: + primary_name, fallback_name = _HOTKEY_MODIFIER_NAMES[modifier] + flags |= getattr(wx_module, primary_name, getattr(wx_module, fallback_name, 0)) + return flags + + def key_code(self, wx_module) -> int: + if len(self.key) == 1: + return ord(self.key) + if self.key.startswith("F") and self.key[1:].isdigit(): + f_key_name = f"WXK_{self.key}" + value = getattr(wx_module, f_key_name, None) + if value is None: + raise ValueError(f"{self.key} is not available in this wx build.") + return value + if self.key in _SPECIAL_KEY_CODE_NAMES: + attr_name, fallback = _SPECIAL_KEY_CODE_NAMES[self.key] + return getattr(wx_module, attr_name, fallback) + raise ValueError(f"Unsupported shortcut key {self.key!r}.") + + +WINDOW_TRAY_SHORTCUTS: tuple[ShortcutPreference, ...] = ( + ShortcutPreference( + setting_name="shortcut_show_main_window", + label="Show main window shortcut", + default=DEFAULT_SHOW_MAIN_WINDOW_SHORTCUT, + handler_name="_on_show_main_window_shortcut", + ), + ShortcutPreference( + setting_name="shortcut_hide_main_window", + label="Hide window shortcut", + default=DEFAULT_HIDE_MAIN_WINDOW_SHORTCUT, + handler_name="_on_hide_main_window_shortcut", + ), + ShortcutPreference( + setting_name="shortcut_read_tray_info", + label="Read tray information shortcut", + default=DEFAULT_READ_TRAY_INFO_SHORTCUT, + handler_name="_on_read_tray_info_shortcut", + ), +) + +WINDOW_TRAY_SHORTCUT_DEFAULTS = { + preference.setting_name: preference.default for preference in WINDOW_TRAY_SHORTCUTS +} + +RESERVED_SHORTCUTS = { + "Ctrl+R": "refresh the weather", + "Ctrl+L": "add a location", + "Ctrl+D": "remove a location", + "Ctrl+H": "open Weather History", + "Ctrl+1": "focus Current Conditions", + "Ctrl+2": "focus Hourly / Near-Term", + "Ctrl+3": "focus Daily Forecast", + "Ctrl+4": "focus Alerts", + "Ctrl+5": "focus Event Center", + "Ctrl+S": "open Settings", + "Ctrl+Q": "exit AccessiWeather", + "Ctrl+E": "open Explain Conditions", + "Ctrl+T": "open Weather Assistant", + "Ctrl+Shift+R": "open NOAA Weather Radio", + "Escape": "hide the window when minimize-to-tray is enabled", + "F5": "refresh the weather", + "F6": "cycle through top-level sections", +} + + +def normalize_shortcut_text(value: str | None, *, allow_empty: bool = True) -> str: + """Return a canonical shortcut string, or an empty string when disabled.""" + text = str(value or "").strip() + if not text: + if allow_empty: + return "" + raise ValueError("Shortcut cannot be blank.") + + parts = [part.strip() for part in text.split("+")] + if not all(parts): + raise ValueError("Use '+' only between modifiers and the key, for example Ctrl+Shift+W.") + + raw_key = parts[-1] + modifier_parts = parts[:-1] + modifiers: list[str] = [] + seen_modifiers: set[str] = set() + for part in modifier_parts: + normalized_modifier = _MODIFIER_ALIASES.get(part.upper()) + if normalized_modifier is None: + raise ValueError(f"Unknown modifier {part!r}. Use Ctrl, Alt, or Shift.") + if normalized_modifier in seen_modifiers: + raise ValueError(f"{normalized_modifier} appears more than once.") + seen_modifiers.add(normalized_modifier) + modifiers.append(normalized_modifier) + + normalized_key = _normalize_key_token(raw_key) + ordered_modifiers = [name for name in _VISIBLE_MODIFIER_ORDER if name in modifiers] + return "+".join([*ordered_modifiers, normalized_key]) + + +def parse_shortcut_text(value: str | None) -> ShortcutBinding | None: + """Parse a canonical or user-entered shortcut string.""" + normalized = normalize_shortcut_text(value, allow_empty=True) + if not normalized: + return None + parts = normalized.split("+") + return ShortcutBinding(modifiers=tuple(parts[:-1]), key=parts[-1]) + + +def resolve_shortcut_binding(value: str | None, *, default: str) -> ShortcutBinding | None: + """Parse a user preference, falling back to the default when malformed.""" + try: + return parse_shortcut_text(value) + except ValueError: + return parse_shortcut_text(default) + + +def _normalize_key_token(token: str) -> str: + token = token.strip() + if not token: + raise ValueError("Shortcut needs a key after the modifiers.") + + special = _SPECIAL_KEYS.get(token.upper()) + if special is not None: + return special + + if len(token) == 1 and token.isalnum(): + return token.upper() + + upper_token = token.upper() + if upper_token.startswith("F") and upper_token[1:].isdigit(): + number = int(upper_token[1:]) + if 1 <= number <= 24: + return f"F{number}" + raise ValueError("Function-key shortcuts must be between F1 and F24.") + + raise ValueError("Shortcut keys must be letters, numbers, F1-F24, Tab, Space, or Escape.") diff --git a/src/accessiweather/ui/dialogs/settings_dialog_handlers.py b/src/accessiweather/ui/dialogs/settings_dialog_handlers.py index 906d9f034..09eb8023e 100644 --- a/src/accessiweather/ui/dialogs/settings_dialog_handlers.py +++ b/src/accessiweather/ui/dialogs/settings_dialog_handlers.py @@ -6,6 +6,12 @@ import wx +from ...shortcut_preferences import ( + RESERVED_SHORTCUTS, + WINDOW_TRAY_SHORTCUTS, + normalize_shortcut_text, +) + logger = logging.getLogger("accessiweather.ui.dialogs.settings_dialog") @@ -441,11 +447,46 @@ def _on_alert_advanced(self, event): def _on_ok(self, event): """Handle OK button press.""" + shortcut_error = self._validate_window_tray_shortcuts() + if shortcut_error is not None: + wx.MessageBox(shortcut_error, "Shortcut Problem", wx.OK | wx.ICON_WARNING) + return if self._save_settings(): self.EndModal(wx.ID_OK) else: wx.MessageBox("Failed to save settings.", "Error", wx.OK | wx.ICON_ERROR) + def _validate_window_tray_shortcuts(self) -> str | None: + """Validate configurable window/tray shortcut fields before save.""" + seen: dict[str, str] = {} + for preference in WINDOW_TRAY_SHORTCUTS: + raw_value = self._controls[preference.setting_name].GetValue() + try: + normalized = normalize_shortcut_text(raw_value, allow_empty=True) + except ValueError as exc: + return f"{preference.label}: {exc}" + + self._controls[preference.setting_name].SetValue(normalized) + if not normalized: + continue + + reserved_use = RESERVED_SHORTCUTS.get(normalized) + if reserved_use is not None: + return ( + f"{preference.label} cannot use {normalized} because that shortcut already " + f"{reserved_use}." + ) + + existing_label = seen.get(normalized) + if existing_label is not None: + return ( + f"{preference.label} duplicates {existing_label} ({normalized}). " + "Choose a different shortcut or leave one field blank." + ) + seen[normalized] = preference.label + + return None + def _on_minimize_tray_changed(self, event): """Handle minimize to tray checkbox state change.""" minimize_to_tray_enabled = self._controls["minimize_tray"].GetValue() diff --git a/src/accessiweather/ui/dialogs/settings_tabs/advanced.py b/src/accessiweather/ui/dialogs/settings_tabs/advanced.py index 54e856831..1039530e1 100644 --- a/src/accessiweather/ui/dialogs/settings_tabs/advanced.py +++ b/src/accessiweather/ui/dialogs/settings_tabs/advanced.py @@ -68,6 +68,40 @@ def create(self, page_label: str = "Advanced"): 10, ) + shortcut_section = self.dialog.create_section( + panel, + sizer, + "Window and tray shortcuts", + "Edit the default window-management shortcuts here. Leave a field blank to disable that shortcut.", + ) + self.dialog.add_help_text( + panel, + shortcut_section, + "Use shortcuts like Ctrl+Shift+W. These commands also register as global hotkeys when your system supports them.", + left=10, + ) + controls["shortcut_show_main_window"] = self.dialog.add_labeled_control_row( + panel, + shortcut_section, + "Show the hidden window:", + lambda parent: wx.TextCtrl(parent), + expand_control=True, + ) + controls["shortcut_hide_main_window"] = self.dialog.add_labeled_control_row( + panel, + shortcut_section, + "Hide the window to the tray:", + lambda parent: wx.TextCtrl(parent), + expand_control=True, + ) + controls["shortcut_read_tray_info"] = self.dialog.add_labeled_control_row( + panel, + shortcut_section, + "Read the current tray information:", + lambda parent: wx.TextCtrl(parent), + expand_control=True, + ) + backup_section = self.dialog.create_section( panel, sizer, @@ -156,6 +190,15 @@ def load(self, settings): minimize_to_tray = getattr(settings, "minimize_to_tray", False) controls["minimize_tray"].SetValue(minimize_to_tray) controls["minimize_on_startup"].SetValue(getattr(settings, "minimize_on_startup", False)) + controls["shortcut_show_main_window"].SetValue( + getattr(settings, "shortcut_show_main_window", "Ctrl+Shift+W") + ) + controls["shortcut_hide_main_window"].SetValue( + getattr(settings, "shortcut_hide_main_window", "Ctrl+Shift+M") + ) + controls["shortcut_read_tray_info"].SetValue( + getattr(settings, "shortcut_read_tray_info", "Ctrl+Shift+I") + ) self.dialog._update_minimize_on_startup_state(minimize_to_tray) controls["startup"].SetValue(getattr(settings, "startup_enabled", False)) @@ -167,6 +210,9 @@ def save(self) -> dict: return { "minimize_to_tray": controls["minimize_tray"].GetValue(), "minimize_on_startup": controls["minimize_on_startup"].GetValue(), + "shortcut_show_main_window": controls["shortcut_show_main_window"].GetValue(), + "shortcut_hide_main_window": controls["shortcut_hide_main_window"].GetValue(), + "shortcut_read_tray_info": controls["shortcut_read_tray_info"].GetValue(), "startup_enabled": controls["startup"].GetValue(), "weather_history_enabled": controls["weather_history"].GetValue(), } @@ -177,6 +223,9 @@ def setup_accessibility(self): names = { "minimize_tray": "Minimize to the notification area when closing", "minimize_on_startup": "Start minimized to the notification area", + "shortcut_show_main_window": "Shortcut for showing the hidden window", + "shortcut_hide_main_window": "Shortcut for hiding the window to the tray", + "shortcut_read_tray_info": "Shortcut for reading the current tray information", "startup": "Launch automatically at startup", "weather_history": "Enable weather history comparisons", } diff --git a/src/accessiweather/ui/system_tray.py b/src/accessiweather/ui/system_tray.py index 6c9b5a0ef..d08dd0e18 100644 --- a/src/accessiweather/ui/system_tray.py +++ b/src/accessiweather/ui/system_tray.py @@ -46,6 +46,7 @@ def __init__(self, app: AccessiWeatherApp): self.app = app self._icon_set = False self._cached_icon = None # Cache the icon to avoid reloading + self._tooltip_text = "AccessiWeather" # Set up the tray icon self._setup_icon() @@ -60,7 +61,7 @@ def _setup_icon(self) -> None: icon = self._load_icon() if icon and icon.IsOk(): self._cached_icon = icon # Cache the icon - self.SetIcon(icon, "AccessiWeather") + self.SetIcon(icon, self._tooltip_text) self._icon_set = True logger.debug("System tray icon set successfully") else: @@ -246,6 +247,16 @@ def _on_quit_menu(self, event: wx.CommandEvent) -> None: """Handle Quit menu item click.""" self.app.request_exit() + def hide_main_window(self) -> None: + """Hide the main window to the tray without exiting the app.""" + frame = getattr(self.app, "main_window", None) + if frame is None: + return + if hasattr(frame, "_minimize_to_tray"): + frame._minimize_to_tray() + else: + frame.Hide() + def show_main_window(self) -> None: """Show and restore the main window.""" if self.app.main_window: @@ -287,6 +298,19 @@ def show_main_window(self) -> None: frame.SetFocus() logger.debug("Main window restored from tray") + def get_tooltip_text(self) -> str: + """Return the last tooltip text shown in the tray.""" + return self._tooltip_text or "AccessiWeather" + + def announce_tooltip(self) -> None: + """Route the tray tooltip through the main window's accessible status path.""" + frame = getattr(self.app, "main_window", None) + message = f"Tray info: {self.get_tooltip_text()}" + if frame is not None and hasattr(frame, "set_status"): + frame.set_status(message) + return + logger.info(message) + def update_tooltip(self, text: str) -> None: """ Update the tray icon tooltip text. @@ -295,6 +319,7 @@ def update_tooltip(self, text: str) -> None: text: The new tooltip text """ + self._tooltip_text = text or "AccessiWeather" if self._icon_set and self._cached_icon and self._cached_icon.IsOk(): - self.SetIcon(self._cached_icon, text) - logger.debug(f"Tray tooltip updated: {text}") + self.SetIcon(self._cached_icon, self._tooltip_text) + logger.debug(f"Tray tooltip updated: {self._tooltip_text}") diff --git a/tests/gui/test_main_window_minimize.py b/tests/gui/test_main_window_minimize.py index d6c94c4cf..6dd6aa4e7 100644 --- a/tests/gui/test_main_window_minimize.py +++ b/tests/gui/test_main_window_minimize.py @@ -313,10 +313,14 @@ class _FakeWxForAccelerators: ACCEL_CTRL = 1 ACCEL_ALT = 2 ACCEL_SHIFT = 4 + MOD_CONTROL = 8 + MOD_ALT = 16 + MOD_SHIFT = 32 WXK_ESCAPE = 27 WXK_F4 = 115 WXK_F5 = 116 EVT_MENU = object() + EVT_HOTKEY = object() ID_REFRESH = 5104 def __init__(self) -> None: @@ -348,6 +352,7 @@ class _AcceleratorFrame: def __init__(self) -> None: self.bound_handlers = {} self.accelerator_table = None + self.registered_hotkeys = {} self.Close = MagicMock() self.SetTitle = MagicMock() @@ -355,6 +360,18 @@ def Bind(self, _event_type, handler, item=None, id=None): bound_id = id if id is not None else item self.bound_handlers[bound_id] = handler + def RegisterHotKey(self, hotkey_id, modifiers, key_code): + self.registered_hotkeys[hotkey_id] = (modifiers, key_code) + return True + + def UnregisterHotKey(self, hotkey_id): + self.registered_hotkeys.pop(hotkey_id, None) + return True + + def Unbind(self, _event_type, id=None): + self.bound_handlers.pop(id, None) + return True + def SetAcceleratorTable(self, table): self.accelerator_table = table @@ -414,3 +431,47 @@ def test_main_window_accelerator_table_keeps_alt_f4_after_title_change(self): window.SetTitle.assert_called_once_with("AccessiWeather \u2014 Boston") window.Close.assert_called_once_with() + + def test_setup_accelerators_registers_configurable_window_tray_shortcuts(self): + fake_wx = _FakeWxForAccelerators() + frame = _AcceleratorFrame() + tray_icon = MagicMock() + app = AppShortcutsMixin() + app.main_window = frame + app.tray_icon = tray_icon + app.request_exit = MagicMock() + app.config_manager = SimpleNamespace( + get_settings=lambda: SimpleNamespace( + shortcut_show_main_window="Ctrl+Shift+W", + shortcut_hide_main_window="Ctrl+Shift+M", + shortcut_read_tray_info="Ctrl+Shift+I", + ) + ) + + with ( + patch("accessiweather.app_shortcuts.wx", fake_wx), + patch("accessiweather.native_shortcuts.wx", fake_wx), + ): + app._setup_accelerators() + + assert any( + entry.flags == fake_wx.ACCEL_CTRL | fake_wx.ACCEL_SHIFT and entry.key == ord("W") + for entry in frame.accelerator_table + ) + assert any( + entry.flags == fake_wx.ACCEL_CTRL | fake_wx.ACCEL_SHIFT and entry.key == ord("M") + for entry in frame.accelerator_table + ) + assert any( + entry.flags == fake_wx.ACCEL_CTRL | fake_wx.ACCEL_SHIFT and entry.key == ord("I") + for entry in frame.accelerator_table + ) + assert len(frame.registered_hotkeys) == 3 + + show_hotkey_id = next( + hotkey_id + for hotkey_id, (_modifiers, key_code) in frame.registered_hotkeys.items() + if key_code == ord("W") + ) + frame.bound_handlers[show_hotkey_id](MagicMock()) + tray_icon.show_main_window.assert_called_once_with() diff --git a/tests/test_settings_dialog_shortcuts.py b/tests/test_settings_dialog_shortcuts.py new file mode 100644 index 000000000..b1aacd315 --- /dev/null +++ b/tests/test_settings_dialog_shortcuts.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from accessiweather.shortcut_preferences import WINDOW_TRAY_SHORTCUTS +from accessiweather.ui.dialogs.settings_dialog_handlers import SettingsDialogHandlersMixin +from accessiweather.ui.dialogs.settings_tabs.advanced import AdvancedTab + + +class _DummyControl: + def __init__(self) -> None: + self._value = "" + self._name = "" + + def SetValue(self, value): + self._value = value + + def GetValue(self): + return self._value + + def SetName(self, value: str) -> None: + self._name = value + + def Enable(self, _value: bool) -> None: + return None + + +class _Controls(dict): + def __missing__(self, key: str) -> _DummyControl: + control = _DummyControl() + self[key] = control + return control + + +class _DialogStub(SettingsDialogHandlersMixin): + def __init__(self) -> None: + self._controls = _Controls() + + +def test_advanced_tab_load_and_save_include_window_tray_shortcuts(): + dialog = SimpleNamespace( + _controls=_Controls(), _update_minimize_on_startup_state=lambda _v: None + ) + tab = AdvancedTab(dialog) + settings = SimpleNamespace( + minimize_to_tray=True, + minimize_on_startup=False, + shortcut_show_main_window="Ctrl+Alt+W", + shortcut_hide_main_window="Ctrl+Alt+M", + shortcut_read_tray_info="Ctrl+Alt+I", + startup_enabled=True, + weather_history_enabled=False, + ) + + tab.load(settings) + saved = tab.save() + + assert saved["shortcut_show_main_window"] == "Ctrl+Alt+W" + assert saved["shortcut_hide_main_window"] == "Ctrl+Alt+M" + assert saved["shortcut_read_tray_info"] == "Ctrl+Alt+I" + + +def test_validate_window_tray_shortcuts_rejects_reserved_shortcut(): + dialog = _DialogStub() + dialog._controls["shortcut_show_main_window"].SetValue("Ctrl+R") + dialog._controls["shortcut_hide_main_window"].SetValue("Ctrl+Shift+M") + dialog._controls["shortcut_read_tray_info"].SetValue("Ctrl+Shift+I") + + error = dialog._validate_window_tray_shortcuts() + + assert error == ( + "Show main window shortcut cannot use Ctrl+R because that shortcut already " + "refresh the weather." + ) + + +def test_validate_window_tray_shortcuts_normalizes_values_and_allows_blank(): + dialog = _DialogStub() + dialog._controls["shortcut_show_main_window"].SetValue(" control + shift + w ") + dialog._controls["shortcut_hide_main_window"].SetValue("") + dialog._controls["shortcut_read_tray_info"].SetValue("ctrl+alt+i") + + error = dialog._validate_window_tray_shortcuts() + + assert error is None + assert dialog._controls["shortcut_show_main_window"].GetValue() == "Ctrl+Shift+W" + assert dialog._controls["shortcut_hide_main_window"].GetValue() == "" + assert dialog._controls["shortcut_read_tray_info"].GetValue() == "Ctrl+Alt+I" + assert {preference.setting_name for preference in WINDOW_TRAY_SHORTCUTS} == { + "shortcut_show_main_window", + "shortcut_hide_main_window", + "shortcut_read_tray_info", + } diff --git a/tests/test_shortcut_preferences.py b/tests/test_shortcut_preferences.py new file mode 100644 index 000000000..688548fd7 --- /dev/null +++ b/tests/test_shortcut_preferences.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import pytest + +from accessiweather.shortcut_preferences import normalize_shortcut_text, parse_shortcut_text + + +class _FakeWx: + ACCEL_NORMAL = 0 + ACCEL_CTRL = 1 + ACCEL_ALT = 2 + ACCEL_SHIFT = 4 + MOD_CONTROL = 8 + MOD_ALT = 16 + MOD_SHIFT = 32 + WXK_TAB = 9 + + +def test_normalize_shortcut_text_canonicalizes_aliases(): + assert normalize_shortcut_text(" control + shift + w ") == "Ctrl+Shift+W" + + +def test_normalize_shortcut_text_allows_blank_to_disable(): + assert normalize_shortcut_text(" ") == "" + + +def test_normalize_shortcut_text_rejects_unknown_key(): + with pytest.raises(ValueError, match="Shortcut keys must be"): + normalize_shortcut_text("Ctrl+Weather") + + +def test_parse_shortcut_text_produces_accelerator_and_hotkey_values(): + binding = parse_shortcut_text("Ctrl+Alt+Tab") + + assert binding is not None + assert binding.accelerator_flags(_FakeWx) == _FakeWx.ACCEL_CTRL | _FakeWx.ACCEL_ALT + assert binding.hotkey_modifiers(_FakeWx) == _FakeWx.MOD_CONTROL | _FakeWx.MOD_ALT + assert binding.key_code(_FakeWx) == _FakeWx.WXK_TAB diff --git a/tests/test_system_tray.py b/tests/test_system_tray.py index 7aff0d018..94f0bb5b3 100644 --- a/tests/test_system_tray.py +++ b/tests/test_system_tray.py @@ -196,6 +196,33 @@ def update_tooltip(icon_set, cached_icon, text): assert set_icon_called == [] +class TestTrayShortcutHelpers: + """Tests for tray actions used by the configurable hotkeys.""" + + def test_hide_main_window_prefers_minimize_to_tray_helper(self): + from accessiweather.ui.system_tray import SystemTrayIcon + + frame = SimpleNamespace(_minimize_to_tray=MagicMock()) + tray = SystemTrayIcon.__new__(SystemTrayIcon) + tray.app = SimpleNamespace(main_window=frame) + + tray.hide_main_window() + + frame._minimize_to_tray.assert_called_once_with() + + def test_announce_tooltip_routes_through_main_window_status(self): + from accessiweather.ui.system_tray import SystemTrayIcon + + frame = MagicMock() + tray = SystemTrayIcon.__new__(SystemTrayIcon) + tray.app = SimpleNamespace(main_window=frame) + tray._tooltip_text = "72F Sunny" + + tray.announce_tooltip() + + frame.set_status.assert_called_once_with("Tray info: 72F Sunny") + + class TestMinimizeOnStartup: """Tests for minimize on startup functionality."""