Skip to content

AutoGrid Reducer

Port the AutoGrid layout engine from Dart (packages/modality/lib/src/autogrid/) to Rust as a Reducer — the same pattern used for Prose. AutoGrid becomes an embeddable reducer that owns an ordered list of layout nodes, delegates all position computation to Taffy (a CSS Grid engine), and handles drag/resize lifecycle entirely in Rust. Dart becomes a thin snapshot-rendering and gesture-dispatching layer.

AutoGrid is order-based: nodes have no coordinates in state — position is determined by index in a Vec<LayoutNode>. Each node declares how many columns and rows it spans. Taffy’s CSS Grid auto-placement computes pixel positions on demand. This model naturally handles vertical stacks, grids, and wrapping flows via columns + AxisMode configuration.

New crate: crates/modality/autogrid/. New Dart package: packages/autogrid/. FRB API in crates/api/src/api/autogrid_api.rs.

Depends on Runtime Struct for the Runtime<R> + Effect model. Consumed by Layout Abstraction — Layout embeds AutoGrid as its spatial engine. AutoGrid computes positions; Layout manages children, compilation, and Loro persistence.

  1. Dart-only engine — The entire AutoGrid engine (~1500 lines of Dart across engine, types, events, widgets) lives in packages/modality/lib/src/autogrid/. None of this participates in Rust compilation, snapshot emission, or the Reducer pattern. Grid state is managed outside the trait system.

  2. No snapshot-driven rendering — The Dart engine mutates node state in place and notifies via callbacks. This is the opposite of the snapshot model used everywhere else (Whiteboard, Prose). Dart widgets read mutable engine state directly rather than rendering immutable snapshots.

  3. Duplicated state management — The Dart engine has its own save/restore, dirty tracking, batch mode, and layout caching — all concerns that Runtime<R> + Session handle generically for Reducers.

  4. No CRDT sync — Grid node positions aren’t persisted through Loro. The worksheet stores AutoGridNode in synced state but the engine operates on its own copy. Changes don’t flow through the CRDT pipeline, so collaborative editing of grid layouts doesn’t work.

  5. Can’t embed in other modalities — Prose can be embedded anywhere (Whiteboard text elements, standalone documents). AutoGrid can’t — it’s a Dart widget, not a Reducer. Embedding grids in whiteboards or lesson plans requires the same embeddable pattern.

  6. Layout patterns are configuration, not code — Vertical stacks, horizontal rows, grids, and wrapping flows are all the same CSS Grid algorithm with different column counts and axis modes. A configurable AutoGrid reducer unifies them.

Items do NOT have x/y coordinates. An item’s position is its index in an ordered Vec<LayoutNode>. Taffy (CSS Grid auto-placement) converts this order into pixel positions based on column/row count, spans, gaps, padding, and flow direction.

This means:

  • Reorder = move item in the Vec (splice)
  • Add = push/insert into the Vec
  • Remove = remove from the Vec
  • Resize = change col_span/row_span

No collision detection, packing, or coordinate management needed — CSS Grid handles all of it.

pub struct AutoGrid {
rt: Runtime<Self>,
sinks: Vec<Box<dyn Sink<AutoGridSnapshot>>>,
}

Following the Prose pattern: AutoGrid owns Runtime<Self> and snapshot sinks. dispatch() is overridden to emit snapshots to all subscribers after each reduce cycle.

pub struct AutoGridState {
/// Ordered list of layout nodes. Index IS position.
pub nodes: Vec<LayoutNode>,
/// Grid configuration.
pub options: GridOptions,
/// Active drag state (if any).
pub drag: Option<DragState>,
/// Active resize state (if any).
pub resize: Option<ResizeState>,
/// Container width in pixels.
pub container_width: f64,
/// Container height in pixels (Some when Y bounded).
pub container_height: Option<f64>,
}

Container dimensions are stored in state because Taffy needs them for layout computation.

Order-based — no coordinates. Each node declares its span and constraints:

