echoact 0.1.1__tar.gz → 0.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. echoact-0.1.2/.vscode/mcp.json +17 -0
  2. {echoact-0.1.1 → echoact-0.1.2}/PKG-INFO +1 -1
  3. {echoact-0.1.1 → echoact-0.1.2}/echoact/config/settings.py +12 -0
  4. {echoact-0.1.1 → echoact-0.1.2}/echoact/policy.py +4 -0
  5. {echoact-0.1.1 → echoact-0.1.2}/echoact/ui/main_window.py +89 -11
  6. {echoact-0.1.1 → echoact-0.1.2}/echoact/ui/settings_view.py +39 -0
  7. echoact-0.1.2/echoact/ui/tray.py +120 -0
  8. {echoact-0.1.1 → echoact-0.1.2}/pyproject.toml +1 -1
  9. {echoact-0.1.1 → echoact-0.1.2}/tests/test_packaging.py +10 -1
  10. {echoact-0.1.1 → echoact-0.1.2}/tests/test_settings.py +3 -0
  11. {echoact-0.1.1 → echoact-0.1.2}/tests/test_ui_settings.py +15 -0
  12. echoact-0.1.2/tests/test_ui_tray.py +219 -0
  13. {echoact-0.1.1 → echoact-0.1.2}/uv.lock +1 -1
  14. {echoact-0.1.1 → echoact-0.1.2}/.gitignore +0 -0
  15. {echoact-0.1.1 → echoact-0.1.2}/.python-version +0 -0
  16. {echoact-0.1.1 → echoact-0.1.2}/CLAUDE.md +0 -0
  17. {echoact-0.1.1 → echoact-0.1.2}/LICENSE +0 -0
  18. {echoact-0.1.1 → echoact-0.1.2}/README.md +0 -0
  19. {echoact-0.1.1 → echoact-0.1.2}/docs/design.md +0 -0
  20. {echoact-0.1.1 → echoact-0.1.2}/docs/icons/caret-echo.svg +0 -0
  21. {echoact-0.1.1 → echoact-0.1.2}/docs/icons/horizon.svg +0 -0
  22. {echoact-0.1.1 → echoact-0.1.2}/docs/icons/ieung.svg +0 -0
  23. {echoact-0.1.1 → echoact-0.1.2}/docs/icons/origin.svg +0 -0
  24. {echoact-0.1.1 → echoact-0.1.2}/docs/icons/ripple-candidates.html +0 -0
  25. {echoact-0.1.1 → echoact-0.1.2}/echoact/__init__.py +0 -0
  26. {echoact-0.1.1 → echoact-0.1.2}/echoact/__main__.py +0 -0
  27. {echoact-0.1.1 → echoact-0.1.2}/echoact/app.py +0 -0
  28. {echoact-0.1.1 → echoact-0.1.2}/echoact/audio/__init__.py +0 -0
  29. {echoact-0.1.1 → echoact-0.1.2}/echoact/audio/devices.py +0 -0
  30. {echoact-0.1.1 → echoact-0.1.2}/echoact/audio/player.py +0 -0
  31. {echoact-0.1.1 → echoact-0.1.2}/echoact/audio/wav.py +0 -0
  32. {echoact-0.1.1 → echoact-0.1.2}/echoact/config/__init__.py +0 -0
  33. {echoact-0.1.1 → echoact-0.1.2}/echoact/config/budget.py +0 -0
  34. {echoact-0.1.1 → echoact-0.1.2}/echoact/db/__init__.py +0 -0
  35. {echoact-0.1.1 → echoact-0.1.2}/echoact/db/backup.py +0 -0
  36. {echoact-0.1.1 → echoact-0.1.2}/echoact/db/migrations.py +0 -0
  37. {echoact-0.1.1 → echoact-0.1.2}/echoact/db/schema.sql +0 -0
  38. {echoact-0.1.1 → echoact-0.1.2}/echoact/db/store.py +0 -0
  39. {echoact-0.1.1 → echoact-0.1.2}/echoact/diagnostics.py +0 -0
  40. {echoact-0.1.1 → echoact-0.1.2}/echoact/domain.py +0 -0
  41. {echoact-0.1.1 → echoact-0.1.2}/echoact/engine/__init__.py +0 -0
  42. {echoact-0.1.1 → echoact-0.1.2}/echoact/engine/container.py +0 -0
  43. {echoact-0.1.1 → echoact-0.1.2}/echoact/engine/protocol.py +0 -0
  44. {echoact-0.1.1 → echoact-0.1.2}/echoact/engine/runtime.py +0 -0
  45. {echoact-0.1.1 → echoact-0.1.2}/echoact/engine/supervisor.py +0 -0
  46. {echoact-0.1.1 → echoact-0.1.2}/echoact/engine/worker.py +0 -0
  47. {echoact-0.1.1 → echoact-0.1.2}/echoact/errors.py +0 -0
  48. {echoact-0.1.1 → echoact-0.1.2}/echoact/instance.py +0 -0
  49. {echoact-0.1.1 → echoact-0.1.2}/echoact/jobs/__init__.py +0 -0
  50. {echoact-0.1.1 → echoact-0.1.2}/echoact/jobs/engine.py +0 -0
  51. {echoact-0.1.1 → echoact-0.1.2}/echoact/jobs/request.py +0 -0
  52. {echoact-0.1.1 → echoact-0.1.2}/echoact/mcp/__init__.py +0 -0
  53. {echoact-0.1.1 → echoact-0.1.2}/echoact/mcp/__main__.py +0 -0
  54. {echoact-0.1.1 → echoact-0.1.2}/echoact/mcp/client.py +0 -0
  55. {echoact-0.1.1 → echoact-0.1.2}/echoact/mcp/config.py +0 -0
  56. {echoact-0.1.1 → echoact-0.1.2}/echoact/mcp/server.py +0 -0
  57. {echoact-0.1.1 → echoact-0.1.2}/echoact/models/__init__.py +0 -0
  58. {echoact-0.1.1 → echoact-0.1.2}/echoact/models/catalog.py +0 -0
  59. {echoact-0.1.1 → echoact-0.1.2}/echoact/models/manifest.py +0 -0
  60. {echoact-0.1.1 → echoact-0.1.2}/echoact/models/registry.py +0 -0
  61. {echoact-0.1.1 → echoact-0.1.2}/echoact/paths.py +0 -0
  62. {echoact-0.1.1 → echoact-0.1.2}/echoact/playrequests.py +0 -0
  63. {echoact-0.1.1 → echoact-0.1.2}/echoact/security/__init__.py +0 -0
  64. {echoact-0.1.1 → echoact-0.1.2}/echoact/security/credentials.py +0 -0
  65. {echoact-0.1.1 → echoact-0.1.2}/echoact/security/ratelimit.py +0 -0
  66. {echoact-0.1.1 → echoact-0.1.2}/echoact/service/__init__.py +0 -0
  67. {echoact-0.1.1 → echoact-0.1.2}/echoact/service/app.py +0 -0
  68. {echoact-0.1.1 → echoact-0.1.2}/echoact/service/deps.py +0 -0
  69. {echoact-0.1.1 → echoact-0.1.2}/echoact/service/errors.py +0 -0
  70. {echoact-0.1.1 → echoact-0.1.2}/echoact/service/routes.py +0 -0
  71. {echoact-0.1.1 → echoact-0.1.2}/echoact/service/schemas.py +0 -0
  72. {echoact-0.1.1 → echoact-0.1.2}/echoact/service/server.py +0 -0
  73. {echoact-0.1.1 → echoact-0.1.2}/echoact/text/__init__.py +0 -0
  74. {echoact-0.1.1 → echoact-0.1.2}/echoact/text/language.py +0 -0
  75. {echoact-0.1.1 → echoact-0.1.2}/echoact/text/loader.py +0 -0
  76. {echoact-0.1.1 → echoact-0.1.2}/echoact/text/normalize.py +0 -0
  77. {echoact-0.1.1 → echoact-0.1.2}/echoact/text/segment.py +0 -0
  78. {echoact-0.1.1 → echoact-0.1.2}/echoact/text/sniff.py +0 -0
  79. {echoact-0.1.1 → echoact-0.1.2}/echoact/ui/__init__.py +0 -0
  80. {echoact-0.1.1 → echoact-0.1.2}/echoact/ui/brand.py +0 -0
  81. {echoact-0.1.1 → echoact-0.1.2}/echoact/ui/bridge.py +0 -0
  82. {echoact-0.1.1 → echoact-0.1.2}/echoact/ui/controls.py +0 -0
  83. {echoact-0.1.1 → echoact-0.1.2}/echoact/ui/credential_dialog.py +0 -0
  84. {echoact-0.1.1 → echoact-0.1.2}/echoact/ui/external_play.py +0 -0
  85. {echoact-0.1.1 → echoact-0.1.2}/echoact/ui/fonts.py +0 -0
  86. {echoact-0.1.1 → echoact-0.1.2}/echoact/ui/i18n.py +0 -0
  87. {echoact-0.1.1 → echoact-0.1.2}/echoact/ui/icons.py +0 -0
  88. {echoact-0.1.1 → echoact-0.1.2}/echoact/ui/library.py +0 -0
  89. {echoact-0.1.1 → echoact-0.1.2}/echoact/ui/licence.py +0 -0
  90. {echoact-0.1.1 → echoact-0.1.2}/echoact/ui/mcp_setup.py +0 -0
  91. {echoact-0.1.1 → echoact-0.1.2}/echoact/ui/models_view.py +0 -0
  92. {echoact-0.1.1 → echoact-0.1.2}/echoact/ui/notifications.py +0 -0
  93. {echoact-0.1.1 → echoact-0.1.2}/echoact/ui/reading.py +0 -0
  94. {echoact-0.1.1 → echoact-0.1.2}/echoact/ui/status_view.py +0 -0
  95. {echoact-0.1.1 → echoact-0.1.2}/echoact/ui/theme.py +0 -0
  96. {echoact-0.1.1 → echoact-0.1.2}/echoact/update.py +0 -0
  97. {echoact-0.1.1 → echoact-0.1.2}/echoact/util/__init__.py +0 -0
  98. {echoact-0.1.1 → echoact-0.1.2}/echoact/util/ids.py +0 -0
  99. {echoact-0.1.1 → echoact-0.1.2}/echoact/util/logging.py +0 -0
  100. {echoact-0.1.1 → echoact-0.1.2}/main.py +0 -0
  101. {echoact-0.1.1 → echoact-0.1.2}/packaging/README.md +0 -0
  102. {echoact-0.1.1 → echoact-0.1.2}/packaging/echoact.ico +0 -0
  103. {echoact-0.1.1 → echoact-0.1.2}/packaging/entry.py +0 -0
  104. {echoact-0.1.1 → echoact-0.1.2}/scripts/fetch_fonts.py +0 -0
  105. {echoact-0.1.1 → echoact-0.1.2}/scripts/make_app_icon.py +0 -0
  106. {echoact-0.1.1 → echoact-0.1.2}/spikes/engine_conformance.py +0 -0
  107. {echoact-0.1.1 → echoact-0.1.2}/spikes/outline_emphasis.py +0 -0
  108. {echoact-0.1.1 → echoact-0.1.2}/spikes/tts_feasibility.py +0 -0
  109. {echoact-0.1.1 → echoact-0.1.2}/spikes/voice_review.py +0 -0
  110. {echoact-0.1.1 → echoact-0.1.2}/tests/__init__.py +0 -0
  111. {echoact-0.1.1 → echoact-0.1.2}/tests/test_backup.py +0 -0
  112. {echoact-0.1.1 → echoact-0.1.2}/tests/test_brand.py +0 -0
  113. {echoact-0.1.1 → echoact-0.1.2}/tests/test_budget.py +0 -0
  114. {echoact-0.1.1 → echoact-0.1.2}/tests/test_container.py +0 -0
  115. {echoact-0.1.1 → echoact-0.1.2}/tests/test_credentials.py +0 -0
  116. {echoact-0.1.1 → echoact-0.1.2}/tests/test_diagnostics.py +0 -0
  117. {echoact-0.1.1 → echoact-0.1.2}/tests/test_end_to_end.py +0 -0
  118. {echoact-0.1.1 → echoact-0.1.2}/tests/test_instance.py +0 -0
  119. {echoact-0.1.1 → echoact-0.1.2}/tests/test_job_engine.py +0 -0
  120. {echoact-0.1.1 → echoact-0.1.2}/tests/test_language.py +0 -0
  121. {echoact-0.1.1 → echoact-0.1.2}/tests/test_loader.py +0 -0
  122. {echoact-0.1.1 → echoact-0.1.2}/tests/test_main_window.py +0 -0
  123. {echoact-0.1.1 → echoact-0.1.2}/tests/test_manifest.py +0 -0
  124. {echoact-0.1.1 → echoact-0.1.2}/tests/test_mcp.py +0 -0
  125. {echoact-0.1.1 → echoact-0.1.2}/tests/test_mcp_setup.py +0 -0
  126. {echoact-0.1.1 → echoact-0.1.2}/tests/test_migrations.py +0 -0
  127. {echoact-0.1.1 → echoact-0.1.2}/tests/test_navigation.py +0 -0
  128. {echoact-0.1.1 → echoact-0.1.2}/tests/test_normalize.py +0 -0
  129. {echoact-0.1.1 → echoact-0.1.2}/tests/test_notifications.py +0 -0
  130. {echoact-0.1.1 → echoact-0.1.2}/tests/test_player.py +0 -0
  131. {echoact-0.1.1 → echoact-0.1.2}/tests/test_playrequests.py +0 -0
  132. {echoact-0.1.1 → echoact-0.1.2}/tests/test_ratelimit.py +0 -0
  133. {echoact-0.1.1 → echoact-0.1.2}/tests/test_reading_surface.py +0 -0
  134. {echoact-0.1.1 → echoact-0.1.2}/tests/test_registry.py +0 -0
  135. {echoact-0.1.1 → echoact-0.1.2}/tests/test_restore_gate.py +0 -0
  136. {echoact-0.1.1 → echoact-0.1.2}/tests/test_runtime.py +0 -0
  137. {echoact-0.1.1 → echoact-0.1.2}/tests/test_segment.py +0 -0
  138. {echoact-0.1.1 → echoact-0.1.2}/tests/test_service.py +0 -0
  139. {echoact-0.1.1 → echoact-0.1.2}/tests/test_service_codes.py +0 -0
  140. {echoact-0.1.1 → echoact-0.1.2}/tests/test_service_mcp.py +0 -0
  141. {echoact-0.1.1 → echoact-0.1.2}/tests/test_settings_actions.py +0 -0
  142. {echoact-0.1.1 → echoact-0.1.2}/tests/test_sniff.py +0 -0
  143. {echoact-0.1.1 → echoact-0.1.2}/tests/test_store.py +0 -0
  144. {echoact-0.1.1 → echoact-0.1.2}/tests/test_supervisor.py +0 -0
  145. {echoact-0.1.1 → echoact-0.1.2}/tests/test_ui_library.py +0 -0
  146. {echoact-0.1.1 → echoact-0.1.2}/tests/test_ui_models.py +0 -0
  147. {echoact-0.1.1 → echoact-0.1.2}/tests/test_ui_status.py +0 -0
  148. {echoact-0.1.1 → echoact-0.1.2}/tests/test_update.py +0 -0
  149. {echoact-0.1.1 → echoact-0.1.2}/tests/test_wav.py +0 -0
  150. {echoact-0.1.1 → echoact-0.1.2}/tests/test_worker.py +0 -0
@@ -0,0 +1,17 @@
1
+ {
2
+ "servers": {
3
+ "echoact": {
4
+ "type": "stdio",
5
+ "command": "C:/Users/lhj56/ws/echo-act/.venv/Scripts/python.exe",
6
+ "args": [
7
+ "-m",
8
+ "echoact.mcp"
9
+ ],
10
+ "env": {
11
+ "ECHOACT_URL": "http://127.0.0.1:8765",
12
+ "PYTHONPATH": "C:/Users/lhj56/ws/echo-act",
13
+ "ECHOACT_TOKEN": "eak_my-mcp-client-ea9056_oLrZDOpS2idR3xt3MaNKGGnNMc-nA_OZi7aLVfJbOBdM"
14
+ }
15
+ }
16
+ }
17
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: echoact
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Local Korean/English speech generation for the desktop
5
5
  Project-URL: Homepage, https://github.com/ZeroAct/echo-act
6
6
  Project-URL: Issues, https://github.com/ZeroAct/echo-act/issues
@@ -56,6 +56,7 @@ from ..paths import redact, settings_path
56
56
  from ..policy import (
57
57
  AUTOPLAY_DEFAULT,
58
58
  AUTOSAVE_DOCUMENTS_DEFAULT,
59
+ CLOSE_TO_TRAY_DEFAULT,
59
60
  CPU_PERCENT_DEFAULT,
60
61
  CPU_PERCENT_MAX,
61
62
  CPU_PERCENT_MIN,
@@ -295,6 +296,9 @@ class Settings:
295
296
  # -- presentation (F-86, 4.1) -----------------------------------------
296
297
  display_language: DisplayLanguage = field(default_factory=_default_display_language)
297
298
  os_notifications: bool = OS_NOTIFICATIONS_DEFAULT
299
+ #: Closing the window hides it to the tray when a tray exists; jobs keep
300
+ #: running. Quitting stays an explicit act (tray menu, or Settings off).
301
+ close_to_tray: bool = CLOSE_TO_TRAY_DEFAULT
298
302
 
299
303
  # -- integrations (4.1, F-46) -----------------------------------------
300
304
  rest_enabled: bool = REST_ENABLED_DEFAULT
@@ -460,6 +464,7 @@ class Settings:
460
464
  "output_device": self.output_device,
461
465
  "display_language": self.display_language.value,
462
466
  "os_notifications": self.os_notifications,
467
+ "close_to_tray": self.close_to_tray,
463
468
  "rest_enabled": self.rest_enabled,
464
469
  "rest_port": self.rest_port,
465
470
  "mcp_enabled": self.mcp_enabled,
@@ -555,6 +560,12 @@ class Settings:
555
560
  "os_notifications",
556
561
  problems,
557
562
  ),
563
+ close_to_tray=_read_bool(
564
+ d.get("close_to_tray"),
565
+ defaults.close_to_tray,
566
+ "close_to_tray",
567
+ problems,
568
+ ),
558
569
  rest_enabled=_read_bool(
559
570
  d.get("rest_enabled"), defaults.rest_enabled, "rest_enabled", problems
560
571
  ),
@@ -632,6 +643,7 @@ _KNOWN_KEYS: Final[frozenset[str]] = frozenset(
632
643
  "output_device",
633
644
  "display_language",
634
645
  "os_notifications",
646
+ "close_to_tray",
635
647
  "rest_enabled",
636
648
  "rest_port",
637
649
  "mcp_enabled",
@@ -179,6 +179,10 @@ MCP_PROTOCOL_REVISION: Final = "2026-07-28"
179
179
  VOICE_PRESET_MAX: Final = 100 # 4.1
180
180
  PLAYBACK_VOLUME_DEFAULT: Final = 1.0
181
181
  OS_NOTIFICATIONS_DEFAULT: Final = False
182
+ #: Closing the window hides EchoAct to the tray rather than quitting, so a
183
+ #: job keeps running. On because it is the Windows convention; the first
184
+ #: hide explains itself with a tray message, and Settings can turn it off.
185
+ CLOSE_TO_TRAY_DEFAULT: Final = True
182
186
  AUTOPLAY_DEFAULT: Final = True # F-83
183
187
  EXTERNAL_PLAY_DEFAULT: Final = True # F-89
184
188
  # F-57 wants a hint a client can honour rather than guess at. The figure is
@@ -20,6 +20,7 @@ from pathlib import Path
20
20
  from PySide6.QtCore import QObject, Qt, QTimer, Signal
21
21
  from PySide6.QtGui import QAction, QCloseEvent, QKeySequence
22
22
  from PySide6.QtWidgets import (
23
+ QApplication,
23
24
  QDialog,
24
25
  QFileDialog,
25
26
  QFrame,
@@ -46,7 +47,7 @@ from ..policy import MAX_INPUT_CODEPOINTS
46
47
  from ..text.loader import load_file
47
48
  from ..util import ids
48
49
  from ..util.logging import get_logger
49
- from . import icons
50
+ from . import brand, icons
50
51
  from .bridge import EngineBridge, PlayRequestBridge
51
52
  from .controls import (
52
53
  TransportBar,
@@ -66,10 +67,12 @@ from .notifications import (
66
67
  NotificationCentre,
67
68
  completion_notice,
68
69
  failure_notice,
70
+ tray_sink,
69
71
  )
70
72
  from .reading import ReadingSurface
71
73
  from .theme import METRICS, Mode, Palette
72
74
  from .theme import apply as apply_theme
75
+ from .tray import TrayController, build_tray
73
76
 
74
77
  log = get_logger("ui.window")
75
78
 
@@ -115,6 +118,10 @@ add_korean(
115
118
  "This file covers {percent}% of the text.": "이 파일은 본문의 {percent}%를 담고 있습니다.",
116
119
  "Line endings were normalised.": "줄바꿈 문자를 정규화했습니다.",
117
120
  "Playing a request from {client}": "{client}의 요청을 재생하는 중",
121
+ "EchoAct is still running": "EchoAct이 계속 실행 중입니다",
122
+ "Close sent EchoAct to the system tray. Double-click the icon to bring it back.":
123
+ "창을 닫아도 EchoAct은 시스템 트레이에서 실행을 계속합니다. "
124
+ "아이콘을 두 번 누르면 다시 나타납니다.",
118
125
  "an app": "어떤 앱",
119
126
  "Your text and reading position are untouched. Stop silences it.":
120
127
  "입력한 본문과 읽는 위치는 그대로입니다. 정지를 누르면 소리가 멈춥니다.",
@@ -330,8 +337,21 @@ class MainWindow(QMainWindow):
330
337
  remember(self, lambda a=action, s=source: a.setText(tr(s)))
331
338
 
332
339
  def _wire(self) -> None:
340
+ self.tray: TrayController | None = build_tray(self, brand.app_icon(self.palette_tokens))
341
+ self._tray_hinted = False
342
+ if self.tray is not None:
343
+ # With the window hideable, closing it is no longer an intent to
344
+ # quit -- only the tray's Quit is. Set only when a tray exists,
345
+ # so a session without one keeps stock quit-on-last-window-closed.
346
+ QApplication.instance().setQuitOnLastWindowClosed(False)
347
+ self.tray.show_requested.connect(self.show_from_tray)
348
+ self.tray.stop_requested.connect(self._stop)
349
+ self.tray.quit_requested.connect(self._quit_from_tray)
350
+ # F-70's OS notices finally have somewhere to go: tray_sink was
351
+ # written for this icon and had never been given one.
333
352
  self.notifications = NotificationCentre(
334
- os_notifications=self.app.settings.os_notifications
353
+ os_notifications=self.app.settings.os_notifications,
354
+ os_sink=tray_sink(self.tray.icon) if self.tray is not None else None,
335
355
  )
336
356
  self.bridge = EngineBridge(self.app.engine, self)
337
357
  self.bridge.accepted.connect(self._on_accepted)
@@ -628,6 +648,11 @@ class MainWindow(QMainWindow):
628
648
  self.transport.set_position(self.app.player.position_ms())
629
649
 
630
650
  def _on_tick(self) -> None:
651
+ if self.tray is not None:
652
+ # The tooltip is the only status a hidden app can show, and the
653
+ # status line is already the plain-language summary of the job.
654
+ status = self.status.text()
655
+ self.tray.set_tooltip(f"EchoAct — {status}" if status else "EchoAct")
631
656
  player = self.app.player
632
657
  self.external.poll()
633
658
  if self.external.active:
@@ -1166,6 +1191,7 @@ class MainWindow(QMainWindow):
1166
1191
  ("credential_days_changed", "credential_days"),
1167
1192
  ("retention_changed", "retention_bytes"),
1168
1193
  ("os_notifications_changed", "os_notifications"),
1194
+ ("close_to_tray_changed", "close_to_tray"),
1169
1195
  ("autosave_documents_changed", "autosave_documents"),
1170
1196
  ("retain_history_changed", "retain_history"),
1171
1197
  )
@@ -1247,6 +1273,8 @@ class MainWindow(QMainWindow):
1247
1273
  apply_translations(self)
1248
1274
  self.voice_panel.retranslate()
1249
1275
  self.transport.retranslate()
1276
+ if self.tray is not None:
1277
+ self.tray.retranslate()
1250
1278
  source, fmt = self._status_source
1251
1279
  self._set_status(source, **fmt)
1252
1280
  self._render_counter()
@@ -1301,6 +1329,8 @@ class MainWindow(QMainWindow):
1301
1329
  self.transport.stop.setEnabled(playable or self.external.active)
1302
1330
  self.save_button.setEnabled(playable)
1303
1331
  self.open_button.setEnabled(not busy)
1332
+ if self.tray is not None:
1333
+ self.tray.set_stop_enabled(playable or self.external.active)
1304
1334
 
1305
1335
  def _set_status(self, source: str, **fmt: object) -> None:
1306
1336
  """Show a status line from its English source, not its rendering.
@@ -1399,18 +1429,66 @@ class MainWindow(QMainWindow):
1399
1429
  # Exit (F-77, F-52)
1400
1430
  # ------------------------------------------------------------------
1401
1431
 
1402
- def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 - Qt override
1403
- if self.app.engine.busy:
1404
- answer = QMessageBox.question(
1405
- self,
1406
- tr("A job is still running."),
1407
- tr("The text you have now has not been saved."),
1408
- QMessageBox.StandardButton.Close | QMessageBox.StandardButton.Cancel,
1432
+ def show_from_tray(self) -> None:
1433
+ self.show()
1434
+ self.raise_()
1435
+ self.activateWindow()
1436
+
1437
+ def _hide_to_tray(self) -> None:
1438
+ """The close button ends the *window*, not the job it was running.
1439
+
1440
+ Nothing is asked first, whatever is running: jobs continuing while
1441
+ hidden is the whole point, and the F-89 service and the engine never
1442
+ looked at window visibility anyway. The first hide of a session
1443
+ explains itself, because the taskbar button vanishing is easy to
1444
+ mistake for a quit. Child dialogs are left alone -- they are
1445
+ independent tasks that close on their own.
1446
+ """
1447
+ self.hide()
1448
+ if self.tray is not None and not self._tray_hinted:
1449
+ self._tray_hinted = True
1450
+ self.tray.notify(
1451
+ tr("EchoAct is still running"),
1452
+ tr("Close sent EchoAct to the system tray. Double-click the icon to bring it back."),
1409
1453
  )
1410
- if answer is not QMessageBox.StandardButton.Close:
1411
- event.ignore()
1454
+
1455
+ def _quit_from_tray(self) -> None:
1456
+ # A modal owned by a hidden window can surface behind other apps on
1457
+ # Windows and read as a hang, so the window is back before it asks.
1458
+ if self.app.engine.busy:
1459
+ self.show()
1460
+ self.raise_()
1461
+ if not self._confirm_quit_with_job():
1412
1462
  return
1463
+ self._quit()
1464
+
1465
+ def _confirm_quit_with_job(self) -> bool:
1466
+ answer = QMessageBox.question(
1467
+ self,
1468
+ tr("A job is still running."),
1469
+ tr("The text you have now has not been saved."),
1470
+ QMessageBox.StandardButton.Close | QMessageBox.StandardButton.Cancel,
1471
+ )
1472
+ return answer is QMessageBox.StandardButton.Close
1473
+
1474
+ def _quit(self) -> None:
1475
+ """The one teardown: every accepted exit comes through here."""
1413
1476
  self._tick.stop()
1414
1477
  self.bridge.detach()
1415
1478
  self.app.shutdown()
1479
+ if self.tray is not None:
1480
+ self.tray.hide()
1481
+ # With quitOnLastWindowClosed off, nothing else ends the event loop;
1482
+ # any future code that accepts a close must route through here.
1483
+ QApplication.instance().quit()
1484
+
1485
+ def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 - Qt override
1486
+ if self.app.settings.close_to_tray and self.tray is not None:
1487
+ event.ignore()
1488
+ self._hide_to_tray()
1489
+ return
1490
+ if self.app.engine.busy and not self._confirm_quit_with_job():
1491
+ event.ignore()
1492
+ return
1493
+ self._quit()
1416
1494
  event.accept()
@@ -53,6 +53,7 @@ from PySide6.QtWidgets import (
53
53
  QSizePolicy,
54
54
  QSlider,
55
55
  QSpinBox,
56
+ QSystemTrayIcon,
56
57
  QTreeWidget,
57
58
  QTreeWidgetItem,
58
59
  QVBoxLayout,
@@ -178,6 +179,10 @@ add_korean(
178
179
  "Changing the display language does not change your documents, saved jobs, "
179
180
  "or what the local service answers.":
180
181
  "표시 언어를 바꿔도 문서, 저장된 작업, 로컬 서비스의 응답은 바뀌지 않습니다.",
182
+ "Keep running in the tray": "창을 닫아도 트레이에서 계속 실행",
183
+ "Closing the window then hides EchoAct beside the clock; jobs keep running.":
184
+ "창을 닫으면 EchoAct가 시계 옆 트레이로 숨겨지고, 작업은 계속 실행됩니다.",
185
+ "Notify me with a system message": "시스템 메시지로 알림",
181
186
  # -- integrations ---------------------------------------------------
182
187
  "Integrations": "연동",
183
188
  "Local REST service": "로컬 REST 서비스",
@@ -526,6 +531,12 @@ class SettingsView(QScrollArea):
526
531
  #: ``Settings.to_dict`` when saving, and ``_render_languages`` here --
527
532
  #: raises. F-86 would then neither persist nor retranslate.
528
533
  display_language_changed = Signal(object)
534
+ #: Closing the window hides it to the tray instead of quitting.
535
+ close_to_tray_changed = Signal(bool)
536
+ #: F-70's other half: mirror notices to the tray icon. The pairs table
537
+ #: in ``MainWindow._connect_settings`` has named this signal since it
538
+ #: was written; the row that emits it is only now here.
539
+ os_notifications_changed = Signal(bool)
529
540
 
530
541
  # -- integrations (F-46, F-71, F-79) ------------------------------------
531
542
  rest_enabled_changed = Signal(bool)
@@ -1175,10 +1186,28 @@ class SettingsView(QScrollArea):
1175
1186
  box.addWidget(self.autoplay)
1176
1187
  box.addWidget(self.follow)
1177
1188
 
1189
+ # Both rows speak for the tray icon; with no tray on this machine
1190
+ # there is nothing for either to mean, so neither is shown rather
1191
+ # than one shown and inert.
1192
+ has_tray = QSystemTrayIcon.isSystemTrayAvailable()
1193
+ self.close_to_tray = self._check("Keep running in the tray")
1194
+ self.tray_note = self._label(
1195
+ "Closing the window then hides EchoAct beside the clock; jobs keep running.",
1196
+ "muted",
1197
+ )
1198
+ self.os_notifications = self._check("Notify me with a system message")
1199
+ box.addWidget(self.close_to_tray)
1200
+ box.addWidget(self.tray_note)
1201
+ box.addWidget(self.os_notifications)
1202
+ for row in (self.close_to_tray, self.tray_note, self.os_notifications):
1203
+ row.setVisible(has_tray)
1204
+
1178
1205
  self._render_languages()
1179
1206
  self.language.currentIndexChanged.connect(self._on_language)
1180
1207
  self.autoplay.toggled.connect(self._on_autoplay)
1181
1208
  self.follow.toggled.connect(self._on_follow)
1209
+ self.close_to_tray.toggled.connect(self._on_close_to_tray)
1210
+ self.os_notifications.toggled.connect(self._on_os_notifications)
1182
1211
  return panel
1183
1212
 
1184
1213
  def _render_languages(self) -> None:
@@ -1210,6 +1239,14 @@ class SettingsView(QScrollArea):
1210
1239
  if not self._loading:
1211
1240
  self.follow_changed.emit(bool(checked))
1212
1241
 
1242
+ def _on_close_to_tray(self, checked: bool) -> None:
1243
+ if not self._loading:
1244
+ self.close_to_tray_changed.emit(bool(checked))
1245
+
1246
+ def _on_os_notifications(self, checked: bool) -> None:
1247
+ if not self._loading:
1248
+ self.os_notifications_changed.emit(bool(checked))
1249
+
1213
1250
  # ==================================================================
1214
1251
  # Integrations (F-46, F-71, F-79, N-31, 4.1)
1215
1252
  # ==================================================================
@@ -2122,6 +2159,8 @@ class SettingsView(QScrollArea):
2122
2159
  self.muted.setChecked(settings.muted)
2123
2160
  self.autoplay.setChecked(settings.autoplay)
2124
2161
  self.follow.setChecked(settings.follow)
2162
+ self.close_to_tray.setChecked(settings.close_to_tray)
2163
+ self.os_notifications.setChecked(settings.os_notifications)
2125
2164
  self.rest.setChecked(settings.rest_enabled)
2126
2165
  self.port.setValue(
2127
2166
  max(MIN_USER_PORT, min(MAX_PORT, settings.rest_port))
@@ -0,0 +1,120 @@
1
+ """Living beside the clock.
2
+
3
+ Closing the window hides EchoAct rather than ending it, because a job
4
+ already accepted (F-47) must run to its end whatever the window is doing;
5
+ the tray icon is then the only visible half of the app, so it carries the
6
+ three things that still need a hand while nobody is watching: bring the
7
+ window back, silence playback, and truly quit.
8
+
9
+ It also completes F-70's other half. ``notifications.tray_sink`` was
10
+ written for exactly this icon and had never been constructed, so OS
11
+ notices had nowhere to go; the icon is built whenever the platform offers
12
+ one, whether or not closing hides. The close button obeys the
13
+ ``close_to_tray`` setting, and no tray (a headless test run, a session
14
+ without one) means the window closes and quits exactly as it always did.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from PySide6.QtCore import QObject, Signal
20
+ from PySide6.QtGui import QIcon
21
+ from PySide6.QtWidgets import QMenu, QSystemTrayIcon
22
+
23
+ from .controls import apply_translations, remember
24
+ from .i18n import add_korean, tr
25
+
26
+ add_korean(
27
+ {
28
+ "Show EchoAct": "EchoAct 열기",
29
+ "Quit EchoAct": "EchoAct 종료",
30
+ # "Stop" is translated in i18n.py already.
31
+ }
32
+ )
33
+
34
+ #: Reasons that mean "the user wants the window back". ``Context`` is the
35
+ #: menu, which handles its own clicks, and the middle click has no meaning
36
+ #: here; a press is ``Trigger``, whatever the platform's one-or-two-click
37
+ #: convention turns out to be.
38
+ _RESTORE = (
39
+ QSystemTrayIcon.ActivationReason.Trigger,
40
+ QSystemTrayIcon.ActivationReason.DoubleClick,
41
+ )
42
+
43
+
44
+ class TrayController(QObject):
45
+ """The tray icon and its menu; it emits intent and decides nothing.
46
+
47
+ Same contract as the transport bar: the window owns every choice, so
48
+ quitting from here travels the very teardown ``closeEvent`` uses.
49
+ """
50
+
51
+ show_requested = Signal()
52
+ stop_requested = Signal()
53
+ quit_requested = Signal()
54
+
55
+ def __init__(self, parent: QObject, icon: QIcon) -> None:
56
+ super().__init__(parent)
57
+ self.icon = QSystemTrayIcon(icon, self)
58
+ # QMenu wants a *widget* parent and a tray controller is not one;
59
+ # setContextMenu does not take ownership either. The reference here
60
+ # is what keeps the menu's C++ object alive as long as the icon.
61
+ menu = QMenu()
62
+ self._menu = menu
63
+ show = menu.addAction("")
64
+ remember(self, lambda: show.setText(tr("Show EchoAct")))
65
+ show.triggered.connect(self.show_requested.emit)
66
+ menu.addSeparator()
67
+ self._stop = menu.addAction("")
68
+ remember(self, lambda: self._stop.setText(tr("Stop")))
69
+ self._stop.triggered.connect(self.stop_requested.emit)
70
+ menu.addSeparator()
71
+ quit_ = menu.addAction("")
72
+ remember(self, lambda: quit_.setText(tr("Quit EchoAct")))
73
+ quit_.triggered.connect(self.quit_requested.emit)
74
+ self.icon.setContextMenu(menu)
75
+ self.icon.activated.connect(self._on_activated)
76
+ self._tooltip = "EchoAct"
77
+ self.icon.setToolTip(self._tooltip)
78
+ self.icon.show()
79
+
80
+ def _on_activated(self, reason: QSystemTrayIcon.ActivationReason) -> None: # noqa: N802
81
+ if reason in _RESTORE:
82
+ self.show_requested.emit()
83
+
84
+ def retranslate(self) -> None:
85
+ apply_translations(self)
86
+
87
+ def set_tooltip(self, text: str) -> None:
88
+ # Called from the window's 40 ms tick; re-setting identical text
89
+ # would churn the shell's notification area for nothing.
90
+ if text != self._tooltip:
91
+ self._tooltip = text
92
+ self.icon.setToolTip(text)
93
+
94
+ def set_stop_enabled(self, enabled: bool) -> None:
95
+ self._stop.setEnabled(enabled)
96
+
97
+ def notify(self, title: str, detail: str = "") -> None:
98
+ # A machine with per-app notifications turned off is not a failure;
99
+ # tray_sink makes the same bargain for the same reason.
100
+ try:
101
+ self.icon.showMessage(title, detail, QSystemTrayIcon.MessageIcon.Information, 6000)
102
+ except Exception: # noqa: BLE001 - the notice still exists in-app
103
+ pass
104
+
105
+ def show(self) -> None:
106
+ self.icon.show()
107
+
108
+ def hide(self) -> None:
109
+ self.icon.hide()
110
+
111
+
112
+ def build_tray(parent: QObject, app_icon: QIcon) -> TrayController | None:
113
+ """The tray, or ``None`` where there is nowhere to put one.
114
+
115
+ ``None`` is a supported outcome, not a degraded one: the caller keeps
116
+ its pre-tray behaviour, including stock quit-on-last-window-closed.
117
+ """
118
+ if not QSystemTrayIcon.isSystemTrayAvailable():
119
+ return None
120
+ return TrayController(parent, app_icon)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "echoact"
3
- version = "0.1.1"
3
+ version = "0.1.2"
4
4
  description = "Local Korean/English speech generation for the desktop"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12,<3.13"
@@ -31,10 +31,19 @@ def _spec_suffixes() -> set[str]:
31
31
 
32
32
 
33
33
  def _runtime_suffixes() -> set[str]:
34
+ # The fonts directory is the spec's one whole-directory copy
35
+ # (``font_data`` ships everything under it, whatever the suffix), so
36
+ # scanning it would flag files that a build plainly does contain.
37
+ # Nothing else ships that way, and no suffix goes unexamined: any file
38
+ # added elsewhere still has to name its suffix in the collector.
39
+ fonts = PACKAGE / "assets" / "fonts"
34
40
  return {
35
41
  path.suffix.lower()
36
42
  for path in PACKAGE.rglob("*")
37
- if path.is_file() and path.suffix != ".py" and "__pycache__" not in path.parts
43
+ if path.is_file()
44
+ and path.suffix != ".py"
45
+ and "__pycache__" not in path.parts
46
+ and fonts not in path.parents
38
47
  }
39
48
 
40
49
 
@@ -29,6 +29,7 @@ from echoact.domain import Budget, Gender, Language, SpeakingStyle, VoiceSetting
29
29
  from echoact.errors import Code, EchoActError
30
30
  from echoact.policy import (
31
31
  AUTOPLAY_DEFAULT,
32
+ CLOSE_TO_TRAY_DEFAULT,
32
33
  CPU_PERCENT_DEFAULT,
33
34
  CREDENTIAL_DAYS_DEFAULT,
34
35
  FOLLOW_DEFAULT,
@@ -82,6 +83,7 @@ def test_defaults_match_the_documented_first_launch_policy() -> None:
82
83
  assert s.follow is FOLLOW_DEFAULT is True
83
84
  assert (s.volume, s.muted, s.output_device) == (1.0, False, None)
84
85
  assert s.os_notifications is False
86
+ assert s.close_to_tray is CLOSE_TO_TRAY_DEFAULT is True
85
87
  assert (s.rest_enabled, s.rest_port, s.mcp_enabled) == (True, REST_PORT_DEFAULT, True)
86
88
  assert s.external_play is True # F-89
87
89
  assert s.credential_days == CREDENTIAL_DAYS_DEFAULT
@@ -107,6 +109,7 @@ def test_every_remembered_setting_survives_a_save_and_reload() -> None:
107
109
  output_device=output_device_key("MME", "Speakers (Realtek)"),
108
110
  display_language=DisplayLanguage.KO,
109
111
  os_notifications=True,
112
+ close_to_tray=False,
110
113
  rest_enabled=False,
111
114
  rest_port=9100,
112
115
  mcp_enabled=False,
@@ -266,6 +266,21 @@ def test_showing_remembered_settings_emits_nothing(app: QApplication) -> None:
266
266
  assert view.memory.value() == pytest.approx(3.0)
267
267
 
268
268
 
269
+ def test_the_tray_and_notice_toggles_reflect_and_emit(app: QApplication) -> None:
270
+ """The F-70 row the pairs table has always named, and the tray's own."""
271
+ view, _ = make_view(app)
272
+ seen_tray = capture(view.close_to_tray_changed)
273
+ seen_notice = capture(view.os_notifications_changed)
274
+ view.apply(Settings(close_to_tray=False, os_notifications=True))
275
+ assert seen_tray == [] and seen_notice == []
276
+ assert view.close_to_tray.isChecked() is False
277
+ assert view.os_notifications.isChecked() is True
278
+ view.close_to_tray.toggle()
279
+ view.os_notifications.toggle()
280
+ assert seen_tray == [True]
281
+ assert seen_notice == [False]
282
+
283
+
269
284
  def test_the_applied_budget_is_shown_apart_from_the_configured_one(app: QApplication) -> None:
270
285
  view, _ = make_view(app, Settings(cpu_percent=60, memory_bytes=6 * GIB))
271
286
  view.set_resource_state(