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/i18n.py ADDED
@@ -0,0 +1,260 @@
1
+ """Display language, per F-86.
2
+
3
+ Korean or English, defaulting to the operating system's language and falling
4
+ back to English. It is independent of the narration language in F-05 and of
5
+ the source text, and A-22 requires that switching it leaves documents, job
6
+ snapshots, and API responses unchanged -- which is why nothing outside
7
+ ``echoact.ui`` imports this module. Error text on the wire comes from
8
+ ``echoact.errors``, in English, always.
9
+
10
+ Strings are keyed by their English text rather than by an identifier. That
11
+ keeps a call site readable, makes a missing translation degrade to correct
12
+ English instead of to a symbol, and means no registry to keep in step. Where
13
+ two English strings coincide but their Korean does not, pass a ``context``.
14
+
15
+ Each UI module registers its own strings with ``add_korean`` from its own
16
+ file, so the catalogue never becomes one contended table.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import locale
22
+ import os
23
+ from collections.abc import Callable, Mapping
24
+ from enum import StrEnum
25
+
26
+
27
+ class Lang(StrEnum):
28
+ EN = "en"
29
+ KO = "ko"
30
+ SYSTEM = "system"
31
+
32
+
33
+ _KO: dict[str, str] = {}
34
+ _CURRENT: Lang = Lang.EN
35
+ _LISTENERS: list[Callable[[Lang], None]] = []
36
+
37
+
38
+ def detect_system_language() -> Lang:
39
+ """Korean if the OS says so, English otherwise.
40
+
41
+ Checked in the order a user would expect to win: an explicit override,
42
+ then the process locale, then the OS default. Anything unrecognised is
43
+ English, because F-86 names English as the fallback rather than the
44
+ nearest match.
45
+ """
46
+ for value in (
47
+ os.environ.get("ECHOACT_LANG"),
48
+ os.environ.get("LANG"),
49
+ os.environ.get("LC_ALL"),
50
+ ):
51
+ if value and value.lower().startswith("ko"):
52
+ return Lang.KO
53
+ if value and value.lower().startswith("en"):
54
+ return Lang.EN
55
+ try:
56
+ code = locale.getlocale()[0] or ""
57
+ except ValueError:
58
+ code = ""
59
+ if code.lower().startswith(("ko", "korean")):
60
+ return Lang.KO
61
+ return Lang.EN
62
+
63
+
64
+ def set_language(lang: Lang) -> Lang:
65
+ """Switch the display language and notify listeners. Returns the
66
+ resolved language, so a caller storing ``SYSTEM`` still learns which
67
+ one is in force."""
68
+ global _CURRENT
69
+ _CURRENT = detect_system_language() if lang is Lang.SYSTEM else lang
70
+ for fn in list(_LISTENERS):
71
+ fn(_CURRENT)
72
+ return _CURRENT
73
+
74
+
75
+ def current() -> Lang:
76
+ return _CURRENT
77
+
78
+
79
+ def on_change(fn: Callable[[Lang], None]) -> None:
80
+ """Register a retranslate callback. Widgets rebuild their text here
81
+ rather than being recreated, so the switch keeps the user's place."""
82
+ _LISTENERS.append(fn)
83
+
84
+
85
+ def add_korean(entries: Mapping[str, str], context: str | None = None) -> None:
86
+ """Contribute translations. Called at import time by each UI module."""
87
+ prefix = f"{context}\x00" if context else ""
88
+ for source, korean in entries.items():
89
+ _KO[prefix + source] = korean
90
+
91
+
92
+ def tr(text: str, context: str | None = None) -> str:
93
+ if _CURRENT is not Lang.KO:
94
+ return text
95
+ if context:
96
+ found = _KO.get(f"{context}\x00{text}")
97
+ if found is not None:
98
+ return found
99
+ return _KO.get(text, text)
100
+
101
+
102
+ def untranslated() -> list[str]:
103
+ """Strings the Korean catalogue does not cover.
104
+
105
+ Not used at runtime; a test asserts this is empty for the strings the
106
+ app actually shows, so a new label cannot ship English-only by accident.
107
+ """
108
+ return sorted(k for k, v in _KO.items() if not v)
109
+
110
+
111
+ def plural(n: int, singular: str, plural_form: str) -> str:
112
+ """English pluralises, Korean does not.
113
+
114
+ Written as one helper so no call site grows an ``if language ==`` of its
115
+ own; Korean returns the singular because Hangul marks number on the
116
+ noun only when it matters, and "1개의 문장" style counting reads wrong
117
+ for a running status line.
118
+ """
119
+ if _CURRENT is Lang.KO:
120
+ return singular
121
+ return singular if n == 1 else plural_form
122
+
123
+
124
+ def duration(ms: int) -> str:
125
+ """m:ss, or h:mm:ss past an hour. Identical in both languages, because
126
+ a timecode is not prose."""
127
+ total = max(0, ms) // 1000
128
+ h, rem = divmod(total, 3600)
129
+ m, s = divmod(rem, 60)
130
+ if h:
131
+ return f"{h}:{m:02d}:{s:02d}"
132
+ return f"{m}:{s:02d}"
133
+
134
+
135
+ def approximate_duration(ms: int) -> str:
136
+ """A rough length for an estimate, where a timecode would imply more
137
+ precision than F-88 promises."""
138
+ total = max(0, ms) // 1000
139
+ if total < 60:
140
+ return tr("about {n} seconds").format(n=total)
141
+ minutes = round(total / 60)
142
+ if minutes < 60:
143
+ return tr("about {n} min").format(n=minutes)
144
+ hours = total / 3600
145
+ return tr("about {n} h").format(n=f"{hours:.1f}")
146
+
147
+
148
+ def count(n: int) -> str:
149
+ """Thousands separators. Korean uses the same grouping as English."""
150
+ return f"{n:,}"
151
+
152
+
153
+ def bytes_size(n: int) -> str:
154
+ """Storage in GB (10^9), per Section 4.1's units rule."""
155
+ if n < 1000:
156
+ return f"{n} B"
157
+ for unit, div in (("GB", 10**9), ("MB", 10**6), ("kB", 10**3)):
158
+ if n >= div:
159
+ value = n / div
160
+ return f"{value:.1f} {unit}" if value < 100 else f"{value:.0f} {unit}"
161
+ return f"{n} B"
162
+
163
+
164
+ def memory_size(n: int) -> str:
165
+ """Memory in GiB (2^30), per Section 4.1's units rule. Deliberately a
166
+ different function from ``bytes_size``: the document uses different
167
+ units for storage and memory and conflating them misreports both."""
168
+ gib = n / (1 << 30)
169
+ if gib >= 1:
170
+ return f"{gib:.1f} GiB"
171
+ return f"{n / (1 << 20):.0f} MiB"
172
+
173
+
174
+ # The strings shared across the app. Module-specific text is registered by
175
+ # the module that shows it.
176
+ add_korean(
177
+ {
178
+ # identity and chrome
179
+ "EchoAct": "에코액트",
180
+ "Library": "라이브러리",
181
+ "Models": "모델",
182
+ "Settings": "설정",
183
+ "Help": "도움말",
184
+ "Close": "닫기",
185
+ "Cancel": "취소",
186
+ "Save": "저장",
187
+ "Delete": "삭제",
188
+ "Open": "열기",
189
+ "Done": "완료",
190
+ "Retry": "다시 시도",
191
+ "Continue": "계속",
192
+ "Back": "뒤로",
193
+ "Copy": "복사",
194
+ "Discard": "저장하지 않음",
195
+ # reading and playback
196
+ "Read aloud": "소리내어 읽기",
197
+ "Play": "재생",
198
+ "Pause": "일시정지",
199
+ "Stop": "정지",
200
+ "Stop reading": "읽기 중지",
201
+ "Previous segment": "이전 문장",
202
+ "Next segment": "다음 문장",
203
+ "Return to the reading position": "읽는 위치로 이동",
204
+ "Play as soon as ready": "준비되는 즉시 재생",
205
+ "Follow the reading position": "읽는 위치 따라가기",
206
+ "Highlighting unavailable": "강조 표시를 사용할 수 없음",
207
+ "The text no longer matches the audio.": "본문이 오디오와 더 이상 일치하지 않습니다.",
208
+ "Waiting for the next segment": "다음 문장을 기다리는 중",
209
+ "Paused": "일시정지됨",
210
+ "Playing": "재생 중",
211
+ "Stopped": "정지됨",
212
+ # generation
213
+ "Preparing the model": "모델 준비 중",
214
+ "Loading the model": "모델 불러오는 중",
215
+ "Generating": "생성 중",
216
+ "Complete": "완료됨",
217
+ "Canceled": "취소됨",
218
+ "Interrupted": "중단됨",
219
+ "Failed": "실패",
220
+ "Cancel generation": "생성 취소",
221
+ # voice settings
222
+ "Voice": "음성",
223
+ "Model": "모델",
224
+ "Language": "언어",
225
+ "Automatic": "자동",
226
+ "Korean": "한국어",
227
+ "English": "영어",
228
+ "Gender": "성별",
229
+ "Female": "여성",
230
+ "Male": "남성",
231
+ "Style": "말하기 스타일",
232
+ "Natural": "자연스럽게",
233
+ "Calm": "차분하게",
234
+ "Bright": "밝게",
235
+ "Narration": "내레이션",
236
+ "Tempo": "속도",
237
+ "Presets": "사전 설정",
238
+ # input
239
+ "{n} / {max} characters": "{n} / {max}자",
240
+ "Open a text file": "텍스트 파일 열기",
241
+ "Unsaved text": "저장하지 않은 본문",
242
+ "Replace the current text?": "현재 본문을 바꿀까요?",
243
+ # resources and service
244
+ "CPU": "CPU",
245
+ "Memory": "메모리",
246
+ "Resource budget": "자원 한도",
247
+ "Local service": "로컬 서비스",
248
+ "on": "켜짐",
249
+ "off": "꺼짐",
250
+ "Integrations unavailable": "연동을 사용할 수 없음",
251
+ # measurements
252
+ "about {n} seconds": "약 {n}초",
253
+ "about {n} min": "약 {n}분",
254
+ "about {n} h": "약 {n}시간",
255
+ "{n} segments": "{n}개 문장",
256
+ "1 segment": "문장 1개",
257
+ }
258
+ )
259
+
260
+ _CURRENT = detect_system_language()
echoact/ui/icons.py ADDED
@@ -0,0 +1,440 @@
1
+ """Icons drawn in code, not shipped as files.
2
+
3
+ Two reasons rather than one. N-30 requires icons to carry accessible names
4
+ and descriptions, and an icon that is a character in a font gets whatever
5
+ name the font gives it -- on Windows the transport glyphs render as colour
6
+ emoji, which is both wrong for the theme and meaningless to a screen reader.
7
+ And the light/dark switch has to recolour every mark; a bitmap cannot follow
8
+ it, while a stroked path is redrawn in the current token colour for nothing.
9
+
10
+ Everything here is a 24-unit square path stroked at 2 units with round caps,
11
+ scaled to whatever size is asked for, so the whole set stays visually
12
+ consistent without a designer's file to keep in sync.
13
+
14
+ A few icons also have to reach Qt's stylesheet, which can only take a URL --
15
+ the combo box arrow and the check mark. Those are rendered once per theme
16
+ into a cache directory and referenced by path; ``stylesheet_assets`` returns
17
+ the mapping.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import tempfile
23
+ from collections.abc import Callable
24
+ from functools import lru_cache
25
+ from pathlib import Path
26
+
27
+ from PySide6.QtCore import QPointF, QRectF, QSize, Qt
28
+ from PySide6.QtGui import QColor, QIcon, QPainter, QPainterPath, QPen, QPixmap
29
+
30
+ # ----------------------------------------------------------------------
31
+ # Path definitions. Each takes a QPainterPath and draws in a 24x24 box.
32
+ # ----------------------------------------------------------------------
33
+
34
+
35
+ def _line(p: QPainterPath, x1: float, y1: float, x2: float, y2: float) -> None:
36
+ p.moveTo(x1, y1)
37
+ p.lineTo(x2, y2)
38
+
39
+
40
+ def _play(p: QPainterPath) -> None:
41
+ p.moveTo(8, 5.5)
42
+ p.lineTo(18.5, 12)
43
+ p.lineTo(8, 18.5)
44
+ p.closeSubpath()
45
+
46
+
47
+ def _pause(p: QPainterPath) -> None:
48
+ p.addRoundedRect(QRectF(8, 5.5, 2.6, 13), 1.3, 1.3)
49
+ p.addRoundedRect(QRectF(13.4, 5.5, 2.6, 13), 1.3, 1.3)
50
+
51
+
52
+ def _stop(p: QPainterPath) -> None:
53
+ p.addRoundedRect(QRectF(7, 7, 10, 10), 2, 2)
54
+
55
+
56
+ def _skip_back(p: QPainterPath) -> None:
57
+ p.moveTo(17, 6)
58
+ p.lineTo(9, 12)
59
+ p.lineTo(17, 18)
60
+ p.closeSubpath()
61
+ p.addRect(QRectF(6.5, 6, 1.6, 12))
62
+
63
+
64
+ def _skip_forward(p: QPainterPath) -> None:
65
+ p.moveTo(7, 6)
66
+ p.lineTo(15, 12)
67
+ p.lineTo(7, 18)
68
+ p.closeSubpath()
69
+ p.addRect(QRectF(15.9, 6, 1.6, 12))
70
+
71
+
72
+ def _chevron_down(p: QPainterPath) -> None:
73
+ p.moveTo(7, 10)
74
+ p.lineTo(12, 15)
75
+ p.lineTo(17, 10)
76
+
77
+
78
+ def _chevron_up(p: QPainterPath) -> None:
79
+ p.moveTo(7, 14)
80
+ p.lineTo(12, 9)
81
+ p.lineTo(17, 14)
82
+
83
+
84
+ def _chevron_right(p: QPainterPath) -> None:
85
+ p.moveTo(10, 7)
86
+ p.lineTo(15, 12)
87
+ p.lineTo(10, 17)
88
+
89
+
90
+ def _check(p: QPainterPath) -> None:
91
+ p.moveTo(6, 12.5)
92
+ p.lineTo(10, 16.5)
93
+ p.lineTo(18, 7.5)
94
+
95
+
96
+ def _close(p: QPainterPath) -> None:
97
+ _line(p, 7, 7, 17, 17)
98
+ _line(p, 17, 7, 7, 17)
99
+
100
+
101
+ def _plus(p: QPainterPath) -> None:
102
+ _line(p, 12, 6, 12, 18)
103
+ _line(p, 6, 12, 18, 12)
104
+
105
+
106
+ def _search(p: QPainterPath) -> None:
107
+ p.addEllipse(QPointF(11, 11), 5, 5)
108
+ _line(p, 14.8, 14.8, 18.5, 18.5)
109
+
110
+
111
+ def _trash(p: QPainterPath) -> None:
112
+ _line(p, 5.5, 7, 18.5, 7)
113
+ p.moveTo(7.5, 7)
114
+ p.lineTo(8.4, 19)
115
+ p.lineTo(15.6, 19)
116
+ p.lineTo(16.5, 7)
117
+ p.moveTo(9.5, 7)
118
+ p.lineTo(9.5, 4.8)
119
+ p.lineTo(14.5, 4.8)
120
+ p.lineTo(14.5, 7)
121
+
122
+
123
+ def _folder(p: QPainterPath) -> None:
124
+ p.moveTo(4, 18.5)
125
+ p.lineTo(4, 6)
126
+ p.lineTo(10, 6)
127
+ p.lineTo(11.8, 8.4)
128
+ p.lineTo(20, 8.4)
129
+ p.lineTo(20, 18.5)
130
+ p.closeSubpath()
131
+
132
+
133
+ def _save(p: QPainterPath) -> None:
134
+ p.addRoundedRect(QRectF(5, 5, 14, 14), 2, 2)
135
+ p.addRect(QRectF(8.5, 5, 7, 5))
136
+ p.addRect(QRectF(8, 13, 8, 6))
137
+
138
+
139
+ def _download(p: QPainterPath) -> None:
140
+ _line(p, 12, 4.5, 12, 15)
141
+ p.moveTo(7.5, 10.5)
142
+ p.lineTo(12, 15)
143
+ p.lineTo(16.5, 10.5)
144
+ _line(p, 5.5, 18.5, 18.5, 18.5)
145
+
146
+
147
+ def _export(p: QPainterPath) -> None:
148
+ _line(p, 12, 15, 12, 4.5)
149
+ p.moveTo(7.5, 9)
150
+ p.lineTo(12, 4.5)
151
+ p.lineTo(16.5, 9)
152
+ _line(p, 5.5, 18.5, 18.5, 18.5)
153
+
154
+
155
+ def _refresh(p: QPainterPath) -> None:
156
+ p.arcMoveTo(QRectF(5, 5, 14, 14), 60)
157
+ p.arcTo(QRectF(5, 5, 14, 14), 60, 280)
158
+ p.moveTo(15.5, 4.5)
159
+ p.lineTo(16.5, 8.6)
160
+ p.lineTo(12.4, 9.2)
161
+
162
+
163
+ def _settings(p: QPainterPath) -> None:
164
+ """Sliders, not a gear.
165
+
166
+ A six-spoke gear collapses into something that reads as a sparkle below
167
+ about 18 px, which is the size this icon is actually used at.
168
+ """
169
+ for y, knob in ((7.5, 15.5), (12.0, 9.0), (16.5, 13.5)):
170
+ _line(p, 4.5, y, 19.5, y)
171
+ p.addEllipse(QPointF(knob, y), 2.1, 2.1)
172
+
173
+
174
+ def _library(p: QPainterPath) -> None:
175
+ p.addRect(QRectF(5, 5.5, 3.4, 13))
176
+ p.addRect(QRectF(10, 5.5, 3.4, 13))
177
+ p.moveTo(15.6, 6.4)
178
+ p.lineTo(18.9, 5.7)
179
+ p.lineTo(20.4, 18.2)
180
+ p.lineTo(17.1, 18.9)
181
+ p.closeSubpath()
182
+
183
+
184
+ def _cube(p: QPainterPath) -> None:
185
+ p.moveTo(12, 3.8)
186
+ p.lineTo(19.5, 8)
187
+ p.lineTo(19.5, 16)
188
+ p.lineTo(12, 20.2)
189
+ p.lineTo(4.5, 16)
190
+ p.lineTo(4.5, 8)
191
+ p.closeSubpath()
192
+ p.moveTo(4.5, 8)
193
+ p.lineTo(12, 12.2)
194
+ p.lineTo(19.5, 8)
195
+ p.moveTo(12, 12.2)
196
+ p.lineTo(12, 20.2)
197
+
198
+
199
+ def _plug(p: QPainterPath) -> None:
200
+ _line(p, 9, 3.5, 9, 8)
201
+ _line(p, 15, 3.5, 15, 8)
202
+ p.moveTo(6.5, 8)
203
+ p.lineTo(17.5, 8)
204
+ p.lineTo(17.5, 12)
205
+ p.arcTo(QRectF(6.5, 6.5, 11, 11), 0, -180)
206
+ p.closeSubpath()
207
+ _line(p, 12, 17.5, 12, 20.5)
208
+
209
+
210
+ def _volume(p: QPainterPath) -> None:
211
+ p.moveTo(4.5, 9.5)
212
+ p.lineTo(8, 9.5)
213
+ p.lineTo(12, 5.5)
214
+ p.lineTo(12, 18.5)
215
+ p.lineTo(8, 14.5)
216
+ p.lineTo(4.5, 14.5)
217
+ p.closeSubpath()
218
+ p.arcMoveTo(QRectF(11, 7.5, 9, 9), 60)
219
+ p.arcTo(QRectF(11, 7.5, 9, 9), 60, -120)
220
+
221
+
222
+ def _volume_muted(p: QPainterPath) -> None:
223
+ p.moveTo(4.5, 9.5)
224
+ p.lineTo(8, 9.5)
225
+ p.lineTo(12, 5.5)
226
+ p.lineTo(12, 18.5)
227
+ p.lineTo(8, 14.5)
228
+ p.lineTo(4.5, 14.5)
229
+ p.closeSubpath()
230
+ _line(p, 15, 9.5, 20, 14.5)
231
+ _line(p, 20, 9.5, 15, 14.5)
232
+
233
+
234
+ def _locate(p: QPainterPath) -> None:
235
+ """Return to the reading position (F-30)."""
236
+ p.addEllipse(QPointF(12, 12), 3, 3)
237
+ _line(p, 12, 3, 12, 6.5)
238
+ _line(p, 12, 17.5, 12, 21)
239
+ _line(p, 3, 12, 6.5, 12)
240
+ _line(p, 17.5, 12, 21, 12)
241
+
242
+
243
+ def _info(p: QPainterPath) -> None:
244
+ p.addEllipse(QPointF(12, 12), 8, 8)
245
+ _line(p, 12, 11, 12, 16.5)
246
+ p.addEllipse(QPointF(12, 7.8), 0.2, 0.2)
247
+
248
+
249
+ def _warning(p: QPainterPath) -> None:
250
+ p.moveTo(12, 4)
251
+ p.lineTo(21, 19.5)
252
+ p.lineTo(3, 19.5)
253
+ p.closeSubpath()
254
+ _line(p, 12, 9.5, 12, 14.5)
255
+ p.addEllipse(QPointF(12, 17), 0.2, 0.2)
256
+
257
+
258
+ def _error(p: QPainterPath) -> None:
259
+ p.addEllipse(QPointF(12, 12), 8, 8)
260
+ _line(p, 9, 9, 15, 15)
261
+ _line(p, 15, 9, 9, 15)
262
+
263
+
264
+ def _check_circle(p: QPainterPath) -> None:
265
+ p.addEllipse(QPointF(12, 12), 8, 8)
266
+ p.moveTo(8.2, 12.2)
267
+ p.lineTo(11, 15)
268
+ p.lineTo(15.8, 9.4)
269
+
270
+
271
+ def _copy(p: QPainterPath) -> None:
272
+ p.addRoundedRect(QRectF(8.5, 4.5, 11, 13), 2, 2)
273
+ p.moveTo(15.5, 19.5)
274
+ p.lineTo(6.5, 19.5)
275
+ p.lineTo(6.5, 8)
276
+
277
+
278
+ def _document(p: QPainterPath) -> None:
279
+ p.moveTo(6, 3.5)
280
+ p.lineTo(14, 3.5)
281
+ p.lineTo(18.5, 8)
282
+ p.lineTo(18.5, 20.5)
283
+ p.lineTo(6, 20.5)
284
+ p.closeSubpath()
285
+ p.moveTo(14, 3.5)
286
+ p.lineTo(14, 8)
287
+ p.lineTo(18.5, 8)
288
+
289
+
290
+ def _pulse(p: QPainterPath) -> None:
291
+ """Activity: a heartbeat line, which reads at 16 px where a gauge does not."""
292
+ p.moveTo(3, 12)
293
+ p.lineTo(8, 12)
294
+ p.lineTo(10.5, 6)
295
+ p.lineTo(13.5, 18)
296
+ p.lineTo(16, 12)
297
+ p.lineTo(21, 12)
298
+
299
+
300
+ def _more(p: QPainterPath) -> None:
301
+ for x in (7.0, 12.0, 17.0):
302
+ p.addEllipse(QPointF(x, 12), 0.9, 0.9)
303
+
304
+
305
+ #: name -> (drawer, filled?, accessible name)
306
+ #:
307
+ #: The accessible name travels with the icon so that a caller cannot forget
308
+ #: it; N-30 requires every icon to have one.
309
+ _ICONS: dict[str, tuple[Callable[[QPainterPath], None], bool, str]] = {
310
+ "play": (_play, True, "Play"),
311
+ "pause": (_pause, True, "Pause"),
312
+ "stop": (_stop, True, "Stop"),
313
+ "skip-back": (_skip_back, True, "Previous segment"),
314
+ "skip-forward": (_skip_forward, True, "Next segment"),
315
+ "chevron-down": (_chevron_down, False, "Open"),
316
+ "chevron-up": (_chevron_up, False, "Close"),
317
+ "chevron-right": (_chevron_right, False, "Expand"),
318
+ "check": (_check, False, "Selected"),
319
+ "close": (_close, False, "Close"),
320
+ "plus": (_plus, False, "Add"),
321
+ "search": (_search, False, "Search"),
322
+ "trash": (_trash, False, "Delete"),
323
+ "folder": (_folder, False, "Open file"),
324
+ "save": (_save, False, "Save"),
325
+ "download": (_download, False, "Download"),
326
+ "export": (_export, False, "Export"),
327
+ "refresh": (_refresh, False, "Refresh"),
328
+ "settings": (_settings, False, "Settings"),
329
+ "library": (_library, False, "Library"),
330
+ "cube": (_cube, False, "Models"),
331
+ "plug": (_plug, False, "Connect an app"),
332
+ "pulse": (_pulse, False, "Activity"),
333
+ "volume": (_volume, False, "Volume"),
334
+ "volume-muted": (_volume_muted, False, "Muted"),
335
+ "locate": (_locate, False, "Return to the reading position"),
336
+ "info": (_info, False, "Information"),
337
+ "warning": (_warning, False, "Warning"),
338
+ "error": (_error, False, "Error"),
339
+ "check-circle": (_check_circle, False, "Done"),
340
+ "copy": (_copy, False, "Copy"),
341
+ "document": (_document, False, "Document"),
342
+ "more": (_more, True, "More"),
343
+ }
344
+
345
+
346
+ def accessible_name(name: str) -> str:
347
+ return _ICONS[name][2]
348
+
349
+
350
+ def available() -> tuple[str, ...]:
351
+ return tuple(_ICONS)
352
+
353
+
354
+ def _render(name: str, color: str, size: int, dpr: float, stroke: float) -> QPixmap:
355
+ drawer, filled, _ = _ICONS[name]
356
+ px = QPixmap(int(size * dpr), int(size * dpr))
357
+ px.setDevicePixelRatio(dpr)
358
+ px.fill(Qt.GlobalColor.transparent)
359
+
360
+ painter = QPainter(px)
361
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
362
+ # Qt already folds the pixmap's device-pixel-ratio into the painter's
363
+ # transform, so the painter works in logical units and the scale is
364
+ # size/24 rather than size*dpr/24. Multiplying by dpr here draws the
365
+ # path at twice its size and clips it against the pixmap.
366
+ painter.scale(size / 24.0, size / 24.0)
367
+
368
+ path = QPainterPath()
369
+ drawer(path)
370
+ c = QColor(color)
371
+ if filled:
372
+ painter.setPen(Qt.PenStyle.NoPen)
373
+ painter.fillPath(path, c)
374
+ else:
375
+ pen = QPen(c)
376
+ pen.setWidthF(stroke)
377
+ pen.setCapStyle(Qt.PenCapStyle.RoundCap)
378
+ pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
379
+ painter.setPen(pen)
380
+ painter.drawPath(path)
381
+ painter.end()
382
+ return px
383
+
384
+
385
+ @lru_cache(maxsize=512)
386
+ def pixmap(name: str, color: str, size: int = 18, dpr: float = 1.0, stroke: float = 1.8) -> QPixmap:
387
+ return _render(name, color, size, dpr, stroke)
388
+
389
+
390
+ @lru_cache(maxsize=512)
391
+ def icon(name: str, color: str, disabled_color: str | None = None, size: int = 18) -> QIcon:
392
+ """A QIcon with a normal and a disabled rendering.
393
+
394
+ Both are supplied because N-09 requires unavailable controls to look
395
+ unavailable, and a single-pixmap icon stays at full contrast when Qt
396
+ disables the button.
397
+ """
398
+ ic = QIcon()
399
+ for dpr in (1.0, 2.0):
400
+ ic.addPixmap(_render(name, color, size, dpr, 1.8), QIcon.Mode.Normal, QIcon.State.Off)
401
+ if disabled_color:
402
+ ic.addPixmap(
403
+ _render(name, disabled_color, size, dpr, 1.8),
404
+ QIcon.Mode.Disabled,
405
+ QIcon.State.Off,
406
+ )
407
+ return ic
408
+
409
+
410
+ def icon_size(size: int = 18) -> QSize:
411
+ return QSize(size, size)
412
+
413
+
414
+ # ----------------------------------------------------------------------
415
+ # Assets the stylesheet needs as files
416
+ # ----------------------------------------------------------------------
417
+
418
+
419
+ def _asset_dir() -> Path:
420
+ d = Path(tempfile.gettempdir()) / "echoact-ui-assets"
421
+ d.mkdir(parents=True, exist_ok=True)
422
+ return d
423
+
424
+
425
+ def stylesheet_assets(theme_key: str, spec: dict[str, tuple[str, str, int]]) -> dict[str, str]:
426
+ """Render the marks Qt's stylesheet can only take as a URL.
427
+
428
+ ``spec`` maps a key to (icon name, colour, size). Returns the same keys
429
+ mapped to forward-slash paths, which is what Qt expects inside
430
+ ``url(...)`` on every platform. Files are written once per theme and
431
+ reused, so switching themes twice does not accumulate files.
432
+ """
433
+ out: dict[str, str] = {}
434
+ d = _asset_dir()
435
+ for key, (name, color, size) in spec.items():
436
+ path = d / f"{theme_key}-{key}-{size}.png"
437
+ if not path.exists():
438
+ _render(name, color, size, 2.0, 2.0).save(str(path))
439
+ out[key] = path.as_posix()
440
+ return out