pub struct LayoutNode {
pub id: String,
/// Columns this item spans (default: 1).
pub col_span: i32,
/// Rows this item spans (default: 1).
pub row_span: i32,
/// Span constraints.
pub min_col_span: Option<i32>,
pub max_col_span: Option<i32>,
pub min_row_span: Option<i32>,
pub max_row_span: Option<i32>,
/// Prevents reorder + resize.
pub locked: bool,
/// Prevents resizing only.
pub no_resize: bool,
}
pub struct GridOptions {
/// Number of columns (None = auto from content).
pub columns: Option<i32>,
/// Number of rows (None = auto from content).
pub rows: Option<i32>,
/// X-axis mode: Bounded = 1fr tracks, Unbounded = auto tracks.
pub x_axis: AxisMode,
/// Y-axis mode: Bounded = 1fr rows, Unbounded = auto rows.
pub y_axis: AxisMode,
/// Gap between items in pixels.
pub gap: f64,
/// Container padding in pixels.
pub padding: Padding,
/// Height of each cell in pixels. None = square cells (based on column width).
pub cell_height: Option<f64>,
/// How to align content along the block axis.
pub align_content: ContentAlignment,
/// How to justify content along the inline axis.
pub justify_content: ContentAlignment,
/// Auto-placement direction.
pub auto_flow: AutoFlow,
}

Default: Some(4) columns, no rows, Bounded X, Unbounded Y, 10px gap, Row auto-flow.

Controls whether an axis stretches to fill or grows to fit:

pub enum AxisMode {
/// Items stretch to fill available space (CSS Grid `1fr` tracks).
Bounded,
/// Container grows to fit content (CSS Grid `auto` tracks).
Unbounded,
}

Controls CSS Grid auto-placement direction:

pub enum AutoFlow {
Row, // left-to-right, top-to-bottom
Column, // top-to-bottom, left-to-right
RowDense, // Row with backfill
ColumnDense, // Column with backfill
}

Controls content alignment (maps to CSS align-content / justify-content):

pub enum ContentAlignment {
Start, End, Center, Stretch,
SpaceBetween, SpaceAround, SpaceEvenly,
}

Only visible when the container has free space (bounded axis + cell_height set for fixed-size tracks).

Patterncolumnsrowsauto_flowx_axisy_axis
Classic gridSome(4)NoneRowBoundedUnbounded
Fixed 4x3Some(4)Some(3)RowBoundedBounded
Vertical stackSome(1)NoneRowBoundedUnbounded
Horizontal flowNoneSome(1)ColumnUnboundedBounded
Column layoutSome(4)Some(3)ColumnBoundedUnbounded
Auto bothNoneNoneRowUnboundedUnbounded
pub enum AutoGridIntent {
// --- CRUD ---
Add { node: LayoutNode, index: Option<i32> },
Remove { id: String },
RemoveAll,
// --- Reorder ---
Reorder { id: String, new_index: i32 },
// --- Drag lifecycle (cursor-based reorder) ---
DragStart { id: String },
DragUpdate { id: String, cursor_x: f64, cursor_y: f64 },
DragEnd { id: String },
DragCancel { id: String },
// --- Resize lifecycle (pixel-delta-based) ---
ResizeStart { id: String },
ResizeUpdate { id: String, dx: f64, dy: f64 },
ResizeEnd { id: String },
SetSpans { id: String, col_span: i32, row_span: i32 },
// --- Configuration ---
SetOptions { options: GridOptions },
SetColumns { columns: Option<i32> },
SetRows { rows: Option<i32> },
SetContainerWidth { width: f64 },
SetContainerHeight { height: Option<f64> },
// --- Flags ---
SetLocked { id: String, locked: bool },
}

All drag/resize values are in pixels (container-relative). AutoGrid handles pixel-to-span conversion internally.

pub enum AutoGridFeedback {
Error(AutoGridError),
}
pub enum AutoGridError {
NodeNotFound(String),
NodeAlreadyExists(String),
InvalidOptions(String),
}

All operations are synchronous. No spawn effects needed.

pub struct AutoGridSnapshot {
pub nodes: Vec<SnapshotNode>,
pub options: GridOptions,
pub container_height: f64, // computed by Taffy
pub container_width: f64,
pub cell_width: f64, // derived from options + container
pub cell_height: f64, // derived from options + container
pub row_count: i32, // rows actually used
pub dragging_id: Option<String>,
}
pub struct SnapshotNode {
pub id: String,
pub x: f64, pub y: f64, // pixel position (from Taffy)
pub w: f64, pub h: f64, // pixel size (from Taffy)
pub index: i32, // position in ordered list
pub col_span: i32, pub row_span: i32,
pub locked: bool, pub no_resize: bool,
}

