echoact 0.1.0__py3-none-any.whl

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 (80) hide show
  1. echoact/__init__.py +3 -0
  2. echoact/__main__.py +117 -0
  3. echoact/app.py +315 -0
  4. echoact/audio/__init__.py +0 -0
  5. echoact/audio/devices.py +192 -0
  6. echoact/audio/player.py +611 -0
  7. echoact/audio/wav.py +854 -0
  8. echoact/config/__init__.py +0 -0
  9. echoact/config/budget.py +370 -0
  10. echoact/config/settings.py +1244 -0
  11. echoact/db/__init__.py +0 -0
  12. echoact/db/backup.py +2429 -0
  13. echoact/db/migrations.py +434 -0
  14. echoact/db/schema.sql +214 -0
  15. echoact/db/store.py +2062 -0
  16. echoact/diagnostics.py +902 -0
  17. echoact/domain.py +487 -0
  18. echoact/engine/__init__.py +0 -0
  19. echoact/engine/container.py +843 -0
  20. echoact/engine/protocol.py +241 -0
  21. echoact/engine/runtime.py +324 -0
  22. echoact/engine/supervisor.py +961 -0
  23. echoact/engine/worker.py +659 -0
  24. echoact/errors.py +281 -0
  25. echoact/instance.py +172 -0
  26. echoact/jobs/__init__.py +0 -0
  27. echoact/jobs/engine.py +776 -0
  28. echoact/jobs/request.py +300 -0
  29. echoact/mcp/__init__.py +0 -0
  30. echoact/mcp/__main__.py +50 -0
  31. echoact/mcp/client.py +202 -0
  32. echoact/mcp/config.py +112 -0
  33. echoact/mcp/server.py +340 -0
  34. echoact/models/__init__.py +0 -0
  35. echoact/models/catalog.py +273 -0
  36. echoact/models/manifest.py +278 -0
  37. echoact/models/registry.py +1551 -0
  38. echoact/paths.py +93 -0
  39. echoact/policy.py +189 -0
  40. echoact/security/__init__.py +0 -0
  41. echoact/security/credentials.py +930 -0
  42. echoact/security/ratelimit.py +534 -0
  43. echoact/service/__init__.py +20 -0
  44. echoact/service/app.py +182 -0
  45. echoact/service/deps.py +563 -0
  46. echoact/service/errors.py +241 -0
  47. echoact/service/routes.py +1125 -0
  48. echoact/service/schemas.py +509 -0
  49. echoact/service/server.py +270 -0
  50. echoact/text/__init__.py +0 -0
  51. echoact/text/language.py +44 -0
  52. echoact/text/loader.py +577 -0
  53. echoact/text/normalize.py +924 -0
  54. echoact/text/segment.py +499 -0
  55. echoact/text/sniff.py +1202 -0
  56. echoact/ui/__init__.py +0 -0
  57. echoact/ui/bridge.py +50 -0
  58. echoact/ui/controls.py +360 -0
  59. echoact/ui/credential_dialog.py +131 -0
  60. echoact/ui/fonts.py +94 -0
  61. echoact/ui/i18n.py +260 -0
  62. echoact/ui/icons.py +440 -0
  63. echoact/ui/library.py +1642 -0
  64. echoact/ui/licence.py +162 -0
  65. echoact/ui/main_window.py +1202 -0
  66. echoact/ui/mcp_setup.py +494 -0
  67. echoact/ui/models_view.py +1142 -0
  68. echoact/ui/notifications.py +202 -0
  69. echoact/ui/reading.py +494 -0
  70. echoact/ui/settings_view.py +2258 -0
  71. echoact/ui/status_view.py +1193 -0
  72. echoact/ui/theme.py +579 -0
  73. echoact/util/__init__.py +0 -0
  74. echoact/util/ids.py +62 -0
  75. echoact/util/logging.py +127 -0
  76. echoact-0.1.0.dist-info/METADATA +162 -0
  77. echoact-0.1.0.dist-info/RECORD +80 -0
  78. echoact-0.1.0.dist-info/WHEEL +4 -0
  79. echoact-0.1.0.dist-info/entry_points.txt +3 -0
  80. echoact-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1202 @@
