Implement macOS dock handling and window state management in SerenaDashboardViewer

This commit is contained in:
zoajimen committed 2026-04-01 11:40:34 +02:00
1 parent e9d2191566
commit 4b0f408e3e
2 files changed
+184 -42

No files matched your search

+78 -41
View File
@@ -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
+106 -1
View File
@@ -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",
]