Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions src/accessiweather/app_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
127 changes: 127 additions & 0 deletions src/accessiweather/app_shortcuts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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.")
3 changes: 3 additions & 0 deletions src/accessiweather/models/config_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions src/accessiweather/models/config_serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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", ""),
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/accessiweather/models/config_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand Down
12 changes: 12 additions & 0 deletions src/accessiweather/models/config_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading