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
|
@@ -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()
|