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.
- echoact-0.1.2/.vscode/mcp.json +17 -0
- {echoact-0.1.1 → echoact-0.1.2}/PKG-INFO +1 -1
- {echoact-0.1.1 → echoact-0.1.2}/echoact/config/settings.py +12 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/policy.py +4 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/ui/main_window.py +89 -11
- {echoact-0.1.1 → echoact-0.1.2}/echoact/ui/settings_view.py +39 -0
- echoact-0.1.2/echoact/ui/tray.py +120 -0
- {echoact-0.1.1 → echoact-0.1.2}/pyproject.toml +1 -1
- {echoact-0.1.1 → echoact-0.1.2}/tests/test_packaging.py +10 -1
- {echoact-0.1.1 → echoact-0.1.2}/tests/test_settings.py +3 -0
- {echoact-0.1.1 → echoact-0.1.2}/tests/test_ui_settings.py +15 -0
- echoact-0.1.2/tests/test_ui_tray.py +219 -0
- {echoact-0.1.1 → echoact-0.1.2}/uv.lock +1 -1
- {echoact-0.1.1 → echoact-0.1.2}/.gitignore +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/.python-version +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/CLAUDE.md +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/LICENSE +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/README.md +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/docs/design.md +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/docs/icons/caret-echo.svg +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/docs/icons/horizon.svg +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/docs/icons/ieung.svg +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/docs/icons/origin.svg +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/docs/icons/ripple-candidates.html +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/__init__.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/__main__.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/app.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/audio/__init__.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/audio/devices.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/audio/player.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/audio/wav.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/config/__init__.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/config/budget.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/db/__init__.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/db/backup.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/db/migrations.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/db/schema.sql +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/db/store.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/diagnostics.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/domain.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/engine/__init__.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/engine/container.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/engine/protocol.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/engine/runtime.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/engine/supervisor.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/engine/worker.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/errors.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/instance.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/jobs/__init__.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/jobs/engine.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/jobs/request.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/mcp/__init__.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/mcp/__main__.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/mcp/client.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/mcp/config.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/mcp/server.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/models/__init__.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/models/catalog.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/models/manifest.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/models/registry.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/paths.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/playrequests.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/security/__init__.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/security/credentials.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/security/ratelimit.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/service/__init__.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/service/app.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/service/deps.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/service/errors.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/service/routes.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/service/schemas.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/service/server.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/text/__init__.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/text/language.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/text/loader.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/text/normalize.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/text/segment.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/text/sniff.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/ui/__init__.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/ui/brand.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/ui/bridge.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/ui/controls.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/ui/credential_dialog.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/ui/external_play.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/ui/fonts.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/ui/i18n.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/ui/icons.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/ui/library.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/ui/licence.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/ui/mcp_setup.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/ui/models_view.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/ui/notifications.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/ui/reading.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/ui/status_view.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/ui/theme.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/update.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/util/__init__.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/util/ids.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/echoact/util/logging.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/main.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/packaging/README.md +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/packaging/echoact.ico +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/packaging/entry.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/scripts/fetch_fonts.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/scripts/make_app_icon.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/spikes/engine_conformance.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/spikes/outline_emphasis.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/spikes/tts_feasibility.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/spikes/voice_review.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/tests/__init__.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/tests/test_backup.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/tests/test_brand.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/tests/test_budget.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/tests/test_container.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/tests/test_credentials.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/tests/test_diagnostics.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/tests/test_end_to_end.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/tests/test_instance.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/tests/test_job_engine.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/tests/test_language.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/tests/test_loader.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/tests/test_main_window.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/tests/test_manifest.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/tests/test_mcp.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/tests/test_mcp_setup.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/tests/test_migrations.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/tests/test_navigation.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/tests/test_normalize.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/tests/test_notifications.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/tests/test_player.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/tests/test_playrequests.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/tests/test_ratelimit.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/tests/test_reading_surface.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/tests/test_registry.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/tests/test_restore_gate.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/tests/test_runtime.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/tests/test_segment.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/tests/test_service.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/tests/test_service_codes.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/tests/test_service_mcp.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/tests/test_settings_actions.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/tests/test_sniff.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/tests/test_store.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/tests/test_supervisor.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/tests/test_ui_library.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/tests/test_ui_models.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/tests/test_ui_status.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/tests/test_update.py +0 -0
- {echoact-0.1.1 → echoact-0.1.2}/tests/test_wav.py +0 -0
- {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
|
+
}
|
|
@@ -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
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
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
|
-
|
|
1411
|
-
|
|
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)
|
|
@@ -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()
|
|
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(
|