echoact 0.1.0__py3-none-any.whl

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