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/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
|