1
+ """The main screen.
2
+
3
+ N-09 asks that input, voice settings, resource settings, generation, and
4
+ playback all be reachable from here, so this window is wide rather than
5
+ deep: the reading surface takes the space, everything else sits around it,
6
+ and the other screens are one button away.
7
+
8
+ The window owns every decision. The panels below it emit intent and show
9
+ state; they never call the engine. That is what makes "unavailable
10
+ controls are disabled" a single method rather than a rule each widget has
11
+ to remember.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import threading
17
+ from pathlib import Path
18
+
19
+ from PySide6.QtCore import Qt, QTimer, Signal
20
+ from PySide6.QtGui import QAction, QCloseEvent, QKeySequence
21
+ from PySide6.QtWidgets import (
22
+ QDialog,
23
+ QFileDialog,
24
+ QFrame,
25
+ QHBoxLayout,
26
+ QLabel,
27
+ QMainWindow,
28
+ QMessageBox,
29
+ QProgressBar,
30
+ QPushButton,
31
+ QVBoxLayout,
32
+ QWidget,
33
+ )
34
+
35
+ from ..app import Application
36
+ from ..audio import wav
37
+ from ..audio.player import PlayerState, Timeline
38
+ from ..domain import JobState, RequestPath, RetentionMode, VoiceSettings
39
+ from ..errors import Code, EchoActError
40
+ from ..jobs.engine import Event
41
+ from ..jobs.request import JobRequest, estimate
42
+ from ..models.catalog import MANIFEST
43
+ from ..policy import MAX_INPUT_CODEPOINTS
44
+ from ..text.loader import load_file
45
+ from ..util import ids
46
+ from ..util.logging import get_logger
47
+ from . import icons
48
+ from .bridge import EngineBridge
49
+ from .controls import TransportBar, VoicePanel, label, tool_button
50
+ from .i18n import add_korean, approximate_duration, count, tr
51
+ from .licence import LicenceDialog
52
+ from .notifications import (
53
+ Level,
54
+ Notice,
55
+ NotificationCentre,
56
+ completion_notice,
57
+ failure_notice,
58
+ )
59
+ from .reading import ReadingSurface
60
+ from .theme import METRICS, Mode, Palette
61
+ from .theme import apply as apply_theme
62
+
63
+ log = get_logger("ui.window")
64
+
65
+ #: How often the reading position is repainted. This is a repaint cadence,
66
+ #: not a clock: the position itself comes from the audio callback's frame
67
+ #: counter (N-12, A.2), and 40 ms leaves the 300 ms budget almost entirely
68
+ #: to the device.
69
+ TICK_MS = 40
70
+
71
+ add_korean(
72
+ {
73
+ "{n} / {max} characters": "{n} / {max}자",
74
+ "Nothing to read yet": "읽을 내용이 없습니다",
75
+ "{n} segments, {duration}": "{n}개 문장, {duration}",
76
+ "Generating {done} of {total}": "{total}개 중 {done}개 생성 중",
77
+ "Ready to read": "읽을 준비가 되었습니다",
78
+ "Reading": "읽는 중",
79
+ "Finished": "끝났습니다",
80
+ "Generation canceled": "생성이 취소되었습니다",
81
+ "Highlighting unavailable while the text differs from the audio":
82
+ "본문이 오디오와 달라 강조 표시를 사용할 수 없습니다",
83
+ "Following paused": "따라가기 일시 중지됨",
84
+ "Open a text file": "텍스트 파일 열기",
85
+ "Activity": "활동",
86
+ "Connect an app": "앱 연결",
87
+ "Storage": "저장 공간",
88
+ "{n} items cleaned up": "{n}개 항목을 정리했습니다",
89
+ "Some items could not be deleted": "일부 항목을 삭제하지 못했습니다",
90
+ "EchoAct cannot check for a newer version in this build.":
91
+ "이 빌드에서는 새 버전을 확인할 수 없습니다.",
92
+ "Text files (*.txt *.md);;All files (*)": "텍스트 파일 (*.txt *.md);;모든 파일 (*)",
93
+ "Save audio": "오디오 저장",
94
+ "WAV audio (*.wav)": "WAV 오디오 (*.wav)",
95
+ "Replace the text that is already here?": "이미 입력된 본문을 바꿀까요?",
96
+ "The text you have now has not been saved.": "지금 입력한 본문은 저장되지 않았습니다.",
97
+ "Replace": "바꾸기",
98
+ "Keep": "유지",
99
+ "Too much text to paste": "붙여넣기에 본문이 너무 많습니다",
100
+ "Pasting {n} characters would pass the {max} character limit; there is room for {room}.":
101
+ "{n}자를 붙여넣으면 {max}자 제한을 넘습니다. {room}자만 넣을 수 있습니다.",
102
+ "A job is still running.": "작업이 아직 실행 중입니다.",
103
+ "Leave anyway": "그래도 종료",
104
+ "Stay": "머무르기",
105
+ "partial": "일부",
106
+ "This file covers {percent}% of the text.": "이 파일은 본문의 {percent}%를 담고 있습니다.",
107
+ "Line endings were normalised.": "줄바꿈 문자를 정규화했습니다.",
108
+ }
109
+ )
110
+
111
+
112
+ class MainWindow(QMainWindow):
113
+ """One window, one job at a time, one reading position."""
114
+
115
+ theme_changed = Signal(object)
116
+
117
+ def __init__(self, app: Application, mode: Mode = Mode.SYSTEM) -> None:
118
+ super().__init__()
119
+ self.app = app
120
+ self.palette_tokens: Palette = apply_theme(
121
+ __import__("PySide6.QtWidgets", fromlist=["QApplication"]).QApplication.instance(),
122
+ mode,
123
+ )
124
+ self.setWindowTitle("EchoAct")
125
+ self.resize(1180, 780)
126
+ self.setMinimumSize(900, 560)
127
+
128
+ self._job_id: str | None = None
129
+ self._timeline: Timeline | None = None
130
+ self._snapshot: str = ""
131
+ self._started_playback = False
132
+ self._entry = MANIFEST.get(app.settings.voice.model_id)
133
+
134
+ self._build()
135
+ self._wire()
136
+ self._apply_settings()
137
+ self._refresh_estimate()
138
+ self._refresh_resources()
139
+ self._update_enabled()
140
+ self._set_status(tr("Ready to read"))
141
+
142
+ self._tick = QTimer(self)
143
+ self._tick.setInterval(TICK_MS)
144
+ self._tick.timeout.connect(self._on_tick)
145
+ self._tick.start()
146
+
147
+ self._report_startup()
148
+
149
+ # ------------------------------------------------------------------
150
+ # Construction
151
+ # ------------------------------------------------------------------
152
+
153
+ def _build(self) -> None:
154
+ m = METRICS
155
+ p = self.palette_tokens
156
+ root = QWidget()
157
+ root.setObjectName("Root")
158
+ outer = QVBoxLayout(root)
159
+ outer.setContentsMargins(m.pad_wide, m.pad_wide, m.pad_wide, m.pad)
160
+ outer.setSpacing(m.gap_wide)
161
+
162
+ # -- header ----------------------------------------------------
163
+ head = QHBoxLayout()
164
+ head.setSpacing(m.gap)
165
+ head.addWidget(label("EchoAct", "title"))
166
+ self.model_chip = label("", "muted")
167
+ head.addWidget(self.model_chip)
168
+ head.addStretch(1)
169
+ self.open_button = QPushButton(" " + tr("Open file"))
170
+ self.open_button.setProperty("variant", "quiet")
171
+ self.open_button.setIcon(icons.icon("folder", p.text_secondary, p.text_muted))
172
+ self.open_button.setIconSize(icons.icon_size(16))
173
+ self.save_button = QPushButton(" " + tr("Save audio"))
174
+ self.save_button.setProperty("variant", "quiet")
175
+ self.save_button.setIcon(icons.icon("export", p.text_secondary, p.text_muted))
176
+ self.save_button.setIconSize(icons.icon_size(16))
177
+ head.addWidget(self.open_button)
178
+ head.addWidget(self.save_button)
179
+
180
+ head.addSpacing(METRICS.gap)
181
+ self.nav: dict[str, QPushButton] = {}
182
+ for key, name, glyph in (
183
+ ("library", tr("Library"), "library"),
184
+ ("models", tr("Models"), "cube"),
185
+ ("connect", tr("Connect an app"), "plug"),
186
+ ("status", tr("Activity"), "pulse"),
187
+ ("settings", tr("Settings"), "settings"),
188
+ ):
189
+ b = QPushButton(" " + name)
190
+ b.setProperty("variant", "quiet")
191
+ b.setIcon(icons.icon(glyph, p.text_secondary, p.text_muted))
192
+ b.setIconSize(icons.icon_size(16))
193
+ b.setAccessibleName(name)
194
+ head.addWidget(b)
195
+ self.nav[key] = b
196
+ outer.addLayout(head)
197
+
198
+ # -- notice banner (F-25, F-70) --------------------------------
199
+ self.notice = QFrame()
200
+ self.notice.setObjectName("Panel")
201
+ notice_row = QHBoxLayout(self.notice)
202
+ notice_row.setContentsMargins(m.pad, m.gap, m.gap, m.gap)
203
+ notice_row.setSpacing(m.gap)
204
+ self.notice_icon = QLabel()
205
+ self.notice_text = label("", "secondary")
206
+ self.notice_text.setWordWrap(True)
207
+ notice_row.addWidget(self.notice_icon)
208
+ notice_row.addWidget(self.notice_text, 1)
209
+ self.notice_close = tool_button("close", p, size=14)
210
+ notice_row.addWidget(self.notice_close)
211
+ self.notice.hide()
212
+ outer.addWidget(self.notice)
213
+
214
+ # -- body ------------------------------------------------------
215
+ body = QHBoxLayout()
216
+ body.setSpacing(m.gap_wide)
217
+
218
+ column = QVBoxLayout()
219
+ column.setSpacing(m.gap)
220
+ self.reading = ReadingSurface(p)
221
+ column.addWidget(self.reading, 1)
222
+
223
+ meta = QHBoxLayout()
224
+ self.counter = label("", "muted")
225
+ self.estimate_label = label("", "muted")
226
+ self.estimate_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
227
+ meta.addWidget(self.counter)
228
+ meta.addStretch(1)
229
+ self.follow_button = QPushButton(" " + tr("Return to the reading position"))
230
+ self.follow_button.setProperty("variant", "quiet")
231
+ self.follow_button.setIcon(icons.icon("locate", p.accent))
232
+ self.follow_button.setIconSize(icons.icon_size(14))
233
+ self.follow_button.hide()
234
+ meta.addWidget(self.follow_button)
235
+ meta.addWidget(self.estimate_label)
236
+ column.addLayout(meta)
237
+
238
+ self.transport = TransportBar(p)
239
+ column.addWidget(self.transport)
240
+
241
+ self.progress = QProgressBar()
242
+ self.progress.setRange(0, 100)
243
+ self.progress.setValue(0)
244
+ self.progress.setTextVisible(False)
245
+ column.addWidget(self.progress)
246
+
247
+ self.status = label("", "muted")
248
+ column.addWidget(self.status)
249
+ body.addLayout(column, 1)
250
+
251
+ self.voice_panel = VoicePanel(p, self._entry)
252
+ body.addWidget(self.voice_panel, 0, Qt.AlignmentFlag.AlignTop)
253
+ outer.addLayout(body, 1)
254
+
255
+ self.setCentralWidget(root)
256
+ self._build_status_bar()
257
+ self._build_menu()
258
+
259
+ def _build_status_bar(self) -> None:
260
+ bar = self.statusBar()
261
+ self.service_label = label("", "muted")
262
+ self.usage_label = label("", "muted")
263
+ bar.addWidget(self.service_label)
264
+ bar.addPermanentWidget(self.usage_label)
265
+ self._refresh_service_label()
266
+
267
+ def _build_menu(self) -> None:
268
+ """Menu entries exist so every action has a keyboard route (N-30),
269
+ not because the window needs a menu."""
270
+ actions = (
271
+ (tr("Open file"), QKeySequence.StandardKey.Open, self._open_file),
272
+ (tr("Save audio"), QKeySequence.StandardKey.Save, self._save_audio),
273
+ (tr("Read aloud"), QKeySequence("Ctrl+Return"), self._primary_action),
274
+ (tr("Play"), QKeySequence("Space"), None),
275
+ (tr("Stop"), QKeySequence("Ctrl+."), self._stop),
276
+ (tr("Return to the reading position"), QKeySequence("Ctrl+J"), self._return_to_position),
277
+ )
278
+ for name, shortcut, slot in actions:
279
+ if slot is None:
280
+ continue
281
+ action = QAction(name, self)
282
+ action.setShortcut(shortcut)
283
+ action.triggered.connect(slot)
284
+ self.addAction(action)
285
+
286
+ def _wire(self) -> None:
287
+ self.notifications = NotificationCentre(
288
+ os_notifications=self.app.settings.os_notifications
289
+ )
290
+ self.bridge = EngineBridge(self.app.engine, self)
291
+ self.bridge.accepted.connect(self._on_accepted)
292
+ self.bridge.state_changed.connect(self._on_state)
293
+ self.bridge.segment_ready.connect(self._on_segment)
294
+ self.bridge.finished.connect(self._on_finished)
295
+
296
+ self.reading.length_changed.connect(self._on_length)
297
+ self.reading.availability_changed.connect(self._on_availability)
298
+ self.reading.following_changed.connect(self._on_following)
299
+ self.reading.paste_refused.connect(self._on_paste_refused)
300
+
301
+ self.transport.read_requested.connect(self._primary_action)
302
+ self.transport.pause_requested.connect(self._toggle_play)
303
+ self.transport.stop_requested.connect(self._stop)
304
+ self.transport.seek_requested.connect(self._seek)
305
+
306
+ self.voice_panel.changed.connect(self._on_voice_changed)
307
+ self.voice_panel.autoplay_toggled.connect(
308
+ lambda on: self.app.update_settings(autoplay=on)
309
+ )
310
+ self.voice_panel.follow_toggled.connect(self._on_follow_toggled)
311
+
312
+ self.nav["library"].clicked.connect(self._show_library)
313
+ self.nav["models"].clicked.connect(self._show_models)
314
+ self.nav["settings"].clicked.connect(self._show_settings)
315
+ self.nav["status"].clicked.connect(self._show_status)
316
+ self.nav["connect"].clicked.connect(self._show_connect)
317
+
318
+ self.open_button.clicked.connect(self._open_file)
319
+ self.save_button.clicked.connect(self._save_audio)
320
+ self.follow_button.clicked.connect(self._return_to_position)
321
+ self.notice_close.clicked.connect(self.notice.hide)
322
+
323
+ def _apply_settings(self) -> None:
324
+ s = self.app.settings
325
+ self.voice_panel.apply(s.voice, autoplay=s.autoplay, follow=s.follow)
326
+ self.reading.set_follow(s.follow)
327
+ self.app.player.set_volume(s.volume)
328
+ self.app.player.set_muted(s.muted)
329
+ state = self.app.registry.status(s.voice.model_id)
330
+ ready = getattr(state, "state", None)
331
+ self.model_chip.setText(
332
+ f"{self._entry.display_name} · {self._entry.sample_rate // 1000}.{(self._entry.sample_rate // 100) % 10} kHz"
333
+ + ("" if str(ready) == "ready" else " · " + tr("not prepared"))
334
+ )
335
+
336
+ # ------------------------------------------------------------------
337
+ # Starting and stopping a job
338
+ # ------------------------------------------------------------------
339
+
340
+ def _primary_action(self) -> None:
341
+ """One button: start, or cancel what is running.
342
+
343
+ A.3 makes cancelling the owner's way to reclaim the single slot,
344
+ and F-69 asks for it to be reachable in one place.
345
+ """
346
+ if self.app.engine.busy:
347
+ self._cancel()
348
+ else:
349
+ self._start()
350
+
351
+ def _start(self) -> None:
352
+ if not self._licence_accepted():
353
+ return
354
+ text = self.reading.source_text()
355
+ settings = self.voice_panel.settings()
356
+ request = JobRequest(
357
+ text=text,
358
+ settings=settings,
359
+ request_path=RequestPath.GUI,
360
+ owner_client_id="owner",
361
+ # A fresh key per press. F-49 exists to stop a retry becoming a
362
+ # second job, and a double-click is a retry.
363
+ idempotency_key=ids.request_id(),
364
+ retention=(
365
+ RetentionMode.RETAINED if self.app.settings.retain_history else RetentionMode.ONE_OFF
366
+ ),
367
+ )
368
+ try:
369
+ job, created = self.app.engine.submit(request)
370
+ except EchoActError as exc:
371
+ self._show_problem(exc)
372
+ return
373
+
374
+ self._job_id = job.job_id
375
+ self._snapshot = text
376
+ self._started_playback = False
377
+ self._timeline = Timeline(sample_rate=self._entry.sample_rate)
378
+ self.app.player.load(self._timeline)
379
+ self.reading.attach_job(text, self.app.store.list_segments(job.job_id))
380
+ self.progress.setValue(0)
381
+ self._update_enabled()
382
+ if not created:
383
+ self._set_status(tr("Ready to read"))
384
+
385
+ def _licence_accepted(self) -> bool:
386
+ """N-11 and F-80: the terms are presented before first preparation.
387
+
388
+ Asked here rather than left to the engine's refusal, because a code
389
+ in a notice bar is not "presented as terms the user accepts".
390
+ Declining is not an error: the user said not now, and the window
391
+ simply does not start a job.
392
+ """
393
+ pending = self.app.licence_pending()
394
+ if pending is None:
395
+ return True
396
+ dialog = LicenceDialog(MANIFEST.get(pending), self.palette_tokens, self)
397
+ if dialog.exec() != QDialog.DialogCode.Accepted:
398
+ return False
399
+ self.app.accept_licence(pending)
400
+ return True
401
+
402
+ def _cancel(self) -> None:
403
+ if not self._job_id:
404
+ return
405
+ try:
406
+ self.app.engine.cancel(self._job_id)
407
+ except EchoActError as exc:
408
+ self._show_problem(exc)
409
+
410
+ # ------------------------------------------------------------------
411
+ # Engine events, already on the GUI thread
412
+ # ------------------------------------------------------------------
413
+
414
+ def _on_accepted(self, event: Event) -> None:
415
+ self._set_status(tr("Preparing the model"))
416
+ self._update_enabled()
417
+
418
+ def _on_state(self, event: Event) -> None:
419
+ if event.job_id != self._job_id:
420
+ return
421
+ text = {
422
+ JobState.PREPARING_MODEL: tr("Preparing the model"),
423
+ JobState.GENERATING: tr("Generating"),
424
+ JobState.COMPLETE: tr("Finished"),
425
+ JobState.CANCELING: tr("Cancel generation"),
426
+ JobState.CANCELED: tr("Generation canceled"),
427
+ JobState.FAILED: tr("Failed"),
428
+ }.get(event.state or JobState.ACCEPTED, "")
429
+ self._set_status(text)
430
+ self._update_enabled()
431
+
432
+ def _on_segment(self, event: Event) -> None:
433
+ """A segment is ready: extend the timeline and start if asked to.
434
+
435
+ F-12 starts playback at the first ready segment; F-83 lets the user
436
+ turn that off, and F-51 forbids it for a job an integration
437
+ created -- which is why this checks the job is ours as well as the
438
+ setting.
439
+ """
440
+ if event.job_id != self._job_id or self._timeline is None:
441
+ return
442
+ detail = event.detail
443
+ start_ms, end_ms = int(detail.get("start_ms", 0)), int(detail.get("end_ms", 0))
444
+ frames = int(detail.get("frame_count", 0))
445
+ sr = self._timeline.sample_rate
446
+ span_frames = wav.frames_for_ms(end_ms, sr) - wav.frames_for_ms(start_ms, sr)
447
+ self._timeline.append(
448
+ event.segment_index or 0,
449
+ str(detail.get("audio_path", "")),
450
+ frames,
451
+ max(0, span_frames - frames),
452
+ )
453
+ self.app.player.timeline_grew()
454
+ self.reading.update_segments(self.app.store.list_segments(event.job_id))
455
+ self.transport.set_playable(self._timeline.duration_ms)
456
+
457
+ if event.total:
458
+ self.progress.setValue(int(100 * event.generated / event.total))
459
+ self._set_status(
460
+ tr("Generating {done} of {total}").format(done=event.generated, total=event.total)
461
+ )
462
+ if not self._started_playback and self.app.settings.autoplay:
463
+ self._started_playback = True
464
+ self._play()
465
+
466
+ def _on_finished(self, event: Event) -> None:
467
+ if event.job_id != self._job_id:
468
+ return
469
+ if self._timeline is not None:
470
+ self._timeline.complete = True
471
+ self.progress.setValue(100 if event.state is JobState.COMPLETE else self.progress.value())
472
+ self._update_enabled()
473
+ if event.state is JobState.FAILED and event.error_code:
474
+ self._post(failure_notice(tr("Generation failed"), event.error_code, job_id=event.job_id))
475
+ elif event.state is JobState.COMPLETE:
476
+ # F-70. A job the user started needs no explanation; one an
477
+ # integration created needs two things said, because the user
478
+ # did not ask for it and a one-off result goes within the hour.
479
+ job = self.app.store.get_job(event.job_id, include_segments=False)
480
+ self._post(
481
+ completion_notice(
482
+ job_id=event.job_id,
483
+ client_label=event.client_label,
484
+ expires_at=job.result.expires_at if job.result else None,
485
+ )
486
+ )
487
+
488
+ # ------------------------------------------------------------------
489
+ # Playback
490
+ # ------------------------------------------------------------------
491
+
492
+ def _play(self) -> None:
493
+ try:
494
+ self.app.player.play()
495
+ except EchoActError as exc:
496
+ self._show_problem(exc)
497
+ return
498
+ self.reading.set_playing()
499
+ self.transport.set_playback_state("", playing=True)
500
+
501
+ def _toggle_play(self) -> None:
502
+ player = self.app.player
503
+ if player.state is PlayerState.PLAYING:
504
+ player.pause()
505
+ self.reading.set_paused()
506
+ self.transport.set_playback_state("", playing=False)
507
+ else:
508
+ self._play()
509
+
510
+ def _stop(self) -> None:
511
+ """F-13: stopping playback does not stop generation."""
512
+ self.app.player.stop()
513
+ self.reading.clear_highlight()
514
+ self.transport.set_playback_state("", playing=False)
515
+ self.transport.set_position(0)
516
+
517
+ def _seek(self, ms: int) -> None:
518
+ if not self.app.player.seek_ms(ms):
519
+ # F-14: ungenerated audio cannot be sought to. Snap back rather
520
+ # than pretend the seek happened.
521
+ self.transport.set_position(self.app.player.position_ms())
522
+
523
+ def _on_tick(self) -> None:
524
+ player = self.app.player
525
+ if self._timeline is None:
526
+ return
527
+ position = player.position_ms()
528
+ waiting = player.state is PlayerState.WAITING
529
+ if not self.transport.scrubbing:
530
+ self.transport.set_position(position)
531
+ if player.state in (PlayerState.PLAYING, PlayerState.WAITING):
532
+ self.reading.set_playback_ms(position, waiting=waiting)
533
+ if player.state is PlayerState.ENDED:
534
+ self.reading.clear_highlight()
535
+ self.transport.set_playback_state("", playing=False)
536
+ self._refresh_usage()
537
+
538
+ # ------------------------------------------------------------------
539
+ # Input
540
+ # ------------------------------------------------------------------
541
+
542
+ def _on_length(self, n: int) -> None:
543
+ self.counter.setText(
544
+ tr("{n} / {max} characters").format(n=count(n), max=count(MAX_INPUT_CODEPOINTS))
545
+ )
546
+ self._refresh_estimate()
547
+ self._update_enabled()
548
+
549
+ def _refresh_estimate(self) -> None:
550
+ """F-88's estimate, shown to the person as well as to a caller.
551
+
552
+ Cheap enough to run on every keystroke -- it is segmentation and
553
+ arithmetic, and it explicitly does not touch the engine.
554
+ """
555
+ text = self.reading.source_text()
556
+ if not text.strip():
557
+ self.estimate_label.setText(tr("Nothing to read yet"))
558
+ return
559
+ est = estimate(
560
+ text,
561
+ self.voice_panel.settings(),
562
+ MANIFEST,
563
+ slot_free=not self.app.engine.busy,
564
+ model_ready=True,
565
+ )
566
+ if not est.valid:
567
+ self.estimate_label.setText(est.problems[0]["message"] if est.problems else "")
568
+ return
569
+ self.estimate_label.setText(
570
+ tr("{n} segments, {duration}").format(
571
+ n=est.segment_count, duration=approximate_duration(est.audio_ms)
572
+ )
573
+ )
574
+
575
+ def _on_voice_changed(self, settings: VoiceSettings) -> None:
576
+ self.app.update_settings(voice=settings)
577
+ self._refresh_estimate()
578
+ self._refresh_resources()
579
+
580
+ def _on_follow_toggled(self, on: bool) -> None:
581
+ self.app.update_settings(follow=on)
582
+ self.reading.set_follow(on)
583
+
584
+ def _on_availability(self, available: bool) -> None:
585
+ if available:
586
+ self.notice.hide()
587
+ elif self._job_id:
588
+ self._show_notice(
589
+ tr("Highlighting unavailable"),
590
+ tr("Highlighting unavailable while the text differs from the audio"),
591
+ kind="info",
592
+ )
593
+
594
+ def _on_following(self, following: bool) -> None:
595
+ self.follow_button.setVisible(self.reading.following_suspended)
596
+
597
+ def _return_to_position(self) -> None:
598
+ self.reading.return_to_position()
599
+ self.follow_button.hide()
600
+
601
+ def _on_paste_refused(self, attempted: int, room: int) -> None:
602
+ QMessageBox.information(
603
+ self,
604
+ tr("Too much text to paste"),
605
+ tr(
606
+ "Pasting {n} characters would pass the {max} character limit; "
607
+ "there is room for {room}."
608
+ ).format(n=count(attempted), max=count(MAX_INPUT_CODEPOINTS), room=count(room)),
609
+ )
610
+
611
+ # ------------------------------------------------------------------
612
+ # Files
613
+ # ------------------------------------------------------------------
614
+
615
+ def _open_file(self) -> None:
616
+ # F-36: replacing unsaved input needs confirmation, and a refusal
617
+ # must leave what is there untouched.
618
+ if self.reading.source_text().strip() and not self._confirm_replace():
619
+ return
620
+ path, _ = QFileDialog.getOpenFileName(
621
+ self, tr("Open a text file"), "", tr("Text files (*.txt *.md);;All files (*)")
622
+ )
623
+ if not path:
624
+ return
625
+ try:
626
+ loaded = load_file(path)
627
+ except EchoActError as exc:
628
+ # F-32: on any failure the existing input is left as it was.
629
+ self._show_problem(exc)
630
+ return
631
+ self.reading.set_text(loaded.text)
632
+ if loaded.line_endings_normalised:
633
+ # The one edit the loader makes, said out loud: a user counting
634
+ # characters would otherwise be counting something else.
635
+ self._show_notice(loaded.source_name, tr("Line endings were normalised."), kind="info")
636
+
637
+ def _save_audio(self) -> None:
638
+ """F-16, including the partial case.
639
+
640
+ A job that has not finished can still be saved; the file is named
641
+ and labelled partial and the coverage is reported, so it is never
642
+ mistaken for the whole document.
643
+ """
644
+ if not self._job_id:
645
+ return
646
+ segments = self.app.store.list_segments(self._job_id)
647
+ ready = [s for s in segments if s.ready]
648
+ if not ready:
649
+ return
650
+ complete = len(ready) == len(segments)
651
+ suggested = "reading.wav" if complete else "reading-partial.wav"
652
+ path, _ = QFileDialog.getSaveFileName(
653
+ self, tr("Save audio"), suggested, tr("WAV audio (*.wav)")
654
+ )
655
+ if not path:
656
+ return
657
+ try:
658
+ report = wav.export_partial(
659
+ segments,
660
+ Path(path),
661
+ sample_rate=self._entry.sample_rate,
662
+ total_codepoints=len(self._snapshot),
663
+ )
664
+ except EchoActError as exc:
665
+ self._show_problem(exc)
666
+ return
667
+ if not report.complete:
668
+ self._show_notice(
669
+ tr("partial"),
670
+ tr("This file covers {percent}% of the text.").format(
671
+ percent=int(report.coverage * 100)
672
+ ),
673
+ kind="info",
674
+ )
675
+
676
+ # ------------------------------------------------------------------
677
+ # The other screens
678
+ # ------------------------------------------------------------------
679
+
680
+ def _open_screen(self, title: str, widget: QWidget, *, width: int, height: int) -> QDialog:
681
+ """Show a screen in its own window.
682
+
683
+ A separate window rather than a stacked page, so the reading
684
+ surface and the job it is following are never replaced by
685
+ something else: F-29 compares the live input against the job
686
+ snapshot continuously, and a screen that swapped the input out
687
+ would drop the highlight every time the user opened the library.
688
+ """
689
+ dialog = QDialog(self)
690
+ dialog.setWindowTitle(title)
691
+ dialog.setModal(False)
692
+ layout = QVBoxLayout(dialog)
693
+ layout.setContentsMargins(0, 0, 0, 0)
694
+ layout.addWidget(widget)
695
+ dialog.resize(width, height)
696
+ dialog.show()
697
+ return dialog
698
+
699
+ def _show_library(self) -> None:
700
+ from .library import LibraryScreen
701
+
702
+ screen = LibraryScreen(self.app.store, self.palette_tokens, manifest=MANIFEST)
703
+ if hasattr(screen, "open_document"):
704
+ screen.open_document.connect(self._open_document)
705
+ if hasattr(screen, "problem"):
706
+ screen.problem.connect(self._show_problem)
707
+ self._library = self._open_screen(tr("Library"), screen, width=1000, height=680)
708
+
709
+ def _show_models(self) -> None:
710
+ from ..config.budget import resolve_budget_from_system
711
+ from .models_view import ModelsView
712
+
713
+ try:
714
+ budget = resolve_budget_from_system(self.app.settings)
715
+ except EchoActError as exc:
716
+ self._show_problem(exc)
717
+ return
718
+ screen = ModelsView(self.palette_tokens, self.app.registry, budget)
719
+ if hasattr(screen, "problem"):
720
+ screen.problem.connect(self._show_problem)
721
+ self._models = self._open_screen(tr("Models"), screen, width=780, height=680)
722
+
723
+ def _show_settings(self) -> None:
724
+ from .settings_view import SettingsView
725
+
726
+ screen = SettingsView(self.palette_tokens, self.app.settings)
727
+ self._connect_settings(screen)
728
+ self._refresh_settings_screen(screen)
729
+ # Wide enough that the section text and the controls beside it do
730
+ # not need the horizontal scrollbar. N-30 allows scrolling as the
731
+ # fallback; needing it at the default size is still a bad default.
732
+ self._settings_screen = self._open_screen(
733
+ tr("Settings"), screen, width=900, height=780
734
+ )
735
+
736
+ def _refresh_settings_screen(self, screen: QWidget) -> None:
737
+ """Fill in what the view cannot measure for itself.
738
+
739
+ F-78's applied budget comes from the *running job*, not from a
740
+ fresh resolution: resolving again would show the same number
741
+ twice and quietly turn the distinction the requirement draws
742
+ into two views of one value.
743
+ """
744
+ job = self.app.engine.current()
745
+ screen.set_resource_state(
746
+ job.budget if job is not None else None,
747
+ job_running=job is not None,
748
+ )
749
+ screen.set_credentials(self.app.credentials.list())
750
+ self._refresh_storage(screen)
751
+
752
+ def _refresh_storage(self, screen: QWidget) -> None:
753
+ """F-73's five figures.
754
+
755
+ Off the main thread: it walks the model cache, which is 385 MB of
756
+ files on an ordinary install and can be far more.
757
+ """
758
+ from .settings_view import storage_snapshot
759
+
760
+ def measure() -> None:
761
+ try:
762
+ sizes = storage_snapshot(self.app.store, self.app.registry)
763
+ except (EchoActError, OSError):
764
+ return
765
+ QTimer.singleShot(0, lambda: screen.set_storage(sizes))
766
+
767
+ threading.Thread(target=measure, name="echoact-storage", daemon=True).start()
768
+
769
+ def _connect_settings_actions(self, screen: QWidget) -> None:
770
+ """The buttons that do work rather than change a value.
771
+
772
+ Each one is the window's to perform: F-76 requires the deletion
773
+ scopes to be offered separately and to report what failed, and
774
+ F-71 requires a revocation to reach that client's running job as
775
+ well as its credential (5.3).
776
+ """
777
+ handlers = {
778
+ "apply_now_requested": lambda: self._apply_budget_now(screen),
779
+ "devices_refresh_requested": lambda: self._refresh_devices(screen),
780
+ "storage_refresh_requested": lambda: self._refresh_storage(screen),
781
+ "cleanup_requested": lambda: self._clean_up(screen),
782
+ "credential_issue_requested": lambda *a: self._issue_credential(screen, *a),
783
+ "credential_reissue_requested": lambda ref: self._reissue_credential(screen, ref),
784
+ "credential_revoke_requested": lambda ref: self._revoke_credential(screen, ref),
785
+ "credential_capabilities_changed": (
786
+ lambda ref, caps: self._set_capabilities(screen, ref, caps)
787
+ ),
788
+ "reset_requested": lambda scope: self._reset(screen, scope),
789
+ "version_check_requested": lambda: self._check_version(screen),
790
+ }
791
+ for name, handler in handlers.items():
792
+ signal = getattr(screen, name, None)
793
+ if signal is not None:
794
+ signal.connect(handler)
795
+
796
+ def _apply_budget_now(self, screen: QWidget) -> None:
797
+ """F-78: applying immediately means cancelling the current job,
798
+ and the view has already confirmed that with the user."""
799
+ job = self.app.engine.current()
800
+ if job is not None:
801
+ try:
802
+ self.app.engine.cancel(job.job_id)
803
+ except EchoActError as exc:
804
+ self._show_problem(exc)
805
+ return
806
+ self._refresh_settings_screen(screen)
807
+ self._refresh_resources()
808
+
809
+ def _refresh_devices(self, screen: QWidget) -> None:
810
+ """F-68: after a resume or a device change, PortAudio still holds
811
+ the list it built at start-up until it is reinitialised."""
812
+ from ..audio import devices
813
+
814
+ devices.refresh()
815
+ if hasattr(screen, "reload_devices"):
816
+ screen.reload_devices()
817
+
818
+ def _clean_up(self, screen: QWidget) -> None:
819
+ """F-73's cleanup: expired data only, and never anything in use."""
820
+ from ..jobs.engine import clear_temp_tree, expire_one_off_results
821
+ from ..paths import temp_dir
822
+ from ..util.logging import prune_old_logs
823
+
824
+ try:
825
+ removed = expire_one_off_results(self.app.store, temp_dir())
826
+ except EchoActError as exc:
827
+ self._show_problem(exc)
828
+ return
829
+ if not self.app.engine.busy:
830
+ # N-28 and F-73: data in use by a running job is excluded, and
831
+ # the scratch tree is exactly what a running job is using.
832
+ removed += clear_temp_tree(temp_dir())
833
+ removed += prune_old_logs(ids.now())
834
+ self._show_notice(tr("Storage"), tr("{n} items cleaned up").format(n=removed))
835
+ self._refresh_storage(screen)
836
+
837
+ def _issue_credential(self, screen: QWidget, name: str, capabilities, days: int) -> None:
838
+ """F-71: shown once, and only a verifier is kept."""
839
+ try:
840
+ issued = self.app.credentials.issue(
841
+ name=name, capabilities=set(capabilities), days=int(days)
842
+ )
843
+ except EchoActError as exc:
844
+ self._show_problem(exc)
845
+ return
846
+ self._show_credential_once(issued)
847
+ screen.set_credentials(self.app.credentials.list())
848
+
849
+ def _reissue_credential(self, screen: QWidget, ref: str) -> None:
850
+ try:
851
+ issued = self.app.credentials.reissue(ref)
852
+ except EchoActError as exc:
853
+ self._show_problem(exc)
854
+ return
855
+ self._show_credential_once(issued)
856
+ screen.set_credentials(self.app.credentials.list())
857
+
858
+ def _revoke_credential(self, screen: QWidget, ref: str) -> None:
859
+ """5.3: revocation cancels that client's in-progress jobs too."""
860
+ try:
861
+ credential = self.app.credentials.get(ref)
862
+ self.app.credentials.revoke(ref)
863
+ except EchoActError as exc:
864
+ self._show_problem(exc)
865
+ return
866
+ self._cancel_jobs_of(credential.client_id)
867
+ screen.set_credentials(self.app.credentials.list())
868
+
869
+ def _set_capabilities(self, screen: QWidget, ref: str, capabilities) -> None:
870
+ try:
871
+ change = self.app.credentials.set_capabilities(ref, set(capabilities))
872
+ except EchoActError as exc:
873
+ self._show_problem(exc)
874
+ return
875
+ if change.cancels_jobs:
876
+ # 5.3: narrowing a client's permissions cancels its running job.
877
+ self._cancel_jobs_of(change.client_id)
878
+ screen.set_credentials(self.app.credentials.list())
879
+
880
+ def _cancel_jobs_of(self, client_id: str) -> None:
881
+ job = self.app.engine.current()
882
+ if job is not None and job.owner_client_id == client_id:
883
+ try:
884
+ self.app.engine.cancel(job.job_id)
885
+ except EchoActError as exc:
886
+ self._show_problem(exc)
887
+
888
+ def _show_credential_once(self, issued) -> None:
889
+ from .credential_dialog import show_credential
890
+
891
+ show_credential(self, issued, self.palette_tokens)
892
+
893
+ def _reset(self, screen: QWidget, scope: str) -> None:
894
+ """F-76's four scopes, each on its own, each reporting failures."""
895
+ from .settings_view import ResetScope
896
+
897
+ failures: list[str] = []
898
+ try:
899
+ if scope == ResetScope.VOICE_AND_DISPLAY:
900
+ self.app.update_settings(
901
+ voice=self.app.settings_store.current.voice.with_(tempo=1.0),
902
+ volume=1.0,
903
+ muted=False,
904
+ )
905
+ elif scope == ResetScope.INTEGRATIONS:
906
+ for credential in list(self.app.credentials.list()):
907
+ if not credential.is_owner:
908
+ self.app.credentials.revoke(credential.ref)
909
+ self._cancel_jobs_of(credential.client_id)
910
+ elif scope == ResetScope.RETAINED_DATA:
911
+ deletion = self.app.store.delete_all_history()
912
+ # N-16 and F-43: a job that is running is not deleted, and
913
+ # the ones that were skipped are reported rather than
914
+ # counted as done.
915
+ failures = list(deletion.blocked_job_ids)
916
+ elif scope == ResetScope.MODEL_CACHE:
917
+ self.app.registry.delete(self.app.settings.voice.model_id)
918
+ except EchoActError as exc:
919
+ self._show_problem(exc)
920
+ return
921
+ if failures:
922
+ self._show_notice(
923
+ tr("Some items could not be deleted"), ", ".join(failures[:5]), kind="error"
924
+ )
925
+ self._refresh_settings_screen(screen)
926
+
927
+ def _check_version(self, screen: QWidget) -> None:
928
+ """F-75: queried only at the user's request, and nothing is
929
+ downloaded or installed. There is no updater in this build, so
930
+ the honest answer is that the check is unavailable rather than a
931
+ silent nothing."""
932
+ screen.set_released_version(
933
+ None, error=tr("EchoAct cannot check for a newer version in this build.")
934
+ )
935
+
936
+ def _show_connect(self) -> None:
937
+ """F-58: the app hands the user what their MCP client needs.
938
+
939
+ Reachable from the header rather than buried in Settings,
940
+ because it is the first thing someone wanting an integration
941
+ looks for and the values are not guessable: the ``--mcp`` flag,
942
+ the two environment variables, and a different key name in every
943
+ client's configuration file.
944
+ """
945
+ from .mcp_setup import McpSetupView
946
+
947
+ settings = self.app.settings
948
+ screen = McpSetupView(
949
+ self.palette_tokens,
950
+ port=settings.rest_port,
951
+ mcp_enabled=settings.mcp_enabled,
952
+ rest_enabled=settings.rest_enabled,
953
+ credentials=[c for c in self.app.credentials.list() if not c.is_owner],
954
+ )
955
+ screen.credential_requested.connect(
956
+ lambda name: self._issue_for_client(screen, name)
957
+ )
958
+ screen.open_settings_requested.connect(self._show_settings)
959
+ self._connect = self._open_screen(
960
+ tr("Connect an app"), screen, width=880, height=760
961
+ )
962
+
963
+ def _issue_for_client(self, screen: QWidget, name: str) -> None:
964
+ """Mint a credential and put it straight into the snippet.
965
+
966
+ F-71 shows a credential once, so this is the only moment the
967
+ value can be written into a configuration block. It is given
968
+ generate and read-results and nothing else: F-61 authorises
969
+ history separately, and a setup screen is the wrong place to
970
+ hand out more than the client asked for.
971
+ """
972
+ from ..domain import Capability
973
+
974
+ try:
975
+ issued = self.app.credentials.issue(
976
+ name=name,
977
+ capabilities={Capability.GENERATE, Capability.READ_RESULTS},
978
+ days=self.app.settings.credential_days,
979
+ )
980
+ except EchoActError as exc:
981
+ self._show_problem(exc)
982
+ return
983
+ screen.set_token(issued.token)
984
+
985
+ def _show_status(self) -> None:
986
+ from .status_view import StatusView
987
+
988
+ screen = StatusView(self.app, self.palette_tokens)
989
+ self._status = self._open_screen(tr("Activity"), screen, width=740, height=560)
990
+
991
+ def _connect_settings(self, screen: QWidget) -> None:
992
+ """One place where a settings change becomes an application change.
993
+
994
+ Each signal carries the new value and nothing else, so the screen
995
+ never holds the Application, and F-78's rule -- a change during a
996
+ job applies to the next job -- is enforced once inside
997
+ Application.update_settings rather than per control.
998
+ """
999
+ pairs = (
1000
+ ("cpu_changed", "cpu_percent"),
1001
+ ("memory_changed", "memory_bytes"),
1002
+ ("volume_changed", "volume"),
1003
+ ("muted_changed", "muted"),
1004
+ ("autoplay_changed", "autoplay"),
1005
+ ("follow_changed", "follow"),
1006
+ ("output_device_changed", "output_device"),
1007
+ ("rest_enabled_changed", "rest_enabled"),
1008
+ ("rest_port_changed", "rest_port"),
1009
+ ("mcp_enabled_changed", "mcp_enabled"),
1010
+ ("credential_days_changed", "credential_days"),
1011
+ ("retention_changed", "retention_bytes"),
1012
+ ("os_notifications_changed", "os_notifications"),
1013
+ ("autosave_documents_changed", "autosave_documents"),
1014
+ ("retain_history_changed", "retain_history"),
1015
+ )
1016
+ for signal_name, field in pairs:
1017
+ signal = getattr(screen, signal_name, None)
1018
+ if signal is not None:
1019
+ signal.connect(lambda value, f=field: self._apply_setting(f, value))
1020
+
1021
+ language = getattr(screen, "display_language_changed", None)
1022
+ if language is not None:
1023
+ language.connect(self._apply_display_language)
1024
+
1025
+ self._connect_settings_actions(screen)
1026
+
1027
+ def _apply_setting(self, field: str, value: object) -> None:
1028
+ try:
1029
+ self.app.update_settings(**{field: value})
1030
+ except EchoActError as exc:
1031
+ self._show_problem(exc)
1032
+ return
1033
+ if field in {"volume", "muted"}:
1034
+ self.app.player.set_volume(self.app.settings.volume)
1035
+ self.app.player.set_muted(self.app.settings.muted)
1036
+ elif field == "follow":
1037
+ self.reading.set_follow(bool(value))
1038
+ elif field in {"cpu_percent", "memory_bytes"}:
1039
+ self._refresh_resources()
1040
+ elif field in {"rest_enabled", "rest_port", "mcp_enabled"}:
1041
+ self._restart_service()
1042
+ elif field == "os_notifications":
1043
+ self.notifications.set_os_notifications(bool(value))
1044
+
1045
+ def _apply_display_language(self, value: str) -> None:
1046
+ """F-86. Only what is shown changes: A-22 requires documents, job
1047
+ snapshots and API responses to be untouched."""
1048
+ from .i18n import Lang, set_language
1049
+
1050
+ self.app.update_settings(display_language=value)
1051
+ set_language(Lang(value) if value in {"ko", "en"} else Lang.SYSTEM)
1052
+
1053
+ def _restart_service(self) -> None:
1054
+ """F-79: a port change restarts the service, not the app, and a
1055
+ failure is an actionable notice rather than a stoppage."""
1056
+ self.app.stop_service()
1057
+ problem = self.app.start_service()
1058
+ self._refresh_service_label()
1059
+ if problem is not None:
1060
+ self._show_notice(problem.code.value, problem.message, kind="error")
1061
+
1062
+ def _open_document(self, document_id: str) -> None:
1063
+ """F-36 still applies here: opening replaces the input, so it asks."""
1064
+ try:
1065
+ document = self.app.store.get_document(document_id)
1066
+ except EchoActError as exc:
1067
+ self._show_problem(exc)
1068
+ return
1069
+ if self.reading.source_text().strip() and not self._confirm_replace():
1070
+ return
1071
+ self.reading.set_text(document.body)
1072
+
1073
+ def _confirm_replace(self) -> bool:
1074
+ answer = QMessageBox.question(
1075
+ self,
1076
+ tr("Replace the text that is already here?"),
1077
+ tr("The text you have now has not been saved."),
1078
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel,
1079
+ )
1080
+ return answer is QMessageBox.StandardButton.Yes
1081
+
1082
+ # ------------------------------------------------------------------
1083
+ # Chrome
1084
+ # ------------------------------------------------------------------
1085
+
1086
+ def _update_enabled(self) -> None:
1087
+ busy = self.app.engine.busy
1088
+ has_text = bool(self.reading.source_text().strip())
1089
+ over = len(self.reading.source_text()) > MAX_INPUT_CODEPOINTS
1090
+ self.transport.read.setEnabled(busy or (has_text and not over))
1091
+ self.transport.set_generating(busy)
1092
+ self.voice_panel.set_locked(busy)
1093
+ playable = self._timeline is not None and self._timeline.total_frames > 0
1094
+ self.transport.pause.setEnabled(playable)
1095
+ self.transport.stop.setEnabled(playable)
1096
+ self.save_button.setEnabled(playable)
1097
+ self.open_button.setEnabled(not busy)
1098
+
1099
+ def _set_status(self, text: str) -> None:
1100
+ self.status.setText(text)
1101
+
1102
+ def _refresh_resources(self) -> None:
1103
+ """F-78 shows the configured value; the job shows what it ran under.
1104
+
1105
+ This is the configured one, because no job is running when the user
1106
+ is reading it -- and when one is, the status bar carries the live
1107
+ figures instead.
1108
+ """
1109
+ from ..config.budget import resolve_budget_from_system
1110
+ from .i18n import memory_size
1111
+
1112
+ try:
1113
+ budget = resolve_budget_from_system(self.app.settings)
1114
+ except EchoActError as exc:
1115
+ self.voice_panel.set_resource_summary(exc.message)
1116
+ return
1117
+ self.voice_panel.set_resource_summary(
1118
+ f"{tr('CPU')} {budget.cpu_percent}% · {memory_size(budget.memory_bytes)}"
1119
+ )
1120
+
1121
+ def _refresh_service_label(self) -> None:
1122
+ s = self.app.settings
1123
+ if self.app.service_running:
1124
+ state = f"{tr('Local service')} {tr('on')} · 127.0.0.1:{s.rest_port}"
1125
+ elif s.rest_enabled:
1126
+ state = tr("Integrations unavailable")
1127
+ else:
1128
+ state = f"{tr('Local service')} {tr('off')}"
1129
+ mcp = f" · MCP {tr('on') if s.mcp_enabled else tr('off')}"
1130
+ self.service_label.setText(state + mcp)
1131
+
1132
+ def _refresh_usage(self) -> None:
1133
+ """F-22: usage while generating, and while idle with a model held."""
1134
+ usage = None
1135
+ try:
1136
+ usage = self.app.supervisor.usage()
1137
+ except Exception: # noqa: BLE001 - a usage read must never break a tick
1138
+ usage = None
1139
+ if usage is None:
1140
+ self.usage_label.setText("")
1141
+ return
1142
+ from .i18n import memory_size
1143
+
1144
+ self.usage_label.setText(
1145
+ f"{tr('CPU')} {usage.cpu_percent:.0f}% · "
1146
+ f"{tr('Memory')} {memory_size(usage.rss_bytes)}"
1147
+ )
1148
+
1149
+ def _post(self, notice: Notice) -> None:
1150
+ """Record a notice and show it. Only notices go to the OS, and
1151
+ only when the user turned that on -- F-70 and 4.1."""
1152
+ self.notifications.post(notice)
1153
+ kind = {Level.OK: "ok", Level.ERROR: "error"}.get(notice.level, "info")
1154
+ self._show_notice(notice.title, notice.detail, kind=kind)
1155
+
1156
+ def _show_notice(self, title: str, detail: str, *, kind: str = "info") -> None:
1157
+ p = self.palette_tokens
1158
+ colour = {"info": p.accent, "ok": p.ok, "error": p.danger}.get(kind, p.accent)
1159
+ name = {"info": "info", "ok": "check-circle", "error": "error"}.get(kind, "info")
1160
+ self.notice_icon.setPixmap(icons.pixmap(name, colour, 18, self.devicePixelRatioF()))
1161
+ self.notice_text.setText(f"{title} — {detail}" if detail else title)
1162
+ self.notice.show()
1163
+
1164
+ def _show_problem(self, exc: EchoActError) -> None:
1165
+ """F-25: a failure is reported with a reason, not a shrug."""
1166
+ if exc.code is Code.BUSY:
1167
+ self._show_notice(tr("A job is still running."), exc.message, kind="info")
1168
+ return
1169
+ self._show_notice(exc.code.value, exc.message, kind="error")
1170
+ log.info("reported to user: %s", exc.code.value)
1171
+
1172
+ def _report_startup(self) -> None:
1173
+ st = self.app.startup
1174
+ if st.interrupted_jobs:
1175
+ self._show_notice(
1176
+ tr("Interrupted"),
1177
+ f"{len(st.interrupted_jobs)}",
1178
+ kind="info",
1179
+ )
1180
+ elif st.problems:
1181
+ first = st.problems[0]
1182
+ self._show_notice(first.code.value, first.message, kind="error")
1183
+
1184
+ # ------------------------------------------------------------------
1185
+ # Exit (F-77, F-52)
1186
+ # ------------------------------------------------------------------
1187
+
1188
+ def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 - Qt override
1189
+ if self.app.engine.busy:
1190
+ answer = QMessageBox.question(
1191
+ self,
1192
+ tr("A job is still running."),
1193
+ tr("The text you have now has not been saved."),
1194
+ QMessageBox.StandardButton.Close | QMessageBox.StandardButton.Cancel,
1195
+ )
1196
+ if answer is not QMessageBox.StandardButton.Close:
1197
+ event.ignore()
1198
+ return
1199
+ self._tick.stop()
1200
+ self.bridge.detach()
1201
+ self.app.shutdown()
1202
+ event.accept()