2026 04 19_layouts_as_pluggable_mods_plan - mark-ik/graphshell GitHub Wiki
Status: First-pass scope landed (graph-canvas registry). Host integration and third-party registration API still pending. Scope: Treat every graph-canvas layout โ built-in or third-party โ as an entry in a common mod registry, so users get a flat "layouts I can pick from" surface combining Graphshell's bundled defaults with third-party additions. Distinct from the WASM-guest-specific lane (which covers the sandboxing + ABI for external layouts) because this plan also applies to native Rust layouts shipped by mods inside the Graphshell process.
Parent: 2026-04-19_step5_spatial_pattern_layouts_plan.md ยง1.1 enumerates the built-in set that needs to flow through this registry.
Related:
- 2026-04-03_wasm_layout_runtime_plan.md โ WASM guest ABI for sandboxed external layouts.
- 2026-04-03_layout_variant_follow_on_plan.md โ the built-in variant portfolio.
- 2026-04-03_layout_backend_state_ownership_plan.md โ the shared state carrier the registry hands out.
Per the project's configurability / modularity principle (captured in
agent-memory as feedback_configurability_over_opinionated_defaults.md),
every major extension surface in Graphshell is modeled as a registry of
interchangeable providers. Layouts are one such surface.
The bundled layouts that currently live at graph_canvas::layout::* are
not special except in that they ship in the Graphshell binary. A
third-party mod should be able to register a Layout<N> impl on the
same footing and have it appear in the user's layout picker next to FR
and Phyllotaxis.
The WASM runtime plan handles sandboxed layouts authored in WASM guests โ a separate security/ABI concern. This plan covers the higher-level registration + discovery model that both native and WASM layouts share. A WASM-hosted layout registers through this registry; the registry just happens to back it with a guest-ABI adapter.
-
graph_canvas::layoutexports eleven concreteLayout<N>impls plus configs. - Graphshell's
app::graph_layouthas aLayoutAlgorithmtrait with four concrete impls (ForceDirectedLayout,ForceDirectedBarnesHutLayout,GridLayout,TreeLayout) registered in aLayoutRegistry. - There's no unified user-facing "layout picker" that shows every registered layout. The two trait systems coexist and don't see each other.
The immediate structural gap: LayoutAlgorithm is a one-shot
mutate-the-graph interface, Layout<N> is an iterative
return-deltas interface. They serve different lifecycles (instant-apply
vs continuous tick). Both are legitimate; the registry should surface
both under one user-facing catalog without forcing either into the other
shape.
The registry recognizes two layout categories:
-
Analytic / static layouts: implement
LayoutAlgorithm(one-shot apply, mutate graph positions directly). Examples: Grid, Tree, Radial, Phyllotaxis (as static snap), Penrose, L-system, SemanticEmbedding. -
Dynamic / iterative layouts: implement
Layout<N>(per-tick return deltas). Examples: FR, BarnesHut, SemanticEdgeWeight, rapier scene physics.
The registry stores both under one catalog, keyed by LayoutId, with a
tag indicating category. The user picks from a flat list; the runtime
dispatches to the correct lifecycle based on category.
Some layouts straddle the line โ e.g., Phyllotaxis can run as one-shot (damping=1.0) or as animate-in (damping<1.0). Those are registered under both categories with shared config.
Every registered layout declares:
pub struct LayoutCapability {
pub id: LayoutId, // "graph_layout:force_directed"
pub display_name: String, // "Force Directed"
pub category: LayoutCategory, // Analytic | Dynamic | Both
pub is_deterministic: bool,
pub is_topology_sensitive: bool,
pub config_schema: ConfigSchema, // for settings UI
pub supports_3d: bool,
pub recommended_max_node_count: Option<usize>,
pub provenance: LayoutProvenance, // Builtin | NativeMod | WasmMod
pub capability_tags: HashSet<String>,// "spatial-memory", "semantic", etc.
}This metadata drives:
- Layout picker UI (grouping by tags / category).
- Recommendation logic (match node-count scale to capability).
- Fallback selection (if requested layout is unavailable).
- Diagnostics (requested vs resolved layout IDs).
pub trait LayoutProvider: Send + Sync {
fn capability(&self) -> LayoutCapability;
fn create_analytic(&self) -> Option<Box<dyn LayoutAlgorithm>>;
fn create_dynamic(&self) -> Option<Box<dyn DynLayout>>;
}
// Where DynLayout is an object-safe shim over Layout<N> for the
// common node key type. Details in ยง3.5.
pub struct LayoutRegistry {
providers: HashMap<LayoutId, Arc<dyn LayoutProvider>>,
}
impl LayoutRegistry {
pub fn register(&mut self, provider: Arc<dyn LayoutProvider>) -> Result<(), RegisterError>;
pub fn unregister(&mut self, id: &LayoutId) -> bool;
pub fn resolve(&self, id: &LayoutId) -> Option<Arc<dyn LayoutProvider>>;
pub fn capabilities(&self) -> impl Iterator<Item = &LayoutCapability>;
pub fn filter_by(&self, tag: &str) -> impl Iterator<Item = &LayoutCapability>;
}Provenance:
- Built-in providers register in
LayoutRegistry::default()at process start. - Native-mod providers register via
inventory::submit!or an explicit mod-load call. - WASM-guest providers register via the WASM mod runtime, which wraps
the guest in a
LayoutProvideradapter.
For a layout to be admitted:
- Stable
LayoutId(URN-like:graph_layout:<family>:<variant>). - Deterministic input ordering (same input โ same output, for analytic layouts).
- Documented fallback: what happens when the layout can't apply (too few nodes, missing metadata, capability mismatch).
- Config schema declared (even if all-optional); enables settings UI.
-
LayoutCapability::recommended_max_node_countset honestly; hosts enforce or warn at this threshold.
Providers that don't meet admission rules are rejected at register
time with a structured RegisterError.
Layout<N> has an associated type (State), which blocks naive
dyn Layout<N>. Two options:
-
Object-safe shim: introduce a
DynLayouttrait whereStateis erased toBox<dyn Any>. Providers box their concrete state type internally and downcast on access. Runtime cost: oneBox+ one downcast per step. -
Sum type: enumerate all registered layouts in a single
ActiveLayout<N>variant. Fast, no allocation, but doesn't support third-party registration dynamically (the sum is fixed at compile time).
The registry's value is runtime extensibility, so DynLayout is the
right choice. ActiveLayout<N> can still exist as a convenience for
the known-built-in set (used by hosts that don't care about third-party
mods).
The picker shows all registered layouts grouped by category and provenance:
- Force / Physics (dynamic): FR, Barnes-Hut, Semantic Edge Weight, Rapier Scene...
- Analytic (static): Grid, Radial, Phyllotaxis, Timeline, Kanban, Tree...
- Semantic (either, tagged): Semantic Embedding, Semantic Edge Weight, Domain Clustering (as primary layout)...
- Experimental (provenance-filtered): Penrose, L-system variants, third-party mods...
Each entry shows the config surface inline (at least the top-level knobs) so the user can see what they're picking, not just a name.
-
app::graph_layout::LayoutRegistrycurrently holds four layouts hardcoded inDefault. That registry evolves to delegate to the unifiedLayoutRegistrydescribed here, not to replace it. -
PhysicsProfileRegistryhandles FR-specific tuning presets. Layouts that consume physics profiles declare that in theirLayoutCapability; the host wires profiles in at activation time. -
LayoutModeinregistries::atomic::lens(theFree / Grid / Treetrichotomy used by lens configs) is a higher-level intent that maps to one or more registry entries. Lens still selects intent; registry resolves to concrete provider.
- Introduce
LayoutRegistry+LayoutProvidertrait in a new modulegraphshell::registries::atomic::layout_registry(or equivalent โ needs the existing registry layer's owner to pick the location). - Adapt the eight current built-in
Layout<N>impls (FR, BarnesHut, Radial, Phyllotaxis, Grid, extras, Rapier) asLayoutProviderinstances. - Surface the registry in the existing Lens config so users can pick layouts by ID.
-
Do not yet expose a "register a third-party layout" public API โ
admission rules + stable
LayoutIdURN scheme need bedding in first. Third-party registration lands in the second pass after the built-in set has proven the shape.
-
Where does the registry physically live?
graphshell-core(portable),graph-canvas(alongside layouts), orgraphshellproper (alongside other registries)? Leaninggraph-canvasso the registry ships with its layouts; the host layer wraps it for user-facing UI. - Runtime layout swapping: should the registry support hot-swapping (unregister + re-register under the same ID while a view is using it)? Probably not for built-ins; useful for WASM guest reloads during mod development.
-
Versioning:
LayoutIdplus a version tag (e.g.,graph_layout:force_directed@2)? Saves persisted configs from breaking when a layout's config schema evolves. -
Config migration: when a layout's
ConfigSchemachanges, how do persisted user configs migrate? Needs a layer similar toPhysicsProfile's#[serde(default)]schema-rev pattern.
- WASM guest ABI โ handled by the WASM layout runtime plan.
- Specific layout algorithm implementations โ each layout has its own plan lane; this registry is the surface they plug into.
- Cross-host portability of third-party layouts โ native mods are host-specific by default (compile-time); WASM layouts bring portability. Not forced here.
-
Plan created alongside the Step-5 design pass. Captures the lane Mark identified when reviewing individual layouts: "every/all of these layouts [should be] pluggable mod[s], with a set of included defaults." Not scheduled for execution โ the built-in set is still stabilizing, and the registry pattern should bed in before third-party API lands.
-
First-pass landed in
graph-canvaslater the same day (crates/graph-canvas/src/layout/registry.rs). Scope delivered:-
LayoutIdURN type alias,LayoutCategory(Force / Projection / Positional / Extras),LayoutProvenance(Builtin / NativeMod / WasmMod),LayoutCapabilitymetadata struct. - Object-safe
DynLayout<N>shim with a blanket impl for everyL: Layout<N> + SendwhoseState: Any + Default + Send. State is erased toBox<dyn Any + Send>and downcast instep_dyn. OneArc<dyn LayoutProvider<N>>per registered layout; one downcast per step. -
LayoutProvider<N>trait + zero-sizedBuiltinProvider<L, N>helper parameterized by a capability-builder function pointer, so each built-in registers in one line. -
LayoutRegistry<N>withempty()/register()/unregister()/resolve()/capabilities()/filter_by_tag()/filter_by_category()/filter_by_provenance()/len()/is_empty(). -
RegisterError::{InvalidId, DuplicateId}. -
Defaultimpl auto-registers sixteen built-ins: ForceDirected, BarnesHut, SemanticEdgeWeight, Grid, Radial, Phyllotaxis, Timeline, Kanban, Penrose, LSystem, SemanticEmbedding, DegreeRepulsion, DomainClustering, SemanticClustering, HubPull, FrameAffinity. A seventeenth (RapierLayout) is registered when thesimulatefeature is active. - Nine unit tests cover default-registry composition, category
filtering, tag filtering, provenance filtering, empty-id and
duplicate-id rejection, unregister removal, and an end-to-end
resolve-create-step round trip on the
graph_layout:gridprovider. - Two small non-registry changes this pass required:
-
Radial<N>switched from#[derive(Default)]to a manualDefaultimpl, soNis not forced to implementDefaultwhen constructed through the registry. (RadialConfig<N>andDomainClustering<N>already had manualDefaultimpls.)
-
-
-
Deferred to follow-on passes (not yet landed):
-
ConfigSchemaonLayoutCapability. The landed capability struct omits it; config editing still happens against concrete types until a settings-UI surface wants introspection. - The
LayoutAlgorithm/ analytic-one-shot category described in ยง3.1. The landed registry coversLayout<N>(iterative + delta) providers only. Static layouts like Grid are expressed asLayout<N>impls that emit a full delta in a single step, which covers the user-facing use cases at registry granularity. Theapp::graph_layout::LayoutAlgorithmregistry in the host still exists and is untouched. - Host-level integration. Lens config still references host-level
layout IDs directly; wiring the
graph-canvasregistry through to the user-facing picker is a separate pass. - Third-party registration API + admission-rule enforcement. The
register()path works for any caller today but the URN scheme, capability-tag vocabulary, and version tagging described in ยง6 are not yet frozen; third-party usage should wait for that. - Versioning (
@Nsuffix onLayoutId) and config migration. Open questions ยง6 items 3 and 4 stand.
-
-
Receipts:
cargo check -p graph-canvas --libclean;cargo test -p graph-canvas --lib registry::9 passed / 0 failed;cargo test -p graph-canvas --features simulate --lib221 passed / 0 failed;cargo check --workspaceclean (only pre-existing warnings in other crates).