Skip to content
Merged
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 .github/workflows/python-macos.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion render/vn_overlay_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions tests/test_vn_overlay_continuity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
43 changes: 42 additions & 1 deletion tests/test_vn_overlay_window_contract.py
Original file line number Diff line number Diff line change
@@ -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

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

Expand All @@ -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")]
Expand Down
2 changes: 1 addition & 1 deletion tools/vn_portrait_overlay_lite.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading