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
echoact/ui/library.py ADDED
@@ -0,0 +1,1642 @@
1
+ """The library screen: saved documents and generation history.
2
+
3
+ F-38 to F-43 and F-45, in one widget the window can put in a dialog or a
4
+ stacked page. Three properties shape everything below.
5
+
6
+ *It never calls the engine.* Like the panels in :mod:`echoact.ui.controls`
7
+ it shows state and emits intent -- ``open_document``, ``play_job``,
8
+ ``regenerate_job``, ``export_job``, ``delete_requested`` -- and the window
9
+ decides. It does read the store directly, because a library that cannot
10
+ list is not a library; reading rows is not the same authority as starting a
11
+ job.
12
+
13
+ *A list costs a list query and nothing more.* F-40 requires a page to be
14
+ displayable without reading whole audio files, and F-56 makes the
15
+ body-text-free summary the default projection. So the trees are built from
16
+ :class:`~echoact.db.store.JobSummary` and
17
+ :class:`~echoact.db.store.DocumentSummary` only: an audio length comes from
18
+ the stored frame count, a document's size from ``length(body)``, and the
19
+ source-text snapshot is fetched exactly once -- for the one entry the user
20
+ opened, through F-56's separate ``get_job_text`` call.
21
+
22
+ *A regenerated result is a different result.* A.5 measured two runs of the
23
+ same text with the same settings differing by 0.47 on a signal bounded at
24
+ 1.0. F-41 therefore forbids presenting a regeneration as the old result,
25
+ and the regenerate action says so where it is pressed rather than in a
26
+ release note.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import time
32
+ from collections.abc import Callable, Sequence
33
+ from dataclasses import dataclass
34
+
35
+ from PySide6.QtCore import QPoint, QRect, QSize, Qt, QTimer, Signal
36
+ from PySide6.QtGui import QColor
37
+ from PySide6.QtWidgets import (
38
+ QAbstractItemView,
39
+ QComboBox,
40
+ QDialog,
41
+ QDialogButtonBox,
42
+ QFrame,
43
+ QHBoxLayout,
44
+ QHeaderView,
45
+ QLayout,
46
+ QLayoutItem,
47
+ QLineEdit,
48
+ QPlainTextEdit,
49
+ QPushButton,
50
+ QRadioButton,
51
+ QScrollArea,
52
+ QSizePolicy,
53
+ QSpacerItem,
54
+ QSplitter,
55
+ QTabWidget,
56
+ QTreeWidget,
57
+ QTreeWidgetItem,
58
+ QVBoxLayout,
59
+ QWidget,
60
+ )
61
+
62
+ from ..db.store import DeletionScope, DocumentSummary, JobSummary, ResultIntegrity, Store
63
+ from ..domain import JobState, Result, RetentionMode
64
+ from ..errors import EchoActError
65
+ from ..models.manifest import Manifest
66
+ from ..policy import LIST_PAGE_DEFAULT, LIST_PAGE_MAX
67
+ from ..util import ids
68
+ from .controls import label, separator
69
+ from .i18n import add_korean, approximate_duration, bytes_size, count, duration, memory_size, tr
70
+ from .theme import METRICS, Palette, qcolor
71
+
72
+ #: How long typing settles before a search runs. A view cadence, not a
73
+ #: policy limit: it exists so a five-letter query is one FTS5 match instead
74
+ #: of five, and nothing in Section 4 has an opinion about it.
75
+ SEARCH_DEBOUNCE_MS = 200
76
+
77
+ #: The date filter's choices, in days back from now (F-40 filters history by
78
+ #: date). Offered windows, not policy: Section 4 fixes retention and log
79
+ #: lifetimes, never how a person narrows a list.
80
+ DATE_WINDOWS_DAYS = (1, 7, 30)
81
+
82
+ _SECONDS_PER_DAY = 86_400
83
+
84
+ #: Rows per page. Both come from 4.1 rather than from taste -- the default
85
+ #: is what a query returns when nobody asks, and the larger choice is the
86
+ #: ceiling the store clamps to anyway.
87
+ PAGE_SIZES = (LIST_PAGE_DEFAULT, LIST_PAGE_MAX)
88
+
89
+ add_korean(
90
+ {
91
+ "Documents": "문서",
92
+ "History": "생성 기록",
93
+ "Search titles and text": "제목과 본문 검색",
94
+ "Untitled": "제목 없음",
95
+ "Title": "제목",
96
+ "Created": "만든 날짜",
97
+ "Edited": "수정한 날짜",
98
+ "Characters": "글자 수",
99
+ "Nothing saved yet": "저장된 항목이 없습니다",
100
+ "No generation history yet": "생성 기록이 없습니다",
101
+ "Nothing matches that search": "검색 결과가 없습니다",
102
+ "Nothing matches those filters": "조건에 맞는 항목이 없습니다",
103
+ # history columns and filters
104
+ "When": "시각",
105
+ "State": "상태",
106
+ "Retention": "보관",
107
+ "Length": "길이",
108
+ "Any time": "전체 기간",
109
+ "Today": "오늘",
110
+ "Last {n} days": "최근 {n}일",
111
+ "Any model": "모든 모델",
112
+ "Any state": "모든 상태",
113
+ "In progress": "진행 중",
114
+ "Anything kept": "보관 여부 무관",
115
+ "Kept": "보관됨",
116
+ "One-off": "일회성",
117
+ "Accepted": "접수됨",
118
+ "Canceling": "취소하는 중",
119
+ # retention (F-42)
120
+ "Kept until you delete it": "삭제할 때까지 보관됩니다",
121
+ "One-off · expires in {when}": "일회성 · {when} 후 만료",
122
+ "One-off · the audio has expired": "일회성 · 오디오가 만료되었습니다",
123
+ "One-off · nothing is kept": "일회성 · 저장되지 않습니다",
124
+ "One-off · removed when the app closes": "일회성 · 앱을 닫으면 삭제됩니다",
125
+ # playability (F-45)
126
+ "audio expired": "오디오 만료됨",
127
+ "audio missing": "오디오 없음",
128
+ "audio damaged": "오디오 손상됨",
129
+ "audio not kept": "오디오 미보관",
130
+ "This one-off audio has expired, so it cannot be played.":
131
+ "이 일회성 오디오는 만료되어 재생할 수 없습니다.",
132
+ "The audio file is missing, so it cannot be played.":
133
+ "오디오 파일이 없어 재생할 수 없습니다.",
134
+ "The audio file failed its integrity check, so it is not played.":
135
+ "오디오 파일이 무결성 검사를 통과하지 못해 재생하지 않습니다.",
136
+ "The audio for this entry is no longer stored.":
137
+ "이 항목의 오디오는 더 이상 저장되어 있지 않습니다.",
138
+ "The app closed before this finished, so there is no audio.":
139
+ "생성이 끝나기 전에 앱이 종료되어 오디오가 없습니다.",
140
+ "This generation failed, so there is no audio.":
141
+ "생성에 실패하여 오디오가 없습니다.",
142
+ "This generation was canceled, so there is no audio.":
143
+ "생성이 취소되어 오디오가 없습니다.",
144
+ "This generation has not finished.": "생성이 아직 끝나지 않았습니다.",
145
+ # detail panel (F-39, F-41)
146
+ "Select an entry to see its text and settings.":
147
+ "항목을 선택하면 본문과 설정을 볼 수 있습니다.",
148
+ "Source text": "원본 본문",
149
+ "The source text was not kept for this entry.":
150
+ "이 항목의 원본 본문은 보관되지 않았습니다.",
151
+ "The saved text could not be read.": "저장된 본문을 읽지 못했습니다.",
152
+ "The saved text could not be read, so it cannot be generated again yet.":
153
+ "저장된 본문을 읽지 못해 아직 다시 생성할 수 없습니다.",
154
+ "Origin": "요청 경로",
155
+ "Applied budget": "적용된 자원 한도",
156
+ "Segments": "문장",
157
+ "{done} of {total}": "{total}개 중 {done}개",
158
+ "Started": "시작",
159
+ "Finished": "종료",
160
+ "Audio": "오디오",
161
+ "Regenerate": "다시 생성",
162
+ "Regenerating makes a new entry. The old one is kept, and the new audio "
163
+ "will not be identical.":
164
+ "다시 생성하면 새 항목이 만들어집니다. 기존 항목은 그대로 남고, 새 오디오는 "
165
+ "이전과 완전히 같지 않습니다.",
166
+ "There is no saved text to generate again from.":
167
+ "다시 생성할 저장된 본문이 없습니다.",
168
+ # paging
169
+ "{first}–{last} of {total}": "{total}개 중 {first}–{last}",
170
+ "Nothing to show": "표시할 항목이 없습니다",
171
+ "Previous page": "이전 페이지",
172
+ "Next page": "다음 페이지",
173
+ "Per page": "쪽당 항목 수",
174
+ # deletion (F-43)
175
+ "Delete all history": "기록 전체 삭제",
176
+ "Delete these items?": "이 항목을 삭제할까요?",
177
+ "Delete all generation history?": "생성 기록을 모두 삭제할까요?",
178
+ "{n} documents": "문서 {n}개",
179
+ "1 document": "문서 1개",
180
+ "{n} history entries": "기록 {n}개",
181
+ "1 history entry": "기록 1개",
182
+ "{n} audio files, {size}": "오디오 파일 {n}개, {size}",
183
+ "{size} of saved text": "저장된 본문 {size}",
184
+ "Delete the entries and their audio": "항목과 오디오를 함께 삭제",
185
+ "Delete only the audio and keep the entries": "오디오만 삭제하고 항목은 남기기",
186
+ "Generation history is stored separately. No job's saved text is "
187
+ "deleted by this.":
188
+ "생성 기록은 별도로 저장됩니다. 이 삭제로 지워지는 작업 본문은 없습니다.",
189
+ "WAV files you exported yourself are not touched.":
190
+ "직접 내보낸 WAV 파일은 삭제되지 않습니다.",
191
+ "Cancel the generation that is still running before deleting this.":
192
+ "아직 실행 중인 생성을 취소한 뒤에 삭제하세요.",
193
+ "This cannot be undone.": "되돌릴 수 없습니다.",
194
+ }
195
+ )
196
+
197
+ _UNKNOWN = "—"
198
+
199
+
200
+ def _set_role(widget: QWidget, role: str) -> None:
201
+ """Change a widget's theme role after it has been shown.
202
+
203
+ Qt resolves a stylesheet's property selectors at polish time, so
204
+ assigning ``role`` alone repaints nothing; the unpolish/polish pair is
205
+ what makes the token in ``theme.stylesheet`` take effect.
206
+ """
207
+ if widget.property("role") == role:
208
+ return
209
+ widget.setProperty("role", role)
210
+ style = widget.style()
211
+ style.unpolish(widget)
212
+ style.polish(widget)
213
+
214
+
215
+ class WrapRow(QLayout):
216
+ """A row of controls that wraps onto a second line instead of forcing
217
+ the window wider.
218
+
219
+ N-30's floor is 1280x720 at 200% OS scaling, which is 640x360 logical
220
+ pixels, and a :class:`QHBoxLayout`'s minimum width is the sum of
221
+ everything in it. A filter row of four combo boxes and two buttons
222
+ therefore sets a minimum no such screen can grant: the row wins, the
223
+ screen is clipped sideways, and -- unlike a column that is merely too
224
+ tall -- there is no scrollbar to recover the controls with. Wrapping
225
+ makes the minimum the widest *single* control instead, so the screen
226
+ compresses and every filter and page button stays reachable.
227
+
228
+ :meth:`add_stretch` leaves the wide layout exactly as it was: while
229
+ everything fits on one line the spacer pushes what follows to the right
230
+ edge, and once it does not, the spacer is where the line breaks.
231
+ """
232
+
233
+ def __init__(self, parent: QWidget | None = None) -> None:
234
+ super().__init__(parent)
235
+ self._items: list[QLayoutItem] = []
236
+ self.setContentsMargins(0, 0, 0, 0)
237
+ self.setSpacing(METRICS.gap)
238
+
239
+ def add_stretch(self) -> None:
240
+ """A gap that right-aligns on one line and breaks the line on two."""
241
+ self.addItem(
242
+ QSpacerItem(0, 0, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
243
+ )
244
+
245
+ # -- QLayout's plumbing --------------------------------------------
246
+
247
+ def addItem(self, item: QLayoutItem) -> None:
248
+ self._items.append(item)
249
+
250
+ def count(self) -> int:
251
+ return len(self._items)
252
+
253
+ def itemAt(self, index: int) -> QLayoutItem | None:
254
+ return self._items[index] if 0 <= index < len(self._items) else None
255
+
256
+ def takeAt(self, index: int) -> QLayoutItem | None:
257
+ return self._items.pop(index) if 0 <= index < len(self._items) else None
258
+
259
+ def expandingDirections(self) -> Qt.Orientation:
260
+ return Qt.Orientation(0)
261
+
262
+ def hasHeightForWidth(self) -> bool:
263
+ return True
264
+
265
+ def heightForWidth(self, width: int) -> int:
266
+ return self._arrange(QRect(0, 0, width, 0), place=False)
267
+
268
+ def setGeometry(self, rect: QRect) -> None:
269
+ super().setGeometry(rect)
270
+ self._arrange(rect, place=True)
271
+
272
+ def sizeHint(self) -> QSize:
273
+ return self._natural()
274
+
275
+ def minimumSize(self) -> QSize:
276
+ """The widest single control, which is the whole point: a row that
277
+ can wrap never needs room for all of its contents at once."""
278
+ size = QSize(0, 0)
279
+ for item in self._items:
280
+ size = size.expandedTo(item.minimumSize())
281
+ return size.grownBy(self.contentsMargins())
282
+
283
+ # -- placement -----------------------------------------------------
284
+
285
+ def _natural(self) -> QSize:
286
+ """What the row wants: one line, spacers at nothing."""
287
+ widths = [item.sizeHint().width() for item in self._items if item.spacerItem() is None]
288
+ heights = [item.sizeHint().height() for item in self._items if item.spacerItem() is None]
289
+ width = sum(widths) + self.spacing() * max(0, len(widths) - 1)
290
+ return QSize(width, max(heights, default=0)).grownBy(self.contentsMargins())
291
+
292
+ def _arrange(self, rect: QRect, *, place: bool) -> int:
293
+ """Lay the row out inside ``rect`` and report the height it needed.
294
+
295
+ Called twice for the same width -- once by ``heightForWidth`` to ask
296
+ and once by ``setGeometry`` to place -- so the two can never
297
+ disagree about where a line breaks.
298
+ """
299
+ margins = self.contentsMargins()
300
+ area = rect.marginsRemoved(margins)
301
+ gap = self.spacing()
302
+ natural = self._natural().width() - margins.left() - margins.right()
303
+ one_line = natural <= area.width()
304
+ spacers = sum(1 for item in self._items if item.spacerItem() is not None)
305
+ share = (area.width() - natural) // spacers if one_line and spacers else 0
306
+
307
+ x, y, line = area.x(), area.y(), 0
308
+ for item in self._items:
309
+ if item.spacerItem() is not None:
310
+ if one_line:
311
+ x += share
312
+ elif line:
313
+ x, y, line = area.x(), y + line + gap, 0
314
+ continue
315
+ hint = item.sizeHint()
316
+ width = max(item.minimumSize().width(), min(hint.width(), area.width()))
317
+ if line and x + width > area.right() + 1:
318
+ x, y, line = area.x(), y + line + gap, 0
319
+ if place:
320
+ item.setGeometry(QRect(QPoint(x, y), QSize(width, hint.height())))
321
+ x += width + gap
322
+ line = max(line, hint.height())
323
+ return y + line - rect.y() + margins.bottom()
324
+
325
+
326
+ # ======================================================================
327
+ # Row text. Pure functions, so what a row claims can be tested without a
328
+ # widget -- and so no two places can word the same state differently.
329
+ # ======================================================================
330
+
331
+
332
+ def state_text(state: JobState) -> str:
333
+ """Section 5.1's states, in words. ``Interrupted`` is one of them:
334
+ F-45 requires a job a forced termination left behind to say so rather
335
+ than to look merely unfinished."""
336
+ return {
337
+ JobState.ACCEPTED: tr("Accepted"),
338
+ JobState.PREPARING_MODEL: tr("Preparing the model"),
339
+ JobState.GENERATING: tr("Generating"),
340
+ JobState.CANCELING: tr("Canceling"),
341
+ JobState.COMPLETE: tr("Complete"),
342
+ JobState.FAILED: tr("Failed"),
343
+ JobState.CANCELED: tr("Canceled"),
344
+ JobState.INTERRUPTED: tr("Interrupted"),
345
+ }[state]
346
+
347
+
348
+ @dataclass(frozen=True, slots=True)
349
+ class Playability:
350
+ """Whether a history entry can be played, and if not, why (F-45).
351
+
352
+ The reason is a whole sentence because it is shown to a person who is
353
+ asking why the play button is grey; ``short`` is the same finding in the
354
+ two words a table column has room for.
355
+ """
356
+
357
+ playable: bool
358
+ reason: str = ""
359
+ short: str = ""
360
+
361
+
362
+ def playability(
363
+ summary: JobSummary,
364
+ *,
365
+ integrity: ResultIntegrity | None = None,
366
+ expired: bool | None = None,
367
+ ) -> Playability:
368
+ """F-45's "the reason playback is unavailable", from a summary alone.
369
+
370
+ ``integrity`` and ``expired`` override what the summary recorded, so the
371
+ detail panel can re-check the file it is about to offer while a list row
372
+ stays a pure read of the query -- F-40 does not allow a page of rows to
373
+ open a page of files.
374
+ """
375
+ state = summary.state
376
+ if state is JobState.INTERRUPTED:
377
+ return Playability(False, tr("The app closed before this finished, so there is no audio."))
378
+ if state is JobState.FAILED:
379
+ return Playability(
380
+ False,
381
+ summary.error_message or tr("This generation failed, so there is no audio."),
382
+ )
383
+ if state is JobState.CANCELED:
384
+ return Playability(False, tr("This generation was canceled, so there is no audio."))
385
+ if not state.is_terminal:
386
+ return Playability(False, tr("This generation has not finished."))
387
+
388
+ if not summary.has_result:
389
+ return Playability(
390
+ False, tr("The audio for this entry is no longer stored."), tr("audio not kept")
391
+ )
392
+ gone = summary.result_expired if expired is None else expired
393
+ if gone:
394
+ return Playability(
395
+ False, tr("This one-off audio has expired, so it cannot be played."), tr("audio expired")
396
+ )
397
+ found = summary.result_integrity if integrity is None else integrity
398
+ if found is ResultIntegrity.MISSING:
399
+ return Playability(
400
+ False, tr("The audio file is missing, so it cannot be played."), tr("audio missing")
401
+ )
402
+ if found is ResultIntegrity.CORRUPT:
403
+ return Playability(
404
+ False,
405
+ tr("The audio file failed its integrity check, so it is not played."),
406
+ tr("audio damaged"),
407
+ )
408
+ return Playability(True)
409
+
410
+
411
+ def retention_text(
412
+ summary: JobSummary, *, expires_at: float | None = None, now: float | None = None
413
+ ) -> str:
414
+ """F-42, in the row: kept or one-off, and when a one-off goes.
415
+
416
+ A one-off GUI result has no stored expiry -- 4.1 ties it to the app
417
+ closing rather than to a clock -- so that case says what actually
418
+ happens instead of inventing a time.
419
+ """
420
+ if summary.retention is RetentionMode.RETAINED:
421
+ return tr("Kept until you delete it")
422
+ if not summary.has_result:
423
+ return tr("One-off · nothing is kept")
424
+ moment = ids.now() if now is None else now
425
+ if expires_at is None:
426
+ if summary.result_expired:
427
+ return tr("One-off · the audio has expired")
428
+ return tr("One-off · removed when the app closes")
429
+ if expires_at <= moment:
430
+ return tr("One-off · the audio has expired")
431
+ return tr("One-off · expires in {when}").format(
432
+ when=approximate_duration(int((expires_at - moment) * 1000))
433
+ )
434
+
435
+
436
+ def when_text(ts: float | None) -> str:
437
+ """A timestamp a person can compare against their own memory. Numeric
438
+ in both languages: a localised month name in a narrow column wraps, and
439
+ F-38's requirement is that the times are displayed, not that they are
440
+ prose."""
441
+ if not ts:
442
+ return _UNKNOWN
443
+ return time.strftime("%Y-%m-%d %H:%M", time.localtime(ts))
444
+
445
+
446
+ @dataclass(frozen=True, slots=True)
447
+ class JobRow:
448
+ """One history row's five cells, plus what the eye needs beyond words."""
449
+
450
+ job_id: str
451
+ when: str
452
+ voice: str
453
+ state: str
454
+ retention: str
455
+ length: str
456
+ playable: bool
457
+ #: The whole sentence behind an unplayable row, for the tooltip and the
458
+ #: accessible description; empty when the row plays.
459
+ reason: str
460
+ #: A palette role name (``""``, ``"warn"`` or ``"danger"``) for the
461
+ #: state cell. Never the only signal: the cell text carries the finding
462
+ #: in words, which is what N-30 asks for.
463
+ severity: str
464
+
465
+
466
+ def build_job_row(
467
+ summary: JobSummary,
468
+ *,
469
+ model_name: str | None = None,
470
+ expires_at: float | None = None,
471
+ now: float | None = None,
472
+ ) -> JobRow:
473
+ """F-39's row, from the summary the list query already returned.
474
+
475
+ The expiry decides both the retention cell and the state cell, so a row
476
+ cannot say "expires in 20 minutes" beside "playable" once the clock has
477
+ passed the moment the query was made.
478
+ """
479
+ moment = ids.now() if now is None else now
480
+ expired = None if expires_at is None else expires_at <= moment
481
+ play = playability(summary, expired=expired)
482
+ state = state_text(summary.state)
483
+ if play.short:
484
+ state = f"{state} · {play.short}"
485
+ severity = ""
486
+ if summary.state in (JobState.FAILED, JobState.INTERRUPTED):
487
+ severity = "danger"
488
+ elif not play.playable and summary.state is JobState.COMPLETE:
489
+ severity = "warn"
490
+ settings = summary.settings
491
+ voice = f"{model_name or settings.model_id} · {settings.voice_id} · {settings.tempo:.2f}x"
492
+ return JobRow(
493
+ job_id=summary.job_id,
494
+ when=when_text(summary.created_at),
495
+ voice=voice,
496
+ state=state,
497
+ retention=retention_text(summary, expires_at=expires_at, now=now),
498
+ length=duration(summary.audio_duration_ms) if summary.audio_duration_ms else _UNKNOWN,
499
+ playable=play.playable,
500
+ reason="" if play.playable else play.reason,
501
+ severity=severity,
502
+ )
503
+
504
+
505
+ @dataclass(frozen=True, slots=True)
506
+ class DocumentRow:
507
+ document_id: str
508
+ title: str
509
+ created: str
510
+ edited: str
511
+ size: str
512
+
513
+
514
+ def build_document_row(summary: DocumentSummary) -> DocumentRow:
515
+ """F-38's row: title with both timestamps, and a size that came from
516
+ ``length(body)`` rather than from the body."""
517
+ return DocumentRow(
518
+ document_id=summary.document_id,
519
+ title=summary.title.strip() or tr("Untitled"),
520
+ created=when_text(summary.created_at),
521
+ edited=when_text(summary.modified_at),
522
+ size=count(summary.body_codepoints),
523
+ )
524
+
525
+
526
+ # ======================================================================
527
+ # Deletion (F-43)
528
+ # ======================================================================
529
+
530
+
531
+ @dataclass(frozen=True, slots=True)
532
+ class DeleteRequest:
533
+ """What the user confirmed, for the window to carry out.
534
+
535
+ The screen previews and asks; it does not delete. The store hands back
536
+ the audio paths a deletion orphans and deliberately does not unlink
537
+ them, so the act belongs where the audio directory's layout is known --
538
+ which is also the only way F-43's "WAV files you exported separately are
539
+ not deleted" can be guaranteed.
540
+ """
541
+
542
+ document_ids: tuple[str, ...] = ()
543
+ job_ids: tuple[str, ...] = ()
544
+ #: Remove the audio and keep the history entry. F-43 distinguishes
545
+ #: documents, jobs and audio, so this is a scope of its own and not a
546
+ #: weaker kind of job deletion.
547
+ audio_only: bool = False
548
+ all_history: bool = False
549
+ #: Exactly the preview the user agreed to, so a caller can report what
550
+ #: was promised rather than recount it.
551
+ scope: DeletionScope | None = None
552
+
553
+
554
+ def scope_lines(scope: DeletionScope, *, audio_only: bool = False) -> list[str]:
555
+ """F-43's preview, itemised so documents, entries and audio stay
556
+ distinguishable in the sentence the user reads."""
557
+ lines: list[str] = []
558
+ if not audio_only:
559
+ if scope.documents == 1:
560
+ lines.append(tr("1 document"))
561
+ elif scope.documents:
562
+ lines.append(tr("{n} documents").format(n=count(scope.documents)))
563
+ if scope.jobs == 1:
564
+ lines.append(tr("1 history entry"))
565
+ elif scope.jobs:
566
+ lines.append(tr("{n} history entries").format(n=count(scope.jobs)))
567
+ if scope.results:
568
+ lines.append(
569
+ tr("{n} audio files, {size}").format(
570
+ n=count(scope.results), size=bytes_size(scope.audio_bytes)
571
+ )
572
+ )
573
+ if scope.text_bytes and not audio_only:
574
+ lines.append(tr("{size} of saved text").format(size=bytes_size(scope.text_bytes)))
575
+ return lines
576
+
577
+
578
+ class DeleteConfirmDialog(QDialog):
579
+ """F-43's confirmation: the scope, the choices, and no default button
580
+ that deletes.
581
+
582
+ Cancel is the default for the same reason the licence dialog's decline
583
+ is: a destructive answer given to the Enter key has not been given.
584
+ """
585
+
586
+ def __init__(
587
+ self,
588
+ palette: Palette,
589
+ scope: DeletionScope,
590
+ *,
591
+ kind: str,
592
+ all_history: bool = False,
593
+ parent: QWidget | None = None,
594
+ ) -> None:
595
+ super().__init__(parent)
596
+ m = METRICS
597
+ self._scope = scope
598
+ self._audio_choice: QRadioButton | None = None
599
+
600
+ self.setModal(True)
601
+ self.setWindowTitle(
602
+ tr("Delete all generation history?") if all_history else tr("Delete these items?")
603
+ )
604
+ self.setMinimumWidth(440)
605
+
606
+ box = QVBoxLayout(self)
607
+ box.setContentsMargins(m.pad_wide, m.pad_wide, m.pad_wide, m.pad_wide)
608
+ box.setSpacing(m.gap)
609
+
610
+ box.addWidget(label(self.windowTitle(), "section"))
611
+ self.summary = label("", "secondary")
612
+ self.summary.setWordWrap(True)
613
+ box.addWidget(self.summary)
614
+
615
+ # F-43: deleting a document is never presented as having deleted a
616
+ # job's saved text. The two are separate copies (4.2, N-14), so the
617
+ # dialog says so where the user is deciding.
618
+ if kind == "documents":
619
+ note = label(
620
+ tr("Generation history is stored separately. No job's saved text is deleted by this."),
621
+ "muted",
622
+ )
623
+ note.setWordWrap(True)
624
+ box.addWidget(note)
625
+
626
+ if kind == "jobs" and scope.results and not all_history:
627
+ box.addWidget(separator())
628
+ self._both = QRadioButton(tr("Delete the entries and their audio"))
629
+ self._both.setChecked(True)
630
+ self._audio_choice = QRadioButton(tr("Delete only the audio and keep the entries"))
631
+ box.addWidget(self._both)
632
+ box.addWidget(self._audio_choice)
633
+ # The preview follows the choice. F-43 asks for the scope to be
634
+ # previewed, and a preview that still lists the history entries
635
+ # after the user narrowed the deletion to audio is not one.
636
+ self._audio_choice.toggled.connect(lambda _: self._show_scope())
637
+
638
+ keep = label(tr("WAV files you exported yourself are not touched."), "muted")
639
+ keep.setWordWrap(True)
640
+ box.addWidget(keep)
641
+
642
+ undo = label(tr("This cannot be undone."), "muted")
643
+ box.addWidget(undo)
644
+
645
+ blocked = bool(scope.blocked_job_ids)
646
+ if blocked:
647
+ # 5.3: a running job is cancelled first and is never deleted in
648
+ # part, so the dialog refuses rather than deleting what it can.
649
+ warn = label(
650
+ tr("Cancel the generation that is still running before deleting this."), "warn"
651
+ )
652
+ warn.setWordWrap(True)
653
+ box.addWidget(warn)
654
+
655
+ buttons = QDialogButtonBox()
656
+ self.cancel_button = buttons.addButton(tr("Cancel"), QDialogButtonBox.ButtonRole.RejectRole)
657
+ self.delete_button = buttons.addButton(tr("Delete"), QDialogButtonBox.ButtonRole.AcceptRole)
658
+ self.delete_button.setProperty("variant", "danger")
659
+ self.delete_button.setDefault(False)
660
+ self.delete_button.setAutoDefault(False)
661
+ self.delete_button.setEnabled(not blocked)
662
+ self.cancel_button.setDefault(True)
663
+ buttons.accepted.connect(self.accept)
664
+ buttons.rejected.connect(self.reject)
665
+ box.addWidget(buttons)
666
+ self._show_scope()
667
+
668
+ def _show_scope(self) -> None:
669
+ lines = scope_lines(self._scope, audio_only=self.audio_only) or [tr("Nothing to show")]
670
+ self.summary.setText("\n".join("· " + line for line in lines))
671
+
672
+ @property
673
+ def audio_only(self) -> bool:
674
+ return self._audio_choice is not None and self._audio_choice.isChecked()
675
+
676
+
677
+ # ======================================================================
678
+ # Paging (4.1: 20 by default, 100 at most)
679
+ # ======================================================================
680
+
681
+
682
+ class PageBar(QWidget):
683
+ """Page position, movement, and page size.
684
+
685
+ The size choices are 4.1's own two numbers. Offering anything larger
686
+ would be a control that lies: the store clamps to the ceiling whatever
687
+ it is handed.
688
+ """
689
+
690
+ changed = Signal()
691
+
692
+ def __init__(self, parent: QWidget | None = None) -> None:
693
+ super().__init__(parent)
694
+ m = METRICS
695
+ self._offset = 0
696
+ self._total = 0
697
+ self._shown = 0
698
+
699
+ # A wrapping row, not a QHBoxLayout: six controls side by side put a
700
+ # floor under the whole screen's width, and N-30's narrowest
701
+ # supported screen is below it.
702
+ row = WrapRow(self)
703
+ row.setSpacing(m.gap)
704
+
705
+ self.range_label = label("", "muted")
706
+ row.addWidget(self.range_label)
707
+ row.add_stretch()
708
+
709
+ row.addWidget(label(tr("Per page"), "muted"))
710
+ self.size = QComboBox()
711
+ for value in PAGE_SIZES:
712
+ self.size.addItem(count(value), value)
713
+ self.size.setAccessibleName(tr("Per page"))
714
+ self.size.setFixedWidth(88)
715
+ row.addWidget(self.size)
716
+
717
+ self.previous = QPushButton(tr("Previous page"))
718
+ self.previous.setProperty("variant", "quiet")
719
+ self.previous.setAccessibleName(tr("Previous page"))
720
+ self.next = QPushButton(tr("Next page"))
721
+ self.next.setProperty("variant", "quiet")
722
+ self.next.setAccessibleName(tr("Next page"))
723
+ row.addWidget(self.previous)
724
+ row.addWidget(self.next)
725
+
726
+ self.previous.clicked.connect(self._back)
727
+ self.next.clicked.connect(self._forward)
728
+ self.size.currentIndexChanged.connect(self._resize)
729
+
730
+ @property
731
+ def offset(self) -> int:
732
+ return self._offset
733
+
734
+ @property
735
+ def page_size(self) -> int:
736
+ value = self.size.currentData()
737
+ return int(value) if value else LIST_PAGE_DEFAULT
738
+
739
+ def reset(self) -> None:
740
+ """Back to the first page, without a reload of its own: a filter
741
+ change reloads once, from the pane."""
742
+ self._offset = 0
743
+
744
+ def apply_page(self, *, total: int, offset: int, shown: int) -> None:
745
+ self._total = total
746
+ self._offset = offset
747
+ self._shown = shown
748
+ if total <= 0:
749
+ self.range_label.setText(tr("Nothing to show"))
750
+ else:
751
+ self.range_label.setText(
752
+ tr("{first}–{last} of {total}").format(
753
+ first=count(offset + 1), last=count(offset + shown), total=count(total)
754
+ )
755
+ )
756
+ self.previous.setEnabled(offset > 0)
757
+ self.next.setEnabled(offset + shown < total)
758
+
759
+ def _back(self) -> None:
760
+ self._offset = max(0, self._offset - self.page_size)
761
+ self.changed.emit()
762
+
763
+ def _forward(self) -> None:
764
+ if self._offset + self._shown < self._total:
765
+ self._offset += self.page_size
766
+ self.changed.emit()
767
+
768
+ def _resize(self) -> None:
769
+ self._offset = 0
770
+ self.changed.emit()
771
+
772
+
773
+ def _tree(columns: Sequence[str], name: str) -> QTreeWidget:
774
+ """A list that is a table, and that works with the arrow keys alone.
775
+
776
+ A tree rather than a list because F-39 and F-40 both want several facts
777
+ per row aligned down the page, and because the header is already themed.
778
+ Selection is whole-row and the item is activated by Enter, so N-30's
779
+ "usable by keyboard" needs no extra shortcut.
780
+ """
781
+ t = QTreeWidget()
782
+ t.setColumnCount(len(columns))
783
+ t.setHeaderLabels(list(columns))
784
+ t.setRootIsDecorated(False)
785
+ t.setUniformRowHeights(True)
786
+ t.setAlternatingRowColors(True)
787
+ t.setAllColumnsShowFocus(True)
788
+ t.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
789
+ t.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
790
+ t.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
791
+ t.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
792
+ t.setAccessibleName(name)
793
+ header = t.header()
794
+ header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
795
+ for i in range(1, len(columns)):
796
+ header.setSectionResizeMode(i, QHeaderView.ResizeMode.ResizeToContents)
797
+ return t
798
+
799
+
800
+ # ======================================================================
801
+ # Documents (F-38, F-40)
802
+ # ======================================================================
803
+
804
+
805
+ class DocumentsPane(QWidget):
806
+ """The saved-document list, its search, and its deletion."""
807
+
808
+ open_document = Signal(str)
809
+ delete_requested = Signal(object)
810
+ problem = Signal(object)
811
+
812
+ def __init__(self, store: Store, palette: Palette, parent: QWidget | None = None) -> None:
813
+ super().__init__(parent)
814
+ self._store = store
815
+ self._palette = palette
816
+ m = METRICS
817
+
818
+ box = QVBoxLayout(self)
819
+ box.setContentsMargins(m.pad, m.pad, m.pad, m.pad)
820
+ box.setSpacing(m.gap)
821
+
822
+ top = QHBoxLayout()
823
+ top.setSpacing(m.gap)
824
+ self.search = QLineEdit()
825
+ self.search.setPlaceholderText(tr("Search titles and text"))
826
+ self.search.setAccessibleName(tr("Search titles and text"))
827
+ top.addWidget(self.search, 1)
828
+ self.open_button = QPushButton(tr("Open"))
829
+ self.open_button.setAccessibleName(tr("Open"))
830
+ self.delete_button = QPushButton(tr("Delete"))
831
+ self.delete_button.setProperty("variant", "danger")
832
+ self.delete_button.setAccessibleName(tr("Delete"))
833
+ top.addWidget(self.open_button)
834
+ top.addWidget(self.delete_button)
835
+ box.addLayout(top)
836
+
837
+ self.tree = _tree(
838
+ (tr("Title"), tr("Created"), tr("Edited"), tr("Characters")), tr("Documents")
839
+ )
840
+ box.addWidget(self.tree, 1)
841
+
842
+ self.status = label("", "muted")
843
+ self.status.setWordWrap(True)
844
+ box.addWidget(self.status)
845
+
846
+ self.pages = PageBar()
847
+ box.addWidget(self.pages)
848
+
849
+ self._debounce = QTimer(self)
850
+ self._debounce.setSingleShot(True)
851
+ self._debounce.setInterval(SEARCH_DEBOUNCE_MS)
852
+ self._debounce.timeout.connect(self._search_now)
853
+
854
+ self.search.textChanged.connect(lambda _: self._debounce.start())
855
+ self.tree.itemActivated.connect(lambda *_: self._open_selected())
856
+ self.tree.itemSelectionChanged.connect(self._update_enabled)
857
+ self.open_button.clicked.connect(self._open_selected)
858
+ self.delete_button.clicked.connect(self._delete_selected)
859
+ self.pages.changed.connect(self.refresh)
860
+ self._update_enabled()
861
+
862
+ # -- data ----------------------------------------------------------
863
+
864
+ def refresh(self) -> None:
865
+ """One page, one query. F-40's page is retrieved without touching
866
+ a body: the store's summary projection reports ``length(body)`` and
867
+ leaves the text in the row."""
868
+ query = self.search.text().strip()
869
+ try:
870
+ page = self._store.search_documents(
871
+ query, limit=self.pages.page_size, offset=self.pages.offset
872
+ )
873
+ except EchoActError as exc:
874
+ self._fail(exc)
875
+ return
876
+ self.tree.clear()
877
+ for summary in page.items:
878
+ row = build_document_row(summary)
879
+ item = QTreeWidgetItem([row.title, row.created, row.edited, row.size])
880
+ item.setData(0, Qt.ItemDataRole.UserRole, row.document_id)
881
+ item.setTextAlignment(3, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
882
+ self.tree.addTopLevelItem(item)
883
+ if page.items:
884
+ self.tree.setCurrentItem(self.tree.topLevelItem(0))
885
+ self.status.setText("")
886
+ else:
887
+ self.status.setText(
888
+ tr("Nothing matches that search") if query else tr("Nothing saved yet")
889
+ )
890
+ self.pages.apply_page(total=page.total, offset=page.offset, shown=len(page.items))
891
+ self._update_enabled()
892
+
893
+ def _search_now(self) -> None:
894
+ self.pages.reset()
895
+ self.refresh()
896
+
897
+ def _fail(self, exc: EchoActError) -> None:
898
+ """F-25: a failed query says why, and the pane stays usable."""
899
+ self.status.setText(f"{exc.code.value} — {exc.message}")
900
+ self.problem.emit(exc)
901
+
902
+ # -- selection and actions -----------------------------------------
903
+
904
+ def selected_ids(self) -> tuple[str, ...]:
905
+ return tuple(
906
+ item.data(0, Qt.ItemDataRole.UserRole) for item in self.tree.selectedItems()
907
+ )
908
+
909
+ def _update_enabled(self) -> None:
910
+ chosen = self.selected_ids()
911
+ self.open_button.setEnabled(len(chosen) == 1)
912
+ self.delete_button.setEnabled(bool(chosen))
913
+
914
+ def _open_selected(self) -> None:
915
+ chosen = self.selected_ids()
916
+ if len(chosen) == 1:
917
+ self.open_document.emit(chosen[0])
918
+
919
+ def _delete_selected(self) -> None:
920
+ chosen = self.selected_ids()
921
+ if not chosen:
922
+ return
923
+ try:
924
+ scope = self._store.preview_deletion(document_ids=list(chosen))
925
+ except EchoActError as exc:
926
+ self._fail(exc)
927
+ return
928
+ dialog = DeleteConfirmDialog(self._palette, scope, kind="documents", parent=self)
929
+ if dialog.exec() != QDialog.DialogCode.Accepted:
930
+ return
931
+ self.delete_requested.emit(DeleteRequest(document_ids=chosen, scope=scope))
932
+
933
+
934
+ # ======================================================================
935
+ # History (F-39 to F-43, F-45)
936
+ # ======================================================================
937
+
938
+
939
+ class JobDetail(QWidget):
940
+ """One reopened history entry: its text, its settings, and what can be
941
+ done with it (F-41, F-45).
942
+
943
+ Nothing here starts anything. F-45 is explicit that an interrupted job
944
+ and a broken result are neither regenerated nor played automatically, so
945
+ every action is a button, and the buttons that cannot work are disabled
946
+ with the reason above them rather than hidden.
947
+ """
948
+
949
+ play_requested = Signal(str)
950
+ export_requested = Signal(str)
951
+ regenerate_requested = Signal(str)
952
+ delete_requested = Signal(str)
953
+ problem = Signal(object)
954
+
955
+ def __init__(
956
+ self,
957
+ store: Store,
958
+ *,
959
+ manifest: Manifest | None = None,
960
+ clock: Callable[[], float] = ids.now,
961
+ parent: QWidget | None = None,
962
+ ) -> None:
963
+ super().__init__(parent)
964
+ self._store = store
965
+ self._manifest = manifest
966
+ self._clock = clock
967
+ self._summary: JobSummary | None = None
968
+ m = METRICS
969
+
970
+ box = QVBoxLayout(self)
971
+ box.setContentsMargins(m.pad, m.pad, m.pad, m.pad)
972
+ box.setSpacing(m.gap)
973
+
974
+ self.heading = label("", "section")
975
+ self.heading.setWordWrap(True)
976
+ box.addWidget(self.heading)
977
+
978
+ self.reason = label("", "warn")
979
+ self.reason.setWordWrap(True)
980
+ box.addWidget(self.reason)
981
+
982
+ self.retention = label("", "secondary")
983
+ self.retention.setWordWrap(True)
984
+ box.addWidget(self.retention)
985
+
986
+ self.facts = label("", "muted")
987
+ self.facts.setWordWrap(True)
988
+ self.facts.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
989
+ box.addWidget(self.facts)
990
+
991
+ box.addWidget(separator())
992
+ box.addWidget(label(tr("Source text"), "secondary"))
993
+ self.text = QPlainTextEdit()
994
+ self.text.setReadOnly(True)
995
+ self.text.setAccessibleName(tr("Source text"))
996
+ box.addWidget(self.text, 1)
997
+
998
+ actions = QHBoxLayout()
999
+ actions.setSpacing(m.gap)
1000
+ self.play_button = QPushButton(tr("Play"))
1001
+ self.play_button.setAccessibleName(tr("Play"))
1002
+ self.export_button = QPushButton(tr("Save audio"))
1003
+ self.export_button.setAccessibleName(tr("Save audio"))
1004
+ self.regenerate_button = QPushButton(tr("Regenerate"))
1005
+ self.regenerate_button.setAccessibleName(tr("Regenerate"))
1006
+ self.delete_button = QPushButton(tr("Delete"))
1007
+ self.delete_button.setProperty("variant", "danger")
1008
+ self.delete_button.setAccessibleName(tr("Delete"))
1009
+ for button in (
1010
+ self.play_button,
1011
+ self.export_button,
1012
+ self.regenerate_button,
1013
+ self.delete_button,
1014
+ ):
1015
+ actions.addWidget(button)
1016
+ actions.addStretch(1)
1017
+ box.addLayout(actions)
1018
+
1019
+ # F-41 and A.5: the same settings do not reproduce the same audio,
1020
+ # so the warning sits next to the button rather than in a notice
1021
+ # the user meets afterwards.
1022
+ self.caution = label(
1023
+ tr(
1024
+ "Regenerating makes a new entry. The old one is kept, and the new audio "
1025
+ "will not be identical."
1026
+ ),
1027
+ "muted",
1028
+ )
1029
+ self.caution.setWordWrap(True)
1030
+ box.addWidget(self.caution)
1031
+
1032
+ self.play_button.clicked.connect(self._play)
1033
+ self.export_button.clicked.connect(self._export)
1034
+ self.regenerate_button.clicked.connect(self._regenerate)
1035
+ self.delete_button.clicked.connect(self._delete)
1036
+ self.show_job(None)
1037
+
1038
+ # -- population ----------------------------------------------------
1039
+
1040
+ def show_job(self, summary: JobSummary | None) -> None:
1041
+ """Reopen one entry (F-41), or clear the panel.
1042
+
1043
+ The snapshot is read here and only here: F-56 keeps it behind a
1044
+ request of its own, and a page of rows must not carry one each.
1045
+ """
1046
+ self._summary = summary
1047
+ if summary is None:
1048
+ self.heading.setText(tr("Select an entry to see its text and settings."))
1049
+ for widget in (self.reason, self.retention, self.facts):
1050
+ widget.setText("")
1051
+ self.text.setPlainText("")
1052
+ self.caution.hide()
1053
+ for button in (
1054
+ self.play_button,
1055
+ self.export_button,
1056
+ self.regenerate_button,
1057
+ self.delete_button,
1058
+ ):
1059
+ button.setEnabled(False)
1060
+ return
1061
+
1062
+ self.caution.show()
1063
+ result: Result | None = None
1064
+ integrity: ResultIntegrity | None = None
1065
+ snapshot: str | None = None
1066
+ failure: EchoActError | None = None
1067
+ try:
1068
+ if summary.has_result:
1069
+ result = self._store.get_result_for_job(summary.job_id)
1070
+ integrity = self._recheck(summary, result)
1071
+ snapshot = self._store.get_job_text(summary.job_id)
1072
+ except EchoActError as exc:
1073
+ failure = exc
1074
+ self.problem.emit(exc)
1075
+
1076
+ now = self._clock()
1077
+ expires_at = result.expires_at if result is not None else None
1078
+ # ``None``, never ``False``: a read that did not happen knows nothing
1079
+ # about the expiry, and a hard ``False`` would override the expiry
1080
+ # the summary already recorded -- offering Play and Save beside a
1081
+ # retention line that says the audio has expired. F-45 wants the
1082
+ # reason, and `build_job_row` makes the same fallback so that the
1083
+ # row and the panel cannot contradict each other.
1084
+ expired = None if expires_at is None else expires_at <= now
1085
+ play = playability(summary, integrity=integrity, expired=expired)
1086
+
1087
+ self.heading.setText(f"{when_text(summary.created_at)} · {state_text(summary.state)}")
1088
+ # F-25: a store that could not answer is a failure of this panel,
1089
+ # not a fact about the entry, so its code and message are shown
1090
+ # beside what is known rather than in place of it -- and are not
1091
+ # overwritten by the playability verdict computed from the summary.
1092
+ lines = [] if play.playable else [play.reason]
1093
+ if failure is not None:
1094
+ lines.append(f"{failure.code.value} — {failure.message}")
1095
+ self.reason.setText("\n".join(lines))
1096
+ self.reason.setVisible(bool(lines))
1097
+ # A job that failed or was interrupted lost its audio; a job whose
1098
+ # file went missing has a problem with the file. They are different
1099
+ # findings and F-25 wants them to read differently.
1100
+ _set_role(
1101
+ self.reason,
1102
+ "danger"
1103
+ if failure is not None or summary.state in (JobState.FAILED, JobState.INTERRUPTED)
1104
+ else "warn",
1105
+ )
1106
+ self.retention.setText(retention_text(summary, expires_at=expires_at, now=now))
1107
+ self.facts.setText(self._facts(summary, result))
1108
+
1109
+ if snapshot is None:
1110
+ self.text.setPlainText("")
1111
+ # "not kept" is a claim about F-42 retention. A read that threw
1112
+ # has not established it, so it says what actually happened.
1113
+ self.text.setPlaceholderText(
1114
+ tr("The saved text could not be read.")
1115
+ if failure is not None
1116
+ else tr("The source text was not kept for this entry.")
1117
+ )
1118
+ else:
1119
+ self.text.setPlaceholderText("")
1120
+ self.text.setPlainText(snapshot)
1121
+
1122
+ can_regenerate = bool(snapshot)
1123
+ self.play_button.setEnabled(play.playable)
1124
+ self.export_button.setEnabled(play.playable)
1125
+ self.regenerate_button.setEnabled(can_regenerate)
1126
+ self.delete_button.setEnabled(True)
1127
+ if failure is not None and not can_regenerate:
1128
+ self.caution.setText(
1129
+ tr("The saved text could not be read, so it cannot be generated again yet.")
1130
+ )
1131
+ elif not can_regenerate:
1132
+ self.caution.setText(tr("There is no saved text to generate again from."))
1133
+ else:
1134
+ self.caution.setText(
1135
+ tr(
1136
+ "Regenerating makes a new entry. The old one is kept, and the new audio "
1137
+ "will not be identical."
1138
+ )
1139
+ )
1140
+
1141
+ def _recheck(self, summary: JobSummary, result: Result | None) -> ResultIntegrity | None:
1142
+ """F-45's detection for the one entry being opened.
1143
+
1144
+ Shallow: a stat against the recorded byte size, which is what a
1145
+ forced termination actually leaves behind, and which does not read a
1146
+ hundred megabytes to draw a panel. A recorded ``corrupt`` or
1147
+ ``missing`` is never re-run, because a size that happens to match
1148
+ would silently overwrite a digest failure with "fine".
1149
+ """
1150
+ if result is None:
1151
+ return None
1152
+ recorded = summary.result_integrity
1153
+ if recorded in (ResultIntegrity.MISSING, ResultIntegrity.CORRUPT):
1154
+ return recorded
1155
+ return self._store.verify_result(result.result_id, deep=False)
1156
+
1157
+ def _facts(self, summary: JobSummary, result: Result | None) -> str:
1158
+ """F-39's stored facts, including the budget actually applied."""
1159
+ s = summary.settings
1160
+ model = s.model_id
1161
+ if self._manifest is not None and self._manifest.has(s.model_id):
1162
+ model = self._manifest.get(s.model_id).display_name
1163
+ parts = [
1164
+ f"{tr('Model')}: {model}",
1165
+ f"{tr('Voice')}: {s.voice_id} · {tr('Tempo')} {s.tempo:.2f}x",
1166
+ f"{tr('Origin')}: {summary.request_path.value}"
1167
+ + (f" ({summary.client_label})" if summary.client_label else ""),
1168
+ f"{tr('Started')}: {when_text(summary.started_at)}",
1169
+ f"{tr('Finished')}: {when_text(summary.ended_at)}",
1170
+ f"{tr('Segments')}: "
1171
+ + tr("{done} of {total}").format(
1172
+ done=count(summary.generated_segments), total=count(summary.total_segments)
1173
+ ),
1174
+ ]
1175
+ if summary.audio_duration_ms:
1176
+ audio = duration(summary.audio_duration_ms)
1177
+ if result is not None:
1178
+ audio += f" · {bytes_size(result.byte_size)}"
1179
+ parts.append(f"{tr('Audio')}: {audio}")
1180
+ if summary.budget is not None:
1181
+ b = summary.budget
1182
+ parts.append(
1183
+ f"{tr('Applied budget')}: {tr('CPU')} {b.cpu_percent}% · "
1184
+ f"{memory_size(b.memory_bytes)}"
1185
+ )
1186
+ if summary.error_code:
1187
+ parts.append(f"{summary.error_code}: {summary.error_message or ''}".strip(": "))
1188
+ return "\n".join(parts)
1189
+
1190
+ # -- actions -------------------------------------------------------
1191
+
1192
+ @property
1193
+ def job_id(self) -> str | None:
1194
+ return None if self._summary is None else self._summary.job_id
1195
+
1196
+ def first_action(self) -> QPushButton:
1197
+ """The button Enter on a row should land on.
1198
+
1199
+ Playing is offered first when there is something to play, and
1200
+ regenerating when there is not -- but neither happens on its own,
1201
+ which is exactly what F-45 forbids.
1202
+ """
1203
+ for button in (self.play_button, self.regenerate_button, self.delete_button):
1204
+ if button.isEnabled():
1205
+ return button
1206
+ return self.delete_button
1207
+
1208
+ def _play(self) -> None:
1209
+ if self._summary is not None:
1210
+ self.play_requested.emit(self._summary.job_id)
1211
+
1212
+ def _export(self) -> None:
1213
+ if self._summary is not None:
1214
+ self.export_requested.emit(self._summary.job_id)
1215
+
1216
+ def _regenerate(self) -> None:
1217
+ if self._summary is not None:
1218
+ self.regenerate_requested.emit(self._summary.job_id)
1219
+
1220
+ def _delete(self) -> None:
1221
+ if self._summary is not None:
1222
+ self.delete_requested.emit(self._summary.job_id)
1223
+
1224
+
1225
+ class HistoryPane(QWidget):
1226
+ """The generation-history list, its filters, and the reopened entry."""
1227
+
1228
+ play_job = Signal(str)
1229
+ export_job = Signal(str)
1230
+ regenerate_job = Signal(str)
1231
+ delete_requested = Signal(object)
1232
+ problem = Signal(object)
1233
+
1234
+ def __init__(
1235
+ self,
1236
+ store: Store,
1237
+ palette: Palette,
1238
+ *,
1239
+ manifest: Manifest | None = None,
1240
+ clock: Callable[[], float] = ids.now,
1241
+ parent: QWidget | None = None,
1242
+ ) -> None:
1243
+ super().__init__(parent)
1244
+ self._store = store
1245
+ self._palette = palette
1246
+ self._manifest = manifest
1247
+ self._clock = clock
1248
+ self._rows: dict[str, JobSummary] = {}
1249
+ m = METRICS
1250
+
1251
+ box = QVBoxLayout(self)
1252
+ box.setContentsMargins(m.pad, m.pad, m.pad, m.pad)
1253
+ box.setSpacing(m.gap)
1254
+
1255
+ self.filters = self._filter_row()
1256
+ box.addWidget(self.filters)
1257
+
1258
+ split = QSplitter(Qt.Orientation.Horizontal)
1259
+ self.tree = _tree(
1260
+ (tr("When"), tr("Voice"), tr("State"), tr("Retention"), tr("Length")), tr("History")
1261
+ )
1262
+ split.addWidget(self.tree)
1263
+
1264
+ self.detail = JobDetail(store, manifest=manifest, clock=clock)
1265
+ holder = QScrollArea()
1266
+ holder.setWidget(self.detail)
1267
+ holder.setWidgetResizable(True)
1268
+ holder.setFrameShape(QFrame.Shape.NoFrame)
1269
+ # N-30: at 200% scaling the panel is taller than the window, and a
1270
+ # scroll area is the layout change that keeps it reachable instead
1271
+ # of clipped.
1272
+ split.addWidget(holder)
1273
+ split.setStretchFactor(0, 3)
1274
+ split.setStretchFactor(1, 2)
1275
+ box.addWidget(split, 1)
1276
+
1277
+ self.status = label("", "muted")
1278
+ self.status.setWordWrap(True)
1279
+ box.addWidget(self.status)
1280
+
1281
+ self.pages = PageBar()
1282
+ box.addWidget(self.pages)
1283
+
1284
+ self.tree.itemSelectionChanged.connect(self._on_selection)
1285
+ self.tree.itemActivated.connect(lambda *_: self._activate())
1286
+ self.pages.changed.connect(self.refresh)
1287
+ self.detail.play_requested.connect(self.play_job)
1288
+ self.detail.export_requested.connect(self.export_job)
1289
+ self.detail.regenerate_requested.connect(self.regenerate_job)
1290
+ self.detail.delete_requested.connect(lambda job_id: self._delete((job_id,)))
1291
+ self.detail.problem.connect(self.problem)
1292
+
1293
+ def _filter_row(self) -> QWidget:
1294
+ """F-40's filters: date, model, and completion state. Retention is
1295
+ here too, because F-42 requires the one-off/kept distinction to be
1296
+ visible and a filter is the cheapest way to answer "what is actually
1297
+ being kept?".
1298
+
1299
+ Six controls in a row that cannot wrap would set the whole screen's
1300
+ minimum width -- N-30's 1280x720 at 200% scaling is 640 logical
1301
+ pixels wide and there is no sideways scrollbar to fall back on --
1302
+ so this is a :class:`WrapRow`.
1303
+ """
1304
+ m = METRICS
1305
+ holder = QWidget()
1306
+ row = WrapRow(holder)
1307
+ row.setSpacing(m.gap)
1308
+
1309
+ self.date = QComboBox()
1310
+ self.date.addItem(tr("Any time"), None)
1311
+ self.date.addItem(tr("Today"), DATE_WINDOWS_DAYS[0])
1312
+ for days in DATE_WINDOWS_DAYS[1:]:
1313
+ self.date.addItem(tr("Last {n} days").format(n=count(days)), days)
1314
+ self.date.setAccessibleName(tr("When"))
1315
+
1316
+ self.model = QComboBox()
1317
+ self.model.addItem(tr("Any model"), None)
1318
+ if self._manifest is not None:
1319
+ for entry in self._manifest.models:
1320
+ self.model.addItem(entry.display_name, entry.model_id)
1321
+ self.model.setAccessibleName(tr("Model"))
1322
+
1323
+ self.state = QComboBox()
1324
+ self.state.addItem(tr("Any state"), None)
1325
+ self.state.addItem(tr("Complete"), (JobState.COMPLETE,))
1326
+ self.state.addItem(tr("Failed"), (JobState.FAILED,))
1327
+ self.state.addItem(tr("Canceled"), (JobState.CANCELED,))
1328
+ self.state.addItem(tr("Interrupted"), (JobState.INTERRUPTED,))
1329
+ self.state.addItem(
1330
+ tr("In progress"),
1331
+ tuple(s for s in JobState if s.is_active),
1332
+ )
1333
+ self.state.setAccessibleName(tr("State"))
1334
+
1335
+ self.retention = QComboBox()
1336
+ self.retention.addItem(tr("Anything kept"), None)
1337
+ self.retention.addItem(tr("Kept"), RetentionMode.RETAINED)
1338
+ self.retention.addItem(tr("One-off"), RetentionMode.ONE_OFF)
1339
+ self.retention.setAccessibleName(tr("Retention"))
1340
+
1341
+ for widget in (self.date, self.model, self.state, self.retention):
1342
+ widget.currentIndexChanged.connect(self._filters_changed)
1343
+ row.addWidget(widget)
1344
+ row.add_stretch()
1345
+
1346
+ # F-43 offers deleting related entries together. The rows the user
1347
+ # picked are the only relation this schema has: 4.2 and N-14 keep a
1348
+ # job's snapshot its own copy with no link back to a document, so
1349
+ # there is no set of "the jobs made from this document" to offer.
1350
+ self.delete_button = QPushButton(tr("Delete"))
1351
+ self.delete_button.setProperty("variant", "danger")
1352
+ self.delete_button.setAccessibleName(tr("Delete"))
1353
+ self.delete_button.setEnabled(False)
1354
+ self.delete_button.clicked.connect(self.delete_selected)
1355
+ row.addWidget(self.delete_button)
1356
+
1357
+ self.delete_all_button = QPushButton(tr("Delete all history"))
1358
+ self.delete_all_button.setProperty("variant", "danger")
1359
+ self.delete_all_button.setAccessibleName(tr("Delete all history"))
1360
+ self.delete_all_button.clicked.connect(self._delete_all)
1361
+ row.addWidget(self.delete_all_button)
1362
+ return holder
1363
+
1364
+ # -- data ----------------------------------------------------------
1365
+
1366
+ def refresh(self) -> None:
1367
+ """One page of history, with no audio opened and no snapshot read.
1368
+
1369
+ The one extra read per row is the expiry of a one-off result, which
1370
+ is a row in ``results`` and is what F-42 means by conveying when
1371
+ retention expires. It is deliberately not derived from the policy
1372
+ TTL: a derived time would drift from the one the cleanup sweep
1373
+ actually uses.
1374
+ """
1375
+ keep = self.retention.currentData()
1376
+ states = self.state.currentData()
1377
+ days = self.date.currentData()
1378
+ now = self._clock()
1379
+ try:
1380
+ page = self._store.list_jobs(
1381
+ created_from=None if days is None else now - days * _SECONDS_PER_DAY,
1382
+ model_id=self.model.currentData(),
1383
+ states=states,
1384
+ retention=None if keep is None else RetentionMode(keep),
1385
+ limit=self.pages.page_size,
1386
+ offset=self.pages.offset,
1387
+ )
1388
+ except EchoActError as exc:
1389
+ self._fail(exc)
1390
+ return
1391
+
1392
+ self.tree.clear()
1393
+ self._rows = {}
1394
+ for summary in page.items:
1395
+ self._rows[summary.job_id] = summary
1396
+ self.tree.addTopLevelItem(self._item(summary, now))
1397
+ if page.items:
1398
+ self.tree.setCurrentItem(self.tree.topLevelItem(0))
1399
+ self.status.setText("")
1400
+ else:
1401
+ self.detail.show_job(None)
1402
+ self.status.setText(
1403
+ tr("No generation history yet")
1404
+ if self._filters_are_open()
1405
+ else tr("Nothing matches those filters")
1406
+ )
1407
+ self.pages.apply_page(total=page.total, offset=page.offset, shown=len(page.items))
1408
+ self.delete_all_button.setEnabled(page.total > 0)
1409
+
1410
+ def _item(self, summary: JobSummary, now: float) -> QTreeWidgetItem:
1411
+ """One row. The state cell carries the finding in words and the
1412
+ colour only reinforces it, which is what N-30 requires of a signal
1413
+ a colour-blind reader has to act on."""
1414
+ row = build_job_row(
1415
+ summary,
1416
+ model_name=self._model_name(summary.settings.model_id),
1417
+ expires_at=self._expiry(summary),
1418
+ now=now,
1419
+ )
1420
+ item = QTreeWidgetItem([row.when, row.voice, row.state, row.retention, row.length])
1421
+ item.setData(0, Qt.ItemDataRole.UserRole, row.job_id)
1422
+ if row.severity:
1423
+ item.setForeground(2, severity_colour(self._palette, row.severity))
1424
+ if row.reason:
1425
+ item.setToolTip(2, row.reason)
1426
+ item.setData(2, Qt.ItemDataRole.AccessibleDescriptionRole, row.reason)
1427
+ return item
1428
+
1429
+ def _expiry(self, summary: JobSummary) -> float | None:
1430
+ if summary.retention is not RetentionMode.ONE_OFF or not summary.has_result:
1431
+ return None
1432
+ try:
1433
+ result = self._store.get_result_for_job(summary.job_id)
1434
+ except EchoActError:
1435
+ return None
1436
+ return None if result is None else result.expires_at
1437
+
1438
+ def _model_name(self, model_id: str) -> str:
1439
+ if self._manifest is not None and self._manifest.has(model_id):
1440
+ return self._manifest.get(model_id).display_name
1441
+ return model_id
1442
+
1443
+ def _filters_are_open(self) -> bool:
1444
+ return (
1445
+ self.date.currentData() is None
1446
+ and self.model.currentData() is None
1447
+ and self.state.currentData() is None
1448
+ and self.retention.currentData() is None
1449
+ )
1450
+
1451
+ def _filters_changed(self) -> None:
1452
+ self.pages.reset()
1453
+ self.refresh()
1454
+
1455
+ def _fail(self, exc: EchoActError) -> None:
1456
+ self.status.setText(f"{exc.code.value} — {exc.message}")
1457
+ self.problem.emit(exc)
1458
+
1459
+ # -- selection and actions -----------------------------------------
1460
+
1461
+ def selected_ids(self) -> tuple[str, ...]:
1462
+ return tuple(
1463
+ item.data(0, Qt.ItemDataRole.UserRole) for item in self.tree.selectedItems()
1464
+ )
1465
+
1466
+ def select_job(self, job_id: str) -> bool:
1467
+ for i in range(self.tree.topLevelItemCount()):
1468
+ item = self.tree.topLevelItem(i)
1469
+ if item.data(0, Qt.ItemDataRole.UserRole) == job_id:
1470
+ self.tree.setCurrentItem(item)
1471
+ return True
1472
+ return False
1473
+
1474
+ def _on_selection(self) -> None:
1475
+ """Reopening is what selection means here (F-41).
1476
+
1477
+ One entry's snapshot is read at a time, never a page of them, so
1478
+ arrowing down the list stays a list query plus one row.
1479
+ """
1480
+ chosen = self.selected_ids()
1481
+ if len(chosen) == 1:
1482
+ self.detail.show_job(self._rows.get(chosen[0]))
1483
+ else:
1484
+ self.detail.show_job(None)
1485
+ self.delete_button.setEnabled(bool(chosen))
1486
+
1487
+ def _activate(self) -> None:
1488
+ """Enter on a row opens it and moves to its first action.
1489
+
1490
+ It does not play. F-45 wants nothing played automatically, and a
1491
+ list where Enter starts audio is exactly the surprise that rules
1492
+ out.
1493
+ """
1494
+ self._on_selection()
1495
+ if self.detail.job_id is not None:
1496
+ self.detail.first_action().setFocus(Qt.FocusReason.TabFocusReason)
1497
+
1498
+ def delete_selected(self) -> None:
1499
+ chosen = self.selected_ids()
1500
+ if chosen:
1501
+ self._delete(chosen)
1502
+
1503
+ def _delete(self, job_ids: Sequence[str]) -> None:
1504
+ try:
1505
+ scope = self._store.preview_deletion(job_ids=list(job_ids))
1506
+ except EchoActError as exc:
1507
+ self._fail(exc)
1508
+ return
1509
+ dialog = DeleteConfirmDialog(self._palette, scope, kind="jobs", parent=self)
1510
+ if dialog.exec() != QDialog.DialogCode.Accepted:
1511
+ return
1512
+ self.delete_requested.emit(
1513
+ DeleteRequest(job_ids=tuple(job_ids), audio_only=dialog.audio_only, scope=scope)
1514
+ )
1515
+
1516
+ def _delete_all(self) -> None:
1517
+ """F-43's "delete all history", which is a scope of its own and
1518
+ leaves documents alone."""
1519
+ try:
1520
+ scope = self._store.preview_deletion(all_history=True)
1521
+ except EchoActError as exc:
1522
+ self._fail(exc)
1523
+ return
1524
+ dialog = DeleteConfirmDialog(
1525
+ self._palette, scope, kind="jobs", all_history=True, parent=self
1526
+ )
1527
+ if dialog.exec() != QDialog.DialogCode.Accepted:
1528
+ return
1529
+ self.delete_requested.emit(DeleteRequest(all_history=True, scope=scope))
1530
+
1531
+
1532
+ # ======================================================================
1533
+ # The screen
1534
+ # ======================================================================
1535
+
1536
+
1537
+ class LibraryScreen(QWidget):
1538
+ """Documents and history, side by side in one widget.
1539
+
1540
+ A plain :class:`QWidget` rather than a dialog or a page, because the
1541
+ window decides where it goes: F-79 requires the library to stay usable
1542
+ when the local service cannot start, which is easier to honour when the
1543
+ library is not welded to one container.
1544
+ """
1545
+
1546
+ open_document = Signal(str)
1547
+ play_job = Signal(str)
1548
+ export_job = Signal(str)
1549
+ regenerate_job = Signal(str)
1550
+ delete_requested = Signal(object)
1551
+ problem = Signal(object)
1552
+
1553
+ def __init__(
1554
+ self,
1555
+ store: Store,
1556
+ palette: Palette,
1557
+ *,
1558
+ manifest: Manifest | None = None,
1559
+ clock: Callable[[], float] = ids.now,
1560
+ parent: QWidget | None = None,
1561
+ ) -> None:
1562
+ super().__init__(parent)
1563
+ self.setObjectName("Root")
1564
+ self._store = store
1565
+ self._palette = palette
1566
+ m = METRICS
1567
+
1568
+ box = QVBoxLayout(self)
1569
+ box.setContentsMargins(m.pad, m.pad, m.pad, m.pad)
1570
+ box.setSpacing(m.gap)
1571
+
1572
+ self.tabs = QTabWidget()
1573
+ self.documents = DocumentsPane(store, palette)
1574
+ self.history = HistoryPane(store, palette, manifest=manifest, clock=clock)
1575
+ self.tabs.addTab(self.documents, tr("Documents"))
1576
+ self.tabs.addTab(self.history, tr("History"))
1577
+ box.addWidget(self.tabs, 1)
1578
+
1579
+ self.documents.open_document.connect(self.open_document)
1580
+ self.documents.delete_requested.connect(self.delete_requested)
1581
+ self.documents.problem.connect(self.problem)
1582
+ self.history.play_job.connect(self.play_job)
1583
+ self.history.export_job.connect(self.export_job)
1584
+ self.history.regenerate_job.connect(self.regenerate_job)
1585
+ self.history.delete_requested.connect(self.delete_requested)
1586
+ self.history.problem.connect(self.problem)
1587
+
1588
+ self.refresh()
1589
+
1590
+ def refresh(self) -> None:
1591
+ """Reload both lists. Called after the window has carried out a
1592
+ deletion or created a job, so the screen never has to guess what
1593
+ changed."""
1594
+ self.documents.refresh()
1595
+ self.history.refresh()
1596
+
1597
+ def show_documents(self) -> None:
1598
+ self.tabs.setCurrentWidget(self.documents)
1599
+
1600
+ def show_history(self) -> None:
1601
+ self.tabs.setCurrentWidget(self.history)
1602
+
1603
+ def select_job(self, job_id: str) -> bool:
1604
+ """Open one entry, for a window arriving from a completion notice."""
1605
+ self.show_history()
1606
+ return self.history.select_job(job_id)
1607
+
1608
+
1609
+ def severity_colour(palette: Palette, severity: str) -> QColor:
1610
+ """The token a row's state cell is tinted with.
1611
+
1612
+ Exposed so a caller that draws its own rows cannot invent a colour:
1613
+ N-13 and N-30 both require the word to carry the meaning, and this only
1614
+ reinforces it.
1615
+ """
1616
+ return qcolor({"warn": palette.warn, "danger": palette.danger}.get(severity, palette.text))
1617
+
1618
+
1619
+ __all__ = [
1620
+ "DATE_WINDOWS_DAYS",
1621
+ "PAGE_SIZES",
1622
+ "SEARCH_DEBOUNCE_MS",
1623
+ "DeleteConfirmDialog",
1624
+ "DeleteRequest",
1625
+ "DocumentRow",
1626
+ "DocumentsPane",
1627
+ "HistoryPane",
1628
+ "JobDetail",
1629
+ "JobRow",
1630
+ "LibraryScreen",
1631
+ "PageBar",
1632
+ "Playability",
1633
+ "WrapRow",
1634
+ "build_document_row",
1635
+ "build_job_row",
1636
+ "playability",
1637
+ "retention_text",
1638
+ "scope_lines",
1639
+ "severity_colour",
1640
+ "state_text",
1641
+ "when_text",
1642
+ ]