carterkit 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. carterkit-0.1.0/LICENSE +21 -0
  2. carterkit-0.1.0/PKG-INFO +98 -0
  3. carterkit-0.1.0/README.md +75 -0
  4. carterkit-0.1.0/carterkit/__init__.py +69 -0
  5. carterkit-0.1.0/carterkit/buffer.py +230 -0
  6. carterkit-0.1.0/carterkit/catalog.py +285 -0
  7. carterkit-0.1.0/carterkit/client.py +138 -0
  8. carterkit-0.1.0/carterkit/codegen.py +188 -0
  9. carterkit-0.1.0/carterkit/controldocs/accordion.md +99 -0
  10. carterkit-0.1.0/carterkit/controldocs/actions.md +51 -0
  11. carterkit-0.1.0/carterkit/controldocs/animations.md +41 -0
  12. carterkit-0.1.0/carterkit/controldocs/appearance.md +67 -0
  13. carterkit-0.1.0/carterkit/controldocs/button.md +143 -0
  14. carterkit-0.1.0/carterkit/controldocs/cardList.md +25 -0
  15. carterkit-0.1.0/carterkit/controldocs/carousel.md +155 -0
  16. carterkit-0.1.0/carterkit/controldocs/chat.md +114 -0
  17. carterkit-0.1.0/carterkit/controldocs/color-picker.md +84 -0
  18. carterkit-0.1.0/carterkit/controldocs/control-def.md +101 -0
  19. carterkit-0.1.0/carterkit/controldocs/date-picker.md +148 -0
  20. carterkit-0.1.0/carterkit/controldocs/divider.md +59 -0
  21. carterkit-0.1.0/carterkit/controldocs/flip-card.md +99 -0
  22. carterkit-0.1.0/carterkit/controldocs/gauge.md +198 -0
  23. carterkit-0.1.0/carterkit/controldocs/graph.md +318 -0
  24. carterkit-0.1.0/carterkit/controldocs/group-def.md +103 -0
  25. carterkit-0.1.0/carterkit/controldocs/haptics.md +32 -0
  26. carterkit-0.1.0/carterkit/controldocs/image.md +110 -0
  27. carterkit-0.1.0/carterkit/controldocs/index.md +141 -0
  28. carterkit-0.1.0/carterkit/controldocs/joystick.md +111 -0
  29. carterkit-0.1.0/carterkit/controldocs/label.md +138 -0
  30. carterkit-0.1.0/carterkit/controldocs/layout-config.md +129 -0
  31. carterkit-0.1.0/carterkit/controldocs/list.md +92 -0
  32. carterkit-0.1.0/carterkit/controldocs/log-console.md +121 -0
  33. carterkit-0.1.0/carterkit/controldocs/long-press.md +46 -0
  34. carterkit-0.1.0/carterkit/controldocs/map.md +165 -0
  35. carterkit-0.1.0/carterkit/controldocs/picker.md +130 -0
  36. carterkit-0.1.0/carterkit/controldocs/privacy.md +52 -0
  37. carterkit-0.1.0/carterkit/controldocs/progress-ring.md +139 -0
  38. carterkit-0.1.0/carterkit/controldocs/pulse.md +82 -0
  39. carterkit-0.1.0/carterkit/controldocs/qr-code.md +101 -0
  40. carterkit-0.1.0/carterkit/controldocs/segmented.md +145 -0
  41. carterkit-0.1.0/carterkit/controldocs/slider.md +235 -0
  42. carterkit-0.1.0/carterkit/controldocs/spacer.md +36 -0
  43. carterkit-0.1.0/carterkit/controldocs/sparkline.md +120 -0
  44. carterkit-0.1.0/carterkit/controldocs/status-light.md +114 -0
  45. carterkit-0.1.0/carterkit/controldocs/stepper.md +131 -0
  46. carterkit-0.1.0/carterkit/controldocs/sync.md +50 -0
  47. carterkit-0.1.0/carterkit/controldocs/terms.md +40 -0
  48. carterkit-0.1.0/carterkit/controldocs/text-input.md +126 -0
  49. carterkit-0.1.0/carterkit/controldocs/theming.md +104 -0
  50. carterkit-0.1.0/carterkit/controldocs/toggle.md +185 -0
  51. carterkit-0.1.0/carterkit/controldocs/visibility.md +43 -0
  52. carterkit-0.1.0/carterkit/controldocs/web-view.md +97 -0
  53. carterkit-0.1.0/carterkit/e2ee.py +47 -0
  54. carterkit-0.1.0/carterkit/grid.py +114 -0
  55. carterkit-0.1.0/carterkit/infer.py +137 -0
  56. carterkit-0.1.0/carterkit/theming.py +157 -0
  57. carterkit-0.1.0/carterkit/tune.py +129 -0
  58. carterkit-0.1.0/carterkit/validate.py +132 -0
  59. carterkit-0.1.0/carterkit.egg-info/PKG-INFO +98 -0
  60. carterkit-0.1.0/carterkit.egg-info/SOURCES.txt +72 -0
  61. carterkit-0.1.0/carterkit.egg-info/dependency_links.txt +1 -0
  62. carterkit-0.1.0/carterkit.egg-info/requires.txt +2 -0
  63. carterkit-0.1.0/carterkit.egg-info/top_level.txt +1 -0
  64. carterkit-0.1.0/pyproject.toml +37 -0
  65. carterkit-0.1.0/setup.cfg +4 -0
  66. carterkit-0.1.0/tests/test_buffer.py +126 -0
  67. carterkit-0.1.0/tests/test_catalog.py +105 -0
  68. carterkit-0.1.0/tests/test_codegen.py +45 -0
  69. carterkit-0.1.0/tests/test_e2ee.py +35 -0
  70. carterkit-0.1.0/tests/test_grid.py +65 -0
  71. carterkit-0.1.0/tests/test_infer.py +73 -0
  72. carterkit-0.1.0/tests/test_theming.py +67 -0
  73. carterkit-0.1.0/tests/test_tune.py +54 -0
  74. carterkit-0.1.0/tests/test_validate.py +92 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Carter Beaudoin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.4
