synapse-cli-agent 0.1.13__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.
- synapse/__init__.py +13 -0
- synapse/__main__.py +6 -0
- synapse/app/__init__.py +1 -0
- synapse/app/agent.py +492 -0
- synapse/app/agent_md.py +107 -0
- synapse/cli.py +750 -0
- synapse/commands/__init__.py +1 -0
- synapse/commands/compression.py +573 -0
- synapse/commands/helpers.py +22 -0
- synapse/commands/mcp.py +406 -0
- synapse/commands/model.py +173 -0
- synapse/commands/result.py +34 -0
- synapse/commands/sessions.py +443 -0
- synapse/commands/slash_cmds.py +521 -0
- synapse/commands/slash_complete.py +816 -0
- synapse/commands/theme.py +99 -0
- synapse/config.py +27 -0
- synapse/content/__init__.py +1 -0
- synapse/content/input_history.py +122 -0
- synapse/content/multimodal.py +733 -0
- synapse/content/prompts.py +249 -0
- synapse/content/skills_catalog.py +128 -0
- synapse/integrations/__init__.py +1 -0
- synapse/integrations/checkpoint_seed.py +281 -0
- synapse/integrations/codex_history.py +375 -0
- synapse/integrations/codex_import.py +393 -0
- synapse/integrations/codex_sessions.py +629 -0
- synapse/integrations/describe_image.py +370 -0
- synapse/integrations/http_clients.py +199 -0
- synapse/integrations/llm_openai_compat.py +90 -0
- synapse/integrations/llm_openai_websocket.py +187 -0
- synapse/integrations/mcp_client.py +646 -0
- synapse/integrations/vision_middleware.py +62 -0
- synapse/models/__init__.py +5 -0
- synapse/models/config.py +240 -0
- synapse/models/helpers.py +206 -0
- synapse/models/profile.py +59 -0
- synapse/models/registry.py +722 -0
- synapse/models_registry.py +7 -0
- synapse/observability/__init__.py +1 -0
- synapse/observability/startup_trace.py +127 -0
- synapse/runtime/__init__.py +1 -0
- synapse/runtime/async_runtime.py +176 -0
- synapse/runtime/backends.py +458 -0
- synapse/runtime/context_compact.py +249 -0
- synapse/runtime/execute_capture.py +48 -0
- synapse/runtime/fs_permissions.py +79 -0
- synapse/runtime/harness.py +57 -0
- synapse/runtime/hitl.py +197 -0
- synapse/runtime/interaction_ledger.py +82 -0
- synapse/runtime/middleware.py +802 -0
- synapse/runtime/model_request_compression_middleware.py +745 -0
- synapse/runtime/pathing.py +146 -0
- synapse/runtime/safety.py +184 -0
- synapse/runtime/steer.py +240 -0
- synapse/runtime/subagents.py +207 -0
- synapse/runtime/tool_ignore.py +221 -0
- synapse/runtime/tool_output_eval.py +118 -0
- synapse/runtime/tool_output_middleware.py +585 -0
- synapse/runtime/tool_output_usage_middleware.py +60 -0
- synapse/sessions/__init__.py +31 -0
- synapse/sessions/cancel_repair.py +208 -0
- synapse/sessions/session_recap.py +174 -0
- synapse/sessions/store.py +695 -0
- synapse/sessions/transcript.py +754 -0
- synapse/settings/__init__.py +5 -0
- synapse/settings/config_paths.py +184 -0
- synapse/settings/schema.py +464 -0
- synapse/tool_output/__init__.py +59 -0
- synapse/tool_output/detection.py +170 -0
- synapse/tool_output/metrics.py +32 -0
- synapse/tool_output/models.py +173 -0
- synapse/tool_output/pipeline.py +330 -0
- synapse/tool_output/repository.py +721 -0
- synapse/tool_output/transformers.py +648 -0
- synapse/tools/__init__.py +5 -0
- synapse/tools/session_tools.py +204 -0
- synapse/ui/__init__.py +10 -0
- synapse/ui/bottombar/__init__.py +73 -0
- synapse/ui/bottombar/components/__init__.py +143 -0
- synapse/ui/bottombar/components/key_hints.py +30 -0
- synapse/ui/bottombar/components/mcp.py +64 -0
- synapse/ui/bottombar/components/mode.py +24 -0
- synapse/ui/bottombar/components/model.py +28 -0
- synapse/ui/bottombar/components/thread.py +29 -0
- synapse/ui/bottombar/context.py +36 -0
- synapse/ui/bottombar/core.py +74 -0
- synapse/ui/dialogs/__init__.py +25 -0
- synapse/ui/dialogs/base.py +362 -0
- synapse/ui/dialogs/codex_session_list.py +84 -0
- synapse/ui/dialogs/compression_diagnostics.py +210 -0
- synapse/ui/dialogs/git_explore.py +702 -0
- synapse/ui/dialogs/mcp_panel.py +407 -0
- synapse/ui/dialogs/model_picker.py +128 -0
- synapse/ui/dialogs/safety_panel.py +63 -0
- synapse/ui/dialogs/session_list.py +98 -0
- synapse/ui/dialogs/theme_designer.py +863 -0
- synapse/ui/dialogs/theme_picker.py +113 -0
- synapse/ui/git_explore/__init__.py +31 -0
- synapse/ui/git_explore/engine.py +82 -0
- synapse/ui/git_explore/provider.py +242 -0
- synapse/ui/git_explore/unified.py +85 -0
- synapse/ui/rendering.py +350 -0
- synapse/ui/sink.py +70 -0
- synapse/ui/steer_widget.py +367 -0
- synapse/ui/stream.py +1207 -0
- synapse/ui/stream_events.py +421 -0
- synapse/ui/stream_runtime.py +252 -0
- synapse/ui/theme.py +1154 -0
- synapse/ui/timeline.py +621 -0
- synapse/ui/topbar/__init__.py +97 -0
- synapse/ui/topbar/components/__init__.py +150 -0
- synapse/ui/topbar/components/branch.py +41 -0
- synapse/ui/topbar/components/title.py +24 -0
- synapse/ui/topbar/components/tool_output.py +24 -0
- synapse/ui/topbar/components/usage.py +24 -0
- synapse/ui/topbar/components/workspace.py +32 -0
- synapse/ui/topbar/context.py +32 -0
- synapse/ui/topbar/core.py +979 -0
- synapse/ui/topbar/git_changes_popover.py +178 -0
- synapse/ui/topbar/git_chrome.py +475 -0
- synapse/ui/topbar/tool_output_popover.py +84 -0
- synapse/ui/topbar/widget.py +474 -0
- synapse/ui/tui.py +5717 -0
- synapse/ui/turn_rail.py +71 -0
- synapse/ui/user_turn.py +83 -0
- synapse/ui/welcome.py +261 -0
- synapse_cli_agent-0.1.13.dist-info/METADATA +412 -0
- synapse_cli_agent-0.1.13.dist-info/RECORD +131 -0
- synapse_cli_agent-0.1.13.dist-info/WHEEL +4 -0
- synapse_cli_agent-0.1.13.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,863 @@
|
|
|
1
|
+
"""Theme designer dialog — compact HSV picker with live preview."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import colorsys
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from rich.style import Style
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
from textual import on
|
|
13
|
+
from textual.app import ComposeResult
|
|
14
|
+
from textual.binding import Binding
|
|
15
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
16
|
+
from textual.events import Click
|
|
17
|
+
from textual.message import Message
|
|
18
|
+
from textual.screen import ModalScreen
|
|
19
|
+
from textual.timer import Timer
|
|
20
|
+
from textual.widgets import Input, Label, Select, Static
|
|
21
|
+
|
|
22
|
+
from synapse.settings.config_paths import user_config_dir
|
|
23
|
+
from synapse.ui.theme import (
|
|
24
|
+
THEMES_FILENAME,
|
|
25
|
+
_theme_from_dict,
|
|
26
|
+
list_theme_names,
|
|
27
|
+
reload_theme_catalog,
|
|
28
|
+
set_theme,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
_COLOR_FIELDS: list[tuple[str, str]] = [
|
|
32
|
+
("bg", "Background"),
|
|
33
|
+
("top", "Top bar"),
|
|
34
|
+
("bar", "Status bar"),
|
|
35
|
+
("fg", "Foreground"),
|
|
36
|
+
("dim", "Dim text"),
|
|
37
|
+
("muted", "Muted text"),
|
|
38
|
+
("green", "Success"),
|
|
39
|
+
("orange", "Warning"),
|
|
40
|
+
("user", "User accent"),
|
|
41
|
+
("border", "Border"),
|
|
42
|
+
("border_focus", "Focus border"),
|
|
43
|
+
("error", "Error"),
|
|
44
|
+
("top_left", "Top left"),
|
|
45
|
+
("top_center", "Top center"),
|
|
46
|
+
("top_right", "Top right"),
|
|
47
|
+
]
|
|
48
|
+
_COLOR_LABELS = dict(_COLOR_FIELDS)
|
|
49
|
+
_BORDER_STYLES = ("tall", "heavy", "solid", "round", "dashed", "double", "panel")
|
|
50
|
+
_HEX_RE = re.compile(r"^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$")
|
|
51
|
+
_PREVIEW_DEBOUNCE_S = 0.18
|
|
52
|
+
_PLANE_WIDTH = 30
|
|
53
|
+
_PLANE_HEIGHT = 8
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _normalize_hex(value: str) -> str | None:
|
|
57
|
+
color = (value or "").strip()
|
|
58
|
+
if not _HEX_RE.fullmatch(color):
|
|
59
|
+
return None
|
|
60
|
+
if len(color) == 4:
|
|
61
|
+
color = f"#{color[1] * 2}{color[2] * 2}{color[3] * 2}"
|
|
62
|
+
return color.lower()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _hex_to_hsv(value: str) -> tuple[float, float, float] | None:
|
|
66
|
+
color = _normalize_hex(value)
|
|
67
|
+
if color is None:
|
|
68
|
+
return None
|
|
69
|
+
red, green, blue = (int(color[index : index + 2], 16) / 255 for index in (1, 3, 5))
|
|
70
|
+
return colorsys.rgb_to_hsv(red, green, blue)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _hsv_to_hex(hue: float, saturation: float, value: float) -> str:
|
|
74
|
+
red, green, blue = colorsys.hsv_to_rgb(
|
|
75
|
+
hue % 1.0,
|
|
76
|
+
max(0.0, min(1.0, saturation)),
|
|
77
|
+
max(0.0, min(1.0, value)),
|
|
78
|
+
)
|
|
79
|
+
return f"#{round(red * 255):02x}{round(green * 255):02x}{round(blue * 255):02x}"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _marker_color(value: float) -> str:
|
|
83
|
+
return "black" if value > 0.62 else "white"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class _DesignerColorChanged(Message):
|
|
87
|
+
"""A theme color changed, from the picker or a direct programmatic update."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, key: str, value: str) -> None:
|
|
90
|
+
super().__init__()
|
|
91
|
+
self.key = key
|
|
92
|
+
self.value = value
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class _ColorRoleSelected(Message):
|
|
96
|
+
def __init__(self, key: str) -> None:
|
|
97
|
+
super().__init__()
|
|
98
|
+
self.key = key
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class _PickerColorChanged(Message):
|
|
102
|
+
def __init__(self, value: str) -> None:
|
|
103
|
+
super().__init__()
|
|
104
|
+
self.value = value
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class _ColorRoleRow(Static):
|
|
108
|
+
"""One compact palette role row."""
|
|
109
|
+
|
|
110
|
+
def __init__(self, key: str, label: str, value: str) -> None:
|
|
111
|
+
super().__init__("", id=f"role-{key}")
|
|
112
|
+
self.key = key
|
|
113
|
+
self.label = label
|
|
114
|
+
self.value = value
|
|
115
|
+
self._selected = False
|
|
116
|
+
self._paint()
|
|
117
|
+
|
|
118
|
+
def set_value(self, value: str) -> None:
|
|
119
|
+
self.value = value
|
|
120
|
+
self._paint()
|
|
121
|
+
|
|
122
|
+
def set_selected(self, selected: bool) -> None:
|
|
123
|
+
self._selected = selected
|
|
124
|
+
self.set_class(selected, "-selected")
|
|
125
|
+
self._paint()
|
|
126
|
+
|
|
127
|
+
def _paint(self) -> None:
|
|
128
|
+
color = _normalize_hex(self.value)
|
|
129
|
+
row = Text("> " if self._selected else " ")
|
|
130
|
+
if color:
|
|
131
|
+
row.append("●", style=Style(color=color))
|
|
132
|
+
else:
|
|
133
|
+
row.append("○", style="dim")
|
|
134
|
+
row.append(f" {self.label:<13}")
|
|
135
|
+
row.append(color or "inherit", style="dim")
|
|
136
|
+
self.update(row)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class _ColorRoleList(VerticalScroll):
|
|
140
|
+
"""Keyboard and mouse selectable list of theme color roles."""
|
|
141
|
+
|
|
142
|
+
can_focus = True
|
|
143
|
+
BINDINGS = [
|
|
144
|
+
Binding("up", "previous", "Previous", show=False),
|
|
145
|
+
Binding("down", "next", "Next", show=False),
|
|
146
|
+
Binding("enter", "choose", "Choose", show=False),
|
|
147
|
+
]
|
|
148
|
+
|
|
149
|
+
def __init__(self, values: dict[str, str]) -> None:
|
|
150
|
+
super().__init__(id="color-roles")
|
|
151
|
+
self._rows = [
|
|
152
|
+
_ColorRoleRow(key, label, values.get(key, "")) for key, label in _COLOR_FIELDS
|
|
153
|
+
]
|
|
154
|
+
self._selected_index = 0
|
|
155
|
+
|
|
156
|
+
def compose(self) -> ComposeResult:
|
|
157
|
+
yield from self._rows
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def selected_key(self) -> str:
|
|
161
|
+
return self._rows[self._selected_index].key
|
|
162
|
+
|
|
163
|
+
def on_mount(self) -> None:
|
|
164
|
+
self._sync_selection(emit=False)
|
|
165
|
+
|
|
166
|
+
def select_key(self, key: str, *, emit: bool = True) -> None:
|
|
167
|
+
for index, row in enumerate(self._rows):
|
|
168
|
+
if row.key == key:
|
|
169
|
+
self._selected_index = index
|
|
170
|
+
self._sync_selection(emit=emit)
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
def update_value(self, key: str, value: str) -> None:
|
|
174
|
+
for row in self._rows:
|
|
175
|
+
if row.key == key:
|
|
176
|
+
row.set_value(value)
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
def _sync_selection(self, *, emit: bool = True) -> None:
|
|
180
|
+
for index, row in enumerate(self._rows):
|
|
181
|
+
row.set_selected(index == self._selected_index)
|
|
182
|
+
selected = self._rows[self._selected_index]
|
|
183
|
+
self.scroll_to_widget(selected, animate=False)
|
|
184
|
+
if emit:
|
|
185
|
+
self.post_message(_ColorRoleSelected(selected.key))
|
|
186
|
+
|
|
187
|
+
def action_previous(self) -> None:
|
|
188
|
+
if self._selected_index > 0:
|
|
189
|
+
self._selected_index -= 1
|
|
190
|
+
self._sync_selection()
|
|
191
|
+
|
|
192
|
+
def action_next(self) -> None:
|
|
193
|
+
if self._selected_index < len(self._rows) - 1:
|
|
194
|
+
self._selected_index += 1
|
|
195
|
+
self._sync_selection()
|
|
196
|
+
|
|
197
|
+
def action_choose(self) -> None:
|
|
198
|
+
self.post_message(_ColorRoleSelected(self.selected_key))
|
|
199
|
+
|
|
200
|
+
def on_click(self, event: Click) -> None:
|
|
201
|
+
if isinstance(event.widget, _ColorRoleRow):
|
|
202
|
+
self.select_key(event.widget.key)
|
|
203
|
+
self.focus()
|
|
204
|
+
event.stop()
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class _ColorPlane(Static):
|
|
208
|
+
"""Saturation/value plane for the selected hue."""
|
|
209
|
+
|
|
210
|
+
can_focus = True
|
|
211
|
+
BINDINGS = [
|
|
212
|
+
Binding("left", "left", "Less saturation", show=False),
|
|
213
|
+
Binding("right", "right", "More saturation", show=False),
|
|
214
|
+
Binding("up", "up", "Brighter", show=False),
|
|
215
|
+
Binding("down", "down", "Darker", show=False),
|
|
216
|
+
]
|
|
217
|
+
|
|
218
|
+
def __init__(self) -> None:
|
|
219
|
+
super().__init__(id="color-plane")
|
|
220
|
+
self.hue = 0.0
|
|
221
|
+
self.saturation = 0.0
|
|
222
|
+
self.value = 0.5
|
|
223
|
+
self._paint()
|
|
224
|
+
|
|
225
|
+
def set_hsv(self, hue: float, saturation: float, value: float, *, emit: bool = False) -> None:
|
|
226
|
+
self.hue = hue % 1.0
|
|
227
|
+
self.saturation = max(0.0, min(1.0, saturation))
|
|
228
|
+
self.value = max(0.0, min(1.0, value))
|
|
229
|
+
self._paint()
|
|
230
|
+
if emit:
|
|
231
|
+
self._emit_color()
|
|
232
|
+
|
|
233
|
+
def _paint(self) -> None:
|
|
234
|
+
selected_x = round(self.saturation * (_PLANE_WIDTH - 1))
|
|
235
|
+
selected_y = round((1.0 - self.value) * (_PLANE_HEIGHT - 1))
|
|
236
|
+
canvas = Text()
|
|
237
|
+
for y in range(_PLANE_HEIGHT):
|
|
238
|
+
value = 1.0 - (y / (_PLANE_HEIGHT - 1))
|
|
239
|
+
for x in range(_PLANE_WIDTH):
|
|
240
|
+
saturation = x / (_PLANE_WIDTH - 1)
|
|
241
|
+
color = _hsv_to_hex(self.hue, saturation, value)
|
|
242
|
+
marker = "◆" if x == selected_x and y == selected_y else " "
|
|
243
|
+
canvas.append(marker, style=Style(color=_marker_color(value), bgcolor=color))
|
|
244
|
+
if y < _PLANE_HEIGHT - 1:
|
|
245
|
+
canvas.append("\n")
|
|
246
|
+
self.update(canvas)
|
|
247
|
+
|
|
248
|
+
def _emit_color(self) -> None:
|
|
249
|
+
self.post_message(_PickerColorChanged(_hsv_to_hex(self.hue, self.saturation, self.value)))
|
|
250
|
+
|
|
251
|
+
def _move(self, dx: int = 0, dy: int = 0) -> None:
|
|
252
|
+
x = max(0, min(_PLANE_WIDTH - 1, round(self.saturation * (_PLANE_WIDTH - 1)) + dx))
|
|
253
|
+
y = max(0, min(_PLANE_HEIGHT - 1, round((1.0 - self.value) * (_PLANE_HEIGHT - 1)) + dy))
|
|
254
|
+
self.set_hsv(self.hue, x / (_PLANE_WIDTH - 1), 1.0 - y / (_PLANE_HEIGHT - 1), emit=True)
|
|
255
|
+
|
|
256
|
+
def action_left(self) -> None:
|
|
257
|
+
self._move(dx=-1)
|
|
258
|
+
|
|
259
|
+
def action_right(self) -> None:
|
|
260
|
+
self._move(dx=1)
|
|
261
|
+
|
|
262
|
+
def action_up(self) -> None:
|
|
263
|
+
self._move(dy=-1)
|
|
264
|
+
|
|
265
|
+
def action_down(self) -> None:
|
|
266
|
+
self._move(dy=1)
|
|
267
|
+
|
|
268
|
+
def on_click(self, event: Click) -> None:
|
|
269
|
+
x = int(event.x - self.content_offset.x)
|
|
270
|
+
y = int(event.y - self.content_offset.y)
|
|
271
|
+
if 0 <= x < _PLANE_WIDTH and 0 <= y < _PLANE_HEIGHT:
|
|
272
|
+
self.set_hsv(self.hue, x / (_PLANE_WIDTH - 1), 1.0 - y / (_PLANE_HEIGHT - 1), emit=True)
|
|
273
|
+
self.focus()
|
|
274
|
+
event.stop()
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class _HueStrip(Static):
|
|
278
|
+
"""Hue selector paired with :class:`_ColorPlane`."""
|
|
279
|
+
|
|
280
|
+
can_focus = True
|
|
281
|
+
BINDINGS = [
|
|
282
|
+
Binding("left", "left", "Previous hue", show=False),
|
|
283
|
+
Binding("right", "right", "Next hue", show=False),
|
|
284
|
+
]
|
|
285
|
+
|
|
286
|
+
def __init__(self) -> None:
|
|
287
|
+
super().__init__(id="hue-strip")
|
|
288
|
+
self.hue = 0.0
|
|
289
|
+
self._paint()
|
|
290
|
+
|
|
291
|
+
def set_hue(self, hue: float, *, emit: bool = False) -> None:
|
|
292
|
+
self.hue = hue % 1.0
|
|
293
|
+
self._paint()
|
|
294
|
+
if emit:
|
|
295
|
+
self.post_message(_PickerColorChanged(""))
|
|
296
|
+
|
|
297
|
+
def _paint(self) -> None:
|
|
298
|
+
selected_x = round(self.hue * (_PLANE_WIDTH - 1))
|
|
299
|
+
strip = Text()
|
|
300
|
+
for x in range(_PLANE_WIDTH):
|
|
301
|
+
hue = x / _PLANE_WIDTH
|
|
302
|
+
color = _hsv_to_hex(hue, 1.0, 1.0)
|
|
303
|
+
strip.append("◆" if x == selected_x else " ", style=Style(color="black", bgcolor=color))
|
|
304
|
+
self.update(strip)
|
|
305
|
+
|
|
306
|
+
def _move(self, delta: int) -> None:
|
|
307
|
+
x = (round(self.hue * (_PLANE_WIDTH - 1)) + delta) % _PLANE_WIDTH
|
|
308
|
+
self.set_hue(x / (_PLANE_WIDTH - 1), emit=True)
|
|
309
|
+
|
|
310
|
+
def action_left(self) -> None:
|
|
311
|
+
self._move(-1)
|
|
312
|
+
|
|
313
|
+
def action_right(self) -> None:
|
|
314
|
+
self._move(1)
|
|
315
|
+
|
|
316
|
+
def on_click(self, event: Click) -> None:
|
|
317
|
+
x = int(event.x - self.content_offset.x)
|
|
318
|
+
if 0 <= x < _PLANE_WIDTH:
|
|
319
|
+
self.set_hue(x / (_PLANE_WIDTH - 1), emit=True)
|
|
320
|
+
self.focus()
|
|
321
|
+
event.stop()
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
class _ColorReadout(Static):
|
|
325
|
+
def __init__(self) -> None:
|
|
326
|
+
super().__init__("", id="color-readout")
|
|
327
|
+
|
|
328
|
+
def set_color(self, label: str, value: str) -> None:
|
|
329
|
+
color = _normalize_hex(value)
|
|
330
|
+
text = Text(f"{label} ", style="bold")
|
|
331
|
+
if color:
|
|
332
|
+
text.append(" ", style=Style(bgcolor=color))
|
|
333
|
+
text.append(f" {color}", style="bold")
|
|
334
|
+
else:
|
|
335
|
+
text.append("○ inherited from base", style="dim")
|
|
336
|
+
self.update(text)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
class ThemeDesignerDialog(ModalScreen[Any]):
|
|
340
|
+
"""Theme editor aligned with the app's standard modal visual language."""
|
|
341
|
+
|
|
342
|
+
DEFAULT_CSS = """
|
|
343
|
+
ThemeDesignerDialog {
|
|
344
|
+
align: center middle;
|
|
345
|
+
/* The inline style in __init__ guarantees this wins over Screen's
|
|
346
|
+
inherited default background at runtime. */
|
|
347
|
+
background: transparent;
|
|
348
|
+
}
|
|
349
|
+
ThemeDesignerDialog > #designer-window {
|
|
350
|
+
layer: overlay;
|
|
351
|
+
width: 78;
|
|
352
|
+
max-width: 96%;
|
|
353
|
+
height: 27;
|
|
354
|
+
max-height: 94%;
|
|
355
|
+
background: $theme-bg;
|
|
356
|
+
border: round $theme-user;
|
|
357
|
+
border-title-color: $theme-fg;
|
|
358
|
+
border-title-background: $theme-top;
|
|
359
|
+
border-title-style: bold;
|
|
360
|
+
border-title-align: left;
|
|
361
|
+
border-subtitle-color: $theme-muted;
|
|
362
|
+
border-subtitle-align: right;
|
|
363
|
+
padding: 0 1;
|
|
364
|
+
layout: vertical;
|
|
365
|
+
}
|
|
366
|
+
ThemeDesignerDialog .section-title {
|
|
367
|
+
height: 1;
|
|
368
|
+
color: $theme-orange;
|
|
369
|
+
text-style: bold;
|
|
370
|
+
margin-top: 1;
|
|
371
|
+
}
|
|
372
|
+
#designer-meta {
|
|
373
|
+
height: 2;
|
|
374
|
+
}
|
|
375
|
+
ThemeDesignerDialog .meta-row {
|
|
376
|
+
height: 1;
|
|
377
|
+
width: 1fr;
|
|
378
|
+
align-vertical: middle;
|
|
379
|
+
}
|
|
380
|
+
ThemeDesignerDialog .meta-label {
|
|
381
|
+
width: 7;
|
|
382
|
+
color: $theme-dim;
|
|
383
|
+
content-align: left middle;
|
|
384
|
+
}
|
|
385
|
+
ThemeDesignerDialog .meta-gap {
|
|
386
|
+
width: 2;
|
|
387
|
+
}
|
|
388
|
+
ThemeDesignerDialog .meta-control {
|
|
389
|
+
width: 1fr;
|
|
390
|
+
height: 1;
|
|
391
|
+
background: $theme-bar;
|
|
392
|
+
color: $theme-fg;
|
|
393
|
+
padding: 0 1;
|
|
394
|
+
}
|
|
395
|
+
ThemeDesignerDialog Input.meta-control:focus {
|
|
396
|
+
background: $theme-top;
|
|
397
|
+
color: $theme-fg;
|
|
398
|
+
}
|
|
399
|
+
ThemeDesignerDialog Select.meta-control > SelectCurrent {
|
|
400
|
+
height: 1;
|
|
401
|
+
min-height: 1;
|
|
402
|
+
background: $theme-bar;
|
|
403
|
+
color: $theme-fg;
|
|
404
|
+
padding: 0;
|
|
405
|
+
border: none;
|
|
406
|
+
}
|
|
407
|
+
ThemeDesignerDialog Select.meta-control:focus > SelectCurrent {
|
|
408
|
+
background: $theme-top;
|
|
409
|
+
border: none;
|
|
410
|
+
}
|
|
411
|
+
#designer-editor {
|
|
412
|
+
height: 1fr;
|
|
413
|
+
min-height: 15;
|
|
414
|
+
margin-top: 1;
|
|
415
|
+
border-top: solid $theme-border;
|
|
416
|
+
}
|
|
417
|
+
#color-roles {
|
|
418
|
+
width: 33;
|
|
419
|
+
min-width: 27;
|
|
420
|
+
height: 1fr;
|
|
421
|
+
background: $theme-bg;
|
|
422
|
+
scrollbar-size: 0 0;
|
|
423
|
+
overflow-x: hidden;
|
|
424
|
+
}
|
|
425
|
+
#color-roles:focus {
|
|
426
|
+
background: $theme-bg;
|
|
427
|
+
}
|
|
428
|
+
_ColorRoleRow {
|
|
429
|
+
width: 1fr;
|
|
430
|
+
height: 1;
|
|
431
|
+
color: $theme-dim;
|
|
432
|
+
padding: 0 1;
|
|
433
|
+
}
|
|
434
|
+
_ColorRoleRow.-selected {
|
|
435
|
+
color: $theme-fg;
|
|
436
|
+
background: $theme-bar;
|
|
437
|
+
text-style: bold;
|
|
438
|
+
}
|
|
439
|
+
#picker-panel {
|
|
440
|
+
width: 1fr;
|
|
441
|
+
min-width: 34;
|
|
442
|
+
height: 1fr;
|
|
443
|
+
padding-left: 2;
|
|
444
|
+
border-left: solid $theme-border;
|
|
445
|
+
}
|
|
446
|
+
#color-readout {
|
|
447
|
+
height: 2;
|
|
448
|
+
color: $theme-fg;
|
|
449
|
+
content-align: left middle;
|
|
450
|
+
}
|
|
451
|
+
#color-plane {
|
|
452
|
+
width: 32;
|
|
453
|
+
height: 10;
|
|
454
|
+
border: tall $theme-border;
|
|
455
|
+
background: $theme-bg;
|
|
456
|
+
}
|
|
457
|
+
#color-plane:focus {
|
|
458
|
+
border: tall $theme-user;
|
|
459
|
+
}
|
|
460
|
+
#hue-title {
|
|
461
|
+
height: 1;
|
|
462
|
+
margin-top: 1;
|
|
463
|
+
color: $theme-muted;
|
|
464
|
+
}
|
|
465
|
+
#hue-strip {
|
|
466
|
+
width: 32;
|
|
467
|
+
height: 3;
|
|
468
|
+
border: tall $theme-border;
|
|
469
|
+
background: $theme-bg;
|
|
470
|
+
}
|
|
471
|
+
#hue-strip:focus {
|
|
472
|
+
border: tall $theme-user;
|
|
473
|
+
}
|
|
474
|
+
#picker-actions {
|
|
475
|
+
height: 1;
|
|
476
|
+
margin-top: 1;
|
|
477
|
+
}
|
|
478
|
+
#inherit-color {
|
|
479
|
+
width: auto;
|
|
480
|
+
color: $theme-user;
|
|
481
|
+
text-style: bold;
|
|
482
|
+
}
|
|
483
|
+
#inherit-color:hover {
|
|
484
|
+
color: $theme-fg;
|
|
485
|
+
background: $theme-bar;
|
|
486
|
+
}
|
|
487
|
+
#picker-help {
|
|
488
|
+
width: 1fr;
|
|
489
|
+
color: $theme-muted;
|
|
490
|
+
content-align: right middle;
|
|
491
|
+
}
|
|
492
|
+
"""
|
|
493
|
+
|
|
494
|
+
BINDINGS = [
|
|
495
|
+
Binding("escape", "cancel", "Cancel", show=False, priority=True),
|
|
496
|
+
Binding("ctrl+s", "save", "Save", show=False, priority=True),
|
|
497
|
+
Binding("delete", "inherit_color", "Use base", show=False),
|
|
498
|
+
]
|
|
499
|
+
|
|
500
|
+
def __init__(self, settings: Any, project_root: Any = None) -> None:
|
|
501
|
+
super().__init__()
|
|
502
|
+
# ``Screen.DEFAULT_CSS`` may otherwise win the root background cascade
|
|
503
|
+
# on non-ANSI themes and paint an opaque full-screen layer. Inline
|
|
504
|
+
# styles have the required priority while the designer window itself
|
|
505
|
+
# remains opaque via ``#designer-window``.
|
|
506
|
+
self.styles.background = "transparent"
|
|
507
|
+
self._settings = settings
|
|
508
|
+
self._project_root = project_root
|
|
509
|
+
|
|
510
|
+
import synapse.ui.theme as tm
|
|
511
|
+
|
|
512
|
+
current = tm.get_theme()
|
|
513
|
+
self._original = current.name
|
|
514
|
+
self._name = ""
|
|
515
|
+
self._label = ""
|
|
516
|
+
self._extends = current.name if current.name in list_theme_names() else "cursor-dark"
|
|
517
|
+
self._prompt_border = str(getattr(current, "prompt_border", "tall") or "tall")
|
|
518
|
+
if self._prompt_border not in _BORDER_STYLES:
|
|
519
|
+
self._prompt_border = "tall"
|
|
520
|
+
self._code_theme = str(getattr(current, "code_theme", "monokai") or "monokai")
|
|
521
|
+
self._rich_user = str(getattr(current, "rich_user", "") or "")
|
|
522
|
+
self._rich_info_border = str(getattr(current, "rich_info_border", "") or "")
|
|
523
|
+
self._rich_ok_border = str(getattr(current, "rich_ok_border", "") or "")
|
|
524
|
+
self._rich_activity = str(getattr(current, "rich_activity", "") or "")
|
|
525
|
+
self._values = {key: str(getattr(current, key, "") or "") for key, _ in _COLOR_FIELDS}
|
|
526
|
+
self._selected_color_key = _COLOR_FIELDS[0][0]
|
|
527
|
+
self._picker_hsv = _hex_to_hsv(self._values[self._selected_color_key]) or (0.0, 0.0, 0.5)
|
|
528
|
+
|
|
529
|
+
self._preview_seq = 0
|
|
530
|
+
self._preview_busy = False
|
|
531
|
+
self._dismiss_started = False
|
|
532
|
+
self._ready = False
|
|
533
|
+
self._last_preview_sig: str | None = None
|
|
534
|
+
self._preview_timer: Timer | None = None
|
|
535
|
+
|
|
536
|
+
def compose(self) -> ComposeResult:
|
|
537
|
+
theme_names = list_theme_names()
|
|
538
|
+
with Vertical(id="designer-window"):
|
|
539
|
+
yield Static("Theme details", classes="section-title")
|
|
540
|
+
with Vertical(id="designer-meta"):
|
|
541
|
+
with Horizontal(classes="meta-row"):
|
|
542
|
+
yield Label("Name", classes="meta-label")
|
|
543
|
+
yield Input(
|
|
544
|
+
placeholder="my-theme",
|
|
545
|
+
id="meta-name",
|
|
546
|
+
classes="meta-control",
|
|
547
|
+
compact=True,
|
|
548
|
+
)
|
|
549
|
+
yield Static("", classes="meta-gap")
|
|
550
|
+
yield Label("Label", classes="meta-label")
|
|
551
|
+
yield Input(
|
|
552
|
+
placeholder="My Theme",
|
|
553
|
+
id="meta-label",
|
|
554
|
+
classes="meta-control",
|
|
555
|
+
compact=True,
|
|
556
|
+
)
|
|
557
|
+
with Horizontal(classes="meta-row"):
|
|
558
|
+
yield Label("Base", classes="meta-label")
|
|
559
|
+
yield Select(
|
|
560
|
+
[(name, name) for name in theme_names],
|
|
561
|
+
value=self._extends,
|
|
562
|
+
allow_blank=False,
|
|
563
|
+
id="meta-extends",
|
|
564
|
+
classes="meta-control",
|
|
565
|
+
compact=True,
|
|
566
|
+
)
|
|
567
|
+
yield Static("", classes="meta-gap")
|
|
568
|
+
yield Label("Border", classes="meta-label")
|
|
569
|
+
yield Select(
|
|
570
|
+
[(style.title(), style) for style in _BORDER_STYLES],
|
|
571
|
+
value=self._prompt_border,
|
|
572
|
+
allow_blank=False,
|
|
573
|
+
id="meta-prompt-border",
|
|
574
|
+
classes="meta-control",
|
|
575
|
+
compact=True,
|
|
576
|
+
)
|
|
577
|
+
with Horizontal(id="designer-editor"):
|
|
578
|
+
yield _ColorRoleList(self._values)
|
|
579
|
+
with Vertical(id="picker-panel"):
|
|
580
|
+
yield _ColorReadout()
|
|
581
|
+
yield _ColorPlane()
|
|
582
|
+
yield Static("Hue", id="hue-title")
|
|
583
|
+
yield _HueStrip()
|
|
584
|
+
with Horizontal(id="picker-actions"):
|
|
585
|
+
yield Static("Use base color", id="inherit-color")
|
|
586
|
+
yield Static("mouse / arrows", id="picker-help")
|
|
587
|
+
|
|
588
|
+
def on_mount(self) -> None:
|
|
589
|
+
window = self.query_one("#designer-window")
|
|
590
|
+
window.border_title = "◈ Theme Designer"
|
|
591
|
+
window.border_subtitle = "Ctrl+S save · Esc cancel"
|
|
592
|
+
self.query_one("#meta-name", Input).value = self._name
|
|
593
|
+
self.query_one("#meta-label", Input).value = self._label
|
|
594
|
+
self._sync_picker_from_selected()
|
|
595
|
+
self.set_focus(self.query_one("#meta-name", Input))
|
|
596
|
+
self.call_after_refresh(self._mark_ready)
|
|
597
|
+
|
|
598
|
+
def _mark_ready(self) -> None:
|
|
599
|
+
self._ready = True
|
|
600
|
+
|
|
601
|
+
def on_click(self, event: Click) -> None:
|
|
602
|
+
if self._dismiss_started:
|
|
603
|
+
return
|
|
604
|
+
if event.widget is self:
|
|
605
|
+
self.action_cancel()
|
|
606
|
+
|
|
607
|
+
@on(_ColorRoleSelected)
|
|
608
|
+
def _on_color_role_selected(self, event: _ColorRoleSelected) -> None:
|
|
609
|
+
self._selected_color_key = event.key
|
|
610
|
+
self._sync_picker_from_selected()
|
|
611
|
+
|
|
612
|
+
@on(_PickerColorChanged)
|
|
613
|
+
def _on_picker_color_changed(self, event: _PickerColorChanged) -> None:
|
|
614
|
+
plane = self.query_one("#color-plane", _ColorPlane)
|
|
615
|
+
strip = self.query_one("#hue-strip", _HueStrip)
|
|
616
|
+
if not event.value:
|
|
617
|
+
plane.set_hsv(strip.hue, plane.saturation, plane.value)
|
|
618
|
+
value = _hsv_to_hex(strip.hue, plane.saturation, plane.value)
|
|
619
|
+
else:
|
|
620
|
+
value = event.value
|
|
621
|
+
hsv = _hex_to_hsv(value)
|
|
622
|
+
if hsv is not None:
|
|
623
|
+
strip.set_hue(hsv[0])
|
|
624
|
+
plane.set_hsv(*hsv)
|
|
625
|
+
self._picker_hsv = (plane.hue, plane.saturation, plane.value)
|
|
626
|
+
self._on_color_changed(_DesignerColorChanged(self._selected_color_key, value))
|
|
627
|
+
|
|
628
|
+
@on(_DesignerColorChanged)
|
|
629
|
+
def _on_color_changed(self, event: _DesignerColorChanged) -> None:
|
|
630
|
+
self._values[event.key] = event.value
|
|
631
|
+
try:
|
|
632
|
+
self.query_one("#color-roles", _ColorRoleList).update_value(event.key, event.value)
|
|
633
|
+
if event.key == self._selected_color_key:
|
|
634
|
+
self.query_one("#color-readout", _ColorReadout).set_color(
|
|
635
|
+
_COLOR_LABELS[event.key], event.value
|
|
636
|
+
)
|
|
637
|
+
except Exception: # noqa: BLE001
|
|
638
|
+
pass
|
|
639
|
+
if _HEX_RE.match((event.value or "").strip()) or not event.value:
|
|
640
|
+
self._schedule_preview()
|
|
641
|
+
|
|
642
|
+
def _sync_picker_from_selected(self) -> None:
|
|
643
|
+
value = self._values.get(self._selected_color_key, "")
|
|
644
|
+
hsv = _hex_to_hsv(value)
|
|
645
|
+
if hsv is not None:
|
|
646
|
+
self._picker_hsv = hsv
|
|
647
|
+
hue, saturation, brightness = self._picker_hsv
|
|
648
|
+
try:
|
|
649
|
+
self.query_one("#color-plane", _ColorPlane).set_hsv(hue, saturation, brightness)
|
|
650
|
+
self.query_one("#hue-strip", _HueStrip).set_hue(hue)
|
|
651
|
+
self.query_one("#color-readout", _ColorReadout).set_color(
|
|
652
|
+
_COLOR_LABELS[self._selected_color_key], value
|
|
653
|
+
)
|
|
654
|
+
except Exception: # noqa: BLE001
|
|
655
|
+
pass
|
|
656
|
+
|
|
657
|
+
@on(Input.Changed, "#meta-name")
|
|
658
|
+
def _on_name_changed(self, event: Input.Changed) -> None:
|
|
659
|
+
self._name = event.value.strip()
|
|
660
|
+
|
|
661
|
+
@on(Input.Changed, "#meta-label")
|
|
662
|
+
def _on_label_changed(self, event: Input.Changed) -> None:
|
|
663
|
+
self._label = event.value.strip()
|
|
664
|
+
|
|
665
|
+
@on(Select.Changed, "#meta-extends")
|
|
666
|
+
def _on_extends_changed(self, event: Select.Changed) -> None:
|
|
667
|
+
if isinstance(event.value, str):
|
|
668
|
+
self._extends = event.value
|
|
669
|
+
self._schedule_preview()
|
|
670
|
+
|
|
671
|
+
@on(Select.Changed, "#meta-prompt-border")
|
|
672
|
+
def _on_border_changed(self, event: Select.Changed) -> None:
|
|
673
|
+
if isinstance(event.value, str) and event.value in _BORDER_STYLES:
|
|
674
|
+
self._prompt_border = event.value
|
|
675
|
+
self._schedule_preview()
|
|
676
|
+
|
|
677
|
+
@on(Click, "#inherit-color")
|
|
678
|
+
def _on_inherit_clicked(self, event: Click) -> None:
|
|
679
|
+
event.stop()
|
|
680
|
+
self.action_inherit_color()
|
|
681
|
+
|
|
682
|
+
def action_inherit_color(self) -> None:
|
|
683
|
+
self._on_color_changed(_DesignerColorChanged(self._selected_color_key, ""))
|
|
684
|
+
self._sync_picker_from_selected()
|
|
685
|
+
|
|
686
|
+
def action_cancel(self) -> None:
|
|
687
|
+
if self._dismiss_started:
|
|
688
|
+
return
|
|
689
|
+
self._dismiss_started = True
|
|
690
|
+
self._cancel_preview_timer()
|
|
691
|
+
self._restore_original()
|
|
692
|
+
self.dismiss(None)
|
|
693
|
+
self._defer_host_theme_apply(self._original, persist=False, announce=False)
|
|
694
|
+
|
|
695
|
+
def action_save(self) -> None:
|
|
696
|
+
if self._dismiss_started:
|
|
697
|
+
return
|
|
698
|
+
name = self._name or "custom"
|
|
699
|
+
try:
|
|
700
|
+
self._save_theme(name)
|
|
701
|
+
except Exception as exc: # noqa: BLE001
|
|
702
|
+
try:
|
|
703
|
+
self.query_one("#designer-window").border_subtitle = f"save failed: {exc}"
|
|
704
|
+
except Exception: # noqa: BLE001
|
|
705
|
+
pass
|
|
706
|
+
return
|
|
707
|
+
self._dismiss_started = True
|
|
708
|
+
self._cancel_preview_timer()
|
|
709
|
+
self.dismiss(("theme", name))
|
|
710
|
+
self._defer_host_theme_apply(name, persist=True, announce=True)
|
|
711
|
+
|
|
712
|
+
def _build_theme_dict(self) -> dict[str, Any]:
|
|
713
|
+
data: dict[str, Any] = {
|
|
714
|
+
"extends": self._extends or "cursor-dark",
|
|
715
|
+
"label": self._label or self._name or "Custom",
|
|
716
|
+
"prompt_border": self._prompt_border or "tall",
|
|
717
|
+
}
|
|
718
|
+
for key, _ in _COLOR_FIELDS:
|
|
719
|
+
value = (self._values.get(key) or "").strip()
|
|
720
|
+
if value:
|
|
721
|
+
data[key] = value
|
|
722
|
+
if self._code_theme:
|
|
723
|
+
data["code_theme"] = self._code_theme
|
|
724
|
+
for field, value in (
|
|
725
|
+
("rich_user", self._rich_user),
|
|
726
|
+
("rich_info_border", self._rich_info_border),
|
|
727
|
+
("rich_ok_border", self._rich_ok_border),
|
|
728
|
+
("rich_activity", self._rich_activity),
|
|
729
|
+
):
|
|
730
|
+
if value:
|
|
731
|
+
data[field] = value
|
|
732
|
+
return data
|
|
733
|
+
|
|
734
|
+
def _preview_signature(self, data: dict[str, Any]) -> str:
|
|
735
|
+
items = sorted((str(key), str(value)) for key, value in data.items())
|
|
736
|
+
return repr(items)
|
|
737
|
+
|
|
738
|
+
def _cancel_preview_timer(self) -> None:
|
|
739
|
+
timer = getattr(self, "_preview_timer", None)
|
|
740
|
+
self._preview_timer = None
|
|
741
|
+
self._preview_seq = int(getattr(self, "_preview_seq", 0)) + 1
|
|
742
|
+
if timer is not None:
|
|
743
|
+
try:
|
|
744
|
+
timer.stop()
|
|
745
|
+
except Exception: # noqa: BLE001
|
|
746
|
+
pass
|
|
747
|
+
|
|
748
|
+
def _schedule_preview(self) -> None:
|
|
749
|
+
if getattr(self, "_dismiss_started", False) or not getattr(self, "_ready", False):
|
|
750
|
+
return
|
|
751
|
+
self._preview_seq = int(getattr(self, "_preview_seq", 0)) + 1
|
|
752
|
+
sequence = self._preview_seq
|
|
753
|
+
timer = getattr(self, "_preview_timer", None)
|
|
754
|
+
if timer is not None:
|
|
755
|
+
try:
|
|
756
|
+
timer.stop()
|
|
757
|
+
except Exception: # noqa: BLE001
|
|
758
|
+
pass
|
|
759
|
+
self._preview_timer = None
|
|
760
|
+
|
|
761
|
+
def fire() -> None:
|
|
762
|
+
self._preview_timer = None
|
|
763
|
+
if getattr(self, "_dismiss_started", False) or sequence != self._preview_seq:
|
|
764
|
+
return
|
|
765
|
+
self._apply_preview()
|
|
766
|
+
|
|
767
|
+
try:
|
|
768
|
+
self._preview_timer = self.set_timer(_PREVIEW_DEBOUNCE_S, fire)
|
|
769
|
+
except Exception: # noqa: BLE001
|
|
770
|
+
if not getattr(self, "_dismiss_started", False) and sequence == self._preview_seq:
|
|
771
|
+
self._apply_preview()
|
|
772
|
+
|
|
773
|
+
def _apply_preview(self) -> None:
|
|
774
|
+
if self._dismiss_started or self._preview_busy:
|
|
775
|
+
return
|
|
776
|
+
self._preview_busy = True
|
|
777
|
+
try:
|
|
778
|
+
from synapse.ui.theme import _custom, set_active_theme
|
|
779
|
+
|
|
780
|
+
data = self._build_theme_dict()
|
|
781
|
+
signature = self._preview_signature(data)
|
|
782
|
+
if signature == self._last_preview_sig:
|
|
783
|
+
return
|
|
784
|
+
theme = _theme_from_dict("__designer_temp__", data, catalog=dict(_custom))
|
|
785
|
+
set_active_theme(theme)
|
|
786
|
+
self._last_preview_sig = signature
|
|
787
|
+
app = self.app
|
|
788
|
+
if hasattr(app, "refresh_css"):
|
|
789
|
+
app.refresh_css(animate=False)
|
|
790
|
+
if hasattr(app, "_repaint_themed_widgets"):
|
|
791
|
+
app._repaint_themed_widgets() # type: ignore[attr-defined]
|
|
792
|
+
except Exception: # noqa: BLE001
|
|
793
|
+
pass
|
|
794
|
+
finally:
|
|
795
|
+
self._preview_busy = False
|
|
796
|
+
|
|
797
|
+
def _restore_original(self) -> None:
|
|
798
|
+
try:
|
|
799
|
+
set_theme(
|
|
800
|
+
self._original,
|
|
801
|
+
workspace=self._project_root,
|
|
802
|
+
persist=False,
|
|
803
|
+
reload=False,
|
|
804
|
+
)
|
|
805
|
+
except Exception: # noqa: BLE001
|
|
806
|
+
pass
|
|
807
|
+
|
|
808
|
+
def _defer_host_theme_apply(
|
|
809
|
+
self,
|
|
810
|
+
name: str,
|
|
811
|
+
*,
|
|
812
|
+
persist: bool,
|
|
813
|
+
announce: bool,
|
|
814
|
+
) -> None:
|
|
815
|
+
"""Refresh the host only after ``dismiss`` has queued screen removal."""
|
|
816
|
+
try:
|
|
817
|
+
app = self.app
|
|
818
|
+
except Exception: # noqa: BLE001
|
|
819
|
+
return
|
|
820
|
+
|
|
821
|
+
def apply_to_host() -> None:
|
|
822
|
+
try:
|
|
823
|
+
if hasattr(app, "apply_theme"):
|
|
824
|
+
app.apply_theme(name, persist=persist, announce=announce)
|
|
825
|
+
elif hasattr(app, "refresh_css"):
|
|
826
|
+
app.refresh_css(animate=False)
|
|
827
|
+
except Exception as exc: # noqa: BLE001
|
|
828
|
+
if hasattr(app, "append_event"):
|
|
829
|
+
app.append_event(f"theme failed: {exc}", "yellow")
|
|
830
|
+
|
|
831
|
+
app.call_later(apply_to_host)
|
|
832
|
+
|
|
833
|
+
def _save_theme(self, name: str) -> None:
|
|
834
|
+
key = (name or "").strip()
|
|
835
|
+
if not key:
|
|
836
|
+
raise ValueError("theme name is empty")
|
|
837
|
+
if not re.fullmatch(r"[A-Za-z0-9._-]+", key):
|
|
838
|
+
raise ValueError("theme name must be [A-Za-z0-9._-]+")
|
|
839
|
+
|
|
840
|
+
data = self._build_theme_dict()
|
|
841
|
+
path = user_config_dir() / THEMES_FILENAME
|
|
842
|
+
existing: dict[str, Any] = {}
|
|
843
|
+
if path.is_file():
|
|
844
|
+
try:
|
|
845
|
+
loaded = json.loads(path.read_text(encoding="utf-8"))
|
|
846
|
+
if isinstance(loaded, dict):
|
|
847
|
+
existing = loaded
|
|
848
|
+
except Exception: # noqa: BLE001
|
|
849
|
+
existing = {}
|
|
850
|
+
|
|
851
|
+
themes = existing.get("themes", {})
|
|
852
|
+
if not isinstance(themes, dict):
|
|
853
|
+
themes = {}
|
|
854
|
+
themes[key] = data
|
|
855
|
+
existing["themes"] = themes
|
|
856
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
857
|
+
path.write_text(
|
|
858
|
+
json.dumps(existing, indent=2, ensure_ascii=False) + "\n",
|
|
859
|
+
encoding="utf-8",
|
|
860
|
+
)
|
|
861
|
+
|
|
862
|
+
reload_theme_catalog(self._project_root)
|
|
863
|
+
set_theme(key, workspace=self._project_root, persist=True, reload=False)
|