diff --git a/.github/workflows/python-macos.yml b/.github/workflows/python-macos.yml index 8aed440b..e1a807dc 100644 --- a/.github/workflows/python-macos.yml +++ b/.github/workflows/python-macos.yml @@ -65,6 +65,7 @@ jobs: tests/test_profile_contract.py tests/test_python_environment_verifier.py tests/test_vn_portrait_environment.py + tests/test_vn_overlay_window_contract.py tests/test_cu124_dependency_audit_tool.py tests/test_core_install_tiers.py tests/test_asset_paths.py diff --git a/render/vn_overlay_window.py b/render/vn_overlay_window.py index 7b2ab1a3..b41a12a7 100644 --- a/render/vn_overlay_window.py +++ b/render/vn_overlay_window.py @@ -61,6 +61,9 @@ def __init__(self, *, host: str = "127.0.0.1", port: int = 8788, x: int = 60, y: self._scale = self.root.winfo_fpixels("1i") / 96 self.root.title("Amadeus ยท VN Companion") self.root.overrideredirect(True) + # Aqua Tk's native creation clears topmost for overrideredirect windows. + # Realize the window before setting its level; do not pump the event loop. + self.root.winfo_id() self.root.attributes("-topmost", True) self.card_width, self.card_height = 470, 226 self.root.configure(bg=TRANSPARENT_KEY) @@ -180,6 +183,11 @@ def do_POST(self): self._scan_timer = self.root.after(50, self._scan_tick) self.root.protocol("WM_DELETE_WINDOW", self.close) + def show(self): + self.root.deiconify() + # Deiconify also rebuilds Aqua's override-redirect window attributes. + self.root.attributes("-topmost", True) + def close(self): for name in ("_poll_timer", "_layout_timer", "_hover_timer", "_scan_timer"): timer = getattr(self, name, None) @@ -366,7 +374,7 @@ def _poll(self): break if path == "/visibility": self.visible = payload["visible"] - self.root.deiconify() if self.visible else self.root.withdraw() + self.show() if self.visible else self.root.withdraw() self._schedule_hover() else: self.apply_reaction(payload) diff --git a/tests/test_vn_overlay_continuity.py b/tests/test_vn_overlay_continuity.py index 8dfce2a0..9305f03c 100644 --- a/tests/test_vn_overlay_continuity.py +++ b/tests/test_vn_overlay_continuity.py @@ -39,6 +39,9 @@ def advance(ms): callback() now[0] = target class OriginalShell: + def show(self): + self.root.deiconify() + def _set_emotion(self, emotion, state="idle"): self._current_emotion, self._current_state = emotion, state legacy = SimpleNamespace(PortraitOverlayTk=OriginalShell, clean_display_text=lambda text: text, diff --git a/tests/test_vn_overlay_window_contract.py b/tests/test_vn_overlay_window_contract.py index 65c7ded1..c6663d11 100644 --- a/tests/test_vn_overlay_window_contract.py +++ b/tests/test_vn_overlay_window_contract.py @@ -1,6 +1,9 @@ """The native VN shell keeps the established caption, emotion and duration contract.""" import http.client import json +import subprocess +import sys +from pathlib import Path from types import SimpleNamespace from unittest.mock import MagicMock, Mock, patch @@ -10,6 +13,42 @@ from tools.vn_portrait_overlay_lite import overlay_class +def test_show_restores_topmost_without_taking_focus(): + shell = PortraitOverlayTk.__new__(PortraitOverlayTk) + shell.root = Mock() + shell.show() + shell.root.deiconify.assert_called_once() + shell.root.attributes.assert_called_once_with("-topmost", True) + shell.root.lift.assert_not_called() + shell.root.focus_force.assert_not_called() + + +@pytest.mark.skipif(sys.platform != "darwin", reason="Aqua Tk native window mapping") +def test_native_portrait_stays_topmost_after_initial_map_and_hide_show(): + code = ''' +from render.vn_overlay_window import PortraitOverlayTk +shell = PortraitOverlayTk(port=0) +try: + shell.root.update() + assert shell.root.attributes("-topmost"), "initial map lost topmost" + for _ in range(2): + shell.root.withdraw() + shell.root.update() + shell.show() + shell.root.update() + assert shell.root.attributes("-topmost"), "hide/show lost topmost" +finally: + shell.close() + shell._server.shutdown() + shell._server.server_close() + shell._thread.join(timeout=2) +''' + result = subprocess.run([sys.executable, "-c", code], + cwd=Path(__file__).resolve().parents[1], + capture_output=True, text=True, timeout=15) + assert result.returncode == 0, result.stdout + result.stderr + + def test_presentation_tags_and_installed_emotion_aliases(): tagged = "[PARAM x=1] Look [EXPR smile][HOTKEY F1][ANIM blink][DELEGATE now] [EMO preset=surprised dur=2.5s]" assert clean_display_text(tagged) == "Look" @@ -108,6 +147,8 @@ def test_standalone_lite_preview_does_not_load_a_missing_selected_role(tmp_path) def test_non_playback_reactions_use_explicit_duration_then_tag_duration(): class FakeShell: + show = PortraitOverlayTk.show + def _resolve_key(self, emotion): return emotion @@ -125,7 +166,7 @@ def _resolve_key(self, emotion): overlay._set_emotion = lambda emotion, state, **kwargs: poses.append((emotion, state)) overlay.root = SimpleNamespace(after=lambda ms, callback: delays.append(ms) or len(delays), after_cancel=lambda timer: None, - deiconify=lambda: None, lift=lambda: None) + deiconify=lambda: None, lift=lambda: None, attributes=lambda *args: None) overlay.apply_reaction({"text": "Hello [EMO preset=surprised dur=2.5s]", "duration_ms": 4500}) assert values == ["Hello"] assert poses == [("sided_surprised", "speaking")] diff --git a/tools/vn_portrait_overlay_lite.py b/tools/vn_portrait_overlay_lite.py index d9def3de..83d11903 100644 --- a/tools/vn_portrait_overlay_lite.py +++ b/tools/vn_portrait_overlay_lite.py @@ -143,7 +143,7 @@ def neutral(): duration = max(350, min(60000, int(payload.get("duration_ms") or duration))) self._return_timer = self.root.after(duration, neutral) if getattr(self, "visible", True): - self.root.deiconify() + self.show() self.root.lift() return LitePortraitOverlay