Skip to content

feat(hytte-ui): declarative widget-tree → GTK reconciler (#195/#35 foundation)#199

Draft
vibechoom wants to merge 1 commit into
mainfrom
feat/widget-tree-reconciler-195
Draft

feat(hytte-ui): declarative widget-tree → GTK reconciler (#195/#35 foundation)#199
vibechoom wants to merge 1 commit into
mainfrom
feat/widget-tree-reconciler-195

Conversation

@vibechoom

Copy link
Copy Markdown
Contributor

What this is

The decision-independent host-side reconciler — the shared foundation for the out-of-process widget-plugin work ("frontend B", #195 / #35). A plugin describes its UI as an in-memory tree of Nodes; the host (Reconciler) diffs each new tree against the previous render and mutates real gtk::Widgets in place via keyed diffing, instead of the naive "remove every child, rebuild" pattern (widgets/workspaces.rs, widgets/tray.rs).

Lives in the new crates/hytte-ui/src/widget_tree.rs, exported from hytte-ui (Node, EventKind, NodeId, Dir, Reconciler).

What's IN

  • Node vocabulary — a closed 7-kind set: Box / Label / Icon / Button / Progress / Revealer / Separator. classes apply verbatim as CSS classes (the existing ts-* / hytte-* token contract).
  • Keyed-diff Reconciler — children with an id are matched across renders (a reorder or prop change reuses the existing widget); id-less children fall back to positional matching. On match it updates in place (text / icon / fraction / reveal / class delta); on a new key it creates; on a vanished key it removes; order follows the new tree.
  • Event hook — a single on_event(NodeId, EventKind) callback (Click, Scroll { dx, dy }). Button click handlers (and scroll controllers on id-bearing Boxes) are bound exactly once at widget creation, so widget reuse can never stack duplicate handlers (no double-fire).
  • Tests
    • The diff algorithm is factored into a pure, GTK-free plan_diff13 hermetic unit tests run under the default cargo test (insert / remove / reorder / keyed-vs-positional / kind-change / mixed / partition invariant).
    • 8 GTK integration tests (real widgets: reuse identity, reorder identity, click-once-after-reuse, kind-change recreation, class delta, progress/revealer/nested updates) are gated behind the system-tests feature and run under xvfb-run.

What's deliberately OUT

Gated on the design stamps for #195not in this PR:

  • transport / socket, message framing
  • encoding / serde / msgpack
  • plugin supervisor / lifecycle
  • capability / effect broker

This is purely an in-memory Node tree → GTK widgets, driven by explicit render(&tree) calls. The transport layer will later drive render and wire on_event to outbound Event frames.

Note on the test harness

GTK is single-threaded, so the GTK tests use #[gtk::test] (one shared GTK main thread). Sparkline's older manual-gtk::init() #[test]s are aligned onto the same idiom in this PR — a plain #[test] lands on a libtest worker thread (tripping "GTK may only be used from the main thread") and can't coexist with #[gtk::test] in the same binary.

Gates (all green, in the Nix devShell)

  • cargo build
  • cargo clippy --workspace --all-targets ✓ (also -p hytte-ui --features system-tests ✓) — zero warnings, pedantic-clean, no unsafe
  • cargo test -p hytte-ui ✓ (20 hermetic)
  • xvfb-run cargo test -p hytte-ui --features system-tests ✓ (31 passed)
  • nix fmt clean

Refs #195, #35.

🤖 Generated with Claude Code

…for #195/#35)

Add the decision-independent host-side reconciler for the out-of-process
widget-plugin work ("frontend B"): a closed 7-node `Node` vocabulary plus a
`Reconciler` that diffs a new tree against the previous render and mutates
real `gtk::Widget`s in place via keyed diffing.

- Keyed diffing: children with an `id` are matched across renders (reorder /
  prop change reuses the existing widget); id-less children fall back to
  positional matching. The diff decision is factored into a pure, GTK-free
  `plan_diff` covered by hermetic unit tests (insert / remove / reorder /
  keyed-vs-positional / kind-change / mixed).
- Event hook: a single `on_event(NodeId, EventKind)` callback. Button click
  handlers (and id-bearing Box scroll controllers) are bound exactly once at
  widget creation, so widget reuse can never stack duplicate handlers.
- GTK integration tests (constructing real widgets) are gated behind the
  `system-tests` feature and use `#[gtk::test]` so they share one GTK main
  thread. Sparkline's older manual-init `#[test]`s are aligned onto the same
  idiom (a plain `#[test]` lands on a libtest worker thread and can't coexist
  with `#[gtk::test]` in the same binary).

Out of scope (gated on the design stamps for #195): transport/socket,
encoding/serde, plugin supervisor, capability/effect broker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vibechoom

Copy link
Copy Markdown
Contributor Author

Adversarial-reviewed the diff — the foundation is sound. ✅ (draft; CI running)

This is the decision-independent host-side reconciler from #195/#35Node tree → gtk::Widgets with keyed diffing + an on_event hook. No socket / encoding / supervisor / serde here; those wait on your design stamps.

What I verified (the parts that bite):

  • Handler-reuse is airtight — the double-fire risk. Click/scroll handlers are connected exactly once in build_node; update paths never reconnect. Reuse is gated by reusable() = kind == kind && id == id, so a Button can never be reused across an id change → its captured target can't go stale. Pinned by a GTK test (button_click_fires_once_even_after_reuse).
  • The pure plan_diff is the hermetic heart — keyed (by id) and keyless (positional) matching work on disjoint prev subsets, so the reuse/remove sets provably partition the previous children (13 headless unit tests + a debug_assert net).
  • Layout (insert_child_after for new, reorder_child_after for reused) produces correct order for any insert/remove/reorder mix; class-diffing only touches classes the reconciler itself added.
  • Gates (agent-run, in the devShell): cargo build + clippy --workspace --all-targets clean (unsafe-free, pedantic), 13 hermetic + 8 GTK (xvfb) tests green, nix fmt clean.

Robust to your 4 open sub-decisions: it keys on an explicit id (so NodeId plugin-vs-host-assigned doesn't matter to it), and the node set is additive (Row/ListBox slot in later with no rework).

One thing worth a design call when you stamp #195 🧵: scroll is currently surfaced on any id-bearing Box (an id doubles as "scroll target"). That overloads id (which is also just a diff key) — you may prefer an explicit scrollable flag or a dedicated node so scrolling isn't implied by having an id. Easy to change; flagging now since it touches the vocab.

Still gated on you before the next chunks (schema/SDK · transport · supervisor · effect-broker): the 4 sub-decisions + your two latest calls I folded in (full subscribed-state snapshot not deltas; supervision as a systemd unit). Left as a draft — your call to merge, @annikahannig. 🧱

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant