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/__init__.py
ADDED
|
File without changes
|
echoact/ui/bridge.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Getting engine news onto the GUI thread.
|
|
2
|
+
|
|
3
|
+
The job engine runs a job on its own thread and calls listeners from there.
|
|
4
|
+
Qt widgets may only be touched from the thread that created them, so every
|
|
5
|
+
event crosses here and nowhere else: this object is created on the main
|
|
6
|
+
thread, so emitting one of its signals from a worker thread is a queued
|
|
7
|
+
connection and Qt does the hand-off.
|
|
8
|
+
|
|
9
|
+
Kept separate from the window so the rule has a name. A listener that
|
|
10
|
+
touched a widget directly would work most of the time, which is the worst
|
|
11
|
+
possible failure mode for a threading bug.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from PySide6.QtCore import QObject, Signal
|
|
17
|
+
|
|
18
|
+
from ..jobs.engine import Event, EventKind, JobEngine
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class EngineBridge(QObject):
|
|
22
|
+
"""Re-emits :class:`~echoact.jobs.engine.Event` as Qt signals."""
|
|
23
|
+
|
|
24
|
+
accepted = Signal(object)
|
|
25
|
+
state_changed = Signal(object)
|
|
26
|
+
segment_ready = Signal(object)
|
|
27
|
+
finished = Signal(object)
|
|
28
|
+
#: Anything at all, for a view that wants the raw stream.
|
|
29
|
+
any_event = Signal(object)
|
|
30
|
+
|
|
31
|
+
def __init__(self, engine: JobEngine, parent: QObject | None = None) -> None:
|
|
32
|
+
super().__init__(parent)
|
|
33
|
+
self._engine = engine
|
|
34
|
+
self._off = engine.listen(self._on_event)
|
|
35
|
+
|
|
36
|
+
def _on_event(self, event: Event) -> None:
|
|
37
|
+
# Runs on the job thread. Nothing below may touch a widget; these
|
|
38
|
+
# emits are queued because this object lives on the main thread.
|
|
39
|
+
self.any_event.emit(event)
|
|
40
|
+
if event.kind is EventKind.ACCEPTED:
|
|
41
|
+
self.accepted.emit(event)
|
|
42
|
+
elif event.kind is EventKind.STATE:
|
|
43
|
+
self.state_changed.emit(event)
|
|
44
|
+
elif event.kind is EventKind.SEGMENT:
|
|
45
|
+
self.segment_ready.emit(event)
|
|
46
|
+
elif event.kind is EventKind.FINISHED:
|
|
47
|
+
self.finished.emit(event)
|
|
48
|
+
|
|
49
|
+
def detach(self) -> None:
|
|
50
|
+
self._off()
|
echoact/ui/controls.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""The controls around the reading surface.
|
|
2
|
+
|
|
3
|
+
Two panels, both deliberately dumb: they show state and emit intent, and
|
|
4
|
+
neither one calls the engine. The window owns every decision, so there is
|
|
5
|
+
one place where "can this be started right now?" is answered -- which is
|
|
6
|
+
what N-09's "unavailable controls are disabled" needs to be true rather
|
|
7
|
+
than mostly true.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from PySide6.QtCore import Qt, Signal
|
|
13
|
+
from PySide6.QtWidgets import (
|
|
14
|
+
QCheckBox,
|
|
15
|
+
QComboBox,
|
|
16
|
+
QFrame,
|
|
17
|
+
QHBoxLayout,
|
|
18
|
+
QLabel,
|
|
19
|
+
QPushButton,
|
|
20
|
+
QSizePolicy,
|
|
21
|
+
QSlider,
|
|
22
|
+
QToolButton,
|
|
23
|
+
QVBoxLayout,
|
|
24
|
+
QWidget,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
from ..domain import Gender, JobState, Language, SpeakingStyle, VoiceSettings
|
|
28
|
+
from ..models.manifest import ModelEntry
|
|
29
|
+
from ..policy import TEMPO_MAX, TEMPO_MIN
|
|
30
|
+
from . import icons
|
|
31
|
+
from .i18n import add_korean, duration, tr
|
|
32
|
+
from .theme import METRICS, Palette
|
|
33
|
+
|
|
34
|
+
add_korean(
|
|
35
|
+
{
|
|
36
|
+
"Reading": "읽기",
|
|
37
|
+
"While reading": "읽는 동안",
|
|
38
|
+
"Recent": "최근 항목",
|
|
39
|
+
"Ready": "준비됨",
|
|
40
|
+
"not prepared": "준비되지 않음",
|
|
41
|
+
"Open file": "파일 열기",
|
|
42
|
+
"Save audio": "오디오 저장",
|
|
43
|
+
"Save the part generated so far": "지금까지 생성된 부분 저장",
|
|
44
|
+
}
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def label(text: str, role: str | None = None) -> QLabel:
|
|
49
|
+
lb = QLabel(text)
|
|
50
|
+
if role:
|
|
51
|
+
lb.setProperty("role", role)
|
|
52
|
+
return lb
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def separator() -> QFrame:
|
|
56
|
+
f = QFrame()
|
|
57
|
+
f.setProperty("role", "separator")
|
|
58
|
+
f.setFrameShape(QFrame.Shape.HLine)
|
|
59
|
+
f.setFixedHeight(1)
|
|
60
|
+
return f
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def tool_button(name: str, palette: Palette, *, size: int = 18) -> QToolButton:
|
|
64
|
+
"""An icon button that always has an accessible name (N-30)."""
|
|
65
|
+
b = QToolButton()
|
|
66
|
+
b.setIcon(icons.icon(name, palette.text_secondary, palette.text_muted, size))
|
|
67
|
+
b.setIconSize(icons.icon_size(size))
|
|
68
|
+
text = tr(icons.accessible_name(name))
|
|
69
|
+
b.setToolTip(text)
|
|
70
|
+
b.setAccessibleName(text)
|
|
71
|
+
b.setAccessibleDescription(text)
|
|
72
|
+
b.setFixedSize(34, 34)
|
|
73
|
+
b.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
|
74
|
+
return b
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class VoicePanel(QFrame):
|
|
78
|
+
"""F-04 to F-08, F-83, F-30 -- everything the user picks before reading.
|
|
79
|
+
|
|
80
|
+
Emits :attr:`changed` with a complete :class:`VoiceSettings`, never a
|
|
81
|
+
partial edit, so the window never has to assemble one from widgets.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
changed = Signal(object) # VoiceSettings
|
|
85
|
+
autoplay_toggled = Signal(bool)
|
|
86
|
+
follow_toggled = Signal(bool)
|
|
87
|
+
|
|
88
|
+
def __init__(self, palette: Palette, entry: ModelEntry, parent: QWidget | None = None) -> None:
|
|
89
|
+
super().__init__(parent)
|
|
90
|
+
self.setObjectName("Panel")
|
|
91
|
+
self._palette = palette
|
|
92
|
+
self._entry = entry
|
|
93
|
+
self._muted = False # guards against emitting while populating
|
|
94
|
+
|
|
95
|
+
m = METRICS
|
|
96
|
+
box = QVBoxLayout(self)
|
|
97
|
+
box.setContentsMargins(m.pad, m.pad, m.pad, m.pad)
|
|
98
|
+
box.setSpacing(m.gap)
|
|
99
|
+
|
|
100
|
+
box.addWidget(label(tr("Voice"), "section"))
|
|
101
|
+
|
|
102
|
+
self.language = QComboBox()
|
|
103
|
+
for lang, text in (
|
|
104
|
+
(Language.AUTO, tr("Automatic")),
|
|
105
|
+
(Language.KO, tr("Korean")),
|
|
106
|
+
(Language.EN, tr("English")),
|
|
107
|
+
):
|
|
108
|
+
self.language.addItem(text, lang)
|
|
109
|
+
self._row(box, tr("Language"), self.language)
|
|
110
|
+
|
|
111
|
+
self.gender = QComboBox()
|
|
112
|
+
self.gender.addItem(tr("Female"), Gender.FEMALE)
|
|
113
|
+
self.gender.addItem(tr("Male"), Gender.MALE)
|
|
114
|
+
self._row(box, tr("Gender"), self.gender)
|
|
115
|
+
|
|
116
|
+
self.voice = QComboBox()
|
|
117
|
+
self._row(box, tr("Voice"), self.voice)
|
|
118
|
+
|
|
119
|
+
self.style = QComboBox()
|
|
120
|
+
for style, text in (
|
|
121
|
+
(SpeakingStyle.NATURAL, tr("Natural")),
|
|
122
|
+
(SpeakingStyle.CALM, tr("Calm")),
|
|
123
|
+
(SpeakingStyle.BRIGHT, tr("Bright")),
|
|
124
|
+
(SpeakingStyle.NARRATION, tr("Narration")),
|
|
125
|
+
):
|
|
126
|
+
self.style.addItem(text, style)
|
|
127
|
+
self._row(box, tr("Style"), self.style)
|
|
128
|
+
|
|
129
|
+
# Tempo is a slider rather than a spin box: it is the one setting a
|
|
130
|
+
# user adjusts by ear, and a slider invites the small nudges that
|
|
131
|
+
# takes. The value is shown beside it because F-07 fixes a range
|
|
132
|
+
# the user is entitled to see exactly.
|
|
133
|
+
tempo_row = QHBoxLayout()
|
|
134
|
+
tempo_row.setSpacing(m.gap)
|
|
135
|
+
tempo_row.addWidget(label(tr("Tempo"), "secondary"))
|
|
136
|
+
tempo_row.addStretch(1)
|
|
137
|
+
self.tempo_value = label("1.00x", "secondary")
|
|
138
|
+
tempo_row.addWidget(self.tempo_value)
|
|
139
|
+
box.addLayout(tempo_row)
|
|
140
|
+
|
|
141
|
+
self.tempo = QSlider(Qt.Orientation.Horizontal)
|
|
142
|
+
self.tempo.setRange(int(TEMPO_MIN * 100), int(TEMPO_MAX * 100))
|
|
143
|
+
self.tempo.setSingleStep(5)
|
|
144
|
+
self.tempo.setPageStep(10)
|
|
145
|
+
self.tempo.setValue(100)
|
|
146
|
+
self.tempo.setAccessibleName(tr("Tempo"))
|
|
147
|
+
box.addWidget(self.tempo)
|
|
148
|
+
|
|
149
|
+
box.addWidget(separator())
|
|
150
|
+
box.addWidget(label(tr("While reading"), "section"))
|
|
151
|
+
|
|
152
|
+
self.autoplay = QCheckBox(tr("Play as soon as ready"))
|
|
153
|
+
self.autoplay.setChecked(True)
|
|
154
|
+
box.addWidget(self.autoplay)
|
|
155
|
+
|
|
156
|
+
self.follow = QCheckBox(tr("Follow the reading position"))
|
|
157
|
+
self.follow.setChecked(True)
|
|
158
|
+
box.addWidget(self.follow)
|
|
159
|
+
|
|
160
|
+
box.addWidget(separator())
|
|
161
|
+
# F-22 and N-09: the budget in force is on the main screen, not
|
|
162
|
+
# only behind the settings button.
|
|
163
|
+
box.addWidget(label(tr("Resource budget"), "secondary"))
|
|
164
|
+
self.resources = label("", "muted")
|
|
165
|
+
self.resources.setWordWrap(True)
|
|
166
|
+
box.addWidget(self.resources)
|
|
167
|
+
|
|
168
|
+
self.setFixedWidth(272)
|
|
169
|
+
# Hugs its content: a card that stretches to the window height ends
|
|
170
|
+
# in a large empty panel, which reads as something missing.
|
|
171
|
+
self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Maximum)
|
|
172
|
+
|
|
173
|
+
self._populate_voices(Gender.FEMALE)
|
|
174
|
+
self.language.currentIndexChanged.connect(self._emit)
|
|
175
|
+
self.gender.currentIndexChanged.connect(self._on_gender)
|
|
176
|
+
self.voice.currentIndexChanged.connect(self._emit)
|
|
177
|
+
self.style.currentIndexChanged.connect(self._emit)
|
|
178
|
+
self.tempo.valueChanged.connect(self._on_tempo)
|
|
179
|
+
self.autoplay.toggled.connect(self.autoplay_toggled)
|
|
180
|
+
self.follow.toggled.connect(self.follow_toggled)
|
|
181
|
+
|
|
182
|
+
def _row(self, box: QVBoxLayout, name: str, widget: QWidget) -> None:
|
|
183
|
+
box.addWidget(label(name, "secondary"))
|
|
184
|
+
widget.setAccessibleName(name)
|
|
185
|
+
box.addWidget(widget)
|
|
186
|
+
|
|
187
|
+
def _populate_voices(self, gender: Gender) -> None:
|
|
188
|
+
was = self._muted
|
|
189
|
+
self._muted = True
|
|
190
|
+
self.voice.clear()
|
|
191
|
+
for v in self._entry.voices_for(gender):
|
|
192
|
+
self.voice.addItem(v.display_name, v.voice_id)
|
|
193
|
+
# F-53 puts the description in front of a person too, not only
|
|
194
|
+
# in the API answer.
|
|
195
|
+
self.voice.setItemData(self.voice.count() - 1, v.description, Qt.ItemDataRole.ToolTipRole)
|
|
196
|
+
self._muted = was
|
|
197
|
+
|
|
198
|
+
def _on_gender(self) -> None:
|
|
199
|
+
self._populate_voices(Gender(self.gender.currentData()))
|
|
200
|
+
self._emit()
|
|
201
|
+
|
|
202
|
+
def _on_tempo(self, value: int) -> None:
|
|
203
|
+
self.tempo_value.setText(f"{value / 100:.2f}x")
|
|
204
|
+
self._emit()
|
|
205
|
+
|
|
206
|
+
def _emit(self) -> None:
|
|
207
|
+
if self._muted:
|
|
208
|
+
return
|
|
209
|
+
self.changed.emit(self.settings())
|
|
210
|
+
|
|
211
|
+
def settings(self) -> VoiceSettings:
|
|
212
|
+
# Qt returns item data as a plain str even for a StrEnum, so the
|
|
213
|
+
# types are restored here rather than left to surprise a caller
|
|
214
|
+
# that asks for ``.value``.
|
|
215
|
+
return VoiceSettings(
|
|
216
|
+
model_id=self._entry.model_id,
|
|
217
|
+
language=Language(self.language.currentData()),
|
|
218
|
+
gender=Gender(self.gender.currentData()),
|
|
219
|
+
voice_id=self.voice.currentData() or self._entry.voices[0].voice_id,
|
|
220
|
+
style=SpeakingStyle(self.style.currentData()),
|
|
221
|
+
tempo=self.tempo.value() / 100,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
def apply(self, settings: VoiceSettings, *, autoplay: bool, follow: bool) -> None:
|
|
225
|
+
"""Show remembered settings without emitting a change (F-24)."""
|
|
226
|
+
self._muted = True
|
|
227
|
+
try:
|
|
228
|
+
self._select(self.language, settings.language)
|
|
229
|
+
self._select(self.gender, settings.gender)
|
|
230
|
+
self._populate_voices(settings.gender)
|
|
231
|
+
self._select(self.voice, settings.voice_id)
|
|
232
|
+
self._select(self.style, settings.style)
|
|
233
|
+
self.tempo.setValue(int(round(settings.tempo * 100)))
|
|
234
|
+
self.tempo_value.setText(f"{settings.tempo:.2f}x")
|
|
235
|
+
self.autoplay.setChecked(autoplay)
|
|
236
|
+
self.follow.setChecked(follow)
|
|
237
|
+
finally:
|
|
238
|
+
self._muted = False
|
|
239
|
+
|
|
240
|
+
@staticmethod
|
|
241
|
+
def _select(combo: QComboBox, value: object) -> None:
|
|
242
|
+
index = combo.findData(value)
|
|
243
|
+
if index >= 0:
|
|
244
|
+
combo.setCurrentIndex(index)
|
|
245
|
+
|
|
246
|
+
def set_resource_summary(self, text: str) -> None:
|
|
247
|
+
self.resources.setText(text)
|
|
248
|
+
|
|
249
|
+
def set_locked(self, locked: bool) -> None:
|
|
250
|
+
"""F-10: an in-progress job's settings do not change.
|
|
251
|
+
|
|
252
|
+
The controls are disabled rather than merely ignored, because N-09
|
|
253
|
+
requires an unavailable control to look unavailable.
|
|
254
|
+
"""
|
|
255
|
+
for w in (self.language, self.gender, self.voice, self.style, self.tempo):
|
|
256
|
+
w.setEnabled(not locked)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class TransportBar(QWidget):
|
|
260
|
+
"""F-12 to F-15: start, pause, stop, seek, and cancel.
|
|
261
|
+
|
|
262
|
+
The seek slider shows *playable* length, not the document's estimated
|
|
263
|
+
length: F-14 forbids seeking into audio that does not exist, and a
|
|
264
|
+
slider whose track runs past the end invites exactly that.
|
|
265
|
+
"""
|
|
266
|
+
|
|
267
|
+
read_requested = Signal()
|
|
268
|
+
pause_requested = Signal()
|
|
269
|
+
stop_requested = Signal()
|
|
270
|
+
cancel_requested = Signal()
|
|
271
|
+
seek_requested = Signal(int) # milliseconds
|
|
272
|
+
|
|
273
|
+
def __init__(self, palette: Palette, parent: QWidget | None = None) -> None:
|
|
274
|
+
super().__init__(parent)
|
|
275
|
+
self._palette = palette
|
|
276
|
+
self._scrubbing = False
|
|
277
|
+
self._playable_ms = 0
|
|
278
|
+
|
|
279
|
+
m = METRICS
|
|
280
|
+
row = QHBoxLayout(self)
|
|
281
|
+
row.setContentsMargins(0, 0, 0, 0)
|
|
282
|
+
row.setSpacing(m.gap)
|
|
283
|
+
|
|
284
|
+
self.read = QPushButton(" " + tr("Read aloud"))
|
|
285
|
+
self.read.setProperty("variant", "primary")
|
|
286
|
+
self.read.setIcon(icons.icon("play", palette.text_on_accent))
|
|
287
|
+
self.read.setIconSize(icons.icon_size(16))
|
|
288
|
+
self.read.setAccessibleName(tr("Read aloud"))
|
|
289
|
+
self.read.setShortcut("Ctrl+Return")
|
|
290
|
+
self.read.setToolTip(tr("Read aloud") + " (Ctrl+Enter)")
|
|
291
|
+
row.addWidget(self.read)
|
|
292
|
+
|
|
293
|
+
self.pause = tool_button("pause", palette)
|
|
294
|
+
self.stop = tool_button("stop", palette)
|
|
295
|
+
row.addWidget(self.pause)
|
|
296
|
+
row.addWidget(self.stop)
|
|
297
|
+
|
|
298
|
+
self.position = QSlider(Qt.Orientation.Horizontal)
|
|
299
|
+
self.position.setRange(0, 0)
|
|
300
|
+
self.position.setAccessibleName(tr("Playback position"))
|
|
301
|
+
self.position.setEnabled(False)
|
|
302
|
+
row.addWidget(self.position, 1)
|
|
303
|
+
|
|
304
|
+
self.time = label("0:00 / 0:00", "secondary")
|
|
305
|
+
self.time.setMinimumWidth(96)
|
|
306
|
+
self.time.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
|
307
|
+
row.addWidget(self.time)
|
|
308
|
+
|
|
309
|
+
self.read.clicked.connect(self.read_requested)
|
|
310
|
+
self.pause.clicked.connect(self.pause_requested)
|
|
311
|
+
self.stop.clicked.connect(self.stop_requested)
|
|
312
|
+
self.position.sliderPressed.connect(self._begin_scrub)
|
|
313
|
+
self.position.sliderReleased.connect(self._end_scrub)
|
|
314
|
+
|
|
315
|
+
def _begin_scrub(self) -> None:
|
|
316
|
+
self._scrubbing = True
|
|
317
|
+
|
|
318
|
+
def _end_scrub(self) -> None:
|
|
319
|
+
self._scrubbing = False
|
|
320
|
+
self.seek_requested.emit(self.position.value())
|
|
321
|
+
|
|
322
|
+
@property
|
|
323
|
+
def scrubbing(self) -> bool:
|
|
324
|
+
return self._scrubbing
|
|
325
|
+
|
|
326
|
+
def set_playable(self, ms: int) -> None:
|
|
327
|
+
self._playable_ms = ms
|
|
328
|
+
self.position.setEnabled(ms > 0)
|
|
329
|
+
if not self._scrubbing:
|
|
330
|
+
self.position.setRange(0, max(0, ms))
|
|
331
|
+
|
|
332
|
+
def set_position(self, ms: int) -> None:
|
|
333
|
+
if not self._scrubbing:
|
|
334
|
+
self.position.setValue(min(ms, self._playable_ms))
|
|
335
|
+
self.time.setText(f"{duration(ms)} / {duration(self._playable_ms)}")
|
|
336
|
+
|
|
337
|
+
def set_generating(self, generating: bool) -> None:
|
|
338
|
+
"""The primary button is start or cancel, never both.
|
|
339
|
+
|
|
340
|
+
F-69 makes cancelling reachable in one place, and A.3 makes
|
|
341
|
+
cancelling the owner's way to reclaim the slot, so it belongs on
|
|
342
|
+
the button the eye already goes to.
|
|
343
|
+
"""
|
|
344
|
+
if generating:
|
|
345
|
+
self.read.setText(" " + tr("Cancel generation"))
|
|
346
|
+
self.read.setIcon(icons.icon("close", self._palette.text_on_accent))
|
|
347
|
+
self.read.setAccessibleName(tr("Cancel generation"))
|
|
348
|
+
else:
|
|
349
|
+
self.read.setText(" " + tr("Read aloud"))
|
|
350
|
+
self.read.setIcon(icons.icon("play", self._palette.text_on_accent))
|
|
351
|
+
self.read.setAccessibleName(tr("Read aloud"))
|
|
352
|
+
|
|
353
|
+
def set_playback_state(self, state: JobState | str, *, playing: bool) -> None:
|
|
354
|
+
name = "pause" if playing else "play"
|
|
355
|
+
self.pause.setIcon(
|
|
356
|
+
icons.icon(name, self._palette.text_secondary, self._palette.text_muted, 18)
|
|
357
|
+
)
|
|
358
|
+
text = tr(icons.accessible_name(name))
|
|
359
|
+
self.pause.setToolTip(text)
|
|
360
|
+
self.pause.setAccessibleName(text)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Showing a credential the one time it exists, per F-71.
|
|
2
|
+
|
|
3
|
+
The app stores only a verifier, from which the credential cannot be
|
|
4
|
+
recovered, so this dialog is the single moment the value is ever visible.
|
|
5
|
+
Everything about it follows from that:
|
|
6
|
+
|
|
7
|
+
* It says so, plainly, before the user closes it. A dialog that looks
|
|
8
|
+
like every other confirmation invites the click that loses the value.
|
|
9
|
+
* Copying is one button, because the alternative is a user retyping 44
|
|
10
|
+
random characters and blaming the app when it does not work.
|
|
11
|
+
* The value is never logged and never written anywhere by this dialog.
|
|
12
|
+
N-17 keeps tokens out of logs and backups, and the clipboard is the
|
|
13
|
+
user's own choice rather than ours.
|
|
14
|
+
|
|
15
|
+
A reissue is offered where it is needed rather than described: F-71 says
|
|
16
|
+
a lost credential is reissued, and a user who has lost one is not in a
|
|
17
|
+
position to read about it.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from PySide6.QtCore import Qt
|
|
23
|
+
from PySide6.QtWidgets import (
|
|
24
|
+
QApplication,
|
|
25
|
+
QDialog,
|
|
26
|
+
QDialogButtonBox,
|
|
27
|
+
QHBoxLayout,
|
|
28
|
+
QLabel,
|
|
29
|
+
QPlainTextEdit,
|
|
30
|
+
QPushButton,
|
|
31
|
+
QVBoxLayout,
|
|
32
|
+
QWidget,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
from . import icons
|
|
36
|
+
from .i18n import add_korean, tr
|
|
37
|
+
from .theme import METRICS, Palette
|
|
38
|
+
|
|
39
|
+
add_korean(
|
|
40
|
+
{
|
|
41
|
+
"Credential for {name}": "{name}의 자격 증명",
|
|
42
|
+
"This is the only time this value is shown.":
|
|
43
|
+
"이 값은 지금 한 번만 표시됩니다.",
|
|
44
|
+
"EchoAct keeps only a verifier, so it cannot show this again. "
|
|
45
|
+
"If it is lost, issue a new one for the same client.":
|
|
46
|
+
"에코액트는 검증 값만 저장하므로 이 값을 다시 표시할 수 없습니다. "
|
|
47
|
+
"잃어버린 경우 같은 클라이언트에 새로 발급하세요.",
|
|
48
|
+
"Copy": "복사",
|
|
49
|
+
"Copied": "복사됨",
|
|
50
|
+
"Done": "완료",
|
|
51
|
+
"Give this to the client, not to a person.":
|
|
52
|
+
"이 값은 사람이 아니라 클라이언트에 전달하세요.",
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class CredentialDialog(QDialog):
|
|
58
|
+
"""One credential, once."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, issued, palette: Palette, parent: QWidget | None = None) -> None:
|
|
61
|
+
super().__init__(parent)
|
|
62
|
+
self._token = issued.token
|
|
63
|
+
m = METRICS
|
|
64
|
+
|
|
65
|
+
self.setWindowTitle(
|
|
66
|
+
tr("Credential for {name}").format(name=issued.credential.name)
|
|
67
|
+
)
|
|
68
|
+
self.setModal(True)
|
|
69
|
+
self.setMinimumWidth(560)
|
|
70
|
+
|
|
71
|
+
outer = QVBoxLayout(self)
|
|
72
|
+
outer.setContentsMargins(m.pad_wide, m.pad_wide, m.pad_wide, m.pad_wide)
|
|
73
|
+
outer.setSpacing(m.gap_wide)
|
|
74
|
+
|
|
75
|
+
head = QHBoxLayout()
|
|
76
|
+
head.setSpacing(m.gap)
|
|
77
|
+
mark = QLabel()
|
|
78
|
+
mark.setPixmap(icons.pixmap("warning", palette.warn, 20, self.devicePixelRatioF()))
|
|
79
|
+
mark.setAlignment(Qt.AlignmentFlag.AlignTop)
|
|
80
|
+
head.addWidget(mark)
|
|
81
|
+
warning = QLabel(tr("This is the only time this value is shown."))
|
|
82
|
+
warning.setProperty("role", "section")
|
|
83
|
+
warning.setWordWrap(True)
|
|
84
|
+
head.addWidget(warning, 1)
|
|
85
|
+
outer.addLayout(head)
|
|
86
|
+
|
|
87
|
+
# Read-only rather than a label: a label cannot be selected with the
|
|
88
|
+
# keyboard, and N-30 wants the basic features usable without a mouse.
|
|
89
|
+
self.value = QPlainTextEdit(self._token)
|
|
90
|
+
self.value.setReadOnly(True)
|
|
91
|
+
self.value.setProperty("mono", True)
|
|
92
|
+
self.value.setFixedHeight(64)
|
|
93
|
+
self.value.setAccessibleName(tr("Credential"))
|
|
94
|
+
outer.addWidget(self.value)
|
|
95
|
+
|
|
96
|
+
explain = QLabel(
|
|
97
|
+
tr(
|
|
98
|
+
"EchoAct keeps only a verifier, so it cannot show this again. "
|
|
99
|
+
"If it is lost, issue a new one for the same client."
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
explain.setProperty("role", "muted")
|
|
103
|
+
explain.setWordWrap(True)
|
|
104
|
+
outer.addWidget(explain)
|
|
105
|
+
|
|
106
|
+
hint = QLabel(tr("Give this to the client, not to a person."))
|
|
107
|
+
hint.setProperty("role", "muted")
|
|
108
|
+
outer.addWidget(hint)
|
|
109
|
+
|
|
110
|
+
buttons = QDialogButtonBox()
|
|
111
|
+
self.copy_button = QPushButton(" " + tr("Copy"))
|
|
112
|
+
self.copy_button.setIcon(icons.icon("copy", palette.text_on_accent))
|
|
113
|
+
self.copy_button.setIconSize(icons.icon_size(16))
|
|
114
|
+
self.copy_button.setProperty("variant", "primary")
|
|
115
|
+
self.copy_button.clicked.connect(self._copy)
|
|
116
|
+
buttons.addButton(self.copy_button, QDialogButtonBox.ButtonRole.ActionRole)
|
|
117
|
+
done = buttons.addButton(tr("Done"), QDialogButtonBox.ButtonRole.AcceptRole)
|
|
118
|
+
done.setProperty("variant", "quiet")
|
|
119
|
+
buttons.accepted.connect(self.accept)
|
|
120
|
+
outer.addWidget(buttons)
|
|
121
|
+
|
|
122
|
+
def _copy(self) -> None:
|
|
123
|
+
clipboard = QApplication.clipboard()
|
|
124
|
+
if clipboard is not None:
|
|
125
|
+
clipboard.setText(self._token)
|
|
126
|
+
self.copy_button.setText(" " + tr("Copied"))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def show_credential(parent: QWidget | None, issued, palette: Palette) -> None:
|
|
130
|
+
dialog = CredentialDialog(issued, palette, parent)
|
|
131
|
+
dialog.exec()
|
echoact/ui/fonts.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Loading the bundled typeface, when there is one.
|
|
2
|
+
|
|
3
|
+
A.2 wants one bundled Korean and Latin family rather than the per-OS
|
|
4
|
+
defaults, so that metrics -- and therefore N-12's synchronisation
|
|
5
|
+
measurements and N-30's layout checks -- are identical on Windows and
|
|
6
|
+
macOS. Without it the app still looks right, but a measurement taken on
|
|
7
|
+
one machine does not transfer to another, because Malgun Gothic and Apple
|
|
8
|
+
SD Gothic Neo do not lay out the same text the same way.
|
|
9
|
+
|
|
10
|
+
The font is not committed to the repository. It is fetched by
|
|
11
|
+
``scripts/fetch_fonts.py`` into ``echoact/assets/fonts`` and picked up
|
|
12
|
+
here if it is present; the packaging step includes whatever it finds.
|
|
13
|
+
Two reasons for that split: a licence that requires its own notice file
|
|
14
|
+
should be acquired deliberately rather than inherited by a checkout, and
|
|
15
|
+
a few megabytes of binary in a source tree is a cost every clone pays
|
|
16
|
+
forever.
|
|
17
|
+
|
|
18
|
+
``load()`` is safe to call when nothing is there. A missing font is a
|
|
19
|
+
measurement caveat, not a failure -- so it degrades to the per-OS stack
|
|
20
|
+
and says so, rather than refusing to start.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from ..util.logging import get_logger
|
|
29
|
+
|
|
30
|
+
log = get_logger("ui.fonts")
|
|
31
|
+
|
|
32
|
+
ASSET_DIR = Path(__file__).resolve().parent.parent / "assets" / "fonts"
|
|
33
|
+
|
|
34
|
+
#: The family A.2 calls for. SIL Open Font License, covers Hangul and
|
|
35
|
+
#: Latin in one family with matching metrics, and has a variable weight
|
|
36
|
+
#: axis -- which matters because N-13's alternative mechanism was "a
|
|
37
|
+
#: variable font whose weight axis preserves metrics".
|
|
38
|
+
BUNDLED_FAMILY = "Pretendard"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class FontReport:
|
|
43
|
+
"""What was loaded, for F-72's diagnostic export and for a test.
|
|
44
|
+
|
|
45
|
+
``metrics_are_portable`` is the fact that actually matters: it says
|
|
46
|
+
whether a layout measurement taken here would mean anything on
|
|
47
|
+
another machine.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
families: tuple[str, ...]
|
|
51
|
+
files: tuple[str, ...]
|
|
52
|
+
bundled_available: bool
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def metrics_are_portable(self) -> bool:
|
|
56
|
+
return self.bundled_available
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def available_files() -> tuple[Path, ...]:
|
|
60
|
+
if not ASSET_DIR.is_dir():
|
|
61
|
+
return ()
|
|
62
|
+
return tuple(
|
|
63
|
+
sorted(p for p in ASSET_DIR.iterdir() if p.suffix.lower() in {".ttf", ".otf", ".ttc"})
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def load() -> FontReport:
|
|
68
|
+
"""Register every bundled face with Qt. Returns what is now usable."""
|
|
69
|
+
from PySide6.QtGui import QFontDatabase
|
|
70
|
+
|
|
71
|
+
families: list[str] = []
|
|
72
|
+
files: list[str] = []
|
|
73
|
+
for path in available_files():
|
|
74
|
+
font_id = QFontDatabase.addApplicationFont(str(path))
|
|
75
|
+
if font_id < 0:
|
|
76
|
+
log.warning("could not load bundled font %s", path.name)
|
|
77
|
+
continue
|
|
78
|
+
files.append(path.name)
|
|
79
|
+
families.extend(QFontDatabase.applicationFontFamilies(font_id))
|
|
80
|
+
|
|
81
|
+
unique = tuple(dict.fromkeys(families))
|
|
82
|
+
report = FontReport(
|
|
83
|
+
families=unique,
|
|
84
|
+
files=tuple(files),
|
|
85
|
+
bundled_available=any(BUNDLED_FAMILY in f for f in unique),
|
|
86
|
+
)
|
|
87
|
+
if not report.bundled_available:
|
|
88
|
+
# Said once, at start-up, because it changes what a measurement
|
|
89
|
+
# means rather than what the user sees.
|
|
90
|
+
log.info(
|
|
91
|
+
"no bundled typeface; using the per-OS stack, so layout "
|
|
92
|
+
"measurements are not comparable across operating systems"
|
|
93
|
+
)
|
|
94
|
+
return report
|