From 2d49b695008ab5bcd0989ed0b63d7ef1f7af8d73 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Wed, 30 Sep 2026 14:38:00 +0200 Subject: [PATCH 01/80] dotbot/site_editor: read and patch a site pack's site.toml over JSON AI-assisted: Claude Opus 5.5 --- dotbot/site_editor.py | 167 ++++++++++++++++++++++++++ dotbot/site_toml.py | 198 +++++++++++++++++++++++++++++++ dotbot/tests/test_site_toml.py | 210 +++++++++++++++++++++++++++++++++ pyproject.toml | 4 +- 4 files changed, 578 insertions(+), 1 deletion(-) create mode 100644 dotbot/site_editor.py create mode 100644 dotbot/site_toml.py create mode 100644 dotbot/tests/test_site_toml.py diff --git a/dotbot/site_editor.py b/dotbot/site_editor.py new file mode 100644 index 00000000..b3cb42f2 --- /dev/null +++ b/dotbot/site_editor.py @@ -0,0 +1,167 @@ +# SPDX-FileCopyrightText: 2026-present Inria +# SPDX-License-Identifier: BSD-3-Clause + +"""The site editor's server: one site pack's `site.toml` over JSON, and the +page that draws it. + +It runs on its own, with no controller, broker or robot. Every save is +checked against the revision the page loaded, so an edit made to the file +meanwhile is never overwritten. +""" + +from __future__ import annotations + +import os +import tempfile +from dataclasses import dataclass, field +from pathlib import Path +from typing import Callable, Literal + +import tomlkit +from fastapi import FastAPI, HTTPException +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel, Field + +from dotbot import site_toml +from dotbot.site import PACK_CALIBRATIONS +from dotbot.site_packs import PACK_FILE + +EDITOR_DIR = Path(__file__).parent / "console-web" / "dist" +EDITOR_PAGE = "site-editor.html" + + +class AreaModel(BaseModel): + name: str + x: int + y: int + w: int = Field(gt=0) + h: int = Field(gt=0) + role: Literal["field", "staging", "corner"] | None = None + comment: str | None = None + # The area's name in the file when the page loaded it; None for a new one + was: str | None = None + + +class SiteModel(BaseModel): + anchor: str | None = None + extent_mm: tuple[int, int] | None = None + areas: list[AreaModel] = Field(default_factory=list) + + +class SaveRequest(BaseModel): + revision: str + site: SiteModel + + +@dataclass +class EditorState: + """The pack being edited and where its calibrations may be.""" + + name: str + pack: Path + calibration_dirs: list[Path] = field(default_factory=list) + + @property + def path(self) -> Path: + return self.pack / PACK_FILE + + def calibrations(self) -> list[dict]: + folders = [self.pack / PACK_CALIBRATIONS, *self.calibration_dirs] + return [ + { + "folder": str(folder), + "count": len(list(folder.glob("*.toml"))) if folder.is_dir() else 0, + } + for folder in folders + ] + + +def _write_atomic(path: Path, text: str) -> None: + """Write `text` beside `path` and rename it over, keeping its mode.""" + mode = path.stat().st_mode & 0o777 if path.exists() else 0o644 + fd, tmp = tempfile.mkstemp(prefix=f".{path.name}-", dir=path.parent) + try: + with os.fdopen(fd, "w", encoding="utf-8", newline="") as handle: + handle.write(text) + os.chmod(tmp, mode) + os.replace(tmp, path) + except BaseException: + Path(tmp).unlink(missing_ok=True) + raise + + +def _model_dict(model: SiteModel) -> dict: + data = model.model_dump() + data["extent_mm"] = list(model.extent_mm) if model.extent_mm else None + return data + + +def create_app( + state: EditorState, + page_dir: Path = EDITOR_DIR, + on_done: Callable[[], None] | None = None, +) -> FastAPI: + """The editor's app for `state`; `on_done` runs when the page says Done.""" + app = FastAPI(title="DotBot site editor", docs_url=None, redoc_url=None) + + def read() -> tuple[bytes, str]: + try: + data = state.path.read_bytes() + except OSError as exc: + raise HTTPException(500, f"could not read {state.path}: {exc}") from exc + return data, data.decode("utf-8") + + def body(data: bytes, text: str) -> dict: + return { + "name": state.name, + "path": str(state.path), + "revision": site_toml.revision(data), + "site": site_toml.site_model(tomlkit.parse(text)), + "calibrations": state.calibrations(), + } + + def patched(text: str, model: SiteModel) -> str: + try: + return site_toml.patched_text(text, _model_dict(model)) + except site_toml.SiteTomlError as exc: + raise HTTPException(422, str(exc)) from exc + + @app.get("/api/site") + def get_site(): + return body(*read()) + + @app.post("/api/preview") + def preview(model: SiteModel): + _, text = read() + result = patched(text, model) + return {"text": result, "changed": result != text} + + @app.put("/api/site") + def save(request: SaveRequest): + data, text = read() + if site_toml.revision(data) != request.revision: + raise HTTPException( + 409, f"{state.path} changed on disk since the page loaded it" + ) + result = patched(text, request.site) + written = result != text + if written: + _write_atomic(state.path, result) + data = result.encode("utf-8") + text = result + return {**body(data, text), "written": written} + + @app.post("/api/done") + def done(): + if on_done is not None: + on_done() + return {"ok": True} + + @app.get("/", include_in_schema=False) + def page(): + return FileResponse(page_dir / EDITOR_PAGE) + + if page_dir.is_dir(): + app.mount("/", StaticFiles(directory=page_dir), name="page") + return app diff --git a/dotbot/site_toml.py b/dotbot/site_toml.py new file mode 100644 index 00000000..0bae1d90 --- /dev/null +++ b/dotbot/site_toml.py @@ -0,0 +1,198 @@ +# SPDX-FileCopyrightText: 2026-present Inria +# SPDX-License-Identifier: BSD-3-Clause + +"""A site pack's `site.toml` as a model the site editor edits, and back. + +`site_model` reads the keys the editor draws from a tomlkit document; +`patch` writes a model back by assigning only what differs, so comments, +blank lines, key order and keys the editor does not model survive. +""" + +from __future__ import annotations + +import hashlib +import tomllib +from typing import Any + +import tomlkit +from pydantic import ValidationError +from tomlkit.items import InlineTable, SingleKey, Table +from tomlkit.toml_document import TOMLDocument + +from dotbot.config import SiteSection + +AREA_KEYS = ("x", "y", "w", "h") + + +class SiteTomlError(ValueError): + """A model the file cannot take, or a patched file the schema refuses.""" + + +def revision(data: bytes) -> str: + """The revision a page edits against: the SHA-256 of the file's bytes.""" + return hashlib.sha256(data).hexdigest() + + +def _comment(item: Any) -> str | None: + comment = item.trivia.comment.strip().removeprefix("#").strip() + return comment or None + + +def _areas_table(doc: TOMLDocument) -> Table | None: + areas = doc.get("areas") + return areas if isinstance(areas, Table) else None + + +def site_model(doc: TOMLDocument) -> dict[str, Any]: + """The editor's model of a `site.toml`: anchor, extent, areas in file + order with their declared role and comment, and the connection.""" + areas = [] + for name, item in (_areas_table(doc) or {}).items(): + areas.append( + { + "name": name, + **{key: int(item[key]) for key in AREA_KEYS}, + "role": item.get("role"), + "comment": _comment(item), + } + ) + extent = doc.get("extent_mm") + connection = doc.get("connection") + return { + "anchor": doc.get("anchor"), + "extent_mm": [int(v) for v in extent] if extent is not None else None, + "areas": areas, + "connection": dict(connection) if connection is not None else None, + } + + +def _set(container: Any, key: str, value: Any) -> None: + """Assign `value` unless it is already there; None removes the key.""" + if value is None: + if key in container: + del container[key] + elif container.get(key) != value: + container[key] = value + + +def _set_comment(item: Any, comment: str | None) -> None: + if _comment(item) == (comment or None): + return + if comment: + if not item.trivia.comment_ws: + item.trivia.comment_ws = " " + item.trivia.comment = f"# {comment}" + else: + item.trivia.comment = "" + item.trivia.comment_ws = "" + + +def _new_area(inline: bool, area: dict[str, Any]) -> Table | InlineTable: + item = tomlkit.inline_table() if inline else tomlkit.table() + if area.get("role") is not None: + item["role"] = area["role"] + for key in AREA_KEYS: + item[key] = area[key] + _set_comment(item, area.get("comment")) + return item + + +def _check_names(areas: list[dict[str, Any]]) -> None: + seen = set() + for area in areas: + name = area["name"] + if not isinstance(name, str) or not name.strip(): + raise SiteTomlError("an area needs a name") + if name != name.strip(): + raise SiteTomlError(f"area {name!r}: no spaces around the name") + if "," in name: + raise SiteTomlError( + f"area {name!r}: a comma makes the name read as an x,y,w,h literal" + ) + if name in seen: + raise SiteTomlError(f"two areas are named {name!r}") + seen.add(name) + + +def _patch_areas(doc: TOMLDocument, areas: list[dict[str, Any]]) -> None: + table = _areas_table(doc) + if table is None: + if not areas: + return + table = tomlkit.table(is_super_table=True) + doc["areas"] = table + inline = any(isinstance(item, InlineTable) for item in table.values()) + renames = { + area["was"]: area["name"] + for area in areas + if area.get("was") is not None and area["was"] != area["name"] + } + kept = {area.get("was") or area["name"] for area in areas} + for name in [name for name in table if name not in kept]: + del table[name] + for was, name in renames.items(): + if was not in table: + raise SiteTomlError(f"area {was!r} is not in the file") + # tomlkit has no public rename; this keeps the area's place, comment + # and layout + item = table[was] + table.value._replace(was, name, item) + if isinstance(item, Table) and item.display_name: + old_key = SingleKey(was).as_string() + prefix = item.display_name.removesuffix(old_key) + item.display_name = prefix + SingleKey(name).as_string() + if isinstance(item, Table): + item.name = name + for area in areas: + name = area["name"] + if name not in table: + table[name] = _new_area(inline, area) + continue + item = table[name] + _set(item, "role", area.get("role")) + for key in AREA_KEYS: + _set(item, key, area[key]) + _set_comment(item, area.get("comment")) + if not table: + del doc["areas"] + + +def patch(doc: TOMLDocument, model: dict[str, Any]) -> TOMLDocument: + """Write `model` into `doc` in place, changing only what differs. + + An area's `was` names it as the file does, so a rename is told from a + delete and an add. `connection` is not written. + """ + areas = model.get("areas") or [] + _check_names(areas) + anchor = model.get("anchor") + _set(doc, "anchor", anchor if anchor else None) + extent = model.get("extent_mm") + if extent is None: + _set(doc, "extent_mm", None) + elif "extent_mm" not in doc: + doc["extent_mm"] = [int(v) for v in extent] + elif [int(v) for v in doc["extent_mm"]] != [int(v) for v in extent]: + array = doc["extent_mm"] + array.clear() + array.extend(int(v) for v in extent) + _patch_areas(doc, areas) + return doc + + +def validate(text: str) -> SiteSection: + """`text` held to the schema a pack is read with; SiteTomlError if not.""" + try: + return SiteSection.model_validate(tomllib.loads(text)) + except tomllib.TOMLDecodeError as exc: + raise SiteTomlError(f"not valid TOML: {exc}") from exc + except ValidationError as exc: + raise SiteTomlError(str(exc)) from exc + + +def patched_text(text: str, model: dict[str, Any]) -> str: + """`text` with `model` written into it, validated.""" + doc = tomlkit.parse(text) + result = tomlkit.dumps(patch(doc, model)) + validate(result) + return result diff --git a/dotbot/tests/test_site_toml.py b/dotbot/tests/test_site_toml.py new file mode 100644 index 00000000..f028d986 --- /dev/null +++ b/dotbot/tests/test_site_toml.py @@ -0,0 +1,210 @@ +# SPDX-FileCopyrightText: 2026-present Inria +# SPDX-License-Identifier: BSD-3-Clause + +"""The site editor's round trip: a model patched back into `site.toml` keeps +every comment, blank line and key it did not change.""" + +import pytest +import tomlkit + +from dotbot import site_toml +from dotbot.cli.config_cmd import default_site_toml +from dotbot.site_toml import SiteTomlError, patched_text, site_model + +ARENA = """\ +# Site arena: zero is the anchor, x right, y down, millimetres. + +anchor = "the corner where the top wall meets the door wall" +extent_mm = [2000, 4000] # encloses every area below + +[areas.field] # the 2 x 2 m square at the top +x = 0 +y = 0 +w = 2000 +h = 2000 + +[areas.staging] # the 2 x 2 m square directly below it +x = 0 +y = 2000 +w = 2000 +h = 2000 + +[areas."field+staging"] # the 2 x 4 m rectangle along the door wall +x = 0 +y = 0 +w = 2000 +h = 4000 + +[areas.dev-corner] # a 1 x 1 m development patch +role = "corner" +x = 1000 +y = 0 +w = 1000 +h = 1000 +""" + +HALL = """\ +# Site hall. Every number below is a placeholder. + +anchor = "the north-west corner of the hall's floor marking" # to be measured +extent_mm = [20000, 30000] # to be measured + +[connection] +conn = "mqtts://broker.example:8883" +swarm_id = "A001" + +[areas.field] # a 2 x 2 m square inside the hall +x = 5000 # to be measured +y = 5000 # to be measured +w = 2000 +h = 2000 +""" + +INLINE = """\ +[areas] +field = { x = 0, y = 0, w = 1000, h = 1000 } # the field +staging = { x = 0, y = 1000, w = 1000, h = 500 } +""" + + +def _loaded(text): + model = site_model(tomlkit.parse(text)) + for area in model["areas"]: + area["was"] = area["name"] + return model + + +@pytest.mark.parametrize("text", [ARENA, HALL, INLINE, default_site_toml((2000, 2000))]) +def test_an_unchanged_model_writes_the_file_back_byte_for_byte(text): + assert patched_text(text, _loaded(text)) == text + + +def test_the_model_carries_the_declared_role_and_the_comment(): + model = site_model(tomlkit.parse(ARENA)) + assert model["extent_mm"] == [2000, 4000] + assert [a["name"] for a in model["areas"]] == [ + "field", + "staging", + "field+staging", + "dev-corner", + ] + corner = model["areas"][3] + assert (corner["role"], corner["comment"]) == ( + "corner", + "a 1 x 1 m development patch", + ) + assert model["areas"][0]["role"] is None + + +def test_moving_an_area_changes_its_numbers_and_nothing_else(): + model = _loaded(ARENA) + model["areas"][3]["x"] = 1050 + out = patched_text(ARENA, model) + changed = [(a, b) for a, b in zip(ARENA.splitlines(), out.splitlines()) if a != b] + assert changed == [("x = 1000", "x = 1050")] + + +def test_a_value_keeps_its_own_comment(): + model = _loaded(HALL) + model["areas"][0]["x"] = 5100 + model["anchor"] = "the door" + model["extent_mm"] = [21000, 30000] + out = patched_text(HALL, model) + assert "x = 5100 # to be measured" in out + assert 'anchor = "the door" # to be measured' in out + assert "extent_mm = [21000, 30000] # to be measured" in out + + +def test_the_connection_is_left_as_it_is(): + model = _loaded(HALL) + model["connection"] = {"conn": "mqtt://elsewhere:1883"} + model["areas"][0]["w"] = 2500 + assert '[connection]\nconn = "mqtts://broker.example:8883"' in patched_text( + HALL, model + ) + + +def test_a_new_area_follows_the_file_style(): + model = _loaded(ARENA) + model["areas"].append( + { + "name": "dock", + "x": 0, + "y": 3500, + "w": 500, + "h": 500, + "role": None, + "comment": "chargers", + } + ) + out = patched_text(ARENA, model) + assert out.startswith(ARENA) + assert out.endswith("[areas.dock] # chargers\nx = 0\ny = 3500\nw = 500\nh = 500\n") + + model = _loaded(INLINE) + model["areas"].append({"name": "dock", "x": 0, "y": 0, "w": 1, "h": 1}) + assert "\ndock = {" in patched_text(INLINE, model) + + +def test_a_deleted_area_goes_with_its_comment(): + model = _loaded(ARENA) + del model["areas"][2] + out = patched_text(ARENA, model) + assert "field+staging" not in out and "along the door wall" not in out + assert "[areas.dev-corner] # a 1 x 1 m development patch" in out + + +def test_a_renamed_area_keeps_its_place_and_comment(): + model = _loaded(ARENA) + model["areas"][2]["name"] = "arena" + out = patched_text(ARENA, model) + assert out == ARENA.replace('[areas."field+staging"]', "[areas.arena]") + + model = _loaded(INLINE) + model["areas"][0]["name"] = "square" + model["areas"][0]["role"] = "field" + line = patched_text(INLINE, model).splitlines()[1] + assert line.startswith("square = {") and line.endswith("# the field") + + +def test_the_first_area_of_an_empty_pack(): + out = patched_text( + 'anchor = "a"\n', + { + "anchor": "a", + "extent_mm": [3000, 3000], + "areas": [{"name": "field", "x": 0, "y": 0, "w": 1000, "h": 1000}], + }, + ) + assert site_toml.validate(out).areas["field"].w == 1000 + assert out.startswith('anchor = "a"\nextent_mm = [3000, 3000]\n') + + +def test_the_comment_is_edited_and_removed(): + model = _loaded(ARENA) + model["areas"][0]["comment"] = "moved" + model["areas"][1]["comment"] = None + out = patched_text(ARENA, model) + assert "[areas.field] # moved\n" in out + assert "[areas.staging]\n" in out + + +@pytest.mark.parametrize( + "change, message", + [ + (lambda m: m["areas"][1].update(role="field"), "at most one field"), + (lambda m: m["areas"][1].update(name="field"), "two areas are named"), + (lambda m: m["areas"][1].update(name="a,b"), "comma"), + (lambda m: m["areas"][1].update(name=""), "needs a name"), + ], +) +def test_a_model_the_schema_refuses_is_an_error(change, message): + model = _loaded(ARENA) + change(model) + with pytest.raises(SiteTomlError, match=message): + patched_text(ARENA, model) + + +def test_the_revision_is_the_hash_of_the_bytes(): + assert site_toml.revision(b"a") != site_toml.revision(b"b") + assert len(site_toml.revision(b"")) == 64 diff --git a/pyproject.toml b/pyproject.toml index 39833132..b362bf27 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,7 +62,9 @@ dependencies = [ "pydotbot-utils >= 0.3.0", "swarmit[dashboard] >= 0.11.0", "toml >= 0.10.2", - "tomlkit >= 0.13.0", + # 0.15.0 writes invalid TOML when a key is added to or removed from an + # inline table, which site_toml does to edit a pack + "tomlkit >= 0.13.0, != 0.15.0", # Runs the simulator's firmware control core. Majors ship monthly; the # loader is tested on 49 only, and the bound admits the next few on the # expectation that the small Engine/Module/Instance/Memory API it uses From 66ba7a1ea97f9b31ff0fe0da3a20e0f4f1acf826 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Wed, 30 Sep 2026 14:38:00 +0200 Subject: [PATCH 02/80] dotbot/console-web: add the site editor page AI-assisted: Claude Opus 5.5 --- dotbot/console-web/site-editor.html | 18 + dotbot/console-web/src/siteEditor/Canvas.tsx | 345 ++++++++++++++ .../console-web/src/siteEditor/Inspector.tsx | 272 +++++++++++ .../console-web/src/siteEditor/SiteEditor.tsx | 447 ++++++++++++++++++ dotbot/console-web/src/siteEditor/api.test.ts | 44 ++ dotbot/console-web/src/siteEditor/api.ts | 69 +++ .../console-web/src/siteEditor/edit.test.ts | 134 ++++++ dotbot/console-web/src/siteEditor/edit.ts | 203 ++++++++ dotbot/console-web/src/siteEditor/main.tsx | 11 + .../src/siteEditor/siteEditor.test.tsx | 107 +++++ dotbot/console-web/src/siteEditor/types.ts | 37 ++ dotbot/console-web/vite.config.ts | 17 +- 12 files changed, 1702 insertions(+), 2 deletions(-) create mode 100644 dotbot/console-web/site-editor.html create mode 100644 dotbot/console-web/src/siteEditor/Canvas.tsx create mode 100644 dotbot/console-web/src/siteEditor/Inspector.tsx create mode 100644 dotbot/console-web/src/siteEditor/SiteEditor.tsx create mode 100644 dotbot/console-web/src/siteEditor/api.test.ts create mode 100644 dotbot/console-web/src/siteEditor/api.ts create mode 100644 dotbot/console-web/src/siteEditor/edit.test.ts create mode 100644 dotbot/console-web/src/siteEditor/edit.ts create mode 100644 dotbot/console-web/src/siteEditor/main.tsx create mode 100644 dotbot/console-web/src/siteEditor/siteEditor.test.tsx create mode 100644 dotbot/console-web/src/siteEditor/types.ts diff --git a/dotbot/console-web/site-editor.html b/dotbot/console-web/site-editor.html new file mode 100644 index 00000000..22e934fc --- /dev/null +++ b/dotbot/console-web/site-editor.html @@ -0,0 +1,18 @@ + + + + + + DotBot Site Editor + + + + + +
+ + + diff --git a/dotbot/console-web/src/siteEditor/Canvas.tsx b/dotbot/console-web/src/siteEditor/Canvas.tsx new file mode 100644 index 00000000..4f9856b6 --- /dev/null +++ b/dotbot/console-web/src/siteEditor/Canvas.tsx @@ -0,0 +1,345 @@ +import React, { useEffect, useRef, useState } from "react"; + +import { areaColor } from "../areaColor"; +import { AREA_FALLBACK, siteViewMarginMm, withMargin } from "../frame"; +import { + axisTicks, + canvasPx, + frameMm, + gridStepMm, + gridSubStepMm, + metreLabel, + pxPerMm, + rulerStepMm, + ticksInSite, +} from "../grid"; +import type { Area } from "../types"; +import { FRAME_CAMERA, viewGeom } from "../zoom"; +import { HANDLES, effectiveRole, moveRect, rectFromDrag, resizeRect } from "./edit"; +import type { Handle, Rect } from "./edit"; +import type { EditArea } from "./types"; + +// The site drawn to scale: the extent, the metric grid and its rulers, and +// every area in its role colour. Areas are moved, resized and drawn here; the +// numbers themselves live in the inspector. + +export type Tool = "select" | "area"; + +const HANDLE_PX = 9; +const AREA_TINT = 0.12; + +type Drag = + | { kind: "move"; index: number; start: Rect; from: { x: number; y: number } } + | { kind: "resize"; index: number; start: Rect; handle: Handle } + | { kind: "draw"; from: { x: number; y: number }; to: { x: number; y: number } }; + +export interface CanvasProps { + extent: [number, number] | null; + areas: EditArea[]; + hidden: Set; + selected: number | null; + tool: Tool; + snapMm: number; + onSelect: (index: number | null) => void; + onChange: (index: number, rect: Rect) => void; + onDraw: (rect: Rect) => void; +} + +function handlePoint(r: Rect, h: Handle): { x: number; y: number } { + const x = h.includes("w") ? r.x : h.includes("e") ? r.x + r.w : r.x + r.w / 2; + const y = h.includes("n") ? r.y : h.includes("s") ? r.y + r.h : r.y + r.h / 2; + return { x, y }; +} + +const CURSOR: Record = { + n: "ns-resize", + s: "ns-resize", + e: "ew-resize", + w: "ew-resize", + ne: "nesw-resize", + sw: "nesw-resize", + nw: "nwse-resize", + se: "nwse-resize", +}; + +export function Canvas(props: CanvasProps) { + const wrapRef = useRef(null); + const [size, setSize] = useState({ w: 800, h: 600 }); + const [drag, setDrag] = useState(null); + const [altHeld, setAltHeld] = useState(false); + + useEffect(() => { + const el = wrapRef.current; + if (!el) return; + const measure = () => { + const r = el.getBoundingClientRect(); + if (r.width > 0 && r.height > 0) setSize({ w: r.width, h: r.height }); + }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + const extentArea: Area = props.extent + ? { x: 0, y: 0, w: props.extent[0], h: props.extent[1] } + : AREA_FALLBACK; + const viewport = withMargin(extentArea, siteViewMarginMm(extentArea)); + const geom = viewGeom(size.w, size.h, viewport); + const cam = FRAME_CAMERA; + const px = (axis: "x" | "y", mm: number) => canvasPx(axis, mm, viewport, geom, cam); + const mm = (axis: "x" | "y", p: number) => frameMm(axis, p, viewport, geom, cam); + const scale = pxPerMm("x", viewport, geom, cam); + const rectPx = (r: Rect) => ({ + x: px("x", r.x), + y: px("y", r.y), + width: r.w * scale, + height: r.h * scale, + }); + + const step = gridStepMm(scale); + const sub = gridSubStepMm(scale); + const ruler = rulerStepMm(step, scale); + const extentForTicks = props.extent ? extentArea : null; + const xs = axisTicks("x", viewport, geom, cam, step); + const ys = axisTicks("y", viewport, geom, cam, step); + const subXs = sub ? axisTicks("x", viewport, geom, cam, sub) : []; + const subYs = sub ? axisTicks("y", viewport, geom, cam, sub) : []; + const labelXs = ticksInSite(axisTicks("x", viewport, geom, cam, ruler), "x", extentForTicks); + const labelYs = ticksInSite(axisTicks("y", viewport, geom, cam, ruler), "y", extentForTicks); + + const colourAreas: Area[] = props.areas.map((a) => ({ ...a, role: effectiveRole(a) })); + const snapNow = (e: { altKey: boolean }) => (e.altKey ? 1 : props.snapMm); + + const toFrame = (e: React.PointerEvent) => { + const r = wrapRef.current!.getBoundingClientRect(); + return { x: mm("x", e.clientX - r.left), y: mm("y", e.clientY - r.top) }; + }; + + const begin = (e: React.PointerEvent, next: Drag) => { + e.stopPropagation(); + wrapRef.current?.setPointerCapture?.(e.pointerId); + setDrag(next); + }; + + const onBackgroundDown = (e: React.PointerEvent) => { + if (e.button !== 0) return; + if (props.tool === "area") { + const p = toFrame(e); + begin(e, { kind: "draw", from: p, to: p }); + } else { + props.onSelect(null); + } + }; + + const onAreaDown = (e: React.PointerEvent, index: number) => { + if (e.button !== 0 || props.tool === "area") return; + const a = props.areas[index]; + props.onSelect(index); + begin(e, { kind: "move", index, start: { x: a.x, y: a.y, w: a.w, h: a.h }, from: toFrame(e) }); + }; + + const onHandleDown = (e: React.PointerEvent, index: number, handle: Handle) => { + if (e.button !== 0) return; + const a = props.areas[index]; + begin(e, { kind: "resize", index, start: { x: a.x, y: a.y, w: a.w, h: a.h }, handle }); + }; + + const onMove = (e: React.PointerEvent) => { + setAltHeld(e.altKey); + if (!drag) return; + const p = toFrame(e); + const s = snapNow(e); + if (drag.kind === "move") { + props.onChange(drag.index, moveRect(drag.start, p.x - drag.from.x, p.y - drag.from.y, s)); + } else if (drag.kind === "resize") { + props.onChange(drag.index, resizeRect(drag.start, drag.handle, p, s)); + } else { + setDrag({ ...drag, to: p }); + } + }; + + const onUp = (e: React.PointerEvent) => { + if (drag?.kind === "draw") { + const r = rectFromDrag(drag.from, toFrame(e), snapNow(e)); + if (r) props.onDraw(r); + } + setDrag(null); + }; + + // Largest first, so a small area inside a large one stays on top to grab. + const order = props.areas + .map((a, i) => i) + .filter((i) => !props.hidden.has(props.areas[i].name) || i === props.selected) + .sort((i, j) => props.areas[j].w * props.areas[j].h - props.areas[i].w * props.areas[i].h); + const sel = props.selected !== null ? props.areas[props.selected] : null; + const preview = drag?.kind === "draw" ? rectFromDrag(drag.from, drag.to, props.snapMm) : null; + const extentPx = rectPx(extentArea); + + return ( +
setDrag(null)} + style={{ + position: "relative", + flex: 1, + minWidth: 0, + minHeight: 0, + overflow: "hidden", + background: "var(--canvas)", + cursor: props.tool === "area" ? "crosshair" : "default", + touchAction: "none", + userSelect: "none", + }} + > + + + + {subXs.map((t) => ( + + ))} + {subYs.map((t) => ( + + ))} + + + {xs.map((t) => ( + + ))} + {ys.map((t) => ( + + ))} + + + + {labelXs.map((t) => ( + + {metreLabel(t.mm, ruler)} + + ))} + {labelYs.map((t) => ( + + {metreLabel(t.mm, ruler)} + + ))} + + {order.map((i) => { + const a = props.areas[i]; + const colour = areaColor(colourAreas[i], colourAreas); + const role = effectiveRole(a); + const composite = a.name.includes("+"); + const r = rectPx(a); + return ( + + onAreaDown(e, i)} + > + {a.name} + + {r.width > 40 && r.height > 18 && ( + + {a.name} + + )} + + ); + })} + {sel && props.selected !== null && props.tool === "select" && ( + + + {`${sel.w} x ${sel.h} mm at (${sel.x}, ${sel.y})`} + + {HANDLES.map((h) => { + const p = handlePoint(sel, h); + return ( + onHandleDown(e, props.selected!, h)} + /> + ); + })} + + )} + {preview && ( + + )} + + + + anchor (0, 0) + + + +
+ {altHeld ? "free placement (Alt)" : `snap ${props.snapMm} mm, Alt for free`} +
+
+ ); +} diff --git a/dotbot/console-web/src/siteEditor/Inspector.tsx b/dotbot/console-web/src/siteEditor/Inspector.tsx new file mode 100644 index 00000000..ed9ecb51 --- /dev/null +++ b/dotbot/console-web/src/siteEditor/Inspector.tsx @@ -0,0 +1,272 @@ +import React from "react"; + +import type { AreaRole } from "../types"; +import { ROLES, declaredRole, effectiveRole } from "./edit"; +import type { Issue } from "./edit"; +import type { EditArea, SiteModel } from "./types"; + +// The right pane: every field is a key of site.toml, and what it shows is +// what Save writes. + +const label: React.CSSProperties = { + display: "block", + fontSize: 11, + color: "var(--muted)", + margin: "10px 0 3px", +}; +const input: React.CSSProperties = { + width: "100%", + padding: "5px 7px", + background: "var(--elevated)", + color: "var(--text)", + border: "1px solid var(--hairline)", + borderRadius: 4, + fontFamily: "var(--font-mono)", + fontSize: 12, +}; +const section: React.CSSProperties = { + borderBottom: "1px solid var(--hairline)", + padding: "12px 14px", +}; +const heading: React.CSSProperties = { + fontSize: 11, + fontWeight: 600, + letterSpacing: 0.6, + textTransform: "uppercase", + color: "var(--muted)", +}; + +function NumberField(props: { + name: string; + value: number; + onChange: (v: number) => void; + min?: number; +}) { + return ( + + ); +} + +export interface InspectorProps { + name: string; + path: string; + site: SiteModel; + selected: number | null; + hidden: Set; + issues: Issue[]; + onSite: (site: SiteModel) => void; + onArea: (index: number, area: EditArea) => void; + onSelect: (index: number | null) => void; + onDelete: (index: number) => void; + onToggle: (name: string) => void; + onAdd: () => void; +} + +export function Inspector(props: InspectorProps) { + const { site } = props; + const area = props.selected !== null ? site.areas[props.selected] : null; + const extent = site.extent_mm ?? [0, 0]; + const setExtent = (i: 0 | 1, v: number) => { + const next: [number, number] = [extent[0], extent[1]]; + next[i] = v; + props.onSite({ ...site, extent_mm: next }); + }; + const setArea = (patch: Partial) => { + if (props.selected === null || !area) return; + props.onArea(props.selected, { ...area, ...patch }); + }; + const role = area ? effectiveRole(area) : null; + const nameIsRole = !!area && (ROLES as string[]).includes(area.name); + + return ( +
+
+
Site
+ name (the pack folder) +
{props.name}
+
+ {props.path} +
+