echoact 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. echoact/__init__.py +3 -0
  2. echoact/__main__.py +117 -0
  3. echoact/app.py +315 -0
  4. echoact/audio/__init__.py +0 -0
  5. echoact/audio/devices.py +192 -0
  6. echoact/audio/player.py +611 -0
  7. echoact/audio/wav.py +854 -0
  8. echoact/config/__init__.py +0 -0
  9. echoact/config/budget.py +370 -0
  10. echoact/config/settings.py +1244 -0
  11. echoact/db/__init__.py +0 -0
  12. echoact/db/backup.py +2429 -0
  13. echoact/db/migrations.py +434 -0
  14. echoact/db/schema.sql +214 -0
  15. echoact/db/store.py +2062 -0
  16. echoact/diagnostics.py +902 -0
  17. echoact/domain.py +487 -0
  18. echoact/engine/__init__.py +0 -0
  19. echoact/engine/container.py +843 -0
  20. echoact/engine/protocol.py +241 -0
  21. echoact/engine/runtime.py +324 -0
  22. echoact/engine/supervisor.py +961 -0
  23. echoact/engine/worker.py +659 -0
  24. echoact/errors.py +281 -0
  25. echoact/instance.py +172 -0
  26. echoact/jobs/__init__.py +0 -0
  27. echoact/jobs/engine.py +776 -0
  28. echoact/jobs/request.py +300 -0
  29. echoact/mcp/__init__.py +0 -0
  30. echoact/mcp/__main__.py +50 -0
  31. echoact/mcp/client.py +202 -0
  32. echoact/mcp/config.py +112 -0
  33. echoact/mcp/server.py +340 -0
  34. echoact/models/__init__.py +0 -0
  35. echoact/models/catalog.py +273 -0
  36. echoact/models/manifest.py +278 -0
  37. echoact/models/registry.py +1551 -0
  38. echoact/paths.py +93 -0
  39. echoact/policy.py +189 -0
  40. echoact/security/__init__.py +0 -0
  41. echoact/security/credentials.py +930 -0
  42. echoact/security/ratelimit.py +534 -0
  43. echoact/service/__init__.py +20 -0
  44. echoact/service/app.py +182 -0
  45. echoact/service/deps.py +563 -0
  46. echoact/service/errors.py +241 -0
  47. echoact/service/routes.py +1125 -0
  48. echoact/service/schemas.py +509 -0
  49. echoact/service/server.py +270 -0
  50. echoact/text/__init__.py +0 -0
  51. echoact/text/language.py +44 -0
  52. echoact/text/loader.py +577 -0
  53. echoact/text/normalize.py +924 -0
  54. echoact/text/segment.py +499 -0
  55. echoact/text/sniff.py +1202 -0
  56. echoact/ui/__init__.py +0 -0
  57. echoact/ui/bridge.py +50 -0
  58. echoact/ui/controls.py +360 -0
  59. echoact/ui/credential_dialog.py +131 -0
  60. echoact/ui/fonts.py +94 -0
  61. echoact/ui/i18n.py +260 -0
  62. echoact/ui/icons.py +440 -0
  63. echoact/ui/library.py +1642 -0
  64. echoact/ui/licence.py +162 -0
  65. echoact/ui/main_window.py +1202 -0
  66. echoact/ui/mcp_setup.py +494 -0
  67. echoact/ui/models_view.py +1142 -0
  68. echoact/ui/notifications.py +202 -0
  69. echoact/ui/reading.py +494 -0
  70. echoact/ui/settings_view.py +2258 -0
  71. echoact/ui/status_view.py +1193 -0
  72. echoact/ui/theme.py +579 -0
  73. echoact/util/__init__.py +0 -0
  74. echoact/util/ids.py +62 -0
  75. echoact/util/logging.py +127 -0
  76. echoact-0.1.0.dist-info/METADATA +162 -0
  77. echoact-0.1.0.dist-info/RECORD +80 -0
  78. echoact-0.1.0.dist-info/WHEEL +4 -0
  79. echoact-0.1.0.dist-info/entry_points.txt +3 -0
  80. echoact-0.1.0.dist-info/licenses/LICENSE +21 -0
echoact/ui/__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