From 4b0f408e3e458d7535431dbd89103a45fc7bcd4d Mon Sep 17 00:00:00 2001 From: zoajimen Date: Wed, 1 Apr 2026 11:40:34 +0200 Subject: [PATCH 1/2] Implement macOS dock handling and window state management in SerenaDashboardViewer --- src/serena/dashboard.py | 119 ++++++++++++++++++++++------------ test/serena/test_dashboard.py | 107 +++++++++++++++++++++++++++++- 2 files changed, 184 insertions(+), 42 deletions(-) diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index 34927596..f0cb7c82 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -706,7 +706,10 @@ class SerenaDashboardViewer: self.window: webview.Window self._tray_icon: Any + self._app_delegate: Any = None self._quitting = False + self._window_hidden_to_tray = start_minimized + self._suppress_next_activate_reopen = start_minimized @staticmethod def is_current_platform_supported() -> bool: @@ -747,53 +750,92 @@ class SerenaDashboardViewer: if self.tray: self._start_tray() - self._set_dock_icon_visible(not self.start_minimized) def _start_callback() -> None: + # pywebview installs its own NSApplication delegate during startup, + # so on macOS we need to wrap that final delegate here instead of + # earlier in run(), or the dock reopen handler gets overwritten. + if sys.platform == "darwin" and self.tray: + from PyObjCTools.AppHelper import callAfter + + callAfter(self._setup_macos_dock_handler) if self.start_minimized: self.window.minimize() else: - self.window.show() - self.window.restore() + self._show_window() webview.start(_start_callback, icon=icon_path) - def _on_closing(self) -> bool: - """Intercept window close: hide to tray instead of quitting.""" - if self._quitting: - return True + def _show_window(self) -> None: + if self.window: + self.window.show() + self.window.restore() + self._window_hidden_to_tray = False + self._suppress_next_activate_reopen = False + + def _hide_window(self) -> None: if self.window: self.window.hide() - if sys.platform == "darwin": - # setActivationPolicy_ must run on the main thread; _on_closing - # may be called from a pywebview background thread. - from PyObjCTools.AppHelper import callAfter + self._window_hidden_to_tray = True - callAfter(self._set_dock_icon_visible, False) + def _setup_macos_dock_handler(self) -> None: + """Set up handler for dock icon clicks on macOS.""" + from AppKit import NSApplication, NSObject + from objc import python_method # type: ignore[import-untyped] + + viewer = self # capture reference for the delegate + + # Use a unique class name to avoid conflict with pywebview's AppDelegate + class SerenaDockDelegate(NSObject): + @python_method + def initWithOriginalDelegate_(self, original: Any) -> Any: + obj = self.init() + if obj is not None: + obj._original_delegate = original + return obj + + def applicationShouldHandleReopen_hasVisibleWindows_(self, sender: Any, has_visible_windows: bool) -> bool: + """Called when dock icon is clicked.""" + if not has_visible_windows and viewer.window: + viewer._show_window() + # Forward to original delegate if it exists and has this method + if hasattr(self, "_original_delegate") and self._original_delegate is not None: + if hasattr(self._original_delegate, "applicationShouldHandleReopen_hasVisibleWindows_"): + return self._original_delegate.applicationShouldHandleReopen_hasVisibleWindows_(sender, has_visible_windows) + return True + + def applicationDidBecomeActive_(self, notification: Any) -> None: + """Fallback for dock activation paths that do not invoke reopen.""" + if viewer._suppress_next_activate_reopen: + viewer._suppress_next_activate_reopen = False + elif viewer._window_hidden_to_tray: + viewer._show_window() + # Forward to original delegate if it exists and has this method + if hasattr(self, "_original_delegate") and self._original_delegate is not None: + if hasattr(self._original_delegate, "applicationDidBecomeActive_"): + self._original_delegate.applicationDidBecomeActive_(notification) + + def forwardingTargetForSelector_(self, selector: Any) -> Any: + """Forward unknown selectors to original delegate.""" + if hasattr(self, "_original_delegate") and self._original_delegate is not None: + if self._original_delegate.respondsToSelector_(selector): + return self._original_delegate + return None + + ns_app = NSApplication.sharedApplication() + original_delegate = ns_app.delegate() + if original_delegate is getattr(self, "_app_delegate", None): + return + self._app_delegate = SerenaDockDelegate.alloc().initWithOriginalDelegate_(original_delegate) + ns_app.setDelegate_(self._app_delegate) + + def _on_closing(self) -> bool: + """Intercept window close: hide window instead of quitting (macOS standard behavior).""" + if self._quitting: + return True + self._hide_window() return False # prevent the window from actually closing - def _set_dock_icon_visible(self, visible: bool) -> None: - """macOS-only; toggles the dock icon. - - Sets the NSApplication activation policy to Regular (shows in dock) or - Accessory (hides from dock). No-op on other platforms. - """ - if sys.platform == "darwin": - from AppKit import ( - NSApplication, - NSApplicationActivationPolicyAccessory, - NSApplicationActivationPolicyRegular, - ) - - ns_app = NSApplication.sharedApplication() - policy = NSApplicationActivationPolicyRegular if visible else NSApplicationActivationPolicyAccessory - ns_app.setActivationPolicy_(policy) - if visible: - # setActivationPolicy_ alone is not enough at runtime; unhide_ fires - # the full applicationWillUnhide:/applicationDidUnhide: cycle, which - # is what actually restores the dock icon and brings windows forward. - ns_app.unhide_(None) - def _start_tray(self) -> None: # import pystray locally, because the import fails when there is no display! import pystray @@ -808,15 +850,10 @@ class SerenaDashboardViewer: icon_img = Image.open(dashboard_path / icon_filename) def show(_icon: TrayIcon, _item: Item) -> None: - if self.window: - self._set_dock_icon_visible(True) - self.window.show() - self.window.restore() + self._show_window() def hide(_icon: TrayIcon, _item: Item) -> None: - if self.window: - self.window.hide() - self._set_dock_icon_visible(False) + self._hide_window() def quit_app(_icon: TrayIcon, _item: Item) -> None: self._quitting = True diff --git a/test/serena/test_dashboard.py b/test/serena/test_dashboard.py index 8de6e8eb..578c06cd 100644 --- a/test/serena/test_dashboard.py +++ b/test/serena/test_dashboard.py @@ -1,6 +1,8 @@ +import sys from types import SimpleNamespace -from serena.dashboard import SerenaDashboardAPI +import serena.dashboard as dashboard_module +from serena.dashboard import SerenaDashboardAPI, SerenaDashboardViewer from solidlsp.ls_config import Language @@ -53,3 +55,106 @@ def test_available_languages_exclude_project_languages(): assert Language.MARKDOWN.value not in available # ensure experimental languages remain available for selection assert Language.ANSIBLE.value in available + + +def test_dashboard_viewer_initializes_macos_delegate_slot(): + viewer = SerenaDashboardViewer("http://localhost:24282/dashboard/index.html") + assert viewer._app_delegate is None + assert viewer._window_hidden_to_tray is False + assert viewer._suppress_next_activate_reopen is False + + +def test_dashboard_viewer_initializes_hidden_state_for_minimized_start(): + viewer = SerenaDashboardViewer("http://localhost:24282/dashboard/index.html", start_minimized=True) + assert viewer._window_hidden_to_tray is True + assert viewer._suppress_next_activate_reopen is True + + +def test_dashboard_viewer_show_and_hide_helpers_update_state(): + viewer = SerenaDashboardViewer("http://localhost:24282/dashboard/index.html", start_minimized=True) + + class _FakeWindow: + def __init__(self) -> None: + self.calls: list[str] = [] + + def show(self) -> None: + self.calls.append("show") + + def restore(self) -> None: + self.calls.append("restore") + + def hide(self) -> None: + self.calls.append("hide") + + viewer.window = _FakeWindow() + + viewer._show_window() + assert viewer.window.calls == ["show", "restore"] + assert viewer._window_hidden_to_tray is False + assert viewer._suppress_next_activate_reopen is False + + viewer._hide_window() + assert viewer.window.calls == ["show", "restore", "hide"] + assert viewer._window_hidden_to_tray is True + + +def test_dashboard_viewer_installs_macos_dock_handler_after_pywebview_start(monkeypatch): + order: list[str] = [] + + class _ClosingEvent: + def __iadd__(self, _handler): + order.append("bind-closing") + return self + + class _FakeWindow: + def __init__(self) -> None: + self.events = SimpleNamespace(closing=_ClosingEvent()) + + def minimize(self) -> None: + order.append("minimize") + + def show(self) -> None: + order.append("show") + + def restore(self) -> None: + order.append("restore") + + fake_window = _FakeWindow() + + def fake_create_window(*args, **kwargs): + order.append("create-window") + return fake_window + + def fake_start(callback, **kwargs): + del kwargs + order.append("start") + callback() + + class _FakeAppHelperModule: + @staticmethod + def callAfter(func, *args, **kwargs): + del args, kwargs + order.append("schedule-dock-handler") + func() + + monkeypatch.setattr(dashboard_module.sys, "platform", "darwin") + monkeypatch.setattr(dashboard_module.webview, "create_window", fake_create_window) + monkeypatch.setattr(dashboard_module.webview, "start", fake_start) + monkeypatch.setitem(sys.modules, "PyObjCTools.AppHelper", _FakeAppHelperModule) + + viewer = SerenaDashboardViewer("http://localhost:24282/dashboard/index.html") + monkeypatch.setattr(viewer, "_start_tray", lambda: order.append("start-tray")) + monkeypatch.setattr(viewer, "_setup_macos_dock_handler", lambda: order.append("setup-dock-handler")) + + viewer.run() + + assert order == [ + "create-window", + "bind-closing", + "start-tray", + "start", + "schedule-dock-handler", + "setup-dock-handler", + "show", + "restore", + ] From 7c202de49366c4c75314a70dec7ecf99681535bc Mon Sep 17 00:00:00 2001 From: zoajimen Date: Wed, 1 Apr 2026 12:15:03 +0200 Subject: [PATCH 2/2] Refactor SerenaDashboardViewer to improve macOS dock handling and streamline event delegation --- src/serena/dashboard.py | 45 +++++++------- test/serena/test_dashboard.py | 107 +--------------------------------- 2 files changed, 21 insertions(+), 131 deletions(-) diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index f0cb7c82..0e2f7129 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -695,10 +695,8 @@ class SerenaDashboardViewer: height: int = 900, ): self.url = url + # Use system tray to allow hiding to tray and intercepting close to hide instead of quit self.tray = True - """ - whether we use the system tray to allow minimizing to tray and intercepting the close event to hide to tray instead of quitting. - """ self.title = "Serena Dashboard" self.width = width self.height = height @@ -733,22 +731,18 @@ class SerenaDashboardViewer: icon_path = str(dashboard_path / icon_filename) # Create hidden to avoid flash; show/restore/minimize in start callback. - # When tray is enabled, confirm_close allows us to intercept the X button. window = webview.create_window( self.title, self.url, width=self.width, height=self.height, hidden=self.start_minimized, - confirm_close=False, ) assert window is not None self.window = window if self.tray: self.window.events.closing += self._on_closing - - if self.tray: self._start_tray() def _start_callback() -> None: @@ -783,26 +777,32 @@ class SerenaDashboardViewer: from AppKit import NSApplication, NSObject from objc import python_method # type: ignore[import-untyped] - viewer = self # capture reference for the delegate + viewer = self - # Use a unique class name to avoid conflict with pywebview's AppDelegate class SerenaDockDelegate(NSObject): + """Wraps pywebview's delegate to handle dock icon clicks.""" + @python_method def initWithOriginalDelegate_(self, original: Any) -> Any: obj = self.init() if obj is not None: - obj._original_delegate = original + obj._original = original return obj + @python_method + def _forward(self, method: str, *args: Any) -> Any: + """Forward method call to original delegate if it has the method.""" + orig = getattr(self, "_original", None) + if orig and hasattr(orig, method): + return getattr(orig, method)(*args) + return None + def applicationShouldHandleReopen_hasVisibleWindows_(self, sender: Any, has_visible_windows: bool) -> bool: """Called when dock icon is clicked.""" - if not has_visible_windows and viewer.window: + if not has_visible_windows: viewer._show_window() - # Forward to original delegate if it exists and has this method - if hasattr(self, "_original_delegate") and self._original_delegate is not None: - if hasattr(self._original_delegate, "applicationShouldHandleReopen_hasVisibleWindows_"): - return self._original_delegate.applicationShouldHandleReopen_hasVisibleWindows_(sender, has_visible_windows) - return True + result = self._forward("applicationShouldHandleReopen_hasVisibleWindows_", sender, has_visible_windows) + return result if result is not None else True def applicationDidBecomeActive_(self, notification: Any) -> None: """Fallback for dock activation paths that do not invoke reopen.""" @@ -810,21 +810,16 @@ class SerenaDashboardViewer: viewer._suppress_next_activate_reopen = False elif viewer._window_hidden_to_tray: viewer._show_window() - # Forward to original delegate if it exists and has this method - if hasattr(self, "_original_delegate") and self._original_delegate is not None: - if hasattr(self._original_delegate, "applicationDidBecomeActive_"): - self._original_delegate.applicationDidBecomeActive_(notification) + self._forward("applicationDidBecomeActive_", notification) def forwardingTargetForSelector_(self, selector: Any) -> Any: """Forward unknown selectors to original delegate.""" - if hasattr(self, "_original_delegate") and self._original_delegate is not None: - if self._original_delegate.respondsToSelector_(selector): - return self._original_delegate - return None + orig = getattr(self, "_original", None) + return orig if orig and orig.respondsToSelector_(selector) else None ns_app = NSApplication.sharedApplication() original_delegate = ns_app.delegate() - if original_delegate is getattr(self, "_app_delegate", None): + if original_delegate is self._app_delegate: return self._app_delegate = SerenaDockDelegate.alloc().initWithOriginalDelegate_(original_delegate) ns_app.setDelegate_(self._app_delegate) diff --git a/test/serena/test_dashboard.py b/test/serena/test_dashboard.py index 578c06cd..8de6e8eb 100644 --- a/test/serena/test_dashboard.py +++ b/test/serena/test_dashboard.py @@ -1,8 +1,6 @@ -import sys from types import SimpleNamespace -import serena.dashboard as dashboard_module -from serena.dashboard import SerenaDashboardAPI, SerenaDashboardViewer +from serena.dashboard import SerenaDashboardAPI from solidlsp.ls_config import Language @@ -55,106 +53,3 @@ def test_available_languages_exclude_project_languages(): assert Language.MARKDOWN.value not in available # ensure experimental languages remain available for selection assert Language.ANSIBLE.value in available - - -def test_dashboard_viewer_initializes_macos_delegate_slot(): - viewer = SerenaDashboardViewer("http://localhost:24282/dashboard/index.html") - assert viewer._app_delegate is None - assert viewer._window_hidden_to_tray is False - assert viewer._suppress_next_activate_reopen is False - - -def test_dashboard_viewer_initializes_hidden_state_for_minimized_start(): - viewer = SerenaDashboardViewer("http://localhost:24282/dashboard/index.html", start_minimized=True) - assert viewer._window_hidden_to_tray is True - assert viewer._suppress_next_activate_reopen is True - - -def test_dashboard_viewer_show_and_hide_helpers_update_state(): - viewer = SerenaDashboardViewer("http://localhost:24282/dashboard/index.html", start_minimized=True) - - class _FakeWindow: - def __init__(self) -> None: - self.calls: list[str] = [] - - def show(self) -> None: - self.calls.append("show") - - def restore(self) -> None: - self.calls.append("restore") - - def hide(self) -> None: - self.calls.append("hide") - - viewer.window = _FakeWindow() - - viewer._show_window() - assert viewer.window.calls == ["show", "restore"] - assert viewer._window_hidden_to_tray is False - assert viewer._suppress_next_activate_reopen is False - - viewer._hide_window() - assert viewer.window.calls == ["show", "restore", "hide"] - assert viewer._window_hidden_to_tray is True - - -def test_dashboard_viewer_installs_macos_dock_handler_after_pywebview_start(monkeypatch): - order: list[str] = [] - - class _ClosingEvent: - def __iadd__(self, _handler): - order.append("bind-closing") - return self - - class _FakeWindow: - def __init__(self) -> None: - self.events = SimpleNamespace(closing=_ClosingEvent()) - - def minimize(self) -> None: - order.append("minimize") - - def show(self) -> None: - order.append("show") - - def restore(self) -> None: - order.append("restore") - - fake_window = _FakeWindow() - - def fake_create_window(*args, **kwargs): - order.append("create-window") - return fake_window - - def fake_start(callback, **kwargs): - del kwargs - order.append("start") - callback() - - class _FakeAppHelperModule: - @staticmethod - def callAfter(func, *args, **kwargs): - del args, kwargs - order.append("schedule-dock-handler") - func() - - monkeypatch.setattr(dashboard_module.sys, "platform", "darwin") - monkeypatch.setattr(dashboard_module.webview, "create_window", fake_create_window) - monkeypatch.setattr(dashboard_module.webview, "start", fake_start) - monkeypatch.setitem(sys.modules, "PyObjCTools.AppHelper", _FakeAppHelperModule) - - viewer = SerenaDashboardViewer("http://localhost:24282/dashboard/index.html") - monkeypatch.setattr(viewer, "_start_tray", lambda: order.append("start-tray")) - monkeypatch.setattr(viewer, "_setup_macos_dock_handler", lambda: order.append("setup-dock-handler")) - - viewer.run() - - assert order == [ - "create-window", - "bind-closing", - "start-tray", - "start", - "schedule-dock-handler", - "setup-dock-handler", - "show", - "restore", - ]