echoact 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. echoact/__init__.py +3 -0
  2. echoact/__main__.py +117 -0
  3. echoact/app.py +315 -0
  4. echoact/audio/__init__.py +0 -0
  5. echoact/audio/devices.py +192 -0
  6. echoact/audio/player.py +611 -0
  7. echoact/audio/wav.py +854 -0
  8. echoact/config/__init__.py +0 -0
  9. echoact/config/budget.py +370 -0
  10. echoact/config/settings.py +1244 -0
  11. echoact/db/__init__.py +0 -0
  12. echoact/db/backup.py +2429 -0
  13. echoact/db/migrations.py +434 -0
  14. echoact/db/schema.sql +214 -0
  15. echoact/db/store.py +2062 -0
  16. echoact/diagnostics.py +902 -0
  17. echoact/domain.py +487 -0
  18. echoact/engine/__init__.py +0 -0
  19. echoact/engine/container.py +843 -0
  20. echoact/engine/protocol.py +241 -0
  21. echoact/engine/runtime.py +324 -0
  22. echoact/engine/supervisor.py +961 -0
  23. echoact/engine/worker.py +659 -0
  24. echoact/errors.py +281 -0
  25. echoact/instance.py +172 -0
  26. echoact/jobs/__init__.py +0 -0
  27. echoact/jobs/engine.py +776 -0
  28. echoact/jobs/request.py +300 -0
  29. echoact/mcp/__init__.py +0 -0
  30. echoact/mcp/__main__.py +50 -0
  31. echoact/mcp/client.py +202 -0
  32. echoact/mcp/config.py +112 -0
  33. echoact/mcp/server.py +340 -0
  34. echoact/models/__init__.py +0 -0
  35. echoact/models/catalog.py +273 -0
  36. echoact/models/manifest.py +278 -0
  37. echoact/models/registry.py +1551 -0
  38. echoact/paths.py +93 -0
  39. echoact/policy.py +189 -0
  40. echoact/security/__init__.py +0 -0
  41. echoact/security/credentials.py +930 -0
  42. echoact/security/ratelimit.py +534 -0
  43. echoact/service/__init__.py +20 -0
  44. echoact/service/app.py +182 -0
  45. echoact/service/deps.py +563 -0
  46. echoact/service/errors.py +241 -0
  47. echoact/service/routes.py +1125 -0
  48. echoact/service/schemas.py +509 -0
  49. echoact/service/server.py +270 -0
  50. echoact/text/__init__.py +0 -0
  51. echoact/text/language.py +44 -0
  52. echoact/text/loader.py +577 -0
  53. echoact/text/normalize.py +924 -0
  54. echoact/text/segment.py +499 -0
  55. echoact/text/sniff.py +1202 -0
  56. echoact/ui/__init__.py +0 -0
  57. echoact/ui/bridge.py +50 -0
  58. echoact/ui/controls.py +360 -0
  59. echoact/ui/credential_dialog.py +131 -0
  60. echoact/ui/fonts.py +94 -0
  61. echoact/ui/i18n.py +260 -0
  62. echoact/ui/icons.py +440 -0
  63. echoact/ui/library.py +1642 -0
  64. echoact/ui/licence.py +162 -0
  65. echoact/ui/main_window.py +1202 -0
  66. echoact/ui/mcp_setup.py +494 -0
  67. echoact/ui/models_view.py +1142 -0
  68. echoact/ui/notifications.py +202 -0
  69. echoact/ui/reading.py +494 -0
  70. echoact/ui/settings_view.py +2258 -0
  71. echoact/ui/status_view.py +1193 -0
  72. echoact/ui/theme.py +579 -0
  73. echoact/util/__init__.py +0 -0
  74. echoact/util/ids.py +62 -0
  75. echoact/util/logging.py +127 -0
  76. echoact-0.1.0.dist-info/METADATA +162 -0
  77. echoact-0.1.0.dist-info/RECORD +80 -0
  78. echoact-0.1.0.dist-info/WHEEL +4 -0
  79. echoact-0.1.0.dist-info/entry_points.txt +3 -0
  80. echoact-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,202 @@