2
+ Name: carterkit
3
+ Version: 0.1.0
4
+ Summary: Build and drive CAR-TER layouts from Python — the control docs are the library.
5
+ Author: Carter Beaudoin
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Mariner10/carterkit
8
+ Project-URL: Repository, https://github.com/Mariner10/carterkit
9
+ Keywords: car-ter,carter,iot,dashboard,remote,meshsocket,layout
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Topic :: Software Development :: Libraries
14
+ Classifier: Framework :: AsyncIO
15
+ Classifier: Development Status :: 4 - Beta
16
+ Classifier: Operating System :: OS Independent
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: meshsocket>=0.1
21
+ Requires-Dist: cryptography>=42
22
+ Dynamic: license-file
23
+
24
+ # carterkit
25
+
26
+ [![PyPI](https://img.shields.io/pypi/v/carterkit.svg)](https://pypi.org/project/carterkit/)
27
+ [![Downloads](https://static.pepy.tech/badge/carterkit)](https://pepy.tech/project/carterkit)
28
+ [![Python versions](https://img.shields.io/pypi/pyversions/carterkit.svg)](https://pypi.org/project/carterkit/)
29
+
30
+ Build and drive [CAR-TER](https://carterbeaudoin.net/CAR-TER) layouts from Python.
31
+
32
+ **The control docs are the library.** Every control's schema, fields, and examples
33
+ are parsed at runtime from the ControlDocs markdown bundled inside the package — the
34
+ exact same docs the CAR-TER app renders — so the catalog never drifts from the
35
+ definitions.
36
+
37
+ ```bash
38
+ pip install carterkit
39
+ ```
40
+
41
+ ## Explore the controls (zero config)
42
+
43
+ ```python
44
+ import carterkit
45
+
46
+ carterkit.controls() # {type: schema} for every placeable control
47
+ carterkit.doc("gauge") # full parsed doc: fields, themeFields, examples
48
+ print(carterkit.doc_markdown("gauge")) # the rendered documentation prose
49
+ carterkit.examples("button") # documented example snippets
50
+ ```
51
+
52
+ ## Build a layout
53
+
54
+ ```python
55
+ from carterkit import LayoutBuffer, validate_layout
56
+
57
+ b = LayoutBuffer.blank(name="Dashboard", columns=4, rows=4)
58
+ b.add_control({"type": "gauge", "id": "cpu", "label": "CPU", "min": 0, "max": 100},
59
+ default_span=[2, 2])
60
+ b.add_control({"type": "button", "id": "refresh", "label": "Refresh"})
61
+
62
+ layout = b.layout
63
+ findings = validate_layout(layout) # schema + grid lint against the bundled catalog
64
+ print(carterkit.format_findings(findings))
65
+ ```
66
+
67
+ `infer.build_layout(payload)` generates a wired layout from a sample telemetry dict;
68
+ `codegen.generate_service_stub(layout)` emits a runnable MeshSocket server skeleton;
69
+ `theming.theme_for(...)` and `tune.tune_gauge(...)` round out the authoring tools.
70
+
71
+ ## Drive a device
72
+
73
+ ```python
74
+ import asyncio
75
+ from carterkit import CarterClient
76
+
77
+ async def main():
78
+ c = CarterClient(gateway_url="ws://localhost:18080", token="<mesh token>",
79
+ channel="home", role="device", name="my-hub")
80
+ c.on("toggle", lambda d: {"ok": True, **d})
81
+ await c.connect()
82
+ await c.broadcast("reading", {"temp_c": 21.4})
83
+ await asyncio.sleep(60)
84
+ await c.close()
85
+
86
+ asyncio.run(main())
87
+ ```
88
+
89
+ End-to-end encryption (ChaCha20-Poly1305 + per-session salt) is transparent when you
90
+ pass an `e2ee_key`. Send a push to every device on a Connect+ account with
91
+ `CarterClient.notify(...)` or the stdlib-only `carterkit.notify_http(...)`.
92
+
93
+ ## Built on
94
+
95
+ [`meshsocket`](https://pypi.org/project/meshsocket/) — the WebSocket mesh transport.
96
+
97
+ The ControlDocs are vendored from the CAR-TER app repo; refresh them with
98
+ `scripts/sync-controldocs.sh`.
@@ -0,0 +1,75 @@
1
+ # carterkit
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/carterkit.svg)](https://pypi.org/project/carterkit/)
4
+ [![Downloads](https://static.pepy.tech/badge/carterkit)](https://pepy.tech/project/carterkit)
5
+ [![Python versions](https://img.shields.io/pypi/pyversions/carterkit.svg)](https://pypi.org/project/carterkit/)
6
+
7
+ Build and drive [CAR-TER](https://carterbeaudoin.net/CAR-TER) layouts from Python.
8
+
9
+ **The control docs are the library.** Every control's schema, fields, and examples
10
+ are parsed at runtime from the ControlDocs markdown bundled inside the package — the
11
+ exact same docs the CAR-TER app renders — so the catalog never drifts from the
12
+ definitions.
13
+
14
+ ```bash
15
+ pip install carterkit
16
+ ```
17
+
18
+ ## Explore the controls (zero config)
19
+
20
+ ```python
21
+ import carterkit
22
+
23
+ carterkit.controls() # {type: schema} for every placeable control
24
+ carterkit.doc("gauge") # full parsed doc: fields, themeFields, examples
25
+ print(carterkit.doc_markdown("gauge")) # the rendered documentation prose
26
+ carterkit.examples("button") # documented example snippets
27
+ ```
28
+
29
+ ## Build a layout
30
+
31
+ ```python
32
+ from carterkit import LayoutBuffer, validate_layout
33
+
34
+ b = LayoutBuffer.blank(name="Dashboard", columns=4, rows=4)
35
+ b.add_control({"type": "gauge", "id": "cpu", "label": "CPU", "min": 0, "max": 100},
36
+ default_span=[2, 2])
37
+ b.add_control({"type": "button", "id": "refresh", "label": "Refresh"})
38
+
39
+ layout = b.layout
40
+ findings = validate_layout(layout) # schema + grid lint against the bundled catalog
41
+ print(carterkit.format_findings(findings))
42
+ ```
43
+
44
+ `infer.build_layout(payload)` generates a wired layout from a sample telemetry dict;
45
+ `codegen.generate_service_stub(layout)` emits a runnable MeshSocket server skeleton;
46
+ `theming.theme_for(...)` and `tune.tune_gauge(...)` round out the authoring tools.
47
+
48
+ ## Drive a device
49
+
50
+ ```python
51
+ import asyncio
52
+ from carterkit import CarterClient
53
+
54
+ async def main():
55
+ c = CarterClient(gateway_url="ws://localhost:18080", token="<mesh token>",
56
+ channel="home", role="device", name="my-hub")
57
+ c.on("toggle", lambda d: {"ok": True, **d})
58
+ await c.connect()
59
+ await c.broadcast("reading", {"temp_c": 21.4})
60
+ await asyncio.sleep(60)
61
+ await c.close()
62
+
63
+ asyncio.run(main())
64
+ ```
65
+
66
+ End-to-end encryption (ChaCha20-Poly1305 + per-session salt) is transparent when you
67
+ pass an `e2ee_key`. Send a push to every device on a Connect+ account with
68
+ `CarterClient.notify(...)` or the stdlib-only `carterkit.notify_http(...)`.
69
+
70
+ ## Built on
71
+
72
+ [`meshsocket`](https://pypi.org/project/meshsocket/) — the WebSocket mesh transport.
73
+
74
+ The ControlDocs are vendored from the CAR-TER app repo; refresh them with
75
+ `scripts/sync-controldocs.sh`.
@@ -0,0 +1,69 @@
1
+ """carterkit — build and drive CAR-TER layouts from Python.
2
+
3
+ The control vocabulary *is* the bundled documentation: every control's schema,
4
+ fields, and examples are parsed at runtime from the ControlDocs markdown shipped
5
+ inside this package (``carterkit/controldocs/``) — the same docs the CAR-TER app
6
+ renders. So the docs never drift from the definitions; they are one and the same.
7
+
8
+ Quick map:
9
+ - ``controls()`` / ``doc()`` / ``examples()`` — the docs-as-catalog surface
10
+ - ``LayoutBuffer`` — incrementally build a layout (auto-placement, dedupe)
11
+ - ``validate_layout()`` — schema + grid lint against the bundled catalog
12
+ - ``infer`` / ``codegen`` / ``theming`` / ``tune`` — generate layouts, servers, themes
13
+ - ``CarterClient`` / ``notify_http`` — connect over MeshSocket, push, send alerts
14
+ """
15
+ from importlib.resources import files
16
+ from pathlib import Path
17
+
18
+ from . import catalog, grid, codegen, infer, theming, tune
19
+ from .buffer import LayoutBuffer, BufferError
20
+ from .validate import validate_layout as _validate_layout, format_findings
21
+ from .client import CarterClient, notify_http, CarterNotifyError
22
+
23
+ __version__ = "0.1.0"
24
+
25
+ #: The layout/wire protocol version carterkit emits and understands. The JSON
26
+ #: contract — not this Python API — is the real compatibility boundary across the
27
+ #: app, the relay, and this library; unknown fields are tolerated on read.
28
+ PROTOCOL_VERSION = 1
29
+
30
+
31
+ def controldocs_dir() -> Path:
32
+ """Filesystem path to the bundled ControlDocs markdown (the source of truth)."""
33
+ return Path(files(__package__) / "controldocs")
34
+
35
+
36
+ def controls(types=None, include_theme: bool = False) -> dict:
37
+ """Machine-readable schema for every placeable control, keyed by layout ``type``."""
38
+ return catalog.build_catalog(controldocs_dir(), types=types, include_theme=include_theme)
39
+
40
+
41
+ def doc(control: str):
42
+ """Full parsed doc (fields, themeFields, body, examples) for a control type or node_id."""
43
+ return catalog.resolve_doc(controldocs_dir(), control)
44
+
45
+
46
+ def doc_markdown(control: str):
47
+ """The control's human/AI documentation prose (the rendered markdown body)."""
48
+ d = doc(control)
49
+ return d["body"] if d else None
50
+
51
+
52
+ def examples(control: str):
53
+ """Documented example snippets for a control: ``[{"name", "json"}, ...]``."""
54
+ return catalog.get_examples(controldocs_dir(), control)
55
+
56
+
57
+ def validate_layout(layout: dict, catalog_: dict = None) -> list:
58
+ """Lint a layout (schema + grid). Defaults to the bundled control catalog."""
59
+ return _validate_layout(layout, catalog_ if catalog_ is not None else controls(include_theme=True))
60
+
61
+
62
+ __all__ = [
63
+ "__version__", "PROTOCOL_VERSION",
64
+ "CarterClient", "notify_http", "CarterNotifyError",
65
+ "LayoutBuffer", "BufferError",
66
+ "controls", "doc", "doc_markdown", "examples", "validate_layout",
67
+ "format_findings", "controldocs_dir",
68
+ "catalog", "grid", "codegen", "infer", "theming", "tune",
69
+ ]
@@ -0,0 +1,230 @@
1
+ """Working-layout buffer + incremental patch ops.
2
+
3
+ The headline authoring model: instead of re-emitting an 800-line layout on every
4
+ change, the LLM issues surgical operations against a server-held draft. The buffer
5
+ keeps the full document; each op mutates it and the result is pushed to the device as
6
+ a full layout (which it already knows how to render). Pure (no I/O, no socket) so it
7
+ is fully unit-testable; the async push/save live in server.py.
8
+
9
+ Top-level children (controls + groups) of a tab are addressable by id. Editing inside
10
+ a group is not yet supported (ids inside groups still count for uniqueness).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import copy
16
+ from typing import Optional
17
+
18
+ from . import grid as gridmod
19
+
20
+ DEFAULT_COLUMNS = 4
21
+ DEFAULT_ROWS = 8
22
+
23
+
24
+ class BufferError(Exception):
25
+ """Raised on an invalid buffer operation (rendered to the user as a tool error)."""
26
+
27
+
28
+ class LayoutBuffer:
29
+ def __init__(self, layout: dict):
30
+ self.layout = layout
31
+
32
+ # ─── construction ────────────────────────────────────────────────────────
33
+
34
+ @classmethod
35
+ def blank(cls, name: str = "Untitled", columns: int = DEFAULT_COLUMNS,
36
+ rows: int = DEFAULT_ROWS, accent: str = "#667eea",
37
+ tab_title: str = "Tab 1", tab_icon: str = "house.fill") -> "LayoutBuffer":
38
+ return cls({
39
+ "name": name,
40
+ "version": 1,
41
+ "accentColor": accent,
42
+ "tabs": [{
43
+ "title": tab_title,
44
+ "icon": tab_icon,
45
+ "grid": {"columns": columns, "rows": rows},
46
+ "children": [],
47
+ }],
48
+ })
49
+
50
+ @classmethod
51
+ def from_layout(cls, layout: dict) -> "LayoutBuffer":
52
+ if not isinstance(layout, dict) or "tabs" not in layout:
53
+ raise BufferError("source layout must be an object with a 'tabs' array")
54
+ return cls(copy.deepcopy(layout))
55
+
56
+ # ─── access ──────────────────────────────────────────────────────────────
57
+
58
+ @property
59
+ def tabs(self) -> list:
60
+ return self.layout.setdefault("tabs", [])
61
+
62
+ def _tab(self, i: int) -> dict:
63
+ tabs = self.tabs
64
+ if i < 0 or i >= len(tabs):
65
+ raise BufferError(f"tab index {i} out of range (have {len(tabs)} tab(s))")
66
+ return tabs[i]
67
+
68
+ @staticmethod
69
+ def _grid_dims(tab: dict) -> tuple[int, int]:
70
+ g = tab.get("grid") or {}
71
+ return int(g.get("columns", DEFAULT_COLUMNS)), int(g.get("rows", DEFAULT_ROWS))
72
+
73
+ def all_ids(self) -> set[str]:
74
+ ids: set[str] = set()
75
+
76
+ def walk(children):
77
+ for ch in children or []:
78
+ if isinstance(ch, dict):
79
+ if "id" in ch:
80
+ ids.add(ch["id"])
81
+ if ch.get("type") == "group":
82
+ walk(ch.get("children"))
83
+
84
+ for tab in self.tabs:
85
+ walk(tab.get("children"))
86
+ return ids
87
+
88
+ def unique_id(self, base: str) -> str:
89
+ base = base or "control"
90
+ ids = self.all_ids()
91
+ if base not in ids:
92
+ return base
93
+ i = 2
94
+ while f"{base}-{i}" in ids:
95
+ i += 1
96
+ return f"{base}-{i}"
97
+
98
+ def find(self, control_id: str) -> Optional[tuple[int, int, dict]]:
99
+ """Locate a top-level child by id -> (tab_index, child_index, child)."""
100
+ for ti, tab in enumerate(self.tabs):
101
+ for ci, ch in enumerate(tab.get("children", [])):
102
+ if isinstance(ch, dict) and ch.get("id") == control_id:
103
+ return ti, ci, ch
104
+ return None
105
+
106
+ # ─── mutations ───────────────────────────────────────────────────────────
107
+
108
+ def add_control(self, control: dict, tab_index: int = 0,
109
+ position: Optional[list[int]] = None,
110
+ default_span: Optional[list[int]] = None) -> dict:
111
+ if not isinstance(control, dict) or "type" not in control:
112
+ raise BufferError("control must be an object with a 'type'")
113
+ control = copy.deepcopy(control)
114
+ control["id"] = self.unique_id(control.get("id") or control["type"])
115
+
116
+ tab = self._tab(tab_index)
117
+ children = tab.setdefault("children", [])
118
+ cols, rows = self._grid_dims(tab)
119
+
120
+ if control.get("span") is None and default_span and default_span != [1, 1]:
121
+ control["span"] = default_span
122
+ span = control.get("span") or [1, 1]
123
+
124
+ if position is None:
125
+ slot = gridmod.find_slot(children, cols, rows, span)
126
+ if slot is None:
127
+ raise BufferError(
128
+ f"no free {span} slot in tab {tab_index} ({rows}x{cols} grid). "
129
+ f"Grow the grid (add_tab/edit grid) or pass an explicit position.")
130
+ position = slot
131
+ control["position"] = position
132
+ children.append(control)
133
+ return control
134
+
135
+ def update_control(self, control_id: str, patch: dict) -> dict:
136
+ found = self.find(control_id)
137
+ if not found:
138
+ raise BufferError(f"no control '{control_id}' in the buffer")
139
+ ch = found[2]
140
+ for k, v in patch.items():
141
+ if v is None:
142
+ ch.pop(k, None)
143
+ else:
144
+ ch[k] = v
145
+ return ch
146
+
147
+ def remove_control(self, control_id: str) -> dict:
148
+ found = self.find(control_id)
149
+ if not found:
150
+ raise BufferError(f"no control '{control_id}' in the buffer")
151
+ ti, ci, _ = found
152
+ return self.tabs[ti]["children"].pop(ci)
153
+
154
+ def move_control(self, control_id: str, position: Optional[list[int]] = None,
155
+ span: Optional[list[int]] = None,
156
+ tab_index: Optional[int] = None) -> dict:
157
+ found = self.find(control_id)
158
+ if not found:
159
+ raise BufferError(f"no control '{control_id}' in the buffer")
160
+ ti, ci, ch = found
161
+ if tab_index is not None and tab_index != ti:
162
+ ch = self.tabs[ti]["children"].pop(ci)
163
+ self._tab(tab_index).setdefault("children", []).append(ch)
164
+ if position is not None:
165
+ ch["position"] = position
166
+ if span is not None:
167
+ ch["span"] = span
168
+ return ch
169
+
170
+ def add_tab(self, title: str, icon: str = "square.grid.2x2",
171
+ columns: int = DEFAULT_COLUMNS, rows: int = DEFAULT_ROWS) -> int:
172
+ self.tabs.append({
173
+ "title": title, "icon": icon,
174
+ "grid": {"columns": columns, "rows": rows}, "children": [],
175
+ })
176
+ return len(self.tabs) - 1
177
+
178
+ def add_group(self, group: dict, tab_index: int = 0,
179
+ position: Optional[list[int]] = None) -> dict:
180
+ group = copy.deepcopy(group)
181
+ group["type"] = "group"
182
+ group["id"] = self.unique_id(group.get("id") or "group")
183
+ tab = self._tab(tab_index)
184
+ children = tab.setdefault("children", [])
185
+ cols, rows = self._grid_dims(tab)
186
+ span = group.get("span") or [1, 1]
187
+ if position is None:
188
+ slot = gridmod.find_slot(children, cols, rows, span)
189
+ if slot is None:
190
+ raise BufferError(f"no free {span} slot in tab {tab_index} for the group")
191
+ position = slot
192
+ group["position"] = position
193
+ children.append(group)
194
+ return group
195
+
196
+ # ─── views ───────────────────────────────────────────────────────────────
197
+
198
+ def issues(self) -> list[dict]:
199
+ """Placement issues across all tabs, each tagged with its tab index."""
200
+ out: list[dict] = []
201
+ for ti, tab in enumerate(self.tabs):
202
+ cols, rows = self._grid_dims(tab)
203
+ for issue in gridmod.validate_placement(tab.get("children", []), cols, rows):
204
+ out.append({"tab": ti, **issue})
205
+ return out
206
+
207
+ def summary(self, show_grids: bool = True) -> str:
208
+ name = self.layout.get("name", "Untitled")
209
+ accent = self.layout.get("accentColor")
210
+ head = f"**{name}**" + (f" · accent {accent}" if accent else "")
211
+ lines = [head]
212
+ for ti, tab in enumerate(self.tabs):
213
+ cols, rows = self._grid_dims(tab)
214
+ children = tab.get("children", [])
215
+ lines.append(f"\nTab {ti}: {tab.get('title','?')} ({tab.get('icon','')}) "
216
+ f"— {rows}x{cols}, {len(children)} item(s)")
217
+ for ch in children:
218
+ pos = ch.get("position")
219
+ span = ch.get("span")
220
+ extra = f" span {span}" if span and span != [1, 1] else ""
221
+ lines.append(f" - {ch.get('id','?')} ({ch.get('type','?')}) @ {pos}{extra}")
222
+ if show_grids and children:
223
+ lines.append(gridmod.render_grid(children, cols, rows))
224
+ problems = self.issues()
225
+ if problems:
226
+ lines.append("\n⚠ placement issues:")
227
+ for p in problems:
228
+ ids = p.get("ids") or [p.get("id")]
229
+ lines.append(f" - [tab {p['tab']}] {p['kind']}: {', '.join(ids)} — {p['detail']}")
230
+ return "\n".join(lines)