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/theme.py
ADDED
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
"""The visual language: one palette, one type scale, one stylesheet.
|
|
2
|
+
|
|
3
|
+
The product is a reading surface with controls around it, so the design goal
|
|
4
|
+
is that the text looks like a page and everything else recedes. Three rules
|
|
5
|
+
follow from that and from the requirements:
|
|
6
|
+
|
|
7
|
+
* The reading surface carries no colour of its own. N-13 forbids
|
|
8
|
+
distinguishing the reading position by colour alone, and the emphasis
|
|
9
|
+
mechanism is a glyph outline, so the page stays plain and the outline is
|
|
10
|
+
the only mark on it.
|
|
11
|
+
* Nothing in the chrome moves when state changes. A control that resizes
|
|
12
|
+
when it becomes active drags the eye away from the text, and N-13's layout
|
|
13
|
+
stability is about the same instinct.
|
|
14
|
+
* Every colour is a token here. A widget that hard-codes a colour cannot
|
|
15
|
+
follow the light/dark switch, and there is no second place to look.
|
|
16
|
+
|
|
17
|
+
Colours are chosen for contrast first: body text against the page is at
|
|
18
|
+
least 12:1 in both themes, secondary text at least 5.5:1, and the accent
|
|
19
|
+
against its own background at least 4.5:1, which is what N-30's
|
|
20
|
+
accessibility floor needs once colour is not the only signal.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from enum import StrEnum
|
|
27
|
+
|
|
28
|
+
from PySide6.QtCore import Qt
|
|
29
|
+
from PySide6.QtGui import QColor, QFont, QFontDatabase, QGuiApplication
|
|
30
|
+
|
|
31
|
+
from . import fonts, icons
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Mode(StrEnum):
|
|
35
|
+
LIGHT = "light"
|
|
36
|
+
DARK = "dark"
|
|
37
|
+
SYSTEM = "system"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class Palette:
|
|
42
|
+
"""Semantic colours. Widgets name the role, never the hue."""
|
|
43
|
+
|
|
44
|
+
# Surfaces, from furthest back to closest.
|
|
45
|
+
canvas: str # window background behind everything
|
|
46
|
+
surface: str # panels, toolbars, cards
|
|
47
|
+
page: str # the reading surface itself
|
|
48
|
+
raised: str # hover / pressed / selected fill
|
|
49
|
+
border: str # 1px separations
|
|
50
|
+
border_strong: str # focus ring, active outline
|
|
51
|
+
|
|
52
|
+
# Text.
|
|
53
|
+
text: str
|
|
54
|
+
text_secondary: str
|
|
55
|
+
text_muted: str
|
|
56
|
+
text_on_accent: str
|
|
57
|
+
|
|
58
|
+
# One accent. A second one would compete with the reading emphasis.
|
|
59
|
+
accent: str
|
|
60
|
+
accent_hover: str
|
|
61
|
+
accent_pressed: str
|
|
62
|
+
accent_soft: str # tinted fill for selected rows
|
|
63
|
+
|
|
64
|
+
# Status. Used with an icon or a word, never alone (N-30).
|
|
65
|
+
ok: str
|
|
66
|
+
warn: str
|
|
67
|
+
danger: str
|
|
68
|
+
|
|
69
|
+
# The reading emphasis outline (N-13). Deliberately near-black rather
|
|
70
|
+
# than the accent: the mark means "here", not "special".
|
|
71
|
+
emphasis: str
|
|
72
|
+
#: Where playback is waiting on a segment that is not generated yet
|
|
73
|
+
#: (Section 5.2 distinguishes that state, F-28 keeps the last position).
|
|
74
|
+
emphasis_waiting: str
|
|
75
|
+
|
|
76
|
+
is_dark: bool
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
LIGHT = Palette(
|
|
80
|
+
canvas="#f4f4f2",
|
|
81
|
+
surface="#ffffff",
|
|
82
|
+
page="#fdfdfc",
|
|
83
|
+
raised="#ececeb",
|
|
84
|
+
border="#e2e2df",
|
|
85
|
+
border_strong="#c6c6c1",
|
|
86
|
+
text="#1a1a19",
|
|
87
|
+
text_secondary="#5c5c58",
|
|
88
|
+
text_muted="#8b8b85",
|
|
89
|
+
text_on_accent="#ffffff",
|
|
90
|
+
accent="#3f4bd6",
|
|
91
|
+
accent_hover="#4d58e0",
|
|
92
|
+
accent_pressed="#333dbb",
|
|
93
|
+
accent_soft="#e8eaff",
|
|
94
|
+
ok="#1f7a4d",
|
|
95
|
+
warn="#9a6300",
|
|
96
|
+
danger="#b3261e",
|
|
97
|
+
emphasis="#111827",
|
|
98
|
+
emphasis_waiting="#8b8b85",
|
|
99
|
+
is_dark=False,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
DARK = Palette(
|
|
103
|
+
canvas="#161615",
|
|
104
|
+
surface="#1e1e1d",
|
|
105
|
+
page="#1a1a19",
|
|
106
|
+
raised="#2a2a28",
|
|
107
|
+
border="#2f2f2d",
|
|
108
|
+
border_strong="#4a4a47",
|
|
109
|
+
text="#eeeeec",
|
|
110
|
+
text_secondary="#a8a8a3",
|
|
111
|
+
text_muted="#77776f",
|
|
112
|
+
text_on_accent="#ffffff",
|
|
113
|
+
accent="#7c86f5",
|
|
114
|
+
accent_hover="#8e97ff",
|
|
115
|
+
accent_pressed="#6a74e6",
|
|
116
|
+
accent_soft="#26284a",
|
|
117
|
+
ok="#4bbd85",
|
|
118
|
+
warn="#d9a441",
|
|
119
|
+
danger="#f0837b",
|
|
120
|
+
emphasis="#f5f5f3",
|
|
121
|
+
emphasis_waiting="#77776f",
|
|
122
|
+
is_dark=True,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True, slots=True)
|
|
127
|
+
class Metrics:
|
|
128
|
+
"""Spacing, radius, and the type scale.
|
|
129
|
+
|
|
130
|
+
A single 4px grid. Every margin and gap in the app is one of these, so
|
|
131
|
+
density is one edit rather than a hunt.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
unit: int = 4
|
|
135
|
+
gap_tight: int = 4
|
|
136
|
+
gap: int = 8
|
|
137
|
+
gap_wide: int = 12
|
|
138
|
+
pad: int = 12
|
|
139
|
+
pad_wide: int = 16
|
|
140
|
+
pad_page: int = 28
|
|
141
|
+
|
|
142
|
+
radius_small: int = 6
|
|
143
|
+
radius: int = 8
|
|
144
|
+
radius_large: int = 12
|
|
145
|
+
|
|
146
|
+
control_height: int = 32
|
|
147
|
+
control_height_large: int = 40
|
|
148
|
+
|
|
149
|
+
# Type sizes in points. The reading size is set apart because it is the
|
|
150
|
+
# only one the user is looking at for minutes at a time.
|
|
151
|
+
size_reading: int = 12
|
|
152
|
+
size_body: int = 10
|
|
153
|
+
size_small: int = 9
|
|
154
|
+
size_title: int = 15
|
|
155
|
+
size_section: int = 11
|
|
156
|
+
|
|
157
|
+
#: Reading line spacing as a percentage. Korean needs more than Latin
|
|
158
|
+
#: because Hangul syllable blocks fill the em box, but much past 160 the
|
|
159
|
+
#: lines stop reading as a paragraph and start reading as a list.
|
|
160
|
+
reading_line_height_pct: int = 158
|
|
161
|
+
#: Space between paragraphs, on top of the line height. Separating the
|
|
162
|
+
#: two means a blank line in the source does not inherit the full
|
|
163
|
+
#: reading leading twice over.
|
|
164
|
+
reading_paragraph_gap: int = 10
|
|
165
|
+
#: A blank line between paragraphs gets this instead, so one visual gap
|
|
166
|
+
#: does not cost three line heights.
|
|
167
|
+
reading_blank_line_height_pct: int = 70
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
METRICS = Metrics()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# Font selection.
|
|
174
|
+
#
|
|
175
|
+
# A.2 wants one bundled Korean and Latin family so that metrics -- and
|
|
176
|
+
# therefore N-12's synchronisation measurements and N-30's layout checks --
|
|
177
|
+
# are identical on Windows and macOS. Until that family is bundled the app
|
|
178
|
+
# falls back to the best per-OS pair, which is correct to look at but means
|
|
179
|
+
# a measurement taken on one machine does not transfer to another. The
|
|
180
|
+
# fallback is ordered, not a guess: the first entry present wins.
|
|
181
|
+
BUNDLED_FAMILY = fonts.BUNDLED_FAMILY # SIL OFL; fetched by scripts/fetch_fonts.py
|
|
182
|
+
_FALLBACKS = (
|
|
183
|
+
"Pretendard",
|
|
184
|
+
"Malgun Gothic", # Windows Korean, ships with the OS
|
|
185
|
+
"Apple SD Gothic Neo", # macOS Korean
|
|
186
|
+
"Noto Sans KR",
|
|
187
|
+
"Segoe UI",
|
|
188
|
+
"Helvetica Neue",
|
|
189
|
+
)
|
|
190
|
+
# Consolas before Cascadia Mono on purpose: on a Korean-locale Windows
|
|
191
|
+
# the latter draws U+005C as the won sign, and a path or a JSON escape
|
|
192
|
+
# shown that way reads as a mistake in something the user is copying.
|
|
193
|
+
_MONO_FALLBACKS = ("JetBrains Mono", "Consolas", "Cascadia Mono", "SF Mono", "Menlo")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def resolve_family(candidates: tuple[str, ...] = _FALLBACKS) -> str:
|
|
197
|
+
"""First installed family, or Qt's default if none of them are."""
|
|
198
|
+
installed = set(QFontDatabase.families())
|
|
199
|
+
for name in candidates:
|
|
200
|
+
if name in installed:
|
|
201
|
+
return name
|
|
202
|
+
return QFont().family()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def reading_font(family: str | None = None) -> QFont:
|
|
206
|
+
"""The font of the reading surface.
|
|
207
|
+
|
|
208
|
+
Hinting is set to ``PreferFullHinting`` off and subpixel positioning left
|
|
209
|
+
to the platform: the outline emphasis in N-13 draws a pen around existing
|
|
210
|
+
glyph shapes, so anything that snaps glyph advances differently between
|
|
211
|
+
the plain and emphasised paint would reintroduce the movement the
|
|
212
|
+
requirement forbids.
|
|
213
|
+
"""
|
|
214
|
+
f = QFont(family or resolve_family(), METRICS.size_reading)
|
|
215
|
+
f.setStyleStrategy(QFont.StyleStrategy.PreferAntialias)
|
|
216
|
+
f.setHintingPreference(QFont.HintingPreference.PreferVerticalHinting)
|
|
217
|
+
return f
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def ui_font(family: str | None = None, size: int | None = None) -> QFont:
|
|
221
|
+
return QFont(family or resolve_family(), size or METRICS.size_body)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def mono_font(size: int | None = None) -> QFont:
|
|
225
|
+
return QFont(resolve_family(_MONO_FALLBACKS), size or METRICS.size_small)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def system_prefers_dark() -> bool:
|
|
229
|
+
"""Qt 6.5+ reports the OS colour scheme; older builds guess from the
|
|
230
|
+
default window colour."""
|
|
231
|
+
hints = QGuiApplication.styleHints()
|
|
232
|
+
scheme = getattr(hints, "colorScheme", None)
|
|
233
|
+
if scheme is not None:
|
|
234
|
+
return scheme() == Qt.ColorScheme.Dark
|
|
235
|
+
window = QGuiApplication.palette().window().color()
|
|
236
|
+
return window.lightness() < 128
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def palette_for(mode: Mode) -> Palette:
|
|
240
|
+
if mode is Mode.SYSTEM:
|
|
241
|
+
return DARK if system_prefers_dark() else LIGHT
|
|
242
|
+
return DARK if mode is Mode.DARK else LIGHT
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def qcolor(hex_value: str, alpha: float = 1.0) -> QColor:
|
|
246
|
+
c = QColor(hex_value)
|
|
247
|
+
if alpha < 1.0:
|
|
248
|
+
c.setAlphaF(alpha)
|
|
249
|
+
return c
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def stylesheet(p: Palette, m: Metrics = METRICS, family: str | None = None) -> str:
|
|
253
|
+
"""The whole application stylesheet.
|
|
254
|
+
|
|
255
|
+
Written as one string rather than per-widget so that a widget cannot
|
|
256
|
+
quietly disagree with the theme, and so the light/dark switch is a single
|
|
257
|
+
``setStyleSheet`` call.
|
|
258
|
+
|
|
259
|
+
Focus is a visible ring everywhere, because N-30 requires the basic
|
|
260
|
+
features to be usable by keyboard and an invisible focus makes that
|
|
261
|
+
false in practice.
|
|
262
|
+
|
|
263
|
+
Four marks -- the combo arrow, the spin arrows, and the check mark --
|
|
264
|
+
can only reach a Qt stylesheet as a URL, so they are rendered to files
|
|
265
|
+
per theme rather than drawn. Styling an indicator without supplying
|
|
266
|
+
its mark is the common Qt mistake: the border and fill follow the
|
|
267
|
+
theme and the check simply vanishes.
|
|
268
|
+
"""
|
|
269
|
+
fam = family or resolve_family()
|
|
270
|
+
mono = resolve_family(_MONO_FALLBACKS)
|
|
271
|
+
key = "dark" if p.is_dark else "light"
|
|
272
|
+
a = icons.stylesheet_assets(
|
|
273
|
+
key,
|
|
274
|
+
{
|
|
275
|
+
"chevron": ("chevron-down", p.text_secondary, 14),
|
|
276
|
+
"chevron_disabled": ("chevron-down", p.text_muted, 14),
|
|
277
|
+
"up": ("chevron-up", p.text_secondary, 12),
|
|
278
|
+
"down": ("chevron-down", p.text_secondary, 12),
|
|
279
|
+
"check": ("check", p.text_on_accent, 14),
|
|
280
|
+
"search": ("search", p.text_muted, 14),
|
|
281
|
+
},
|
|
282
|
+
)
|
|
283
|
+
return f"""
|
|
284
|
+
* {{
|
|
285
|
+
font-family: "{fam}";
|
|
286
|
+
font-size: {m.size_body}pt;
|
|
287
|
+
color: {p.text};
|
|
288
|
+
outline: none;
|
|
289
|
+
}}
|
|
290
|
+
|
|
291
|
+
QWidget#Root, QMainWindow, QDialog {{
|
|
292
|
+
background: {p.canvas};
|
|
293
|
+
}}
|
|
294
|
+
|
|
295
|
+
QWidget#Panel, QFrame#Panel {{
|
|
296
|
+
background: {p.surface};
|
|
297
|
+
border: 1px solid {p.border};
|
|
298
|
+
border-radius: {m.radius}px;
|
|
299
|
+
}}
|
|
300
|
+
|
|
301
|
+
/* A monospaced face, as a property rather than a call.
|
|
302
|
+
The `*` rule above sets font-family on every widget and a stylesheet
|
|
303
|
+
beats setFont, so a code block or a path asked for in Python quietly
|
|
304
|
+
renders in the interface face -- which on a Korean-locale Windows
|
|
305
|
+
draws U+005C as the won sign, turning every path into currency. */
|
|
306
|
+
*[mono="true"] {{
|
|
307
|
+
font-family: "{mono}";
|
|
308
|
+
}}
|
|
309
|
+
|
|
310
|
+
QLabel[role="title"] {{
|
|
311
|
+
font-size: {m.size_title}pt;
|
|
312
|
+
font-weight: 600;
|
|
313
|
+
color: {p.text};
|
|
314
|
+
}}
|
|
315
|
+
QLabel[role="section"] {{
|
|
316
|
+
font-size: {m.size_section}pt;
|
|
317
|
+
font-weight: 600;
|
|
318
|
+
color: {p.text};
|
|
319
|
+
}}
|
|
320
|
+
QLabel[role="secondary"] {{ color: {p.text_secondary}; }}
|
|
321
|
+
QLabel[role="muted"] {{ color: {p.text_muted}; font-size: {m.size_small}pt; }}
|
|
322
|
+
QLabel[role="ok"] {{ color: {p.ok}; }}
|
|
323
|
+
QLabel[role="warn"] {{ color: {p.warn}; }}
|
|
324
|
+
QLabel[role="danger"] {{ color: {p.danger}; }}
|
|
325
|
+
|
|
326
|
+
/* ---- buttons ------------------------------------------------------- */
|
|
327
|
+
QPushButton {{
|
|
328
|
+
background: {p.surface};
|
|
329
|
+
border: 1px solid {p.border_strong};
|
|
330
|
+
border-radius: {m.radius_small}px;
|
|
331
|
+
padding: 0 {m.pad}px;
|
|
332
|
+
min-height: {m.control_height}px;
|
|
333
|
+
color: {p.text};
|
|
334
|
+
}}
|
|
335
|
+
QPushButton:hover:!disabled {{ background: {p.raised}; }}
|
|
336
|
+
QPushButton:pressed:!disabled {{ background: {p.border}; }}
|
|
337
|
+
QPushButton:focus {{ border: 1px solid {p.accent}; }}
|
|
338
|
+
QPushButton:disabled {{ color: {p.text_muted}; border-color: {p.border}; }}
|
|
339
|
+
|
|
340
|
+
QPushButton[variant="primary"] {{
|
|
341
|
+
background: {p.accent};
|
|
342
|
+
border: 1px solid {p.accent};
|
|
343
|
+
color: {p.text_on_accent};
|
|
344
|
+
font-weight: 600;
|
|
345
|
+
min-height: {m.control_height_large}px;
|
|
346
|
+
padding: 0 {m.pad_wide}px;
|
|
347
|
+
}}
|
|
348
|
+
QPushButton[variant="primary"]:hover:!disabled {{
|
|
349
|
+
background: {p.accent_hover}; border-color: {p.accent_hover};
|
|
350
|
+
}}
|
|
351
|
+
QPushButton[variant="primary"]:pressed:!disabled {{
|
|
352
|
+
background: {p.accent_pressed}; border-color: {p.accent_pressed};
|
|
353
|
+
}}
|
|
354
|
+
QPushButton[variant="primary"]:disabled {{
|
|
355
|
+
background: {p.raised}; border-color: {p.border}; color: {p.text_muted};
|
|
356
|
+
}}
|
|
357
|
+
QPushButton[variant="quiet"] {{
|
|
358
|
+
background: transparent; border: 1px solid transparent; color: {p.text_secondary};
|
|
359
|
+
}}
|
|
360
|
+
QPushButton[variant="quiet"]:hover:!disabled {{ background: {p.raised}; color: {p.text}; }}
|
|
361
|
+
QPushButton[variant="danger"] {{ color: {p.danger}; border-color: {p.border_strong}; }}
|
|
362
|
+
QPushButton[variant="danger"]:hover:!disabled {{ background: {p.raised}; }}
|
|
363
|
+
|
|
364
|
+
QToolButton {{
|
|
365
|
+
background: transparent;
|
|
366
|
+
border: 1px solid transparent;
|
|
367
|
+
border-radius: {m.radius_small}px;
|
|
368
|
+
padding: {m.gap_tight}px;
|
|
369
|
+
}}
|
|
370
|
+
QToolButton:hover:!disabled {{ background: {p.raised}; }}
|
|
371
|
+
QToolButton:checked {{ background: {p.accent_soft}; border-color: {p.accent}; }}
|
|
372
|
+
QToolButton:focus {{ border-color: {p.accent}; }}
|
|
373
|
+
QToolButton:disabled {{ color: {p.text_muted}; }}
|
|
374
|
+
|
|
375
|
+
/* ---- inputs -------------------------------------------------------- */
|
|
376
|
+
QLineEdit, QComboBox, QSpinBox, QDoubleSpinBox, QPlainTextEdit {{
|
|
377
|
+
background: {p.surface};
|
|
378
|
+
border: 1px solid {p.border_strong};
|
|
379
|
+
border-radius: {m.radius_small}px;
|
|
380
|
+
padding: 0 {m.gap}px;
|
|
381
|
+
min-height: {m.control_height}px;
|
|
382
|
+
selection-background-color: {p.accent};
|
|
383
|
+
selection-color: {p.text_on_accent};
|
|
384
|
+
}}
|
|
385
|
+
QLineEdit:focus, QComboBox:focus, QSpinBox:focus, QDoubleSpinBox:focus {{
|
|
386
|
+
border-color: {p.accent};
|
|
387
|
+
}}
|
|
388
|
+
QLineEdit:disabled, QComboBox:disabled, QSpinBox:disabled {{
|
|
389
|
+
color: {p.text_muted}; background: {p.canvas};
|
|
390
|
+
}}
|
|
391
|
+
QComboBox::drop-down {{ border: none; width: 24px; }}
|
|
392
|
+
QComboBox::down-arrow {{ image: url("{a["chevron"]}"); width: 14px; height: 14px; }}
|
|
393
|
+
QComboBox::down-arrow:disabled {{ image: url("{a["chevron_disabled"]}"); }}
|
|
394
|
+
QComboBox QAbstractItemView {{
|
|
395
|
+
background: {p.surface};
|
|
396
|
+
border: 1px solid {p.border_strong};
|
|
397
|
+
border-radius: {m.radius_small}px;
|
|
398
|
+
selection-background-color: {p.accent_soft};
|
|
399
|
+
selection-color: {p.text};
|
|
400
|
+
padding: {m.gap_tight}px;
|
|
401
|
+
}}
|
|
402
|
+
|
|
403
|
+
QCheckBox, QRadioButton {{ spacing: {m.gap}px; min-height: {m.control_height}px; }}
|
|
404
|
+
QCheckBox::indicator, QRadioButton::indicator {{
|
|
405
|
+
width: 16px; height: 16px;
|
|
406
|
+
border: 1px solid {p.border_strong};
|
|
407
|
+
background: {p.surface};
|
|
408
|
+
}}
|
|
409
|
+
QCheckBox::indicator {{ border-radius: 4px; }}
|
|
410
|
+
QRadioButton::indicator {{ border-radius: 8px; }}
|
|
411
|
+
QCheckBox::indicator:hover, QRadioButton::indicator:hover {{ border-color: {p.accent}; }}
|
|
412
|
+
QCheckBox::indicator:checked, QRadioButton::indicator:checked {{
|
|
413
|
+
background: {p.accent}; border-color: {p.accent};
|
|
414
|
+
}}
|
|
415
|
+
QCheckBox::indicator:checked {{ image: url("{a["check"]}"); }}
|
|
416
|
+
QCheckBox::indicator:disabled, QRadioButton::indicator:disabled {{
|
|
417
|
+
background: {p.canvas}; border-color: {p.border};
|
|
418
|
+
}}
|
|
419
|
+
QCheckBox:focus, QRadioButton:focus {{ color: {p.accent}; }}
|
|
420
|
+
QCheckBox:disabled, QRadioButton:disabled {{ color: {p.text_muted}; }}
|
|
421
|
+
|
|
422
|
+
QSpinBox::up-button, QDoubleSpinBox::up-button,
|
|
423
|
+
QSpinBox::down-button, QDoubleSpinBox::down-button {{
|
|
424
|
+
background: transparent;
|
|
425
|
+
border: none;
|
|
426
|
+
border-left: 1px solid {p.border};
|
|
427
|
+
width: 20px;
|
|
428
|
+
}}
|
|
429
|
+
QSpinBox::up-button:hover, QDoubleSpinBox::up-button:hover,
|
|
430
|
+
QSpinBox::down-button:hover, QDoubleSpinBox::down-button:hover {{ background: {p.raised}; }}
|
|
431
|
+
QSpinBox::up-arrow, QDoubleSpinBox::up-arrow {{
|
|
432
|
+
image: url("{a["up"]}"); width: 12px; height: 12px;
|
|
433
|
+
}}
|
|
434
|
+
QSpinBox::down-arrow, QDoubleSpinBox::down-arrow {{
|
|
435
|
+
image: url("{a["down"]}"); width: 12px; height: 12px;
|
|
436
|
+
}}
|
|
437
|
+
|
|
438
|
+
/* ---- the reading surface ------------------------------------------- */
|
|
439
|
+
QTextEdit#ReadingSurface {{
|
|
440
|
+
background: {p.page};
|
|
441
|
+
border: 1px solid {p.border};
|
|
442
|
+
border-radius: {m.radius}px;
|
|
443
|
+
padding: {m.pad_page}px;
|
|
444
|
+
font-size: {m.size_reading}pt;
|
|
445
|
+
selection-background-color: {p.accent_soft};
|
|
446
|
+
selection-color: {p.text};
|
|
447
|
+
}}
|
|
448
|
+
QTextEdit#ReadingSurface:focus {{ border-color: {p.border_strong}; }}
|
|
449
|
+
|
|
450
|
+
/* ---- sliders and progress ------------------------------------------ */
|
|
451
|
+
QSlider::groove:horizontal {{
|
|
452
|
+
height: 4px; background: {p.border}; border-radius: 2px;
|
|
453
|
+
}}
|
|
454
|
+
QSlider::sub-page:horizontal {{ background: {p.accent}; border-radius: 2px; }}
|
|
455
|
+
QSlider::handle:horizontal {{
|
|
456
|
+
background: {p.surface};
|
|
457
|
+
border: 2px solid {p.accent};
|
|
458
|
+
width: 12px; height: 12px;
|
|
459
|
+
margin: -6px 0;
|
|
460
|
+
border-radius: 8px;
|
|
461
|
+
}}
|
|
462
|
+
QSlider::handle:horizontal:disabled {{ border-color: {p.border_strong}; }}
|
|
463
|
+
QSlider:focus::handle:horizontal {{ border-color: {p.accent_hover}; }}
|
|
464
|
+
|
|
465
|
+
QProgressBar {{
|
|
466
|
+
background: {p.raised};
|
|
467
|
+
border: none;
|
|
468
|
+
border-radius: 2px;
|
|
469
|
+
max-height: 4px;
|
|
470
|
+
min-height: 4px;
|
|
471
|
+
text-align: center;
|
|
472
|
+
color: transparent;
|
|
473
|
+
}}
|
|
474
|
+
QProgressBar::chunk {{ background: {p.accent}; border-radius: 2px; }}
|
|
475
|
+
|
|
476
|
+
/* ---- lists and tables ---------------------------------------------- */
|
|
477
|
+
QListView, QTreeView, QTableView {{
|
|
478
|
+
background: {p.surface};
|
|
479
|
+
border: 1px solid {p.border};
|
|
480
|
+
border-radius: {m.radius}px;
|
|
481
|
+
alternate-background-color: {p.canvas};
|
|
482
|
+
selection-background-color: {p.accent_soft};
|
|
483
|
+
selection-color: {p.text};
|
|
484
|
+
padding: {m.gap_tight}px;
|
|
485
|
+
}}
|
|
486
|
+
QListView::item, QTreeView::item, QTableView::item {{
|
|
487
|
+
padding: {m.gap}px;
|
|
488
|
+
border-radius: {m.radius_small}px;
|
|
489
|
+
min-height: 24px;
|
|
490
|
+
}}
|
|
491
|
+
QListView::item:hover, QTreeView::item:hover {{ background: {p.raised}; }}
|
|
492
|
+
QHeaderView::section {{
|
|
493
|
+
background: {p.canvas};
|
|
494
|
+
color: {p.text_secondary};
|
|
495
|
+
border: none;
|
|
496
|
+
border-bottom: 1px solid {p.border};
|
|
497
|
+
padding: {m.gap}px;
|
|
498
|
+
font-weight: 600;
|
|
499
|
+
}}
|
|
500
|
+
|
|
501
|
+
/* ---- tabs ----------------------------------------------------------- */
|
|
502
|
+
QTabWidget::pane {{
|
|
503
|
+
border: 1px solid {p.border};
|
|
504
|
+
border-radius: {m.radius}px;
|
|
505
|
+
top: -1px;
|
|
506
|
+
background: {p.surface};
|
|
507
|
+
}}
|
|
508
|
+
QTabBar::tab {{
|
|
509
|
+
background: transparent;
|
|
510
|
+
color: {p.text_secondary};
|
|
511
|
+
padding: {m.gap}px {m.pad_wide}px;
|
|
512
|
+
margin-right: {m.gap_tight}px;
|
|
513
|
+
border: 1px solid transparent;
|
|
514
|
+
border-radius: {m.radius_small}px;
|
|
515
|
+
}}
|
|
516
|
+
QTabBar::tab:selected {{ color: {p.text}; background: {p.surface}; border-color: {p.border}; }}
|
|
517
|
+
QTabBar::tab:hover:!selected {{ color: {p.text}; background: {p.raised}; }}
|
|
518
|
+
QTabBar::tab:focus {{ border-color: {p.accent}; }}
|
|
519
|
+
|
|
520
|
+
/* ---- scrollbars: present but quiet --------------------------------- */
|
|
521
|
+
QScrollBar:vertical {{ background: transparent; width: 10px; margin: 2px; }}
|
|
522
|
+
QScrollBar:horizontal {{ background: transparent; height: 10px; margin: 2px; }}
|
|
523
|
+
QScrollBar::handle {{ background: {p.border_strong}; border-radius: 4px; min-height: 32px; }}
|
|
524
|
+
QScrollBar::handle:hover {{ background: {p.text_muted}; }}
|
|
525
|
+
QScrollBar::add-line, QScrollBar::sub-line {{ height: 0; width: 0; }}
|
|
526
|
+
QScrollBar::add-page, QScrollBar::sub-page {{ background: transparent; }}
|
|
527
|
+
|
|
528
|
+
/* ---- chrome --------------------------------------------------------- */
|
|
529
|
+
QToolBar {{ background: transparent; border: none; spacing: {m.gap}px; padding: {m.gap}px; }}
|
|
530
|
+
QStatusBar {{ background: transparent; color: {p.text_secondary}; }}
|
|
531
|
+
QStatusBar::item {{ border: none; }}
|
|
532
|
+
QMenuBar {{ background: transparent; }}
|
|
533
|
+
QMenuBar::item {{ padding: {m.gap_tight}px {m.gap}px; border-radius: {m.radius_small}px; }}
|
|
534
|
+
QMenuBar::item:selected {{ background: {p.raised}; }}
|
|
535
|
+
QMenu {{
|
|
536
|
+
background: {p.surface};
|
|
537
|
+
border: 1px solid {p.border_strong};
|
|
538
|
+
border-radius: {m.radius}px;
|
|
539
|
+
padding: {m.gap_tight}px;
|
|
540
|
+
}}
|
|
541
|
+
QMenu::item {{ padding: {m.gap}px {m.pad}px; border-radius: {m.radius_small}px; }}
|
|
542
|
+
QMenu::item:selected {{ background: {p.accent_soft}; }}
|
|
543
|
+
QMenu::separator {{ height: 1px; background: {p.border}; margin: {m.gap_tight}px 0; }}
|
|
544
|
+
|
|
545
|
+
QToolTip {{
|
|
546
|
+
background: {p.text};
|
|
547
|
+
color: {p.canvas};
|
|
548
|
+
border: none;
|
|
549
|
+
border-radius: {m.radius_small}px;
|
|
550
|
+
padding: {m.gap_tight}px {m.gap}px;
|
|
551
|
+
}}
|
|
552
|
+
|
|
553
|
+
QFrame[role="separator"] {{ background: {p.border}; max-height: 1px; border: none; }}
|
|
554
|
+
|
|
555
|
+
QGroupBox {{
|
|
556
|
+
border: 1px solid {p.border};
|
|
557
|
+
border-radius: {m.radius}px;
|
|
558
|
+
margin-top: {m.pad}px;
|
|
559
|
+
padding: {m.pad}px;
|
|
560
|
+
font-weight: 600;
|
|
561
|
+
}}
|
|
562
|
+
QGroupBox::title {{
|
|
563
|
+
subcontrol-origin: margin;
|
|
564
|
+
left: {m.pad}px;
|
|
565
|
+
padding: 0 {m.gap_tight}px;
|
|
566
|
+
color: {p.text_secondary};
|
|
567
|
+
}}
|
|
568
|
+
|
|
569
|
+
QSplitter::handle {{ background: transparent; }}
|
|
570
|
+
QSplitter::handle:horizontal {{ width: {m.gap}px; }}
|
|
571
|
+
QSplitter::handle:vertical {{ height: {m.gap}px; }}
|
|
572
|
+
"""
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def apply(app, mode: Mode = Mode.SYSTEM, family: str | None = None) -> Palette:
|
|
576
|
+
"""Set the application stylesheet and return the palette in force."""
|
|
577
|
+
p = palette_for(mode)
|
|
578
|
+
app.setStyleSheet(stylesheet(p, METRICS, family))
|
|
579
|
+
return p
|
echoact/util/__init__.py
ADDED
|
File without changes
|
echoact/util/ids.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Identifier minting.
|
|
2
|
+
|
|
3
|
+
Job, segment, result, client, and request identifiers are opaque to every
|
|
4
|
+
caller. N-19 is explicit that knowing one must not by itself grant access,
|
|
5
|
+
so these are not required to be unguessable -- authorisation is checked
|
|
6
|
+
regardless -- but they are random anyway, because a guessable id invites the
|
|
7
|
+
mistake N-19 forbids.
|
|
8
|
+
|
|
9
|
+
Credential *secrets* are not minted here; those live in
|
|
10
|
+
``echoact.security.credentials``, which has different requirements.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import secrets
|
|
16
|
+
import time
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _token(nbytes: int = 12) -> str:
|
|
20
|
+
return secrets.token_hex(nbytes)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def job_id() -> str:
|
|
24
|
+
return "job_" + _token()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def segment_id() -> str:
|
|
28
|
+
return "seg_" + _token(8)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def result_id() -> str:
|
|
32
|
+
return "res_" + _token()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def document_id() -> str:
|
|
36
|
+
return "doc_" + _token()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def client_id() -> str:
|
|
40
|
+
return "cli_" + _token(8)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def request_id() -> str:
|
|
44
|
+
"""F-57 puts one of these in every error so a user's report can be
|
|
45
|
+
matched to a log line without the log carrying their text."""
|
|
46
|
+
return "req_" + _token(8)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def backup_id() -> str:
|
|
50
|
+
return "bak_" + _token(8)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def now() -> float:
|
|
54
|
+
"""Wall-clock seconds. Used for created/expiry timestamps that must
|
|
55
|
+
survive a restart and be shown to the user."""
|
|
56
|
+
return time.time()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def monotonic() -> float:
|
|
60
|
+
"""For durations and deadlines. Never mixed with ``now()``: a clock
|
|
61
|
+
adjustment must not extend a bounded wait or a retry-after."""
|
|
62
|
+
return time.monotonic()
|