Snapshots contain pixel positions computed by Taffy. Dart receives absolute coordinates — no conversion needed.

impl Reducer for AutoGrid {
type State = AutoGridState;
type Intent = AutoGridIntent;
type Feedback = AutoGridFeedback;
type Error = AutoGridError;
type Services = AutoGridServices;
fn runtime(&self) -> &Runtime<Self> { &self.rt }
fn runtime_mut(&mut self) -> &mut Runtime<Self> { &mut self.rt }
fn reduce(cmd, state) -> AutoGridFx {
match cmd {
Command::Intent(intent) => {
if let Err(e) = reduce_intent(intent, state) {
return Effect::feedback(AutoGridFeedback::Error(e));
}
Effect::none()
}
Command::Feedback(_) => Effect::none(),
}
}
fn dispatch(&mut self, cmd) {
let effect = Self::reduce(cmd, self.state_mut());
self.execute(effect, 0);
self.emit_snapshot(); // emit to all sinks after every dispatch
}
}

TaffyBridge wraps a TaffyTree and maps AutoGrid state to CSS Grid:

pub struct TaffyBridge {
pub tree: TaffyTree<()>,
pub root: NodeId,
pub node_ids: Vec<NodeId>,
}

Container -> CSS Grid mapping:

  • columns: Some(N) -> N explicit column tracks
  • AxisMode::Bounded -> 1fr tracks (stretch to fill) or length(px) when cell_height is set
  • AxisMode::Unbounded -> auto tracks or length(px) (grow to fit)
  • gap, padding -> CSS Grid gap and padding
  • auto_flow -> GridAutoFlow::Row / Column / RowDense / ColumnDense
  • align_content, justify_content -> CSS Grid alignment properties

Node -> CSS Grid child mapping:

  • col_span -> grid-column: span(N)
  • row_span -> grid-row: span(N)
  • No explicit coordinates — Taffy auto-places based on Vec order

Snapshot flow:

  1. sync() — rebuilds Taffy tree children from Vec<LayoutNode> in order
  2. compute() — runs Taffy layout, returns (id, x, y, w, h) in pixels
  3. root_size() — returns computed container dimensions (height grows for unbounded Y)

Drag lifecycle — smart reorder with hysteresis

Section titled “Drag lifecycle — smart reorder with hysteresis”

Drag reorders nodes in the Vec. Reorder changes Taffy auto-placement order, which changes all downstream positions.

DragStart: Computes current pixel position via Taffy. Initializes DragState with original_index, start_x/y, and last_swap_cursor (item center). grab_offset starts as None.

DragUpdate (the core algorithm):

  1. Grab offset — on first update, compute grab_offset = cursor - item_origin from Taffy layout. All subsequent swap logic uses item_origin = cursor - grab_offset (so reorder is based on where the item would be, not the raw cursor position).
  2. Hysteresis — cursor must move >= cell_width * 0.35 from last_swap_cursor before any swap is attempted. Prevents oscillation between adjacent positions.
  3. find_target_index() — groups items into “rows” (items with similar Y within 50% item height tolerance), sorts rows by Y, finds which row the cursor is in, then finds column position by comparing cursor X against item midpoints.
  4. find_accepted_target() — tries the primary target + adjacent indices + extremes as fallback candidates.
  5. Clone-simulate validation (would_place_near_cursor()) — for each candidate: clone the Vec, simulate the reorder, run Taffy on the tentative layout, accept only if the dragged item lands within +/- 0.5 * cell_size of the cursor. This rejects “useless” swaps where the item would bounce to an unexpected position.
  6. If accepted: nodes.remove(drag_idx) then nodes.insert(target_idx, node), update last_swap_cursor.

DragEnd: Clears drag state. New order is committed.

DragCancel: Removes node from current position, inserts at original_index (restores pre-drag order).

Resize lifecycle — pixel delta to span conversion

Section titled “Resize lifecycle — pixel delta to span conversion”

ResizeStart: Stores original pixel dimensions and spans via Taffy computation. Checks locked and no_resize flags.

