2026 04 20_navigation_policy_plan - mark-ik/graphshell GitHub Wiki
Status: Archived 2026-04-20 โ landed end-to-end including
the settings UI and zombie-prefs removal follow-on.
Scope: Lift every host-side hardcoded navigation constant (zoom
bounds, fit padding, pan-inertia damping, scroll rate, drag threshold,
lasso-gating modifier) into a first-class user-tunable
NavigationPolicy with per-view overrides and per-graph defaults, so
Graphshell's canvas feel is configurable rather than opinionated. The
policy lives in graph-canvas so the future iced host reads the same
values as the current egui host without a parallel constants table.
Related:
- 2026-04-19_graph_canvas_overlays_and_camera_relands_plan.md โ where the hardcoded constants landed that this plan is lifting.
- ../graph/2026-04-19_step5_spatial_pattern_layouts_plan.md โ precedent for retroactive configurability (Batches AโD exposed every discretionary layout choice as a knob).
- ../../../memory/feedback_configurability_over_opinionated_defaults.md โ the user-pinned directive driving this pass.
- 2026-04-14_iced_host_migration_execution_plan.md โ iced bring-up that benefits directly.
The earlier overlays+camera re-lands plan landed navigation-defaults
(wheel=pan, Ctrl+wheel=zoom, middle-click=pan, pan inertia) and Fit
commands, but left the tuning knobs as hardcoded constants in
render/canvas_bridge.rs:
-
FIT_ZOOM_MIN = 0.1,FIT_ZOOM_MAX = 10.0โ duplicated betweenapply_fit_camera_commandandapply_zoom. -
FIT_FALLBACK_ZOOM = 1.0,FIT_PADDING_RATIO = 1.08. -
DEFAULT_PAN_DAMPING_PER_SECOND = 0.003(imported from graph-canvas but used as a constant; no user tuning). -
scroll_pan_pixels_per_unit = 50.0,scroll_zoom_factor = 0.1,drag_threshold_px = 6.0โ defaults baked intoInteractionConfig::default()with no host-level override path. - Lasso modifier hardcoded to
Shiftin the engine.
Per the configurability directive (the layouts lesson), every discretionary choice should be a user knob. This plan is the retroactive sweep for the navigation surface.
User direction (2026-04-20): "per view navigation policy with per
graph policy defaults, as long as iced benefits." โ all policy types
live in graph-canvas (portable), per-view override + per-graph
default sit on app-side durable state, and the resolver is a
GraphBrowserApp method that both hosts call.
Lives at crates/graph-canvas/src/navigation.rs. Flat serde struct of
primitives plus one LassoModifier enum. Every field has an exposed
constant for the baseline value, so callers that want to compare to
"the default" don't pattern-match against magic numbers.
Fields:
-
zoom_min: f32,zoom_max: f32โ clamp applied by bothapply_zoomandfit_to_bounds. Deduplicated compared to the pre-policy code, which carried two separate clamps. -
fit_padding_ratio: f32โ ratio โฅ 1.0 around fit bounds. -
fit_fallback_zoom: f32โ zoom used when the fit bounds collapse to a point. -
pan_damping_per_second: f32โ inertia decay. -
scroll_pan_pixels_per_unit: f32,scroll_zoom_factor: f32โ scroll tuning. -
drag_threshold_px: f32โ click-vs-drag disambiguation. -
pan_inertia_enabled,lasso_enabled,node_drag_enabledโ feature toggles. -
lasso_modifier: LassoModifierโ enum{ Shift, Ctrl, Alt, None }.Noneis the Figma/Sketch convention: primary-drag always lassoes.
NavigationPolicy::to_interaction_config() projects the input-relevant
subset into an InteractionConfig, so the engine keeps a narrow
config surface but stays in sync with any policy change.
-
GraphViewState.navigation_policy_override: Option<NavigationPolicy>(new).#[serde(default)]for backwards-compatible deserialize of older view snapshots; serde Deserialize helper, Clone impl, Debug impl,new_with_idconstructor all carry the field. -
DomainState.navigation_policy_default: NavigationPolicy(new). Non-optional โ everyDomainStatehas a baseline. BothGraphBrowserApp::new_from_dirandnew_for_testinginitialize it toNavigationPolicy::default().
Single entry point on GraphBrowserApp:
pub fn resolve_navigation_policy(&self, view_id: GraphViewId) -> NavigationPolicyPrecedence: view override โ graph default โ NavigationPolicy::default()
baseline (only if the view is missing entirely; otherwise the graph
default backs every view). Plus two setters โ
set_graph_view_navigation_policy_override(...) and
set_navigation_policy_default(...) โ so the settings surface (a
future plan) writes without reaching into private fields.
render/canvas_bridge.rs::run_graph_canvas_frame resolves the policy
once per frame, before the engine is refreshed. From there the policy
threads into:
- The engine's
InteractionConfigvianavigation_policy.to_interaction_config(), refreshed every frame so user tuning takes effect immediately without engine rebuild. -
camera.tick_inertia(1.0 / 60.0, navigation_policy.pan_damping_per_second). -
apply_zoom(..., &navigation_policy)โ the clamp now lives inNavigationPolicy::clamp_zoomand is shared with fit. -
apply_fit_camera_command(..., &navigation_policy)โ zoom bounds, padding, fallback zoom all flow from the policy.
The hardcoded FIT_* and DEFAULT_PAN_DAMPING_PER_SECOND constants
in canvas_bridge.rs are removed; the section header was kept with a
pointer comment back to this plan.
InteractionConfig gained a lasso_modifier: LassoModifier field
(defaulting to Shift) and a helper
press_should_lasso(modifiers: Modifiers) -> bool that the engine
uses instead of the old inline modifiers.shift && lasso_enabled
check. This keeps modifier-routing logic in one place and makes it
obvious how None (always-lasso) works.
Iced will pick up the exact same NavigationPolicy via the
host-neutral resolver. No parallel constants table, no per-host
divergence in zoom bounds or scroll-pan rate. When the future iced
compositor calls run_graph_canvas_frame (or its iced equivalent),
the same resolve_navigation_policy(view_id) call returns the
resolved policy and threads into the iced-side InteractionEngine
and painter identically.
Intentionally out of scope for this pass:
- Settings UI / preferences page to tune the policy interactively. The durable state and accessors are in place; the presentation layer is a separate plan (probably the graph-canvas input/ accessibility follow-on).
-
Migration of per-view persisted policy. There is no persisted
policy to migrate yet; new views serialize with
Noneoverride, old snapshots deserialize with the default via#[serde(default)]. - Per-edge / per-node overrides. Navigation feel is per-view, not per-element.
- Non-navigation policies (physics profile, layout policy, etc.) already have their own per-view/per-graph surfaces and are unchanged.
-
cargo test -p graph-canvas --lib navigation:: engine::โ 25 pass (4 new NavigationPolicy tests, 3 new engine tests covering Ctrl, None, and Shift-when-policy-is-Ctrl variants). -
cargo test -p graph-canvas --features simulate --libโ 255/255 pass (was 248 before this plan; +7 navigation tests). -
cargo test -p graphshell --libโ 2152/2152 pass (was 2149 before this plan; +3 canvas_bridge resolver tests:resolve_navigation_policy_falls_back_to_graph_default,resolve_navigation_policy_prefers_view_override_over_graph_default,run_graph_canvas_frame_honors_per_view_zoom_clamp). -
cargo check -p graphshell --libclean.
- Plan landed end-to-end in one session following the /loop cadence
that shipped ยง4 host wiring.
NavigationPolicyin graph-canvas, per-view + per-graph storage, resolver onGraphBrowserApp,canvas_bridgeswitched from hardcoded constants to resolved policy, engine modifier-routing consolidated. All receipts above green. - Follow-on plan referenced in the overlays+camera re-lands doc is settled: ยง2.1 was parent; this plan closes the configurability gap that followed the initial Miro-style landing.
While scoping the settings UI for NavigationPolicy I found a parallel
config system that needed reconciling: workspace.chrome_ui.camera_pan_inertia_enabled
and camera_pan_inertia_damping existed as workspace-global fields,
persisted to disk and displayed in the Physics settings page, but
never consumed by the actual inertia tick โ canvas_bridge's
tick_inertia call pulled from NavigationPolicy::pan_damping_per_second
directly and the old fields were orphaned.
Picked option C from the three reconciliation options (rip out the zombies; no shim needed because they added no unique value):
-
Deleted fields from app/workspace_state.rs:
camera_pan_inertia_enabled: bool,camera_pan_inertia_damping: f32. -
Deleted accessors from app/settings_persistence.rs:
camera_pan_inertia_enabled,set_camera_pan_inertia_enabled,camera_pan_inertia_damping,set_camera_pan_inertia_damping, and thesave_camera_pan_inertia_*helpers. -
Deleted persistence keys:
SETTINGS_CAMERA_PAN_INERTIA_ENABLED_NAMEandSETTINGS_CAMERA_PAN_INERTIA_DAMPING_NAMEfromgraph_app.rs, plus theis_reserved_workspace_layout_nameentries and the load- path blocks insettings_persistence.rs. Legacy workspace JSON carrying the old keys is silently ignored on load. -
Deleted defaults:
DEFAULT_CAMERA_PAN_INERTIA_ENABLEDandDEFAULT_CAMERA_PAN_INERTIA_DAMPINGconstants ingraph_app.rs, plus the twoGraphBrowserAppconstructor sites that initialized the zombie fields. -
Deleted test
test_camera_pan_inertia_settings_persist_across_restartingraph_app_tests.rsโ it tested persistence of the zombie fields only; pan-inertia persistence now lives onNavigationPolicyinsideGraphViewStateandDomainState.
New settings UI in render/panels.rs:
render_navigation_policy_settings_in_ui replaces the old zombie
section inside render_camera_controls_settings_in_ui. Exposes every
NavigationPolicy knob the portable struct holds:
- Zoom range (min / max sliders, clamped so min โค max).
- Fit padding ratio slider (1.0ร..1.50ร).
- Scroll pan rate slider (5..200 px/unit).
- Drag threshold slider (1..24 px).
- Pan inertia checkbox + logarithmic damping slider (0.0001..0.05 per second), hidden when inertia is off.
- Lasso modifier radio: Shift / Ctrl / Alt / None (with a tooltip on None noting the Figma convention flip).
- Two reset buttons: "Reset view override" (drops the per-view override; inherits graph default) and "Reset per-graph default" (returns the graph default to the portable baseline).
The UI resolves the policy once at the top, lets the user edit a
local mutable copy, then writes through set_graph_view_navigation_policy_override
when a view is focused or set_navigation_policy_default when not โ
mirroring how other per-view-vs-per-graph surfaces work elsewhere
(physics profile, layout policy).
Receipts:
-
cargo check -p graphshell --libclean. -
cargo test -p graphshell --libโ 2154 pass (was 2155; -1 from the deleted zombie persistence test, no regressions elsewhere). -
cargo test -p graph-canvas --features simulate --libโ 259 pass (unchanged; the portable layer didn't change).
The settings UI for NodeStyle (color pickers for primary / secondary / search-hit states plus the default_radius slider) is a separate pass โ that one needs an egui color-picker integration and its own settings section. Tracked as a follow-on.