diff --git a/CHANGELOG.md b/CHANGELOG.md index b31897888..d2f811057 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added +- New keyboard shortcuts now let you edit the selected location, show or hide the main window, and read the current notification-area text without using the tray menu. + ### Changed - 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. diff --git a/docs/user_manual.md b/docs/user_manual.md index 81df4574c..8b579eabd 100644 --- a/docs/user_manual.md +++ b/docs/user_manual.md @@ -1058,12 +1058,16 @@ These shortcuts are available in the current app: - F5 or Ctrl+R: Refresh weather - Ctrl+L: Add location +- Ctrl+Shift+L: Edit the selected location - Ctrl+D: Remove location - Ctrl+S: Open Settings - Ctrl+H: Open Weather History - Ctrl+E: Explain Weather - Ctrl+T: Open Weather Assistant - Ctrl+Shift+R: Open NOAA Weather Radio +- Ctrl+Shift+W: Show and focus the main window +- Ctrl+Shift+H: Hide the main window to the notification area +- Ctrl+Shift+I: Read the current notification-area text - Ctrl+Q: Quit ## 10. Troubleshooting diff --git a/src/accessiweather/app_shortcuts.py b/src/accessiweather/app_shortcuts.py index bb15c27fa..03ed7d1e1 100644 --- a/src/accessiweather/app_shortcuts.py +++ b/src/accessiweather/app_shortcuts.py @@ -21,6 +21,7 @@ def _setup_accelerators(self) -> None: accelerators = [ (wx.ACCEL_CTRL, ord("R"), self._on_refresh_shortcut), (wx.ACCEL_CTRL, ord("L"), self._on_add_location_shortcut), + (wx.ACCEL_CTRL | wx.ACCEL_SHIFT, ord("L"), self._on_edit_location_shortcut), (wx.ACCEL_CTRL, ord("D"), self._on_remove_location_shortcut), (wx.ACCEL_CTRL, ord("H"), self._on_history_shortcut), (wx.ACCEL_CTRL, ord("1"), self._on_focus_current_conditions_shortcut), @@ -29,6 +30,9 @@ def _setup_accelerators(self) -> None: (wx.ACCEL_CTRL, ord("4"), self._on_focus_alerts_shortcut), (wx.ACCEL_CTRL, ord("5"), self._on_focus_event_center_shortcut), (wx.ACCEL_CTRL, ord("S"), self._on_settings_shortcut), + (wx.ACCEL_CTRL | wx.ACCEL_SHIFT, ord("W"), self._on_show_window_shortcut), + (wx.ACCEL_CTRL | wx.ACCEL_SHIFT, ord("H"), self._on_hide_window_shortcut), + (wx.ACCEL_CTRL | wx.ACCEL_SHIFT, ord("I"), self._on_read_tray_info_shortcut), (wx.ACCEL_CTRL, ord("Q"), self._on_exit_shortcut), (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), @@ -56,6 +60,11 @@ def _on_add_location_shortcut(self, event) -> None: if self.main_window: self.main_window.on_add_location() + def _on_edit_location_shortcut(self, event) -> None: + """Handle Ctrl+Shift+L shortcut.""" + if self.main_window: + self.main_window.on_edit_location() + def _on_remove_location_shortcut(self, event) -> None: """Handle Ctrl+D shortcut.""" if self.main_window: @@ -101,6 +110,39 @@ def _on_settings_shortcut(self, event) -> None: if self.main_window: self.main_window.on_settings() + def _on_show_window_shortcut(self, event) -> None: + """Handle Ctrl+Shift+W shortcut.""" + tray_icon = getattr(self, "tray_icon", None) + if tray_icon and hasattr(tray_icon, "show_main_window"): + 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_window_shortcut(self, event) -> None: + """Handle Ctrl+Shift+H shortcut.""" + if self.main_window and getattr(self, "tray_icon", None): + self.main_window._minimize_to_tray() + return + self._announce_shortcut_status("System tray is unavailable.") + + def _on_read_tray_info_shortcut(self, event) -> None: + """Handle Ctrl+Shift+I shortcut.""" + tray_icon = getattr(self, "tray_icon", None) + if tray_icon and hasattr(tray_icon, "get_tooltip_text"): + tooltip = tray_icon.get_tooltip_text() + self._announce_shortcut_status(f"Tray information: {tooltip}") + return + self._announce_shortcut_status("System tray information is unavailable.") + + def _announce_shortcut_status(self, message: str) -> None: + """Announce shortcut feedback via the main window status channel.""" + if self.main_window and hasattr(self.main_window, "set_status"): + self.main_window.set_status(message) + def _on_exit_shortcut(self, event) -> None: """Handle Ctrl+Q shortcut.""" self.request_exit() diff --git a/src/accessiweather/ui/main_window_ui.py b/src/accessiweather/ui/main_window_ui.py index 295c9cb7c..b53655537 100644 --- a/src/accessiweather/ui/main_window_ui.py +++ b/src/accessiweather/ui/main_window_ui.py @@ -248,7 +248,7 @@ def _create_menu_bar(self) -> None: self._edit_location_id = wx.NewIdRef() edit_item = location_menu.Append( self._edit_location_id, - "&Edit Location...", + "&Edit Location...\tCtrl+Shift+L", "Edit the selected location (e.g. enable Marine Mode)", ) self._remove_location_id = wx.NewIdRef() @@ -270,6 +270,25 @@ def _create_menu_bar(self) -> None: history_item = view_menu.Append( self._history_id, "Weather &History\tCtrl+H", "View weather history" ) + self._show_window_id = wx.NewIdRef() + show_window_item = view_menu.Append( + self._show_window_id, + "Sho&w Window\tCtrl+Shift+W", + "Restore and focus the main window", + ) + self._hide_window_id = wx.NewIdRef() + hide_window_item = view_menu.Append( + self._hide_window_id, + "&Hide to Notification Area\tCtrl+Shift+H", + "Hide the main window to the system tray", + ) + self._read_tray_info_id = wx.NewIdRef() + read_tray_info_item = view_menu.Append( + self._read_tray_info_id, + "Read Notification Area &Text\tCtrl+Shift+I", + "Announce the current system tray information", + ) + view_menu.AppendSeparator() self._precipitation_timeline_id = wx.NewIdRef() self._precipitation_timeline_item = view_menu.Append( self._precipitation_timeline_id, @@ -371,6 +390,13 @@ def _create_menu_bar(self) -> None: self.Bind(wx.EVT_MENU, lambda e: self.on_refresh(), refresh_item) self.Bind(wx.EVT_MENU, lambda e: self._on_explain_weather(), explain_item) self.Bind(wx.EVT_MENU, lambda e: self.on_view_history(), history_item) + self.Bind(wx.EVT_MENU, lambda e: self.app._on_show_window_shortcut(e), show_window_item) + self.Bind(wx.EVT_MENU, lambda e: self.app._on_hide_window_shortcut(e), hide_window_item) + self.Bind( + wx.EVT_MENU, + lambda e: self.app._on_read_tray_info_shortcut(e), + read_tray_info_item, + ) self.Bind( wx.EVT_MENU, lambda e: self._on_precipitation_timeline(), @@ -430,9 +456,13 @@ def _setup_escape_accelerator(self) -> None: (wx.ACCEL_CTRL, "S", "_settings_id"), (wx.ACCEL_CTRL, "Q", "_exit_id"), (wx.ACCEL_CTRL, "L", "_add_location_id"), + (wx.ACCEL_CTRL | wx.ACCEL_SHIFT, "L", "_edit_location_id"), (wx.ACCEL_CTRL, "D", "_remove_location_id"), (wx.ACCEL_CTRL, "E", "_explain_id"), (wx.ACCEL_CTRL, "H", "_history_id"), + (wx.ACCEL_CTRL | wx.ACCEL_SHIFT, "W", "_show_window_id"), + (wx.ACCEL_CTRL | wx.ACCEL_SHIFT, "H", "_hide_window_id"), + (wx.ACCEL_CTRL | wx.ACCEL_SHIFT, "I", "_read_tray_info_id"), (wx.ACCEL_CTRL | wx.ACCEL_SHIFT, "R", "_noaa_radio_id"), (wx.ACCEL_CTRL, "T", "_weather_chat_id"), ] diff --git a/src/accessiweather/ui/system_tray.py b/src/accessiweather/ui/system_tray.py index 6c9b5a0ef..cb0d6ec79 100644 --- a/src/accessiweather/ui/system_tray.py +++ b/src/accessiweather/ui/system_tray.py @@ -22,6 +22,7 @@ from ..app import AccessiWeatherApp logger = logging.getLogger(__name__) +DEFAULT_TRAY_TOOLTIP_TEXT = "AccessiWeather" class SystemTrayIcon(wx.adv.TaskBarIcon): @@ -46,6 +47,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 = DEFAULT_TRAY_TOOLTIP_TEXT # Set up the tray icon self._setup_icon() @@ -60,7 +62,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, DEFAULT_TRAY_TOOLTIP_TEXT) self._icon_set = True logger.debug("System tray icon set successfully") else: @@ -295,6 +297,11 @@ def update_tooltip(self, text: str) -> None: text: The new tooltip text """ + self._tooltip_text = text or DEFAULT_TRAY_TOOLTIP_TEXT 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}") + + def get_tooltip_text(self) -> str: + """Return the most recent tray tooltip text for announcements and tests.""" + return self._tooltip_text diff --git a/tests/gui/test_main_window_minimize.py b/tests/gui/test_main_window_minimize.py index d6c94c4cf..04a654636 100644 --- a/tests/gui/test_main_window_minimize.py +++ b/tests/gui/test_main_window_minimize.py @@ -385,17 +385,40 @@ def test_app_accelerator_table_preserves_alt_f4_as_native_close(self): frame.Close.assert_called_once_with() app.request_exit.assert_not_called() + def test_app_accelerator_table_registers_issue_683_shortcuts(self): + fake_wx = _FakeWxForAccelerators() + frame = _AcceleratorFrame() + app = AppShortcutsMixin() + app.main_window = frame + app.request_exit = MagicMock() + + with ( + patch("accessiweather.app_shortcuts.wx", fake_wx), + patch("accessiweather.native_shortcuts.wx", fake_wx), + ): + app._setup_accelerators() + + combos = {(entry.flags, entry.key) for entry in frame.accelerator_table} + assert (fake_wx.ACCEL_CTRL | fake_wx.ACCEL_SHIFT, ord("L")) in combos + assert (fake_wx.ACCEL_CTRL | fake_wx.ACCEL_SHIFT, ord("W")) in combos + assert (fake_wx.ACCEL_CTRL | fake_wx.ACCEL_SHIFT, ord("H")) in combos + assert (fake_wx.ACCEL_CTRL | fake_wx.ACCEL_SHIFT, ord("I")) in combos + def test_main_window_accelerator_table_keeps_alt_f4_after_title_change(self): fake_wx = _FakeWxForAccelerators() window = _AcceleratorFrame() window._settings_id = 1 window._exit_id = 2 window._add_location_id = 3 - window._remove_location_id = 4 - window._explain_id = 5 - window._history_id = 6 - window._noaa_radio_id = 7 - window._weather_chat_id = 8 + window._edit_location_id = 4 + window._remove_location_id = 5 + window._explain_id = 6 + window._history_id = 7 + window._show_window_id = 8 + window._hide_window_id = 9 + window._read_tray_info_id = 10 + window._noaa_radio_id = 11 + window._weather_chat_id = 12 window._on_escape_pressed = MagicMock() with ( @@ -414,3 +437,52 @@ 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_main_window_accelerator_table_re_registers_issue_683_shortcuts(self): + fake_wx = _FakeWxForAccelerators() + window = _AcceleratorFrame() + window._settings_id = 1 + window._exit_id = 2 + window._add_location_id = 3 + window._edit_location_id = 4 + window._remove_location_id = 5 + window._explain_id = 6 + window._history_id = 7 + window._show_window_id = 8 + window._hide_window_id = 9 + window._read_tray_info_id = 10 + window._noaa_radio_id = 11 + window._weather_chat_id = 12 + window._on_escape_pressed = MagicMock() + + with ( + patch("accessiweather.ui.main_window_ui.wx", fake_wx), + patch("accessiweather.native_shortcuts.wx", fake_wx), + ): + MainWindowUIMixin._setup_escape_accelerator(window) + + combos = {(entry.flags, entry.key) for entry in window.accelerator_table} + assert (fake_wx.ACCEL_CTRL | fake_wx.ACCEL_SHIFT, ord("L")) in combos + assert (fake_wx.ACCEL_CTRL | fake_wx.ACCEL_SHIFT, ord("W")) in combos + assert (fake_wx.ACCEL_CTRL | fake_wx.ACCEL_SHIFT, ord("H")) in combos + assert (fake_wx.ACCEL_CTRL | fake_wx.ACCEL_SHIFT, ord("I")) in combos + + +class TestIssue683ShortcutHandlers: + def test_read_tray_info_shortcut_announces_cached_tray_text(self): + app = AppShortcutsMixin() + app.main_window = MagicMock() + app.tray_icon = SimpleNamespace(get_tooltip_text=MagicMock(return_value="72°F Sunny")) + + app._on_read_tray_info_shortcut(MagicMock()) + + app.main_window.set_status.assert_called_once_with("Tray information: 72°F Sunny") + + def test_hide_window_shortcut_uses_existing_minimize_to_tray_path(self): + app = AppShortcutsMixin() + app.main_window = MagicMock() + app.tray_icon = object() + + app._on_hide_window_shortcut(MagicMock()) + + app.main_window._minimize_to_tray.assert_called_once_with() diff --git a/tests/test_system_tray.py b/tests/test_system_tray.py index 9f4dc65a1..32b8a7554 100644 --- a/tests/test_system_tray.py +++ b/tests/test_system_tray.py @@ -195,6 +195,28 @@ def update_tooltip(icon_set, cached_icon, text): assert result is False assert set_icon_called == [] + def test_get_tooltip_text_defaults_to_app_name(self): + """The tray should keep a readable default tooltip before weather updates arrive.""" + from accessiweather.ui.system_tray import DEFAULT_TRAY_TOOLTIP_TEXT, SystemTrayIcon + + tray = SystemTrayIcon.__new__(SystemTrayIcon) + tray._tooltip_text = DEFAULT_TRAY_TOOLTIP_TEXT + + assert tray.get_tooltip_text() == DEFAULT_TRAY_TOOLTIP_TEXT + + def test_update_tooltip_caches_text_even_without_icon(self): + """Reading tray info should still return the newest text when icon updates are skipped.""" + from accessiweather.ui.system_tray import SystemTrayIcon + + tray = SystemTrayIcon.__new__(SystemTrayIcon) + tray._icon_set = False + tray._cached_icon = None + tray._tooltip_text = "AccessiWeather" + + tray.update_tooltip("72°F Sunny") + + assert tray.get_tooltip_text() == "72°F Sunny" + class TestMinimizeOnStartup: """Tests for minimize on startup functionality."""