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,979 @@
|
|
|
1
|
+
"""Extensible topbar: freeform regions + components.
|
|
2
|
+
|
|
3
|
+
Regions are first-class layout slots (not limited to left/center/right).
|
|
4
|
+
Each region can configure:
|
|
5
|
+
|
|
6
|
+
- horizontal order
|
|
7
|
+
- width (fixed cells, hug content, or flex fill)
|
|
8
|
+
- min/max width
|
|
9
|
+
- align (left / center / right)
|
|
10
|
+
- fg / bg styles
|
|
11
|
+
- gap after the region
|
|
12
|
+
- shrink priority
|
|
13
|
+
|
|
14
|
+
Components render text into a named region and are ordered by ``order``.
|
|
15
|
+
|
|
16
|
+
Default chrome still installs three regions (``left`` / ``center`` / ``right``)
|
|
17
|
+
with historical behavior, painted into a single-line Textual ``Static``.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from collections.abc import Callable, Iterable
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from dataclasses import replace as dc_replace
|
|
25
|
+
from enum import StrEnum
|
|
26
|
+
|
|
27
|
+
from rich.text import Text
|
|
28
|
+
|
|
29
|
+
# Default join between sibling components in the same region.
|
|
30
|
+
DEFAULT_REGION_GAP = " · "
|
|
31
|
+
# Spaces after a default region when the next region is present.
|
|
32
|
+
# Theme ``top_gap`` overrides this at runtime for the live TUI topbar.
|
|
33
|
+
DEFAULT_COL_GAP = 3
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TopBarAlign(StrEnum):
|
|
37
|
+
LEFT = "left"
|
|
38
|
+
CENTER = "center"
|
|
39
|
+
RIGHT = "right"
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def parse(cls, value: TopBarAlign | str | None) -> TopBarAlign:
|
|
43
|
+
if isinstance(value, cls):
|
|
44
|
+
return value
|
|
45
|
+
key = str(value or "left").strip().lower()
|
|
46
|
+
if key in {"middle", "mid"}:
|
|
47
|
+
key = "center"
|
|
48
|
+
try:
|
|
49
|
+
return cls(key)
|
|
50
|
+
except ValueError as exc:
|
|
51
|
+
allowed = ", ".join(a.value for a in cls)
|
|
52
|
+
raise ValueError(f"unknown topbar align {value!r}; expected one of: {allowed}") from exc
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class TopBarRegion(StrEnum):
|
|
56
|
+
"""Built-in region ids (custom region ids are plain strings)."""
|
|
57
|
+
|
|
58
|
+
LEFT = "left"
|
|
59
|
+
CENTER = "center"
|
|
60
|
+
RIGHT = "right"
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def parse(cls, value: TopBarRegion | str) -> TopBarRegion:
|
|
64
|
+
"""Parse a built-in region enum value (rejects freeform ids)."""
|
|
65
|
+
if isinstance(value, cls):
|
|
66
|
+
return value
|
|
67
|
+
key = str(value or "").strip().lower()
|
|
68
|
+
try:
|
|
69
|
+
return cls(key)
|
|
70
|
+
except ValueError as exc:
|
|
71
|
+
allowed = ", ".join(r.value for r in cls)
|
|
72
|
+
raise ValueError(
|
|
73
|
+
f"unknown topbar region {value!r}; expected one of: {allowed}"
|
|
74
|
+
) from exc
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def normalize_region_id(value: TopBarRegion | str | None) -> str:
|
|
78
|
+
"""Accept built-in enum or any non-empty freeform region id."""
|
|
79
|
+
if isinstance(value, TopBarRegion):
|
|
80
|
+
return value.value
|
|
81
|
+
key = str(value or "").strip()
|
|
82
|
+
if not key:
|
|
83
|
+
raise ValueError("topbar region id must be non-empty")
|
|
84
|
+
return key
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
RenderFn = Callable[[], str | Text]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(slots=True)
|
|
91
|
+
class TopBarRegionSpec:
|
|
92
|
+
"""Layout + style configuration for one horizontal topbar region."""
|
|
93
|
+
|
|
94
|
+
id: str
|
|
95
|
+
order: int = 0
|
|
96
|
+
# None = hug content; int = preferred/fixed cell width (still clamped).
|
|
97
|
+
width: int | None = None
|
|
98
|
+
min_width: int = 0
|
|
99
|
+
max_width: int | None = None
|
|
100
|
+
# flex > 0 shares leftover row width (CSS flex-grow style).
|
|
101
|
+
flex: int = 0
|
|
102
|
+
align: TopBarAlign = TopBarAlign.LEFT
|
|
103
|
+
fg: str | None = None
|
|
104
|
+
bg: str | None = None
|
|
105
|
+
# Trailing spaces emitted after this region when a later region is shown.
|
|
106
|
+
gap_after: int = DEFAULT_COL_GAP
|
|
107
|
+
# Higher priority survives longer when the row overflows.
|
|
108
|
+
priority: int = 0
|
|
109
|
+
visible: bool = True
|
|
110
|
+
|
|
111
|
+
def style_string(self, *, fallback_fg: str | None = None) -> str | None:
|
|
112
|
+
fg = (self.fg if self.fg is not None else fallback_fg) or ""
|
|
113
|
+
bg = (self.bg or "").strip()
|
|
114
|
+
fg = fg.strip()
|
|
115
|
+
if fg and bg:
|
|
116
|
+
return f"{fg} on {bg}"
|
|
117
|
+
if bg:
|
|
118
|
+
return f"on {bg}"
|
|
119
|
+
return fg or None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(slots=True)
|
|
123
|
+
class TopBarComponent:
|
|
124
|
+
"""One renderable unit inside a named topbar region."""
|
|
125
|
+
|
|
126
|
+
id: str
|
|
127
|
+
region: str
|
|
128
|
+
render: RenderFn
|
|
129
|
+
order: int = 0
|
|
130
|
+
priority: int = 0
|
|
131
|
+
min_width: int = 0
|
|
132
|
+
gap_before: str = DEFAULT_REGION_GAP
|
|
133
|
+
style: str | None = None
|
|
134
|
+
visible: bool = True
|
|
135
|
+
|
|
136
|
+
def content(self) -> str | Text:
|
|
137
|
+
"""Return render output as plain str or styled Rich Text."""
|
|
138
|
+
if not self.visible:
|
|
139
|
+
return ""
|
|
140
|
+
try:
|
|
141
|
+
raw = self.render()
|
|
142
|
+
except Exception: # noqa: BLE001
|
|
143
|
+
return ""
|
|
144
|
+
if isinstance(raw, Text):
|
|
145
|
+
return raw
|
|
146
|
+
return str(raw or "")
|
|
147
|
+
|
|
148
|
+
def text(self) -> str:
|
|
149
|
+
raw = self.content()
|
|
150
|
+
if isinstance(raw, Text):
|
|
151
|
+
return raw.plain
|
|
152
|
+
return raw
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _default_regions() -> dict[str, TopBarRegionSpec]:
|
|
156
|
+
# Classic chrome: left/right hug content, center flex-fills leftover.
|
|
157
|
+
# Theme ``top_gap`` may override gap_after at runtime.
|
|
158
|
+
return {
|
|
159
|
+
TopBarRegion.LEFT.value: TopBarRegionSpec(
|
|
160
|
+
id=TopBarRegion.LEFT.value,
|
|
161
|
+
order=10,
|
|
162
|
+
flex=0,
|
|
163
|
+
align=TopBarAlign.LEFT,
|
|
164
|
+
priority=40,
|
|
165
|
+
gap_after=DEFAULT_COL_GAP,
|
|
166
|
+
),
|
|
167
|
+
TopBarRegion.CENTER.value: TopBarRegionSpec(
|
|
168
|
+
id=TopBarRegion.CENTER.value,
|
|
169
|
+
order=20,
|
|
170
|
+
flex=1,
|
|
171
|
+
align=TopBarAlign.CENTER,
|
|
172
|
+
priority=10,
|
|
173
|
+
min_width=4,
|
|
174
|
+
gap_after=DEFAULT_COL_GAP,
|
|
175
|
+
),
|
|
176
|
+
TopBarRegion.RIGHT.value: TopBarRegionSpec(
|
|
177
|
+
id=TopBarRegion.RIGHT.value,
|
|
178
|
+
order=30,
|
|
179
|
+
flex=0,
|
|
180
|
+
align=TopBarAlign.RIGHT,
|
|
181
|
+
priority=50,
|
|
182
|
+
gap_after=0,
|
|
183
|
+
),
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclass(slots=True)
|
|
188
|
+
class TopBarRegistry:
|
|
189
|
+
"""Mutable registry of regions + components."""
|
|
190
|
+
|
|
191
|
+
_regions: dict[str, TopBarRegionSpec] = field(default_factory=_default_regions)
|
|
192
|
+
_items: dict[str, TopBarComponent] = field(default_factory=dict)
|
|
193
|
+
|
|
194
|
+
def register_region(
|
|
195
|
+
self,
|
|
196
|
+
spec: TopBarRegionSpec | str,
|
|
197
|
+
*,
|
|
198
|
+
order: int | None = None,
|
|
199
|
+
width: int | None = None,
|
|
200
|
+
min_width: int | None = None,
|
|
201
|
+
max_width: int | None = None,
|
|
202
|
+
flex: int | None = None,
|
|
203
|
+
align: TopBarAlign | str | None = None,
|
|
204
|
+
fg: str | None = None,
|
|
205
|
+
bg: str | None = None,
|
|
206
|
+
gap_after: int | None = None,
|
|
207
|
+
priority: int | None = None,
|
|
208
|
+
visible: bool | None = None,
|
|
209
|
+
replace: bool = True,
|
|
210
|
+
) -> TopBarRegionSpec:
|
|
211
|
+
"""Add or update a region slot."""
|
|
212
|
+
if isinstance(spec, TopBarRegionSpec):
|
|
213
|
+
region = spec
|
|
214
|
+
rid = normalize_region_id(region.id)
|
|
215
|
+
if rid != region.id:
|
|
216
|
+
region = dc_replace(region, id=rid)
|
|
217
|
+
else:
|
|
218
|
+
rid = normalize_region_id(spec)
|
|
219
|
+
region = self._regions.get(rid) or TopBarRegionSpec(id=rid, order=100)
|
|
220
|
+
|
|
221
|
+
updates: dict[str, object] = {}
|
|
222
|
+
if order is not None:
|
|
223
|
+
updates["order"] = int(order)
|
|
224
|
+
if width is not None:
|
|
225
|
+
updates["width"] = int(width)
|
|
226
|
+
if min_width is not None:
|
|
227
|
+
updates["min_width"] = max(0, int(min_width))
|
|
228
|
+
if max_width is not None:
|
|
229
|
+
updates["max_width"] = int(max_width) if max_width else None
|
|
230
|
+
if flex is not None:
|
|
231
|
+
updates["flex"] = max(0, int(flex))
|
|
232
|
+
if align is not None:
|
|
233
|
+
updates["align"] = TopBarAlign.parse(align)
|
|
234
|
+
if fg is not None:
|
|
235
|
+
updates["fg"] = fg
|
|
236
|
+
if bg is not None:
|
|
237
|
+
updates["bg"] = bg
|
|
238
|
+
if gap_after is not None:
|
|
239
|
+
updates["gap_after"] = max(0, int(gap_after))
|
|
240
|
+
if priority is not None:
|
|
241
|
+
updates["priority"] = int(priority)
|
|
242
|
+
if visible is not None:
|
|
243
|
+
updates["visible"] = bool(visible)
|
|
244
|
+
|
|
245
|
+
if updates:
|
|
246
|
+
region = dc_replace(region, **updates) # type: ignore[arg-type]
|
|
247
|
+
region = dc_replace(region, id=rid)
|
|
248
|
+
|
|
249
|
+
if not replace and rid in self._regions:
|
|
250
|
+
raise KeyError(f"topbar region already registered: {rid}")
|
|
251
|
+
self._regions[rid] = region
|
|
252
|
+
return region
|
|
253
|
+
|
|
254
|
+
def unregister_region(self, id: str, *, drop_components: bool = False) -> bool:
|
|
255
|
+
rid = str(id)
|
|
256
|
+
existed = self._regions.pop(rid, None) is not None
|
|
257
|
+
if drop_components:
|
|
258
|
+
for cid, comp in list(self._items.items()):
|
|
259
|
+
if comp.region == rid:
|
|
260
|
+
self._items.pop(cid, None)
|
|
261
|
+
return existed
|
|
262
|
+
|
|
263
|
+
def get_region(self, id: str) -> TopBarRegionSpec | None:
|
|
264
|
+
return self._regions.get(str(id))
|
|
265
|
+
|
|
266
|
+
def ensure_region(self, id: TopBarRegion | str) -> TopBarRegionSpec:
|
|
267
|
+
rid = normalize_region_id(id)
|
|
268
|
+
existing = self._regions.get(rid)
|
|
269
|
+
if existing is not None:
|
|
270
|
+
return existing
|
|
271
|
+
spec = TopBarRegionSpec(id=rid, order=100, flex=0, align=TopBarAlign.LEFT)
|
|
272
|
+
self._regions[rid] = spec
|
|
273
|
+
return spec
|
|
274
|
+
|
|
275
|
+
def regions(self, *, include_hidden: bool = False) -> list[TopBarRegionSpec]:
|
|
276
|
+
items = list(self._regions.values())
|
|
277
|
+
if not include_hidden:
|
|
278
|
+
items = [r for r in items if r.visible]
|
|
279
|
+
items.sort(key=lambda r: (r.order, r.id))
|
|
280
|
+
return items
|
|
281
|
+
|
|
282
|
+
def set_region_style(
|
|
283
|
+
self,
|
|
284
|
+
id: str,
|
|
285
|
+
*,
|
|
286
|
+
fg: str | None = None,
|
|
287
|
+
bg: str | None = None,
|
|
288
|
+
align: TopBarAlign | str | None = None,
|
|
289
|
+
width: int | None = None,
|
|
290
|
+
min_width: int | None = None,
|
|
291
|
+
max_width: int | None = None,
|
|
292
|
+
flex: int | None = None,
|
|
293
|
+
gap_after: int | None = None,
|
|
294
|
+
priority: int | None = None,
|
|
295
|
+
order: int | None = None,
|
|
296
|
+
visible: bool | None = None,
|
|
297
|
+
) -> bool:
|
|
298
|
+
if self.get_region(id) is None:
|
|
299
|
+
return False
|
|
300
|
+
self.register_region(
|
|
301
|
+
id,
|
|
302
|
+
fg=fg,
|
|
303
|
+
bg=bg,
|
|
304
|
+
align=align,
|
|
305
|
+
width=width,
|
|
306
|
+
min_width=min_width,
|
|
307
|
+
max_width=max_width,
|
|
308
|
+
flex=flex,
|
|
309
|
+
gap_after=gap_after,
|
|
310
|
+
priority=priority,
|
|
311
|
+
order=order,
|
|
312
|
+
visible=visible,
|
|
313
|
+
replace=True,
|
|
314
|
+
)
|
|
315
|
+
return True
|
|
316
|
+
|
|
317
|
+
def register(
|
|
318
|
+
self,
|
|
319
|
+
component: TopBarComponent,
|
|
320
|
+
*,
|
|
321
|
+
replace: bool = True,
|
|
322
|
+
) -> None:
|
|
323
|
+
cid = str(component.id or "").strip()
|
|
324
|
+
if not cid:
|
|
325
|
+
raise ValueError("topbar component id must be non-empty")
|
|
326
|
+
if not replace and cid in self._items:
|
|
327
|
+
raise KeyError(f"topbar component already registered: {cid}")
|
|
328
|
+
rid = normalize_region_id(component.region)
|
|
329
|
+
self.ensure_region(rid)
|
|
330
|
+
if component.id != cid or component.region != rid:
|
|
331
|
+
component = TopBarComponent(
|
|
332
|
+
id=cid,
|
|
333
|
+
region=rid,
|
|
334
|
+
render=component.render,
|
|
335
|
+
order=component.order,
|
|
336
|
+
priority=component.priority,
|
|
337
|
+
min_width=component.min_width,
|
|
338
|
+
gap_before=component.gap_before,
|
|
339
|
+
style=component.style,
|
|
340
|
+
visible=component.visible,
|
|
341
|
+
)
|
|
342
|
+
else:
|
|
343
|
+
component.region = rid
|
|
344
|
+
self._items[cid] = component
|
|
345
|
+
|
|
346
|
+
def register_fn(
|
|
347
|
+
self,
|
|
348
|
+
id: str,
|
|
349
|
+
render: RenderFn,
|
|
350
|
+
*,
|
|
351
|
+
region: TopBarRegion | str = TopBarRegion.RIGHT,
|
|
352
|
+
order: int = 100,
|
|
353
|
+
priority: int = 0,
|
|
354
|
+
min_width: int = 0,
|
|
355
|
+
gap_before: str = DEFAULT_REGION_GAP,
|
|
356
|
+
style: str | None = None,
|
|
357
|
+
visible: bool = True,
|
|
358
|
+
replace: bool = True,
|
|
359
|
+
) -> TopBarComponent:
|
|
360
|
+
comp = TopBarComponent(
|
|
361
|
+
id=str(id),
|
|
362
|
+
region=normalize_region_id(region),
|
|
363
|
+
render=render,
|
|
364
|
+
order=int(order),
|
|
365
|
+
priority=int(priority),
|
|
366
|
+
min_width=max(0, int(min_width or 0)),
|
|
367
|
+
gap_before=str(gap_before),
|
|
368
|
+
style=style,
|
|
369
|
+
visible=bool(visible),
|
|
370
|
+
)
|
|
371
|
+
self.register(comp, replace=replace)
|
|
372
|
+
return comp
|
|
373
|
+
|
|
374
|
+
def unregister(self, id: str) -> bool:
|
|
375
|
+
return self._items.pop(str(id), None) is not None
|
|
376
|
+
|
|
377
|
+
def get(self, id: str) -> TopBarComponent | None:
|
|
378
|
+
return self._items.get(str(id))
|
|
379
|
+
|
|
380
|
+
def set_visible(self, id: str, visible: bool) -> bool:
|
|
381
|
+
comp = self.get(id)
|
|
382
|
+
if comp is None:
|
|
383
|
+
return False
|
|
384
|
+
comp.visible = bool(visible)
|
|
385
|
+
return True
|
|
386
|
+
|
|
387
|
+
def set_order(self, id: str, order: int) -> bool:
|
|
388
|
+
comp = self.get(id)
|
|
389
|
+
if comp is None:
|
|
390
|
+
return False
|
|
391
|
+
comp.order = int(order)
|
|
392
|
+
return True
|
|
393
|
+
|
|
394
|
+
def set_region(self, id: str, region: TopBarRegion | str) -> bool:
|
|
395
|
+
comp = self.get(id)
|
|
396
|
+
if comp is None:
|
|
397
|
+
return False
|
|
398
|
+
rid = normalize_region_id(region)
|
|
399
|
+
self.ensure_region(rid)
|
|
400
|
+
comp.region = rid
|
|
401
|
+
return True
|
|
402
|
+
|
|
403
|
+
def clear(self) -> None:
|
|
404
|
+
self._items.clear()
|
|
405
|
+
self._regions = _default_regions()
|
|
406
|
+
|
|
407
|
+
def components(
|
|
408
|
+
self,
|
|
409
|
+
region: TopBarRegion | str | None = None,
|
|
410
|
+
*,
|
|
411
|
+
include_hidden: bool = False,
|
|
412
|
+
) -> list[TopBarComponent]:
|
|
413
|
+
items = list(self._items.values())
|
|
414
|
+
if region is not None:
|
|
415
|
+
rid = normalize_region_id(region)
|
|
416
|
+
items = [c for c in items if c.region == rid]
|
|
417
|
+
if not include_hidden:
|
|
418
|
+
items = [c for c in items if c.visible]
|
|
419
|
+
items.sort(key=lambda c: (c.order, c.id))
|
|
420
|
+
return items
|
|
421
|
+
|
|
422
|
+
def __len__(self) -> int:
|
|
423
|
+
return len(self._items)
|
|
424
|
+
|
|
425
|
+
def __contains__(self, id: object) -> bool:
|
|
426
|
+
return str(id) in self._items
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def display_width(text: str) -> int:
|
|
430
|
+
"""Terminal cell width (CJK / fullwidth / emoji count as 2)."""
|
|
431
|
+
total = 0
|
|
432
|
+
for ch in text or "":
|
|
433
|
+
o = ord(ch)
|
|
434
|
+
if (
|
|
435
|
+
0x1100 <= o <= 0x115F
|
|
436
|
+
or 0x2E80 <= o <= 0xA4CF
|
|
437
|
+
or 0xAC00 <= o <= 0xD7A3
|
|
438
|
+
or 0xF900 <= o <= 0xFAFF
|
|
439
|
+
or 0xFE10 <= o <= 0xFE19
|
|
440
|
+
or 0xFE30 <= o <= 0xFE6F
|
|
441
|
+
or 0xFF00 <= o <= 0xFF60
|
|
442
|
+
or 0xFFE0 <= o <= 0xFFE6
|
|
443
|
+
or 0x1F300 <= o <= 0x1FAFF
|
|
444
|
+
):
|
|
445
|
+
total += 2
|
|
446
|
+
else:
|
|
447
|
+
total += 1
|
|
448
|
+
return total
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def truncate_to_width(text: str, max_w: int) -> str:
|
|
452
|
+
"""Truncate ``text`` so its display width fits ``max_w`` cells."""
|
|
453
|
+
raw = text or ""
|
|
454
|
+
max_w = int(max_w or 0)
|
|
455
|
+
if max_w <= 0:
|
|
456
|
+
return ""
|
|
457
|
+
if display_width(raw) <= max_w:
|
|
458
|
+
return raw
|
|
459
|
+
if max_w == 1:
|
|
460
|
+
return "…"
|
|
461
|
+
out: list[str] = []
|
|
462
|
+
used = 0
|
|
463
|
+
limit = max_w - 1
|
|
464
|
+
for ch in raw:
|
|
465
|
+
cw = display_width(ch)
|
|
466
|
+
if used + cw > limit:
|
|
467
|
+
break
|
|
468
|
+
out.append(ch)
|
|
469
|
+
used += cw
|
|
470
|
+
return "".join(out) + "…"
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def center_in_width(text: str, width: int) -> str:
|
|
474
|
+
"""Pad ``text`` so it appears centered within ``width`` terminal cells."""
|
|
475
|
+
return align_in_width(text, width, TopBarAlign.CENTER)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def align_in_width(text: str, width: int, align: TopBarAlign | str) -> str:
|
|
479
|
+
"""Pad/truncate ``text`` into ``width`` cells with the given alignment."""
|
|
480
|
+
align_e = TopBarAlign.parse(align)
|
|
481
|
+
width = max(0, int(width or 0))
|
|
482
|
+
if width <= 0:
|
|
483
|
+
return ""
|
|
484
|
+
body = truncate_to_width(text or "", width)
|
|
485
|
+
w = display_width(body)
|
|
486
|
+
if w >= width:
|
|
487
|
+
return body
|
|
488
|
+
pad = width - w
|
|
489
|
+
if align_e is TopBarAlign.RIGHT:
|
|
490
|
+
return (" " * pad) + body
|
|
491
|
+
if align_e is TopBarAlign.CENTER:
|
|
492
|
+
left = pad // 2
|
|
493
|
+
right = pad - left
|
|
494
|
+
return (" " * left) + body + (" " * right)
|
|
495
|
+
return body + (" " * pad)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def join_region_parts(parts: Iterable[tuple[str, str]]) -> str:
|
|
499
|
+
"""Join ``(gap_before, text)`` pairs, skipping empty texts."""
|
|
500
|
+
chunks: list[str] = []
|
|
501
|
+
for gap, text in parts:
|
|
502
|
+
body = text or ""
|
|
503
|
+
if not body:
|
|
504
|
+
continue
|
|
505
|
+
if not chunks:
|
|
506
|
+
chunks.append(body)
|
|
507
|
+
else:
|
|
508
|
+
chunks.append(f"{gap}{body}")
|
|
509
|
+
return "".join(chunks)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def render_region_text(components: list[TopBarComponent]) -> str:
|
|
513
|
+
"""Render ordered components in one region to a plain string."""
|
|
514
|
+
return join_region_parts((comp.gap_before, comp.text()) for comp in components)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _compress_region_parts(components: list[TopBarComponent], budget: int) -> str:
|
|
518
|
+
"""Fit region into ``budget`` cells by dropping/truncating low priority first."""
|
|
519
|
+
if budget <= 0:
|
|
520
|
+
return ""
|
|
521
|
+
snaps: list[tuple[str, str, int, int, int]] = []
|
|
522
|
+
for idx, comp in enumerate(components):
|
|
523
|
+
body = comp.text()
|
|
524
|
+
if not body:
|
|
525
|
+
continue
|
|
526
|
+
snaps.append(
|
|
527
|
+
(
|
|
528
|
+
comp.gap_before, body, int(comp.priority),
|
|
529
|
+
max(0, int(comp.min_width or 0)), idx,
|
|
530
|
+
)
|
|
531
|
+
)
|
|
532
|
+
if not snaps:
|
|
533
|
+
return ""
|
|
534
|
+
|
|
535
|
+
def joined(items: list[tuple[str, str, int, int, int]]) -> str:
|
|
536
|
+
return join_region_parts((g, t) for g, t, *_ in items)
|
|
537
|
+
|
|
538
|
+
text = joined(snaps)
|
|
539
|
+
if display_width(text) <= budget:
|
|
540
|
+
return text
|
|
541
|
+
working = list(snaps)
|
|
542
|
+
while len(working) > 1 and display_width(joined(working)) > budget:
|
|
543
|
+
drop_i = min(range(len(working)), key=lambda i: (working[i][2], -working[i][4]))
|
|
544
|
+
working.pop(drop_i)
|
|
545
|
+
text = joined(working)
|
|
546
|
+
if display_width(text) <= budget:
|
|
547
|
+
return text
|
|
548
|
+
return truncate_to_width(text, budget)
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
@dataclass(slots=True)
|
|
552
|
+
class PackedRegion:
|
|
553
|
+
"""One region after width allocation."""
|
|
554
|
+
|
|
555
|
+
spec: TopBarRegionSpec
|
|
556
|
+
content: str
|
|
557
|
+
width: int
|
|
558
|
+
components: list[TopBarComponent] = field(default_factory=list)
|
|
559
|
+
|
|
560
|
+
def aligned_text(self) -> str:
|
|
561
|
+
return align_in_width(self.content, self.width, self.spec.align)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
@dataclass(slots=True)
|
|
565
|
+
class TopBarLayout:
|
|
566
|
+
"""Result of packing regions into one row."""
|
|
567
|
+
|
|
568
|
+
regions: list[PackedRegion]
|
|
569
|
+
usable: int
|
|
570
|
+
left: str = ""
|
|
571
|
+
center: str = ""
|
|
572
|
+
right: str = ""
|
|
573
|
+
pad_left: int = 0
|
|
574
|
+
pad_right: int = 0
|
|
575
|
+
|
|
576
|
+
def as_plain(self) -> str:
|
|
577
|
+
parts: list[str] = []
|
|
578
|
+
visible = [r for r in self.regions if r.width > 0 or r.content]
|
|
579
|
+
for i, reg in enumerate(visible):
|
|
580
|
+
parts.append(reg.aligned_text())
|
|
581
|
+
if i < len(visible) - 1 and reg.spec.gap_after > 0:
|
|
582
|
+
parts.append(" " * reg.spec.gap_after)
|
|
583
|
+
body = "".join(parts)
|
|
584
|
+
w = display_width(body)
|
|
585
|
+
if w < self.usable:
|
|
586
|
+
body = body + (" " * (self.usable - w))
|
|
587
|
+
elif w > self.usable:
|
|
588
|
+
body = truncate_to_width(body, self.usable)
|
|
589
|
+
return body
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _clamp_width(spec: TopBarRegionSpec, desired: int) -> int:
|
|
593
|
+
w = max(0, int(desired))
|
|
594
|
+
w = max(w, max(0, int(spec.min_width or 0)))
|
|
595
|
+
if spec.max_width is not None:
|
|
596
|
+
w = min(w, max(0, int(spec.max_width)))
|
|
597
|
+
return w
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def pack_region_list(
|
|
601
|
+
items: list[tuple[TopBarRegionSpec, str, list[TopBarComponent]]],
|
|
602
|
+
*,
|
|
603
|
+
usable_width: int,
|
|
604
|
+
) -> TopBarLayout:
|
|
605
|
+
"""Pack arbitrary regions into a single non-wrapping row."""
|
|
606
|
+
usable = max(1, int(usable_width or 0))
|
|
607
|
+
active: list[tuple[TopBarRegionSpec, str, list[TopBarComponent]]] = []
|
|
608
|
+
for spec, content, comps in items:
|
|
609
|
+
if not spec.visible:
|
|
610
|
+
continue
|
|
611
|
+
content = content or ""
|
|
612
|
+
if not content and int(spec.flex or 0) <= 0 and spec.width is None:
|
|
613
|
+
continue
|
|
614
|
+
active.append((spec, content, comps))
|
|
615
|
+
if not active:
|
|
616
|
+
return TopBarLayout(regions=[], usable=usable)
|
|
617
|
+
|
|
618
|
+
base: list[int] = []
|
|
619
|
+
for spec, content, _comps in active:
|
|
620
|
+
natural = display_width(content)
|
|
621
|
+
desired = int(spec.width) if spec.width is not None else natural
|
|
622
|
+
if not content and spec.width is None:
|
|
623
|
+
desired = 0
|
|
624
|
+
base.append(_clamp_width(spec, desired))
|
|
625
|
+
|
|
626
|
+
def gaps_width(n: int) -> int:
|
|
627
|
+
if n <= 1:
|
|
628
|
+
return 0
|
|
629
|
+
return sum(max(0, int(active[i][0].gap_after or 0)) for i in range(n - 1))
|
|
630
|
+
|
|
631
|
+
widths = list(base)
|
|
632
|
+
gap_w = gaps_width(len(active))
|
|
633
|
+
remaining = usable - (sum(widths) + gap_w)
|
|
634
|
+
|
|
635
|
+
if remaining > 0:
|
|
636
|
+
flex_idx = [i for i, (spec, _, _) in enumerate(active) if int(spec.flex or 0) > 0]
|
|
637
|
+
if flex_idx:
|
|
638
|
+
total_flex = sum(int(active[i][0].flex) for i in flex_idx)
|
|
639
|
+
give = remaining
|
|
640
|
+
for j, i in enumerate(flex_idx):
|
|
641
|
+
if j == len(flex_idx) - 1:
|
|
642
|
+
add = give
|
|
643
|
+
else:
|
|
644
|
+
add = remaining * int(active[i][0].flex) // total_flex
|
|
645
|
+
give -= add
|
|
646
|
+
widths[i] = _clamp_width(active[i][0], widths[i] + add)
|
|
647
|
+
used = sum(widths) + gap_w
|
|
648
|
+
rest = usable - used
|
|
649
|
+
if rest > 0:
|
|
650
|
+
last = flex_idx[-1]
|
|
651
|
+
widths[last] = _clamp_width(active[last][0], widths[last] + rest)
|
|
652
|
+
|
|
653
|
+
overflow = (sum(widths) + gap_w) - usable
|
|
654
|
+
if overflow > 0:
|
|
655
|
+
order = sorted(
|
|
656
|
+
range(len(active)),
|
|
657
|
+
key=lambda i: (
|
|
658
|
+
int(active[i][0].priority),
|
|
659
|
+
-int(active[i][0].flex or 0),
|
|
660
|
+
-active[i][0].order,
|
|
661
|
+
),
|
|
662
|
+
)
|
|
663
|
+
for i in order:
|
|
664
|
+
if overflow <= 0:
|
|
665
|
+
break
|
|
666
|
+
spec, content, _comps = active[i]
|
|
667
|
+
floor = max(0, int(spec.min_width or 0))
|
|
668
|
+
reducible = max(0, widths[i] - floor)
|
|
669
|
+
if reducible <= 0:
|
|
670
|
+
continue
|
|
671
|
+
cut = min(reducible, overflow)
|
|
672
|
+
widths[i] -= cut
|
|
673
|
+
overflow -= cut
|
|
674
|
+
if overflow > 0:
|
|
675
|
+
for i in order:
|
|
676
|
+
if overflow <= 0:
|
|
677
|
+
break
|
|
678
|
+
cut = min(widths[i], overflow)
|
|
679
|
+
widths[i] -= cut
|
|
680
|
+
overflow -= cut
|
|
681
|
+
|
|
682
|
+
packed: list[PackedRegion] = []
|
|
683
|
+
for (spec, content, comps), width in zip(active, widths, strict=True):
|
|
684
|
+
body = content
|
|
685
|
+
if display_width(body) > width:
|
|
686
|
+
body = _compress_region_parts(comps, width) if comps else truncate_to_width(body, width)
|
|
687
|
+
packed.append(PackedRegion(spec=spec, content=body, width=width, components=comps))
|
|
688
|
+
|
|
689
|
+
layout = TopBarLayout(regions=packed, usable=usable)
|
|
690
|
+
by_id = {p.spec.id: p for p in packed}
|
|
691
|
+
if "left" in by_id:
|
|
692
|
+
layout.left = by_id["left"].content
|
|
693
|
+
if "center" in by_id:
|
|
694
|
+
layout.center = by_id["center"].content
|
|
695
|
+
cw = display_width(by_id["center"].content)
|
|
696
|
+
band = by_id["center"].width
|
|
697
|
+
if band > cw:
|
|
698
|
+
layout.pad_left = (band - cw) // 2
|
|
699
|
+
layout.pad_right = band - cw - layout.pad_left
|
|
700
|
+
if "right" in by_id:
|
|
701
|
+
layout.right = by_id["right"].content
|
|
702
|
+
return layout
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def pack_topbar_regions(
|
|
706
|
+
*,
|
|
707
|
+
usable_width: int,
|
|
708
|
+
left: str = "",
|
|
709
|
+
center: str = "",
|
|
710
|
+
right: str = "",
|
|
711
|
+
col_gap: int = DEFAULT_COL_GAP,
|
|
712
|
+
) -> TopBarLayout:
|
|
713
|
+
"""Legacy three-region packer (kept for tests / simple callers)."""
|
|
714
|
+
gap = max(0, int(col_gap or 0))
|
|
715
|
+
items = [
|
|
716
|
+
(
|
|
717
|
+
TopBarRegionSpec(
|
|
718
|
+
id="left", order=10, flex=0,
|
|
719
|
+
align=TopBarAlign.LEFT, priority=40, gap_after=gap,
|
|
720
|
+
),
|
|
721
|
+
left or "",
|
|
722
|
+
[],
|
|
723
|
+
),
|
|
724
|
+
(
|
|
725
|
+
TopBarRegionSpec(
|
|
726
|
+
id="center",
|
|
727
|
+
order=20,
|
|
728
|
+
flex=1,
|
|
729
|
+
align=TopBarAlign.CENTER,
|
|
730
|
+
priority=10,
|
|
731
|
+
min_width=4,
|
|
732
|
+
gap_after=gap,
|
|
733
|
+
),
|
|
734
|
+
center or "",
|
|
735
|
+
[],
|
|
736
|
+
),
|
|
737
|
+
(
|
|
738
|
+
TopBarRegionSpec(
|
|
739
|
+
id="right", order=30, flex=0,
|
|
740
|
+
align=TopBarAlign.RIGHT, priority=50, gap_after=0,
|
|
741
|
+
),
|
|
742
|
+
right or "",
|
|
743
|
+
[],
|
|
744
|
+
),
|
|
745
|
+
]
|
|
746
|
+
return pack_region_list(items, usable_width=usable_width)
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def pack_layout_from_registry(
|
|
750
|
+
registry: TopBarRegistry,
|
|
751
|
+
*,
|
|
752
|
+
usable_width: int,
|
|
753
|
+
col_gap: int | None = None,
|
|
754
|
+
) -> TopBarLayout:
|
|
755
|
+
"""Pack registry regions without styling (for hit-testing / span queries)."""
|
|
756
|
+
region_specs = registry.regions(include_hidden=False)
|
|
757
|
+
if col_gap is not None:
|
|
758
|
+
gap = max(0, int(col_gap))
|
|
759
|
+
region_specs = [
|
|
760
|
+
dc_replace(spec, gap_after=gap) if spec.id in {"left", "center"} else spec
|
|
761
|
+
for spec in region_specs
|
|
762
|
+
]
|
|
763
|
+
|
|
764
|
+
items: list[tuple[TopBarRegionSpec, str, list[TopBarComponent]]] = []
|
|
765
|
+
for spec in region_specs:
|
|
766
|
+
comps = registry.components(spec.id)
|
|
767
|
+
items.append((spec, render_region_text(comps), comps))
|
|
768
|
+
return pack_region_list(items, usable_width=usable_width)
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def layout_from_registry(
|
|
772
|
+
registry: TopBarRegistry,
|
|
773
|
+
*,
|
|
774
|
+
usable_width: int,
|
|
775
|
+
col_gap: int | None = None,
|
|
776
|
+
left_style: str = "",
|
|
777
|
+
center_style: str = "",
|
|
778
|
+
right_style: str = "",
|
|
779
|
+
gap_style: str = "",
|
|
780
|
+
default_fg: str = "",
|
|
781
|
+
) -> Text:
|
|
782
|
+
"""Render a full topbar line from a registry of regions + components."""
|
|
783
|
+
del gap_style
|
|
784
|
+
fallback = {
|
|
785
|
+
"left": left_style or default_fg,
|
|
786
|
+
"center": center_style or default_fg,
|
|
787
|
+
"right": right_style or default_fg,
|
|
788
|
+
}
|
|
789
|
+
packed = pack_layout_from_registry(
|
|
790
|
+
registry, usable_width=usable_width, col_gap=col_gap
|
|
791
|
+
)
|
|
792
|
+
return render_packed_line(packed, fallback_fg=fallback)
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def locate_component_span(
|
|
796
|
+
registry: TopBarRegistry,
|
|
797
|
+
component_id: str,
|
|
798
|
+
*,
|
|
799
|
+
usable_width: int,
|
|
800
|
+
col_gap: int | None = None,
|
|
801
|
+
) -> tuple[int, int] | None:
|
|
802
|
+
"""Return ``(start_col, width)`` of a component within the packed topbar row.
|
|
803
|
+
|
|
804
|
+
Coordinates are content cells (0 = first cell of the usable topbar line),
|
|
805
|
+
matching ``Static`` content after CSS horizontal padding.
|
|
806
|
+
"""
|
|
807
|
+
cid = str(component_id or "").strip()
|
|
808
|
+
if not cid:
|
|
809
|
+
return None
|
|
810
|
+
packed = pack_layout_from_registry(
|
|
811
|
+
registry, usable_width=usable_width, col_gap=col_gap
|
|
812
|
+
)
|
|
813
|
+
cursor = 0
|
|
814
|
+
visible = [r for r in packed.regions if r.width > 0 or r.content]
|
|
815
|
+
for i, reg in enumerate(visible):
|
|
816
|
+
natural = reg.content or ""
|
|
817
|
+
lead = 0
|
|
818
|
+
if reg.spec.align is TopBarAlign.RIGHT:
|
|
819
|
+
lead = max(0, reg.width - display_width(natural))
|
|
820
|
+
elif reg.spec.align is TopBarAlign.CENTER:
|
|
821
|
+
lead = max(0, (reg.width - display_width(natural)) // 2)
|
|
822
|
+
|
|
823
|
+
x = cursor + lead
|
|
824
|
+
first = True
|
|
825
|
+
for comp in reg.components:
|
|
826
|
+
plain = comp.text()
|
|
827
|
+
if not plain:
|
|
828
|
+
continue
|
|
829
|
+
if not first and comp.gap_before:
|
|
830
|
+
x += display_width(comp.gap_before)
|
|
831
|
+
w = display_width(plain)
|
|
832
|
+
if comp.id == cid:
|
|
833
|
+
return (x, w)
|
|
834
|
+
x += w
|
|
835
|
+
first = False
|
|
836
|
+
|
|
837
|
+
cursor += reg.width
|
|
838
|
+
if i < len(visible) - 1 and reg.spec.gap_after > 0:
|
|
839
|
+
cursor += int(reg.spec.gap_after or 0)
|
|
840
|
+
return None
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def _region_bg(spec: TopBarRegionSpec) -> str:
|
|
844
|
+
"""Normalized region background color, or empty if unset."""
|
|
845
|
+
return (spec.bg or "").strip()
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
def _with_bg(style: str | None, bg: str) -> str | None:
|
|
849
|
+
"""Ensure a Rich style string includes ``on <bg>`` (for region color blocks)."""
|
|
850
|
+
bg = (bg or "").strip()
|
|
851
|
+
if not bg:
|
|
852
|
+
return style
|
|
853
|
+
raw = (style or "").strip()
|
|
854
|
+
if not raw:
|
|
855
|
+
return f"on {bg}"
|
|
856
|
+
# Already has an explicit background — keep caller choice.
|
|
857
|
+
if " on " in f" {raw} ":
|
|
858
|
+
return raw
|
|
859
|
+
return f"{raw} on {bg}"
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def _append_chunk_with_region_bg(
|
|
863
|
+
line: Text,
|
|
864
|
+
chunk: str | Text,
|
|
865
|
+
*,
|
|
866
|
+
style: str | None,
|
|
867
|
+
bg: str,
|
|
868
|
+
) -> None:
|
|
869
|
+
"""Append plain/Text content, painting the region band background when set."""
|
|
870
|
+
if isinstance(chunk, Text):
|
|
871
|
+
if bg:
|
|
872
|
+
painted = chunk.copy()
|
|
873
|
+
painted.stylize(f"on {bg}")
|
|
874
|
+
line.append_text(painted)
|
|
875
|
+
else:
|
|
876
|
+
line.append_text(chunk)
|
|
877
|
+
return
|
|
878
|
+
line.append(str(chunk), style=_with_bg(style, bg) if bg else style)
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def render_packed_line(
|
|
882
|
+
layout: TopBarLayout,
|
|
883
|
+
*,
|
|
884
|
+
fallback_fg: dict[str, str] | str | None = None,
|
|
885
|
+
) -> Text:
|
|
886
|
+
"""Turn a packed layout into styled Rich Text.
|
|
887
|
+
|
|
888
|
+
When a region sets ``bg``, the full allocated band (content + horizontal
|
|
889
|
+
pad) is painted as a solid color block. Per-component styles / pre-styled
|
|
890
|
+
``Text`` keep their foregrounds; the region bg is layered underneath.
|
|
891
|
+
"""
|
|
892
|
+
line = Text()
|
|
893
|
+
if isinstance(fallback_fg, str) or fallback_fg is None:
|
|
894
|
+
fb_map: dict[str, str] = {}
|
|
895
|
+
default_fb = fallback_fg or ""
|
|
896
|
+
else:
|
|
897
|
+
fb_map = dict(fallback_fg)
|
|
898
|
+
default_fb = ""
|
|
899
|
+
|
|
900
|
+
visible = [r for r in layout.regions if r.width > 0 or r.content]
|
|
901
|
+
for i, reg in enumerate(visible):
|
|
902
|
+
fb = fb_map.get(reg.spec.id, default_fb)
|
|
903
|
+
style = reg.spec.style_string(fallback_fg=fb or None)
|
|
904
|
+
bg = _region_bg(reg.spec)
|
|
905
|
+
body = reg.aligned_text()
|
|
906
|
+
natural = reg.content
|
|
907
|
+
# Prefer component path so branch/workspace keep their own styles, even
|
|
908
|
+
# when the region paints a solid background band.
|
|
909
|
+
if reg.components and render_region_text(reg.components) == natural:
|
|
910
|
+
lead = 0
|
|
911
|
+
if reg.spec.align is TopBarAlign.RIGHT:
|
|
912
|
+
lead = max(0, reg.width - display_width(natural))
|
|
913
|
+
elif reg.spec.align is TopBarAlign.CENTER:
|
|
914
|
+
lead = max(0, (reg.width - display_width(natural)) // 2)
|
|
915
|
+
# Always paint pad cells with the region style so bg fills the band,
|
|
916
|
+
# not only the glyphs (hug regions used to look like text highlight).
|
|
917
|
+
band_style = style or (f"on {bg}" if bg else None)
|
|
918
|
+
if lead > 0:
|
|
919
|
+
line.append(" " * lead, style=band_style)
|
|
920
|
+
first = True
|
|
921
|
+
for comp in reg.components:
|
|
922
|
+
chunk = comp.content()
|
|
923
|
+
plain = chunk.plain if isinstance(chunk, Text) else str(chunk or "")
|
|
924
|
+
if not plain:
|
|
925
|
+
continue
|
|
926
|
+
if not first and comp.gap_before:
|
|
927
|
+
line.append(comp.gap_before, style=band_style)
|
|
928
|
+
if isinstance(chunk, Text):
|
|
929
|
+
_append_chunk_with_region_bg(line, chunk, style=style, bg=bg)
|
|
930
|
+
else:
|
|
931
|
+
c_style = comp.style if comp.style is not None else style
|
|
932
|
+
_append_chunk_with_region_bg(line, chunk, style=c_style, bg=bg)
|
|
933
|
+
first = False
|
|
934
|
+
trail = reg.width - lead - display_width(natural)
|
|
935
|
+
if trail > 0:
|
|
936
|
+
line.append(" " * trail, style=band_style)
|
|
937
|
+
elif body:
|
|
938
|
+
line.append(body, style=style)
|
|
939
|
+
elif reg.width > 0 and (style or bg):
|
|
940
|
+
# Empty content but allocated width still paints a solid band.
|
|
941
|
+
band_style = style or (f"on {bg}" if bg else None)
|
|
942
|
+
line.append(" " * reg.width, style=band_style)
|
|
943
|
+
|
|
944
|
+
if i < len(visible) - 1 and reg.spec.gap_after > 0:
|
|
945
|
+
# Gaps stay unstyled so the widget CSS topbar bg shows between blocks.
|
|
946
|
+
line.append(" " * reg.spec.gap_after, style=None)
|
|
947
|
+
|
|
948
|
+
plain_w = display_width(line.plain)
|
|
949
|
+
if plain_w < layout.usable:
|
|
950
|
+
line.append(" " * (layout.usable - plain_w), style=None)
|
|
951
|
+
elif plain_w > layout.usable:
|
|
952
|
+
return Text(truncate_to_width(line.plain, layout.usable))
|
|
953
|
+
return line
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
__all__ = [
|
|
957
|
+
"DEFAULT_COL_GAP",
|
|
958
|
+
"DEFAULT_REGION_GAP",
|
|
959
|
+
"PackedRegion",
|
|
960
|
+
"TopBarAlign",
|
|
961
|
+
"TopBarComponent",
|
|
962
|
+
"TopBarLayout",
|
|
963
|
+
"TopBarRegion",
|
|
964
|
+
"TopBarRegionSpec",
|
|
965
|
+
"TopBarRegistry",
|
|
966
|
+
"align_in_width",
|
|
967
|
+
"center_in_width",
|
|
968
|
+
"display_width",
|
|
969
|
+
"join_region_parts",
|
|
970
|
+
"layout_from_registry",
|
|
971
|
+
"locate_component_span",
|
|
972
|
+
"normalize_region_id",
|
|
973
|
+
"pack_layout_from_registry",
|
|
974
|
+
"pack_region_list",
|
|
975
|
+
"pack_topbar_regions",
|
|
976
|
+
"render_packed_line",
|
|
977
|
+
"render_region_text",
|
|
978
|
+
"truncate_to_width",
|
|
979
|
+
]
|