ResizeUpdate: Converts pixel deltas to span changes:

new_pixel_w = original_pixel_w + dx
new_col_span = round((new_pixel_w + gap) / (cell_width + gap))
// Clamped to [1, columns] and node's min/max constraints

Same for row_span with dy.

ResizeEnd: Clears resize state.

SetSpans: Direct span assignment with constraint clamping.

AutoGrid’s primary consumer is Layout (the Layout Abstraction RFC). Each Layout instance embeds one AutoGrid:

pub struct LayoutState {
pub children: loro::LoroList, // synced — Loro handles
pub config: loro::LoroMap, // synced — Loro handles
pub grid: AutoGrid, // ephemeral — spatial engine
}

Layout forwards spatial intents to AutoGrid and persists order + spans (not coordinates) to Loro after mutations. AutoGrid itself has no knowledge of Loro.

AutoGrid can also be used standalone via the FRB session API for pure layout or as an independent widget.

Sub-grids are separate AutoGrid instances managed by the parent modality — not recursive state in a single engine. This matches the Prose pattern: each text element gets its own Prose instance, each sub-grid gets its own AutoGrid instance.

Session-based API in crates/api/src/api/autogrid_api.rs:

// Node constructors (sync)
pub fn layout_node_new(id: String) -> LayoutNode;
pub fn layout_node_with_spans(id: String, col_span: i32, row_span: i32) -> LayoutNode;
pub fn layout_node_full(id, col_span, row_span, min/max constraints, locked, no_resize) -> LayoutNode;
// Session management
pub fn open_autogrid_session(
session_id: Option<String>,
options: GridOptions,
container_width: f64,
sink: StreamSink<AutoGridSnapshot>,
) -> String; // returns session ID, emits initial snapshot
pub fn dispatch_autogrid(session_id: String, intent: AutoGridIntent);
pub fn close_autogrid_session(session_id: String);

Global AUTOGRID_SESSIONS registry holds Arc<Mutex<AutoGrid>> per standalone session. Embedded sessions (in Layout) bypass this registry.

crates/modality/autogrid/
├── Cargo.toml # deps: modality_core, taffy 0.9, thiserror, serde
├── src/
│ ├── lib.rs // pub mod, re-exports
│ ├── autogrid.rs // AutoGrid struct, Reducer impl, dispatch, subscribe, snapshot
│ ├── commands.rs // AutoGridIntent, AutoGridFeedback, AutoGridError
│ ├── options.rs // GridOptions, AxisMode, AutoFlow, ContentAlignment, Padding
│ ├── state.rs // AutoGridState, LayoutNode, DragState, ResizeState
│ ├── reduce.rs // reduce_intent(), find_target_index(), would_place_near_cursor()
│ ├── snapshot.rs // AutoGridSnapshot, SnapshotNode, build_snapshot()
│ ├── taffy_bridge.rs // TaffyBridge, container_style(), child_style(), cell dimensions
│ └── frb.rs // re-exports for FRB codegen scanning
packages/autogrid/
├── lib/
│ ├── autogrid.dart // barrel exports
│ └── src/
│ ├── core/autogrid_session_controller.dart
│ └── widgets/
│ ├── autogrid_widget.dart // AutoGridWidget + _PositionedItem
│ └── grid_background.dart
└── example/ // standalone demo app

AutoGridSessionControllerChangeNotifier wrapping the FRB session API:

class AutoGridSessionController extends ChangeNotifier {
AutoGridSnapshot? get snapshot;
void open({String? sessionId, required GridOptions options, required double containerWidth});
Future<void> close();
void dispatch(AutoGridIntent intent);
void addNode({required String id, int colSpan = 1, int rowSpan = 1, int? index});
void removeNode(String id);
}

AutoGridWidget — snapshot-driven Stack with gesture handling:

class AutoGridWidget extends StatelessWidget {
final AutoGridSnapshot snapshot;
final List<AutoGridItem> items;
final bool interactive; // enables drag/resize
final Duration animationDuration; // default 120ms
// Drag callbacks: onDragStart, onDragUpdate, onDragEnd, onDragCancel
// Resize callbacks: onResizeStart, onResizeUpdate, onResizeEnd
}

Items positioned via AnimatedPositioned with snapshot pixel coordinates (80ms during drag, 120ms on drop). Drag shows a floating ghost copy with shadow. Resize grip at bottom-right corner (20x20).

GridBackground — draws faint rounded-corner cell outlines behind the grid.

Coordinate-based layout with custom strategies

Section titled “Coordinate-based layout with custom strategies”

The original RFC design specified GridNode { x, y, w, h } in grid units with four LayoutStrategy variants (Pack, Flow, EqualSplit, Fixed) and custom algorithms for collision detection, packing, swapping, and redistribution. Rejected in implementation because:

  • Taffy’s CSS Grid auto-placement handles all common layout patterns with zero custom algorithms
  • Order-based layout is simpler: reorder the Vec, Taffy recomputes positions. No collision resolution, packing, or gap filling needed
  • CSS Grid’s 1fr / auto tracks, span(), and auto-flow subsume the strategy enum with more flexibility
  • Custom collision/packing algorithms would be ~1000 lines of complex spatial logic that Taffy handles correctly out of the box
  • The clone-simulate drag validation is more robust than coordinate-based swap heuristics

Keep the layout engine in Dart and only sync grid positions via Rust. Rejected because:

  • Dual state management (Dart engine + Rust state) is the exact problem Prose migration solved
  • No CRDT sync for grid positions
  • Can’t embed in other Rust-side modalities

A set of pure arrange(bounds, children) -> positions functions. Rejected because:

  • Drag lifecycle needs persistent state (original index, grab offset, hysteresis cursor)
  • The Reducer pattern gives standard dispatch, snapshot emission, and service access

Q: Does AutoGrid state sync through Loro? A: AutoGrid owns its node list directly (like Prose owns cursor/selection). The parent modality is responsible for persisting order and spans to Loro after dispatch — AutoGrid doesn’t touch Loro.

Q: Are coordinates stored in state? A: No. Nodes have no x/y coordinates. Position = index in a Vec<LayoutNode>. Taffy computes pixel positions on demand for each snapshot. Container dimensions are stored because Taffy needs them.

Q: How do snapshots work? A: Snapshots contain pixel positions computed by Taffy. Dart receives absolute coordinates — no conversion needed. Cell dimensions are included for grid background rendering and resize delta interpretation.

Q: How does drag reorder work? A: Drag uses cursor pixel coordinates (container-relative). On each DragUpdate, AutoGrid: (1) applies grab-offset normalization, (2) checks hysteresis threshold, (3) finds target index by row/column grouping, (4) clone-simulates the proposed reorder to verify the item lands near the cursor, (5) commits by reordering the Vec. Typically 5-15 dispatches per drag.

Q: How does resize work? A: Resize receives pixel deltas from Dart. AutoGrid converts to span changes: new_span = round((original_px + delta + gap) / (cell + gap)), clamped to [1, columns] and node constraints.

Q: What about interaction flags? A: locked prevents both reorder and resize. no_resize prevents resize only. Both checked in the reducer — DragStart/ResizeStart return errors for protected nodes.

Q: How does AutoGrid know its container bounds? A: container_width is set at construction and updated via SetContainerWidth. container_height is optional — None for unbounded Y (Taffy grows to fit).

Q: What is the undo granularity for drag/resize? A: DragStart opens a Loro transaction (undo group) at the Layout level, DragEnd closes it. All reorder mutations between them are one atomic undo step. DragCancel restores original_index — never commits. Session’s undo grouping (the Session Undo/Redo RFC) handles the boundaries.

Q: Why Taffy instead of custom algorithms? A: CSS Grid is a well-specified, battle-tested layout algorithm. Taffy implements it correctly and handles edge cases (spanning items, auto-placement, content alignment, dense packing) that would require significant effort to reimplement. The order-based model naturally prevents overlaps.

Q: What about animation? A: Purely Dart. AnimatedPositioned interpolates between snapshot positions. 80ms during drag (snappy), 120ms on drop (settle). No animation state in the Reducer.

Q: How do sub-grids work? A: Each sub-grid is a separate AutoGrid instance, managed by the parent modality. Same pattern as Prose editors in Whiteboard. No recursive state in a single engine.

None — all design questions resolved.