GraphView as a Layout Widget (Feasibility + Implementation Plan)
Summary
Goal: refactor GraphView so it can be composed like a normal widget (i.e., it has a layout node and can be placed in other layouts such as Flex/Stack), while preserving its current rendering (points + edges + labels) and interactions.
Key requirements:
GraphViewmust expose alayoutnode so parent widgets can size/position it.- ForceLayout bounds must be derived from the size assigned by the parent (not hard-coded).
- Node positions must be relative to the widget’s layout node (position + rotation), not global coordinates.
- The “main” graph view should be wrapped in a dialog widget (so the dialog is draggable/resizable like other panels).
- Interaction change:
- Alt + Left Drag moves the dialog/panel (unchanged vs other dialogs).
- Left Drag (no Alt) moves individual graph nodes (points) inside the graph.
This is feasible, but it touches several subsystems at once: coordinate spaces, persistence, and input routing.
Current State (what exists today)
From assets/lua/graph/view/init.fnl and related modules (see doc/graph.md):
GraphViewis an object (not a widget) that owns:ForceLayout(layout)LayeredPointinstances for nodes (viactx.points)- edge rendering (triangle line batches)
- labels (
graph/view/labels) - selection + focus management
- drag targets via
GraphViewMovablesregistering withapp.movables - persistence (
graph/view/persistence)
- ForceLayout bounds are currently set explicitly during graph view construction, not based on any widget size.
- Node positions are treated as world coordinates; the graph has no layout node that would let a parent place/rotate it.
- Dragging points currently uses
app.movables, which is only engaged bystate-basewhen Alt is held. - Dialog/panel movement is also powered by
app.movables(Alt + Left) via HUD/Scene panel wrappers.
Implication: today we can’t reliably embed multiple graph views into independent “rectangles” because there’s no per-instance transform/bounds derived from a parent layout region.
Proposed Design
1) Single GraphView widget + GraphViewDialog wrapper
No split between “core” and “widget shell”.
GraphViewbecomes a normal widget module (still the canonical owner of view-only concerns):- exposes a
layoutnode (Layoutfromlayout.fnl) - owns ForceLayout, points, labels, selection, node dialogs, and persistence
- derives ForceLayout bounds from the size assigned by the parent
- maintains a graph-local coordinate system with origin at bottom-left (like other widgets)
- exposes a
GraphViewDialogis just composition:- wraps
GraphViewinDefaultDialog(so the whole graph can be floated, moved, and resized)
- wraps
This keeps a single “GraphView” concept for callers while enabling layout composition via GraphView.layout.
Naming note:
- Today
GraphView.layoutis the ForceLayout instance. As part of this refactor, reserveGraphView.layoutfor the widget layout node. - Rename the ForceLayout field to
GraphView.force-layout(or similar) to avoid confusion and to match other widget conventions.
2) Define two coordinate spaces and make them explicit
Define:
- Graph-local space: ForceLayout positions live here. Origin is the widget’s bottom-left corner.
- World space:
LayeredPoint.position, edge endpoints, and label placement live here (renderers + intersection operate on world space today).
Mapping should use the widget layout node’s transform:
- Let
Lpos = graph_view.layout.position - Let
Lrot = graph_view.layout.rotation - For a local point
p_local:p_world = Lpos + Lrot:rotate(p_local)
- Inverse mapping used for persistence and dragging:
p_local = (Lrot:inverse):rotate(p_world - Lpos)
Important: keep the mapping centralized (one helper module) to avoid subtly inconsistent transforms across edges/labels/persistence.
Suggested helper:
assets/lua/graph/view/transform.fnllocal->world(layout, vec3)world->local(layout, vec3)
3) Bounds derived from widget size
ForceLayout bounds should be set from the widget’s assigned size (in its local space).
For a widget rectangle that starts at local origin and extends positive:
bounds_min_local = (0, 0, 0)bounds_max_local = (layout.size.x, layout.size.y, 0)
Then:
graph_view.force-layout:set-bounds(bounds_min_local, bounds_max_local)- Leave
auto-center-within-boundsenabled so center is computed as(size.x/2, size.y/2)automatically.
When layout.size changes:
- Update bounds.
- Do not automatically restart the force layout (resizing should not trigger a re-run).
4) Rendering changes (minimal)
Keep the current render pipeline:
- Points: still
LayeredPointviactx.points, stored in world space. - Edges: still triangle lines (
graph-edge-batch/graph/view/edge), using world endpoints. - Labels: still
Textspans placed in world space.
Main change:
- Whenever ForceLayout produces a position (
p_local), write the corresponding world position to the point:point:set-position(p_world)
This implies updating:
graph/view/layout.fnlrefresh logic to apply the transform before callingset-point-position.- Any code path that “sets node position” (dragging, persistence, explicit node opts) must set ForceLayout in local space and then refresh the point world position.
5) Persistence stores shared, graph-local positions
Persistence remains shared (single store) and keyed only by node key, but positions stored on disk are graph-local positions.
- Path stays as-is (
graph/view/persistence.fnlcurrently uses a sharedgraph-view/metadata.json). - Store local positions
[x y z]where origin is the widget’s bottom-left. - It is acceptable for saved positions to “fluctuate” when multiple graph views with overlapping node keys are open simultaneously.
- No migration logic is required.
6) Input / drag behavior changes
Requirement
- Alt + Left drag should move the dialog/panel (existing behavior).
- Left drag (no Alt) should move nodes within the graph.
Constraints in current input pipeline
state-baseonly forwards toapp.movableson Alt + Left.GraphViewMovablesregisters each point withapp.movablestoday.
GraphView-local drag controller (preferred)
Do not use app.movables for point dragging.
Plan:
- Keep dialog/panel dragging as-is: Alt + Left engages
app.movablesand moves the dialog wrapper (existing HUD/Scene behavior). - Implement point dragging inside
GraphViewitself with its own drag state + hit testing.
Efficiency requirement (“only hit-test points if GraphView was hit”):
- First hit-test the
GraphView.layoutbounds (cheap AABB/OBB ray-box intersection viaLayout:intersect). - Only if that hits, do a second-stage hit-test against the graph’s point handles to choose a node to drag.
Routing strategy (minimal global changes, GraphView-owned logic):
- Add a small global router that only knows how to delegate to registered graph views, e.g.:
app.graph_view_router(new module, created once during bootstrap)- API:
register(view),unregister(view),on-mouse-button-down,on-mouse-motion,on-mouse-button-up,drag-active?
- Modify
state-baseto consult this router for left button events when Alt is not held:- On mouse down: router may “arm” a potential drag, but do not consume the event (keep clickables/double-click behavior as-is).
- On mouse motion/up: forward to router when it has an active drag.
Drag start threshold + clicks:
- Implement a drag threshold (same concept as
Movables: don’t start dragging until pointer moved far enough). - Do not add special “click suppression” logic; keep click/double-click behavior as-is (i.e., whatever
Clickablesdoes based on its pointer threshold).
Dragging constraints:
- Dragged positions must be clamped to widget bounds in local space so the user cannot drag points outside:
- clamp local x to
[0, layout.size.x] - clamp local y to
[0, layout.size.y] - z remains 0 (or preserved if needed)
- clamp local x to
- After updating the ForceLayout local position, update the rendered world position via the layout transform.
- Dragging a point must not trigger a force-layout run/restart (
ForceLayout:start); it only updates the dragged node’s position.
Suggested code reuse:
- Extract the ray-plane intersection helpers from
assets/lua/movables.fnlinto a small math module (no dependency onMovables), so GraphView can compute drag world positions without duplicating geometry code.
7) Dialog wrapping
Once GraphView is a widget, define:
GraphViewDialog=DefaultDialogwhose:childisGraphView.- This makes it trivially movable/resizable via existing HUD/Scene panel wrappers.
GraphViewControlView should continue to target a specific graph view instance via :graph-view (already supported), not rely on app.graph-view if multiple instances exist.
Detailed Implementation Plan
Phase 1 — Make GraphView a widget (layout node + transforms)
- Update
assets/lua/graph/view/init.fnlso the returned object includes a reallayout(Layoutfromlayout.fnl) owned by the graph view, and rename the ForceLayout field fromlayouttoforce-layout. - Add
assets/lua/graph/view/transform.fnlwith local↔world helpers based onGraphView.layout.position/rotation. - Change all node positioning flows to treat ForceLayout positions as local:
- When
GraphView.force-layoutupdates a node top_local, writepoint.position = local->world(layout, p_local). - When code asks for a node’s position for persistence or APIs, return local unless explicitly requesting world.
- When
- Ensure
GraphView.dropdrops all owned render objects and disconnects signals (same as today, but now includeslayout:drop).
Exit criteria:
- Existing graph tests pass.
- GraphView can be embedded in a parent layout tree (it has a
layoutnode).
Phase 2 — Bounds derived from size (no auto re-run)
- Remove hard-coded
ForceLayout:set-boundsdefaults from graph view initialization. - In
GraphView.layoutlayouter (or in the widget’s update path):- set
GraphView.force-layoutbounds to[0,0] -> [layout.size.x, layout.size.y]in local space - do not call
ForceLayout:starton resize
- set
- Clamp node drag updates (see Phase 4) to the same bounds so user cannot move points outside.
Exit criteria:
- ForceLayout bounds track parent-assigned size.
- Resizing the widget does not restart the layout.
Phase 3 — GraphViewDialog wrapper
- Add
assets/lua/graph-view-dialog.fnlcomposing:DefaultDialogwith:childset toGraphView.
- Update existing “open graph” entry points (HUD control panel) to create a dialog wrapper instead of a global singleton view.
- Ensure
GraphViewControlViewcan target a specific graph view instance (accept:graph-view), but keepapp.graph-viewwired to the “main” GraphView for minimal disruption.
Exit criteria:
- GraphView can be used both as a widget and as a dialog (canonical UX stays dialog).
- Existing user workflows remain stable (see “Minimal disruption” below).
Phase 4 — GraphView-owned point dragging (no app.movables)
- Implement per-GraphView drag state:
drag = {node, start_pointer, started?, start_local, plane, ...}- drag threshold before “started?”
- Add a small router (global delegator) that only forwards events to graph views:
- register/unregister graph views (likely on build/drop)
- on left mouse down (no Alt): choose topmost GraphView under pointer using
GraphView.layout:intersect - only then perform point hit-test inside that GraphView to select a node
- While dragging:
- compute new world hit point (ray-plane intersection)
- convert to local via
world->local(layout, p_world) - clamp to
[0..size]and write into ForceLayout - update point world position
- do not call
ForceLayout:startas part of dragging
- Do not add special click suppression; keep click/double-click independent.
Exit criteria:
- No-Alt dragging moves points, clamped inside bounds.
- Alt dragging still moves the dialog (existing
app.movablesbehavior). - Point dragging only hit-tests points after GraphView bounds is hit.
Phase 5 — Persistence stores shared local positions
- Update
assets/lua/graph/view/persistence.fnlto store local positions (shared store, keyed by node key). - Update load/save boundaries:
- load: local → world when instantiating points
- save: world → local when capturing positions
- Do not add migration logic; accept that values may fluctuate across multiple views.
Exit criteria:
- Local positions persist and re-load correctly for a single view.
- Multiple concurrent views are allowed to “fight” over saved positions.
Testing Strategy
Minimum:
SKIP_KEYRING_TESTS=1 XDG_DATA_HOME=/tmp/space/tests/xdg-data SPACE_DISABLE_AUDIO=1 SPACE_ASSETS_PATH=$(pwd)/assets make test
Add/extend unit tests:
- Transform roundtrip correctness.
- Bounds update behavior when parent resizes widget.
- Drag behavior with/without Alt (no special click suppression).
- Shared local-space persistence (local saved positions).
Optional but recommended:
- Add an E2E snapshot under
assets/lua/tests/e2e/that places twoGraphViewinstances side-by-side in aFlexrow with different sizes to visually confirm separation.
Minimal Disruption / Compatibility Checklist
The goal is that introducing GraphViewDialog does not “break the app” or change how users interact with graph views beyond the intentional changes (being inside a dialog + point dragging without Alt).
Treat these as “must keep working” features, and add covering tests where practical:
- Graph lifecycle: GraphView still attaches to a graph, listens to add/remove/replace edge/node signals, and cleans up correctly on
drop. - Rendering: edges still emit triangles; points still update positions; labels still update and reposition.
- Selection + focus: clicking points still focuses; selection borders/focus borders remain correct (existing tests should continue to cover this).
- Node views: double click still opens node views; Alt+double-click expansion still works.
- Graph control panel:
GraphViewControlViewcontinues to operate (continuous mode, start/stop, center_force Apply) against the active GraphView instance.- To minimize disruption, keep
app.graph-viewpointing at the “main” GraphView instance created byGraphViewDialogso legacy call sites and defaults keep working. GraphViewControlViewshould still accept an explicit:graph-viewfor future multi-graph layouts.
- To minimize disruption, keep
- Dialog behavior: Alt+left dragging the dialog continues to work via existing HUD/Scene panel movables.
- Resizing: resizing the dialog/widget updates ForceLayout bounds but does not restart force layout; points remain clamped within bounds.
Suggested covering tests to add (if missing):
tests.test-graph-view-dialog: buildsGraphViewDialog, verifies it yields a widget with alayoutnode and an internal GraphView reference, and thatdropreleases resources.- Extend
tests.test-graph-view-control-view: ensure it works when given a graph-view instance coming fromGraphViewDialog(not only a mock). - Extend
tests.test-graph-view: verify that changing the GraphView layout transform offsets/rotates rendered world positions consistently (a small transform test around a known local position).
Risks / Gotchas
- Click vs drag thresholds: without explicit suppression, small drags can still register as clicks (and large drags may not count as clicks), depending on
Clickablesthresholds. - Persistence semantics: switching to local-space in a shared store means layouts can “jump” when multiple views with overlapping keys are open; this is accepted by design.
- Label ownership/clipping: labels are not children of the widget layout; they may render “outside” during transitions. ForceLayout bounds + drag clamping should keep them inside, and explicit clipping isn’t required.
- Rotation pivot: layout rotation rotates around
layout.position(local origin). If “rotate around center” becomes required, the transform helper must incorporate a pivot(size * 0.5). - Multi-instance globals: anything relying on
app.graph-viewmust be audited to avoid singleton assumptions.
Decisions / Constraints
- Local coordinate origin is bottom-left (local bounds
[0..size]). - Resizing updates bounds but does not trigger
ForceLayout:start. - No explicit clipping required; ForceLayout bounds + drag clamping keep points within the widget.
