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.
- carterkit-0.1.0/LICENSE +21 -0
- carterkit-0.1.0/PKG-INFO +98 -0
- carterkit-0.1.0/README.md +75 -0
- carterkit-0.1.0/carterkit/__init__.py +69 -0
- carterkit-0.1.0/carterkit/buffer.py +230 -0
- carterkit-0.1.0/carterkit/catalog.py +285 -0
- carterkit-0.1.0/carterkit/client.py +138 -0
- carterkit-0.1.0/carterkit/codegen.py +188 -0
- carterkit-0.1.0/carterkit/controldocs/accordion.md +99 -0
- carterkit-0.1.0/carterkit/controldocs/actions.md +51 -0
- carterkit-0.1.0/carterkit/controldocs/animations.md +41 -0
- carterkit-0.1.0/carterkit/controldocs/appearance.md +67 -0
- carterkit-0.1.0/carterkit/controldocs/button.md +143 -0
- carterkit-0.1.0/carterkit/controldocs/cardList.md +25 -0
- carterkit-0.1.0/carterkit/controldocs/carousel.md +155 -0
- carterkit-0.1.0/carterkit/controldocs/chat.md +114 -0
- carterkit-0.1.0/carterkit/controldocs/color-picker.md +84 -0
- carterkit-0.1.0/carterkit/controldocs/control-def.md +101 -0
- carterkit-0.1.0/carterkit/controldocs/date-picker.md +148 -0
- carterkit-0.1.0/carterkit/controldocs/divider.md +59 -0
- carterkit-0.1.0/carterkit/controldocs/flip-card.md +99 -0
- carterkit-0.1.0/carterkit/controldocs/gauge.md +198 -0
- carterkit-0.1.0/carterkit/controldocs/graph.md +318 -0
- carterkit-0.1.0/carterkit/controldocs/group-def.md +103 -0
- carterkit-0.1.0/carterkit/controldocs/haptics.md +32 -0
- carterkit-0.1.0/carterkit/controldocs/image.md +110 -0
- carterkit-0.1.0/carterkit/controldocs/index.md +141 -0
- carterkit-0.1.0/carterkit/controldocs/joystick.md +111 -0
- carterkit-0.1.0/carterkit/controldocs/label.md +138 -0
- carterkit-0.1.0/carterkit/controldocs/layout-config.md +129 -0
- carterkit-0.1.0/carterkit/controldocs/list.md +92 -0
- carterkit-0.1.0/carterkit/controldocs/log-console.md +121 -0
- carterkit-0.1.0/carterkit/controldocs/long-press.md +46 -0
- carterkit-0.1.0/carterkit/controldocs/map.md +165 -0
- carterkit-0.1.0/carterkit/controldocs/picker.md +130 -0
- carterkit-0.1.0/carterkit/controldocs/privacy.md +52 -0
- carterkit-0.1.0/carterkit/controldocs/progress-ring.md +139 -0
- carterkit-0.1.0/carterkit/controldocs/pulse.md +82 -0
- carterkit-0.1.0/carterkit/controldocs/qr-code.md +101 -0
- carterkit-0.1.0/carterkit/controldocs/segmented.md +145 -0
- carterkit-0.1.0/carterkit/controldocs/slider.md +235 -0
- carterkit-0.1.0/carterkit/controldocs/spacer.md +36 -0
- carterkit-0.1.0/carterkit/controldocs/sparkline.md +120 -0
- carterkit-0.1.0/carterkit/controldocs/status-light.md +114 -0
- carterkit-0.1.0/carterkit/controldocs/stepper.md +131 -0
- carterkit-0.1.0/carterkit/controldocs/sync.md +50 -0
- carterkit-0.1.0/carterkit/controldocs/terms.md +40 -0
- carterkit-0.1.0/carterkit/controldocs/text-input.md +126 -0
- carterkit-0.1.0/carterkit/controldocs/theming.md +104 -0
- carterkit-0.1.0/carterkit/controldocs/toggle.md +185 -0
- carterkit-0.1.0/carterkit/controldocs/visibility.md +43 -0
- carterkit-0.1.0/carterkit/controldocs/web-view.md +97 -0
- carterkit-0.1.0/carterkit/e2ee.py +47 -0
- carterkit-0.1.0/carterkit/grid.py +114 -0
- carterkit-0.1.0/carterkit/infer.py +137 -0
- carterkit-0.1.0/carterkit/theming.py +157 -0
- carterkit-0.1.0/carterkit/tune.py +129 -0
- carterkit-0.1.0/carterkit/validate.py +132 -0
- carterkit-0.1.0/carterkit.egg-info/PKG-INFO +98 -0
- carterkit-0.1.0/carterkit.egg-info/SOURCES.txt +72 -0
- carterkit-0.1.0/carterkit.egg-info/dependency_links.txt +1 -0
- carterkit-0.1.0/carterkit.egg-info/requires.txt +2 -0
- carterkit-0.1.0/carterkit.egg-info/top_level.txt +1 -0
- carterkit-0.1.0/pyproject.toml +37 -0
- carterkit-0.1.0/setup.cfg +4 -0
- carterkit-0.1.0/tests/test_buffer.py +126 -0
- carterkit-0.1.0/tests/test_catalog.py +105 -0
- carterkit-0.1.0/tests/test_codegen.py +45 -0
- carterkit-0.1.0/tests/test_e2ee.py +35 -0
- carterkit-0.1.0/tests/test_grid.py +65 -0
- carterkit-0.1.0/tests/test_infer.py +73 -0
- carterkit-0.1.0/tests/test_theming.py +67 -0
- carterkit-0.1.0/tests/test_tune.py +54 -0
- carterkit-0.1.0/tests/test_validate.py +92 -0
carterkit-0.1.0/LICENSE
ADDED
|
@@ -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.
|
carterkit-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
[](https://pypi.org/project/carterkit/)
|
|
27
|
+
[](https://pepy.tech/project/carterkit)
|
|
28
|
+
[](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
|
+
[](https://pypi.org/project/carterkit/)
|
|
4
|
+
[](https://pepy.tech/project/carterkit)
|
|
5
|
+
[](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)
|