1
+ """Telling the user something happened, per F-70.
2
+
3
+ Generation completion, download failure, resource shortage and backup
4
+ failure are notified inside the app. Three constraints shape this:
5
+
6
+ * An OS notification only appears if the user turned them on (4.1 defaults
7
+ it off), and it never contains body text or audio (F-70, N-20).
8
+ * Clicking a notification does not auto-play audio (F-70). Nor does it
9
+ for an in-app notice, and F-51 is stricter still: a job an integration
10
+ created never auto-plays at all.
11
+ * A completion notice for a job an integration created names the client
12
+ that asked for it and, where the result is one-off, says when it
13
+ expires -- so work produced while nobody was watching can still be found
14
+ and saved before it is cleaned up.
15
+
16
+ In-app notices are a list the window renders. The OS notification is a
17
+ best-effort extra: on Windows Qt's tray message is the only route that does
18
+ not add a dependency, and if there is no tray the notice still exists
19
+ in-app, which is where F-70 puts it anyway.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from collections.abc import Callable
25
+ from dataclasses import dataclass, field
26
+ from enum import StrEnum
27
+
28
+ from ..util import ids
29
+ from .i18n import add_korean, tr
30
+
31
+ add_korean(
32
+ {
33
+ "Reading finished": "읽기가 끝났습니다",
34
+ "{client} asked for this": "{client}이(가) 요청했습니다",
35
+ "It will be cleaned up {when}": "{when}에 정리됩니다",
36
+ "in under a minute": "1분 이내",
37
+ "in about {n} minutes": "약 {n}분 후",
38
+ "in about an hour": "약 1시간 후",
39
+ "Generation failed": "생성에 실패했습니다",
40
+ "Download failed": "다운로드에 실패했습니다",
41
+ "Not enough memory": "메모리가 부족합니다",
42
+ "Backup failed": "백업에 실패했습니다",
43
+ "Open this job": "이 작업 열기",
44
+ }
45
+ )
46
+
47
+
48
+ class Level(StrEnum):
49
+ INFO = "info"
50
+ OK = "ok"
51
+ WARNING = "warning"
52
+ ERROR = "error"
53
+
54
+
55
+ @dataclass(frozen=True, slots=True)
56
+ class Notice:
57
+ """One thing worth telling the user.
58
+
59
+ ``job_id`` is what "offers to open that job" needs; the notice itself
60
+ carries no text and no audio, because the same object reaches the OS
61
+ notification, which N-20 keeps clean.
62
+ """
63
+
64
+ notice_id: str
65
+ level: Level
66
+ title: str
67
+ detail: str = ""
68
+ job_id: str | None = None
69
+ client_label: str | None = None
70
+ expires_at: float | None = None
71
+ created_at: float = field(default_factory=ids.now)
72
+ #: True when the user may act on it, e.g. open the job it names.
73
+ actionable: bool = False
74
+
75
+
76
+ def _expiry_phrase(expires_at: float, now: float) -> str:
77
+ remaining = max(0.0, expires_at - now)
78
+ if remaining < 60:
79
+ return tr("in under a minute")
80
+ if remaining < 3000:
81
+ return tr("in about {n} minutes").format(n=int(round(remaining / 60)))
82
+ return tr("in about an hour")
83
+
84
+
85
+ def completion_notice(
86
+ *,
87
+ job_id: str,
88
+ client_label: str | None,
89
+ expires_at: float | None,
90
+ now: float | None = None,
91
+ ) -> Notice:
92
+ """F-70's completion notice, including the two parts easy to leave out.
93
+
94
+ A job the desktop user started needs no explanation. A job an
95
+ integration created does: the user did not ask for it, may not know it
96
+ ran, and if its result is one-off it disappears within the hour 4.1
97
+ allows. Both facts go in the notice rather than only in a screen the
98
+ user would have to think to open.
99
+ """
100
+ moment = ids.now() if now is None else now
101
+ parts: list[str] = []
102
+ if client_label:
103
+ parts.append(tr("{client} asked for this").format(client=client_label))
104
+ if expires_at is not None:
105
+ parts.append(tr("It will be cleaned up {when}").format(when=_expiry_phrase(expires_at, moment)))
106
+ return Notice(
107
+ notice_id=ids.request_id(),
108
+ level=Level.OK,
109
+ title=tr("Reading finished"),
110
+ detail=" · ".join(parts),
111
+ job_id=job_id,
112
+ client_label=client_label,
113
+ expires_at=expires_at,
114
+ created_at=moment,
115
+ actionable=True,
116
+ )
117
+
118
+
119
+ def failure_notice(title: str, detail: str = "", *, job_id: str | None = None) -> Notice:
120
+ return Notice(
121
+ notice_id=ids.request_id(),
122
+ level=Level.ERROR,
123
+ title=title,
124
+ detail=detail,
125
+ job_id=job_id,
126
+ actionable=job_id is not None,
127
+ )
128
+
129
+
130
+ class NotificationCentre:
131
+ """Holds recent notices and, when allowed, mirrors them to the OS.
132
+
133
+ Bounded on purpose: N-23's spirit is that nothing accumulates without
134
+ limit, and a client generating in a loop would otherwise grow this
135
+ list for as long as the app runs.
136
+ """
137
+
138
+ MAX_NOTICES = 50
139
+
140
+ def __init__(
141
+ self,
142
+ *,
143
+ os_notifications: bool = False,
144
+ os_sink: Callable[[Notice], None] | None = None,
145
+ ) -> None:
146
+ self._notices: list[Notice] = []
147
+ self._listeners: list[Callable[[Notice], None]] = []
148
+ self.os_notifications = os_notifications
149
+ self._os_sink = os_sink
150
+
151
+ def set_os_notifications(self, enabled: bool) -> None:
152
+ self.os_notifications = enabled
153
+
154
+ def listen(self, fn: Callable[[Notice], None]) -> Callable[[], None]:
155
+ self._listeners.append(fn)
156
+ return lambda: self._listeners.remove(fn) if fn in self._listeners else None
157
+
158
+ def post(self, notice: Notice) -> Notice:
159
+ self._notices.append(notice)
160
+ del self._notices[: max(0, len(self._notices) - self.MAX_NOTICES)]
161
+ for fn in list(self._listeners):
162
+ fn(notice)
163
+ if self.os_notifications and self._os_sink is not None:
164
+ # Title and detail only: neither ever holds body text or audio,
165
+ # which is what makes mirroring them outside the app safe.
166
+ self._os_sink(notice)
167
+ return notice
168
+
169
+ @property
170
+ def notices(self) -> tuple[Notice, ...]:
171
+ return tuple(self._notices)
172
+
173
+ def unread_count(self) -> int:
174
+ return len(self._notices)
175
+
176
+ def clear(self) -> None:
177
+ self._notices.clear()
178
+
179
+
180
+ def tray_sink(tray) -> Callable[[Notice], None]:
181
+ """Mirror a notice to a ``QSystemTrayIcon``.
182
+
183
+ Separate from the centre so that the centre stays testable without Qt,
184
+ and so a machine with no tray degrades to in-app notices rather than
185
+ to an exception.
186
+ """
187
+ from PySide6.QtWidgets import QSystemTrayIcon
188
+
189
+ icon_for = {
190
+ Level.INFO: QSystemTrayIcon.MessageIcon.Information,
191
+ Level.OK: QSystemTrayIcon.MessageIcon.Information,
192
+ Level.WARNING: QSystemTrayIcon.MessageIcon.Warning,
193
+ Level.ERROR: QSystemTrayIcon.MessageIcon.Critical,
194
+ }
195
+
196
+ def send(notice: Notice) -> None:
197
+ try:
198
+ tray.showMessage(notice.title, notice.detail, icon_for[notice.level], 6000)
199
+ except Exception: # noqa: BLE001 - a missing tray is not a failure
200
+ pass
201
+
202
+ return send
echoact/ui/reading.py ADDED
@@ -0,0 +1,494 @@
1
+ """The reading surface: the text, and the mark that says where we are.
2
+
3
+ The emphasis is a glyph *outline*, applied through a view-level extra
4
+ selection. Both halves of that are forced by requirements rather than
5
+ chosen:
6
+
7
+ * An extra selection never enters the document, so F-29 holds by
8
+ construction -- copying, saving, and regenerating see plain text because
9
+ there is nothing else to see -- and F-30's "must not steal the caret" is
10
+ automatic because an extra selection carries its own cursor.
11
+ * An outline is a *painting* attribute, so nothing in the mechanism can
12
+ re-shape a glyph. N-13's layout stability is therefore a property of the
13
+ mechanism and not of tuning. ``spikes/outline_emphasis.py`` measured the
14
+ naive alternative failing: bold merged into the document reflows every
15
+ line below the emphasised one, and bold applied through an extra selection
16
+ silently changes nothing at all, at every scale factor and on both
17
+ candidate widgets.
18
+
19
+ Two subtleties that are easy to get wrong:
20
+
21
+ * Segment ranges are Unicode code points (4.2); Qt positions are UTF-16
22
+ units. They differ exactly when the text contains anything outside the
23
+ BMP -- which for this product means emoji, the characters F-27 calls out
24
+ by name. The conversion happens here and nowhere else.
25
+ * The position comes from the audio clock, not from a timer (A.2, N-12).
26
+ This widget is told a millisecond and looks it up; it never asks anyone
27
+ what time it is.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from bisect import bisect_right
33
+ from dataclasses import dataclass
34
+
35
+ from PySide6.QtCore import QEvent, Qt, Signal
36
+ from PySide6.QtGui import (
37
+ QColor,
38
+ QKeyEvent,
39
+ QPen,
40
+ QTextBlockFormat,
41
+ QTextCharFormat,
42
+ QTextCursor,
43
+ QTextOption,
44
+ QWheelEvent,
45
+ )
46
+ from PySide6.QtWidgets import QTextEdit, QWidget
47
+
48
+ from ..domain import Segment
49
+ from ..policy import MAX_INPUT_CODEPOINTS
50
+ from .theme import METRICS, Palette, reading_font
51
+
52
+ #: Pen width for the outline, in device-independent units. The spike found
53
+ #: 0.3 to 0.4 reads as emphasis while keeping under one percent of the ink
54
+ #: outside the emphasised characters' own cells; below that it is invisible
55
+ #: at 100% scaling and above it starts to look like a smear.
56
+ OUTLINE_WIDTH = 0.38
57
+
58
+
59
+ class HighlightState:
60
+ """Why the mark looks the way it does. Section 5.2 distinguishes these
61
+ and F-28 requires waiting to be indicated separately from playing."""
62
+
63
+ NONE = "none"
64
+ PLAYING = "playing"
65
+ PAUSED = "paused"
66
+ WAITING = "waiting"
67
+ UNAVAILABLE = "unavailable"
68
+
69
+
70
+ @dataclass(slots=True)
71
+ class _Utf16Map:
72
+ """Code point index -> UTF-16 unit index, for one snapshot.
73
+
74
+ Built once per snapshot rather than per highlight update: a 50,000
75
+ character document changes highlight several times a second and must not
76
+ pay for a scan each time. When the text is entirely inside the BMP --
77
+ the common case for Korean and Latin -- the two indices are equal and no
78
+ table is built at all.
79
+ """
80
+
81
+ identity: bool
82
+ table: list[int]
83
+
84
+ @classmethod
85
+ def build(cls, text: str) -> _Utf16Map:
86
+ if max((ord(c) for c in text), default=0) < 0x10000:
87
+ return cls(identity=True, table=[])
88
+ table: list[int] = [0] * (len(text) + 1)
89
+ pos = 0
90
+ for i, ch in enumerate(text):
91
+ table[i] = pos
92
+ pos += 2 if ord(ch) > 0xFFFF else 1
93
+ table[len(text)] = pos
94
+ return cls(identity=False, table=table)
95
+
96
+ def to_utf16(self, cp: int) -> int:
97
+ if self.identity:
98
+ return cp
99
+ if cp <= 0:
100
+ return 0
101
+ if cp >= len(self.table):
102
+ return self.table[-1]
103
+ return self.table[cp]
104
+
105
+
106
+ class ReadingSurface(QTextEdit):
107
+ """The editable input and the reading view, which are the same widget.
108
+
109
+ They have to be: F-29 describes the user editing while audio plays, and
110
+ a separate read-only view would either duplicate the text or forbid that.
111
+ """
112
+
113
+ #: Emitted when highlight availability changes, so the window can say
114
+ #: "highlighting unavailable" per F-29 and F-31.
115
+ availability_changed = Signal(bool)
116
+ #: Emitted when the user scrolls by hand and following is suspended
117
+ #: (F-30), and again when following resumes.
118
+ following_changed = Signal(bool)
119
+ #: Character count, for F-03's live counter.
120
+ length_changed = Signal(int)
121
+ #: A paste that would exceed the limit: (attempted, room remaining).
122
+ #: F-36 forbids truncating to fit, so the window explains instead.
123
+ paste_refused = Signal(int, int)
124
+
125
+ # Class-level defaults, because QTextEdit's constructor delivers a
126
+ # changeEvent before ``__init__`` reaches its own assignments and the
127
+ # override would then read an attribute that does not exist yet.
128
+ _applying_format = False
129
+ _programmatic_scroll = False
130
+ _available = False
131
+
132
+ def __init__(self, palette: Palette, parent: QWidget | None = None) -> None:
133
+ super().__init__(parent)
134
+ self.setObjectName("ReadingSurface")
135
+ self.setAcceptRichText(False) # F-29: nothing but plain text, ever
136
+ self.setFont(reading_font())
137
+ self.setWordWrapMode(QTextOption.WrapMode.WrapAtWordBoundaryOrAnywhere)
138
+ self.setTabChangesFocus(True) # N-30: Tab must leave the field
139
+
140
+ self._palette = palette
141
+ self._snapshot: str | None = None
142
+ self._segments: tuple[Segment, ...] = ()
143
+ # Segments that have a time, as (start_ms, index into _segments).
144
+ # Kept apart from _segments because a segment without audio yet has
145
+ # no place on the timeline, and indexing one list with the other's
146
+ # position is the kind of mistake that silently marks the wrong
147
+ # sentence.
148
+ self._starts: list[int] = []
149
+ self._timed: list[int] = []
150
+ self._map = _Utf16Map(identity=True, table=[])
151
+ self._current: int | None = None
152
+ self._state: str = HighlightState.NONE
153
+ self._available = False
154
+ self._follow = True
155
+ self._following_suspended = False
156
+ self._programmatic_scroll = False
157
+ self._applying_format = False
158
+
159
+ doc = self.document()
160
+ doc.setDocumentMargin(0) # the stylesheet supplies the page padding
161
+ self._apply_line_height()
162
+
163
+ self.textChanged.connect(self._on_text_changed)
164
+ # Line height is re-applied only when the paragraph count changes.
165
+ # Doing it on every textChanged would re-enter through the format
166
+ # merge, and on a 50,000 character document would also re-format
167
+ # the whole thing on every keystroke.
168
+ doc.blockCountChanged.connect(self._apply_line_height)
169
+ self.verticalScrollBar().valueChanged.connect(self._on_scrolled)
170
+
171
+ # -- appearance ----------------------------------------------------
172
+
173
+ def set_palette_tokens(self, palette: Palette) -> None:
174
+ """Follow a light/dark switch without losing the current mark."""
175
+ self._palette = palette
176
+ self._repaint_highlight()
177
+
178
+ def _apply_line_height(self, _block_count: int = 0) -> None:
179
+ """Give every paragraph the reading line height.
180
+
181
+ Merging a block format edits the document, which emits
182
+ ``textChanged``, so this is guarded rather than merely careful: an
183
+ unguarded version recurses until the stack runs out.
184
+ """
185
+ if self._applying_format:
186
+ return
187
+ self._applying_format = True
188
+ try:
189
+ self._merge_line_height()
190
+ finally:
191
+ self._applying_format = False
192
+
193
+ def _merge_line_height(self) -> None:
194
+ doc = self.document()
195
+ fmt = QTextBlockFormat()
196
+ fmt.setLineHeight(
197
+ METRICS.reading_line_height_pct,
198
+ QTextBlockFormat.LineHeightTypes.ProportionalHeight.value,
199
+ )
200
+ fmt.setBottomMargin(METRICS.reading_paragraph_gap)
201
+ cursor = QTextCursor(doc)
202
+ cursor.select(QTextCursor.SelectionType.Document)
203
+ cursor.mergeBlockFormat(fmt)
204
+ cursor.clearSelection()
205
+
206
+ # A blank line between paragraphs is itself a paragraph, so leaving
207
+ # it at the reading leading spends three line heights on one gap.
208
+ # Collapsing it keeps a pasted document looking like prose rather
209
+ # than like a list.
210
+ blank = QTextBlockFormat()
211
+ blank.setLineHeight(
212
+ METRICS.reading_blank_line_height_pct,
213
+ QTextBlockFormat.LineHeightTypes.ProportionalHeight.value,
214
+ )
215
+ blank.setBottomMargin(0)
216
+ block = doc.begin()
217
+ while block.isValid():
218
+ if not block.text().strip():
219
+ c = QTextCursor(block)
220
+ c.setBlockFormat(blank)
221
+ block = block.next()
222
+
223
+ # -- text ----------------------------------------------------------
224
+
225
+ def set_text(self, text: str) -> None:
226
+ """Replace the input. Used by file opening and by the library; the
227
+ caller has already handled F-36's confirmation."""
228
+ self.setPlainText(text)
229
+ self._apply_line_height()
230
+
231
+ def source_text(self) -> str:
232
+ """The text as code points.
233
+
234
+ ``toPlainText`` returns exactly what the user typed with paragraph
235
+ separators as newlines, which is the form every offset in this
236
+ product is measured against.
237
+ """
238
+ return self.toPlainText()
239
+
240
+ def _on_text_changed(self) -> None:
241
+ if self._applying_format:
242
+ return
243
+ self.length_changed.emit(len(self.toPlainText()))
244
+ self._recheck_availability()
245
+
246
+ # -- the job's segment table ---------------------------------------
247
+
248
+ def attach_job(self, snapshot: str, segments: list[Segment] | tuple[Segment, ...]) -> None:
249
+ """Bind to a job's frozen source text and its segments.
250
+
251
+ F-29 fixes the source text per job. Everything after this compares
252
+ the live input against ``snapshot``; the widget never re-anchors a
253
+ highlight onto edited text.
254
+ """
255
+ self._snapshot = snapshot
256
+ self._segments = tuple(segments)
257
+ self._map = _Utf16Map.build(snapshot)
258
+ self._rebuild_time_index()
259
+ self._current = None
260
+ self._recheck_availability()
261
+
262
+ def update_segments(self, segments: list[Segment] | tuple[Segment, ...]) -> None:
263
+ """Segments arrive as they are generated (F-12), so the time table
264
+ grows during playback."""
265
+ self._segments = tuple(segments)
266
+ self._rebuild_time_index()
267
+
268
+ def detach_job(self) -> None:
269
+ self._snapshot = None
270
+ self._segments = ()
271
+ self._starts = []
272
+ self._current = None
273
+ self._set_state(HighlightState.NONE)
274
+ self._recheck_availability()
275
+
276
+ def _rebuild_time_index(self) -> None:
277
+ self._starts = []
278
+ self._timed = []
279
+ for i, s in enumerate(self._segments):
280
+ if s.time is not None:
281
+ self._starts.append(s.time.start_ms)
282
+ self._timed.append(i)
283
+
284
+ # -- availability (F-29, F-31) --------------------------------------
285
+
286
+ @property
287
+ def highlight_available(self) -> bool:
288
+ return self._available
289
+
290
+ def _recheck_availability(self) -> None:
291
+ """Availability is a pure function of the current text.
292
+
293
+ F-29 is explicit that it carries no hidden state: divergence clears
294
+ the mark and reports it unavailable, and a text that matches the
295
+ snapshot again brings it back. Implementing it as a comparison
296
+ rather than a latch is what makes that true.
297
+ """
298
+ ok = self._snapshot is not None and self.toPlainText() == self._snapshot
299
+ if ok == self._available:
300
+ return
301
+ self._available = ok
302
+ if not ok:
303
+ self._clear_marks()
304
+ self._set_state(HighlightState.UNAVAILABLE if self._snapshot else HighlightState.NONE)
305
+ else:
306
+ self._repaint_highlight()
307
+ self.availability_changed.emit(ok)
308
+
309
+ # -- the mark -------------------------------------------------------
310
+
311
+ @property
312
+ def state(self) -> str:
313
+ return self._state
314
+
315
+ @property
316
+ def current_segment(self) -> int | None:
317
+ return self._current
318
+
319
+ def _set_state(self, state: str) -> None:
320
+ if state != self._state:
321
+ self._state = state
322
+ self._repaint_highlight()
323
+
324
+ def set_playback_ms(self, ms: int, *, waiting: bool = False) -> None:
325
+ """Move the mark to whatever segment covers ``ms``.
326
+
327
+ Called from the audio clock. Note what this does NOT do: it does
328
+ not advance on its own, it does not interpolate, and it does not
329
+ guess. N-12's 300 ms budget is met by being called often with a
330
+ true position, not by predicting one.
331
+ """
332
+ index = self._segment_at(ms)
333
+ if index is None:
334
+ # Between segments, or past the last ready one. F-28 keeps the
335
+ # previous segment's mark rather than clearing it, because a
336
+ # highlight that blinks off in every gap reads as a fault.
337
+ self._set_state(HighlightState.WAITING if waiting else self._state)
338
+ return
339
+ changed = index != self._current
340
+ self._current = index
341
+ self._state = HighlightState.WAITING if waiting else HighlightState.PLAYING
342
+ self._repaint_highlight()
343
+ if changed and self._follow and not self._following_suspended:
344
+ self._scroll_to_current()
345
+
346
+ def _segment_at(self, ms: int) -> int | None:
347
+ if not self._starts:
348
+ return None
349
+ slot = bisect_right(self._starts, ms) - 1
350
+ if slot < 0:
351
+ return None
352
+ # Past the end of the last ready segment still resolves to it:
353
+ # F-28 keeps the previous segment marked while playback waits on
354
+ # the buffer, because a mark that blinks off in every gap reads as
355
+ # a fault rather than as waiting.
356
+ return self._timed[slot]
357
+
358
+ def set_paused(self) -> None:
359
+ """F-28: keep the last position, indicate the state separately."""
360
+ if self._current is not None:
361
+ self._set_state(HighlightState.PAUSED)
362
+
363
+ def set_playing(self) -> None:
364
+ if self._current is not None:
365
+ self._set_state(HighlightState.PLAYING)
366
+
367
+ def clear_highlight(self) -> None:
368
+ """Stop, cancel, or natural end (F-28)."""
369
+ self._current = None
370
+ self._clear_marks()
371
+ self._set_state(HighlightState.NONE)
372
+
373
+ def _clear_marks(self) -> None:
374
+ self.setExtraSelections([])
375
+
376
+ def _repaint_highlight(self) -> None:
377
+ if not self._available or self._current is None:
378
+ self._clear_marks()
379
+ return
380
+ if self._state in (HighlightState.NONE, HighlightState.UNAVAILABLE):
381
+ self._clear_marks()
382
+ return
383
+ seg = self._segments[self._current]
384
+ colour = (
385
+ self._palette.emphasis_waiting
386
+ if self._state == HighlightState.WAITING
387
+ else self._palette.emphasis
388
+ )
389
+ fmt = QTextCharFormat()
390
+ fmt.setTextOutline(QPen(QColor(colour), OUTLINE_WIDTH))
391
+ sel = QTextEdit.ExtraSelection()
392
+ sel.cursor = self._cursor_for(seg)
393
+ sel.format = fmt
394
+ self.setExtraSelections([sel])
395
+
396
+ def _cursor_for(self, seg: Segment) -> QTextCursor:
397
+ cur = QTextCursor(self.document())
398
+ cur.setPosition(self._map.to_utf16(seg.source.start))
399
+ cur.setPosition(self._map.to_utf16(seg.source.end), QTextCursor.MoveMode.KeepAnchor)
400
+ return cur
401
+
402
+ # -- following (F-30) ------------------------------------------------
403
+
404
+ @property
405
+ def follow_enabled(self) -> bool:
406
+ return self._follow
407
+
408
+ def set_follow(self, on: bool) -> None:
409
+ self._follow = on
410
+ self._following_suspended = False
411
+ self.following_changed.emit(on)
412
+ if on:
413
+ self._scroll_to_current()
414
+
415
+ @property
416
+ def following_suspended(self) -> bool:
417
+ return self._following_suspended
418
+
419
+ def return_to_position(self) -> None:
420
+ """F-30's return-to-current-position action."""
421
+ self._following_suspended = False
422
+ self.following_changed.emit(self._follow)
423
+ self._scroll_to_current()
424
+
425
+ def _scroll_to_current(self) -> None:
426
+ """Scroll the viewport without touching the cursor or the selection.
427
+
428
+ ``ensureCursorVisible`` would move the real cursor, which is exactly
429
+ what F-30 forbids, so the rectangle is computed from a throwaway
430
+ cursor and the scrollbar is moved directly.
431
+ """
432
+ if self._current is None or not self._available:
433
+ return
434
+ seg = self._segments[self._current]
435
+ rect = self.cursorRect(self._cursor_for(seg))
436
+ bar = self.verticalScrollBar()
437
+ view_h = self.viewport().height()
438
+ # Keep the line a third of the way down: reading ahead is easier
439
+ # than reading at the bottom edge, and it leaves room for the next
440
+ # segment to appear without another scroll.
441
+ target = bar.value() + rect.top() - view_h // 3
442
+ target = max(bar.minimum(), min(bar.maximum(), target))
443
+ if target != bar.value():
444
+ self._programmatic_scroll = True
445
+ bar.setValue(target)
446
+ self._programmatic_scroll = False
447
+
448
+ def _on_scrolled(self, _value: int) -> None:
449
+ if self._programmatic_scroll or not self._follow:
450
+ return
451
+ if not self._following_suspended:
452
+ self._following_suspended = True
453
+ self.following_changed.emit(False)
454
+
455
+ def wheelEvent(self, event: QWheelEvent) -> None:
456
+ # A wheel scroll is a manual scroll even when it changes nothing,
457
+ # e.g. already at the bottom; the scrollbar signal would not fire.
458
+ if self._follow and not self._following_suspended:
459
+ self._following_suspended = True
460
+ self.following_changed.emit(False)
461
+ super().wheelEvent(event)
462
+
463
+ def keyPressEvent(self, event: QKeyEvent) -> None:
464
+ # Refuse input past the limit rather than truncating afterwards
465
+ # (F-03, F-36). Deletions, navigation, and shortcuts pass through.
466
+ if event.text() and not event.modifiers() & (
467
+ Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.AltModifier
468
+ ):
469
+ cur = self.textCursor()
470
+ added = len(event.text()) - len(cur.selectedText())
471
+ if added > 0 and len(self.toPlainText()) + added > MAX_INPUT_CODEPOINTS:
472
+ event.ignore()
473
+ return
474
+ super().keyPressEvent(event)
475
+
476
+ def insertFromMimeData(self, source) -> None: # noqa: N802 - Qt override
477
+ """Paste, clamped by refusal rather than by silent truncation."""
478
+ text = source.text() if source.hasText() else ""
479
+ if not text:
480
+ return
481
+ cur = self.textCursor()
482
+ room = MAX_INPUT_CODEPOINTS - (len(self.toPlainText()) - len(cur.selectedText()))
483
+ if len(text) > room:
484
+ # F-36 forbids circumventing the limit by truncating, so paste
485
+ # nothing and let the window explain. Silently dropping the
486
+ # tail is the behaviour the requirement names.
487
+ self.paste_refused.emit(len(text), max(0, room))
488
+ return
489
+ cur.insertText(text)
490
+
491
+ def changeEvent(self, event: QEvent) -> None:
492
+ super().changeEvent(event)
493
+ if event.type() == QEvent.Type.FontChange:
494
+ self._apply_line_height()