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.
- echoact/__init__.py +3 -0
- echoact/__main__.py +117 -0
- echoact/app.py +315 -0
- echoact/audio/__init__.py +0 -0
- echoact/audio/devices.py +192 -0
- echoact/audio/player.py +611 -0
- echoact/audio/wav.py +854 -0
- echoact/config/__init__.py +0 -0
- echoact/config/budget.py +370 -0
- echoact/config/settings.py +1244 -0
- echoact/db/__init__.py +0 -0
- echoact/db/backup.py +2429 -0
- echoact/db/migrations.py +434 -0
- echoact/db/schema.sql +214 -0
- echoact/db/store.py +2062 -0
- echoact/diagnostics.py +902 -0
- echoact/domain.py +487 -0
- echoact/engine/__init__.py +0 -0
- echoact/engine/container.py +843 -0
- echoact/engine/protocol.py +241 -0
- echoact/engine/runtime.py +324 -0
- echoact/engine/supervisor.py +961 -0
- echoact/engine/worker.py +659 -0
- echoact/errors.py +281 -0
- echoact/instance.py +172 -0
- echoact/jobs/__init__.py +0 -0
- echoact/jobs/engine.py +776 -0
- echoact/jobs/request.py +300 -0
- echoact/mcp/__init__.py +0 -0
- echoact/mcp/__main__.py +50 -0
- echoact/mcp/client.py +202 -0
- echoact/mcp/config.py +112 -0
- echoact/mcp/server.py +340 -0
- echoact/models/__init__.py +0 -0
- echoact/models/catalog.py +273 -0
- echoact/models/manifest.py +278 -0
- echoact/models/registry.py +1551 -0
- echoact/paths.py +93 -0
- echoact/policy.py +189 -0
- echoact/security/__init__.py +0 -0
- echoact/security/credentials.py +930 -0
- echoact/security/ratelimit.py +534 -0
- echoact/service/__init__.py +20 -0
- echoact/service/app.py +182 -0
- echoact/service/deps.py +563 -0
- echoact/service/errors.py +241 -0
- echoact/service/routes.py +1125 -0
- echoact/service/schemas.py +509 -0
- echoact/service/server.py +270 -0
- echoact/text/__init__.py +0 -0
- echoact/text/language.py +44 -0
- echoact/text/loader.py +577 -0
- echoact/text/normalize.py +924 -0
- echoact/text/segment.py +499 -0
- echoact/text/sniff.py +1202 -0
- echoact/ui/__init__.py +0 -0
- echoact/ui/bridge.py +50 -0
- echoact/ui/controls.py +360 -0
- echoact/ui/credential_dialog.py +131 -0
- echoact/ui/fonts.py +94 -0
- echoact/ui/i18n.py +260 -0
- echoact/ui/icons.py +440 -0
- echoact/ui/library.py +1642 -0
- echoact/ui/licence.py +162 -0
- echoact/ui/main_window.py +1202 -0
- echoact/ui/mcp_setup.py +494 -0
- echoact/ui/models_view.py +1142 -0
- echoact/ui/notifications.py +202 -0
- echoact/ui/reading.py +494 -0
- echoact/ui/settings_view.py +2258 -0
- echoact/ui/status_view.py +1193 -0
- echoact/ui/theme.py +579 -0
- echoact/util/__init__.py +0 -0
- echoact/util/ids.py +62 -0
- echoact/util/logging.py +127 -0
- echoact-0.1.0.dist-info/METADATA +162 -0
- echoact-0.1.0.dist-info/RECORD +80 -0
- echoact-0.1.0.dist-info/WHEEL +4 -0
- echoact-0.1.0.dist-info/entry_points.txt +3 -0
- 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
|
+
]
|