Skip to content

Nested Toolbar Context

Introduce a nested toolbar context system where multiple toolbar layers are stacked based on the current focus/selection depth, and the UI always renders the most deeply nested context. Each layer provides tupled (PropertySchema, PropertyValue) pairs — the same data contract established by the Schema-Driven Toolbar RFC, but paired rather than split. The toolbar stack lives in ephemeral state and is mutated in reduce() in response to select/focus intents, following the same architectural rules as all other state.

Toolbar intents (property changes from the UI) enter at the session level via SessionIntent::Toolbar(ToolbarIntent) and route down through the hierarchy: Session → Modality → ChildComponent → recurse. This is the same dispatch pattern as Undo/Redo and Export. Routing and reduction are governed by a Toolbar trait with blanket implementations — no ad-hoc impl Whiteboard methods with potential for undefined behavior outside the architecture.

The key distinction: the context toolbar (top bar, floating, or side peek — modality decides) is schema-driven and changes with focus/selection state. The tool palette (pen, eraser, shape, select, undo/redo) is modality-specific, static, and completely separate. This RFC only concerns the context toolbar.

1. the Schema-Driven Toolbar RFC is flat — one component, one toolbar

Section titled “1. the Schema-Driven Toolbar RFC is flat — one component, one toolbar”

the Schema-Driven Toolbar RFC solved the problem of generating toolbars from component schemas. But it assumes a single toolbar surface driven by a single selected component. In practice, toolbar context is layered:

  • A whiteboard slide with nothing selected should show slide-level properties (background color, grid settings).
  • Selecting a shape element should show that element’s component schema (fill, stroke, border radius).
  • Focusing a text element and selecting text within it should show prose formatting options (bold, italic, link, font size).

the Schema-Driven Toolbar RFC’s SchemaToolbar renders one set of controls. It has no concept of “which controls should I show right now?” or “what happens when the user clicks outside the text but keeps the element selected?“

2. Selection-level toolbars are not components

Section titled “2. Selection-level toolbars are not components”

A text selection (bold/italic/link controls) is not a Component in the Typed Element Components RFC sense — it does not have a ComponentProperties struct. The text selection toolbar operates on a buffer range within an element, not on the element’s own property bindings. Yet it needs to present the same paired (schema, value) data contract so that PropertyEditor widgets can render it uniformly.

Similarly, the slide-level toolbar operates on the modality itself (or its metadata), not on any specific element. These are additional toolbar layers that don’t fit the Schema-Driven Toolbar RFC’s component-only model.

The current system has no way to express “I am three layers deep — show the innermost one.” When a user focuses a text element inside a slide inside a lesson plan, there are potentially four toolbar contexts stacked:

  1. Lesson plan — lesson-level properties
  2. Slide — slide-level properties (the whiteboard modality)
  3. Element — the text element’s component schema
  4. Prose selection — bold, italic, link, font size

The UI should show layer 4 when text is selected, layer 3 when the element is selected but no text range is active, layer 2 when nothing is selected on the slide, and layer 1 when no slide is focused. There is no mechanism for this today.

4. Different modalities render toolbars differently

Section titled “4. Different modalities render toolbars differently”

A whiteboard might show the context toolbar as a persistent top bar. A document modality might show it as a floating toolbar near the selection. A worksheet might show it in a side peek panel. The rendering strategy is modality-specific, but the underlying data — a stack of toolbar contexts — should be uniform so that PropertyEditor widgets are reusable everywhere.

A toolbar entry tuples a property schema with its current value. This replaces the previous pattern of separate Vec<PropertySchema> + HashMap<String, PropertyValue> collections:

/// A single property control in a toolbar, pairing its schema with its current value.
pub struct ToolbarEntry {
pub schema: PropertySchema,
pub value: PropertyValue,
}

This pairing is important: the schema and value are always consumed together. Separating them into parallel collections invites mismatches and forces consumers to do key-based lookups. Tupling them makes the data self-contained.

ToolbarContext — the unit of toolbar data

Section titled “ToolbarContext — the unit of toolbar data”

A ToolbarContext is the data a single toolbar layer provides:

/// A single layer of toolbar context.
pub struct ToolbarContext {
/// Human-readable label for this layer (e.g. "Slide", "Shape", "Text Formatting").
pub label: String,
/// Paired schema + value entries for this layer.
pub entries: Vec<ToolbarEntry>,
/// Identifies which layer this context belongs to,
/// so the session can route toolbar intents to the correct reducer.
pub layer: ToolbarLayer,
}
pub enum ToolbarLayer {
/// Modality-level properties (e.g. slide background).
Modality,
/// Component-level properties for a specific element.
Component { element_id: String },
/// Multi-select operations (group, align, distribute) + shared properties.
MultiSelect { element_ids: Vec<String> },
/// Selection-level properties (e.g. prose formatting).
Selection { element_id: String },
}

On the Dart side, each ToolbarContext maps directly to a SchemaToolbar widget — entries drive the controls and ToolbarLayer determines how onChange dispatches back.

The toolbar stack is ephemeral state, stored alongside other non-persisted runtime state. It is mutated in reduce() in response to select/focus intents, following the same architectural rules as everything else.

/// Ordered from outermost (modality) to innermost (selection).
pub type ToolbarStack = Vec<ToolbarContext>;

The toolbar stack is stored on the modality’s ephemeral state:

pub struct WhiteboardEphemeral {
// ... existing ephemeral fields ...
pub toolbar_stack: ToolbarStack,
}

Why ephemeral state, not computed at snapshot time

Section titled “Why ephemeral state, not computed at snapshot time”
  1. Consistency — all state that drives the UI goes through the same reduce->snapshot pipeline. No special-case “compute on read” paths.
  2. Controllability — the toolbar stack is explicitly mutated in response to known intents. You can reason about when it changes by reading the reduce function.
  3. Performance — building the stack once on state change is cheaper than recomputing it on every snapshot read.

Toolbar trait — who can provide toolbar context and reduce toolbar intents

Section titled “Toolbar trait — who can provide toolbar context and reduce toolbar intents”

The Toolbar trait covers both sides: building the toolbar context (what to show) and reducing toolbar intents (what happens when the user changes a value). This is a single trait because the type that knows its schema also knows how to apply changes to itself.

/// Trait for types that can provide toolbar context and handle toolbar property changes.
pub trait Toolbar {
/// Return the toolbar context for this type, if active.
/// Returns `None` if this type has no toolbar to show.
fn toolbar_context(&self) -> Option<ToolbarContext>;
/// Reduce a toolbar property change against this type's state.
fn reduce_toolbar(&mut self, key: &str, value: PropertyValue);
}

No blanket default impl. A type must explicitly implement Toolbar. The one exception: a conditional blanket impl exists for types whose Selectable associated type also implements Toolbar — because then the behavior is obvious (selected instance’s toolbar goes on the stack, property changes route to the selected instance). But Selectable itself doesn’t and shouldn’t abstract over multi-select vs. single-select, so a generic “if selectable, then toolbar” default would be wrong.

Any component that derives ComponentProperties (the Typed Component Properties RFC) gets a free Toolbar implementation. The schema comes from the derive macro; the reduction updates the binding bag:

impl<T: TypedComponent> Toolbar for T {
fn toolbar_context(&self) -> Option<ToolbarContext> {
let schemas = <T::Properties as ComponentProperties>::schemas();
let values = self.current_bindings();
let entries = schemas
.into_iter()
.filter(|s| s.toolbar != ToolbarHint::Hidden)
.map(|schema| {
let value = values
.get(&schema.key)
.cloned()
.or(schema.default_value.clone())
.unwrap_or(PropertyValue::Null);
ToolbarEntry { schema, value }
})
.collect();
Some(ToolbarContext {
label: T::COMPONENT_ID.into(),
entries,
layer: ToolbarLayer::Component {
element_id: self.placement_id().into(),
},
})
}
fn reduce_toolbar(&mut self, key: &str, value: PropertyValue) {
self.update_binding(key, value);
}
}

This means every registered component automatically participates in the toolbar system. No per-component toolbar wiring needed.

Blanket impl for Modality when Selectable::Item: Toolbar

Section titled “Blanket impl for Modality when Selectable::Item: Toolbar”

When a modality’s selectable item type implements Toolbar, routing is well-defined — the selected item’s toolbar context goes on the stack, and toolbar intents route to the selected item:

/// Conditional: if the thing you select implements Toolbar,
/// the single-selection toolbar case is automatic.
impl<M> ToolbarRouting for M
where
M: Modality + Selectable,
<M as Selectable>::Item: Toolbar,
{
fn selected_toolbar_context(&self) -> Option<ToolbarContext> {
let selected = self.selected_item()?;
selected.toolbar_context()
}
fn reduce_selected_toolbar(&mut self, key: &str, value: PropertyValue) {
if let Some(item) = self.selected_item_mut() {
item.reduce_toolbar(key, value);
}
}
}

This does NOT cover multi-select (the modality must implement that itself) or modality-level toolbar (also modality-specific). It only covers the single-selection → component toolbar case.

Session-level dispatch — SessionIntent::Toolbar

Section titled “Session-level dispatch — SessionIntent::Toolbar”

Toolbar intents enter at the session level, just like Undo/Redo and Export. The session routes based on ToolbarLayer:

pub enum SessionIntent<I> {
Modality(I),
Ai(AiIntent),
Export(OutputFormat),
SetTheme(ThemeUpdate),
Undo,
Redo,
/// Toolbar property change — session routes to the correct reducer.
Toolbar(ToolbarIntent),
}
/// A property change from the toolbar UI.
pub struct ToolbarIntent {
/// Which layer the change targets.
pub layer: ToolbarLayer,
/// The property key being changed.
pub key: String,
/// The new value.
pub value: PropertyValue,
}

The session reduces Toolbar intents by routing through the hierarchy based on ToolbarLayer. This follows the same pattern as Undo/Redo — the session knows the structure and delegates:

SessionIntent::Toolbar(intent) => {
match &intent.layer {
ToolbarLayer::Modality => {
// Route to the modality's own toolbar reducer
state.modality.reduce_toolbar(&intent.key, intent.value);
}
ToolbarLayer::Component { element_id } => {
// Route to the specific element's component
state.modality.reduce_element_toolbar(
element_id, &intent.key, intent.value,
);
}
ToolbarLayer::MultiSelect { element_ids } => {
// Route to the modality's multi-select handler
state.modality.reduce_multi_select_toolbar(
element_ids, &intent.key, intent.value,
);
}
ToolbarLayer::Selection { element_id } => {
// Route to the selection-level reducer (e.g. prose formatting)
state.modality.reduce_selection_toolbar(
element_id, &intent.key, intent.value,
);
}
}
// Rebuild the toolbar stack after any property change
state.modality.rebuild_toolbar_stack();
Effect::none()
}

For parent sessions (lesson plan), toolbar routing follows the same pattern as child dispatch and undo. If the toolbar targets a child session’s layer, the parent forwards:

ParentSessionIntent::Base(SessionIntent::Toolbar(intent)) => {
// If the modality has a focused child and the intent targets
// a layer deeper than the parent modality, forward to child.
if let Some(child_id) = state.modality.selected_child_id() {
if intent.layer.depth() > 0 {
// Shift the layer depth and forward to child session
if let Some(slot) = state.modality.children().get(child_id) {
if let Some(arc) = slot.as_hot() {
let mut child = arc.lock();
child.dispatch(Command::Intent(
SessionIntent::Toolbar(intent.shift_depth()),
));
}
}
state.invalidated_children.push(child_id.to_string());
return Effect::none();
}
}
// Otherwise handle at parent level (same as leaf session)
// ...
}

ToolbarReducer trait — the modality-level contract

Section titled “ToolbarReducer trait — the modality-level contract”

Each modality implements this trait. It defines the toolbar-related methods that the session routes to. The blanket impls above provide defaults for the common cases; modalities override where their selection model differs.

/// Modality-level toolbar contract.
/// Session routes ToolbarIntent to these methods based on ToolbarLayer.
pub trait ToolbarReducer {
/// Modality-level toolbar context (always the base layer).
fn modality_toolbar_context(&self) -> Option<ToolbarContext>;
/// Reduce a modality-level property change.
fn reduce_toolbar(&mut self, key: &str, value: PropertyValue);
/// Reduce a component-level property change for a specific element.
fn reduce_element_toolbar(&mut self, element_id: &str, key: &str, value: PropertyValue);
/// Reduce a multi-select property change across elements.
/// Default: no-op (modalities without multi-select don't need this).
fn reduce_multi_select_toolbar(
&mut self, _element_ids: &[String], _key: &str, _value: PropertyValue,
) {}
/// Reduce a selection-level property change (e.g. prose formatting).
/// Default: no-op (modalities without sub-element selection don't need this).
fn reduce_selection_toolbar(
&mut self, _element_id: &str, _key: &str, _value: PropertyValue,
) {}
/// Rebuild the full toolbar stack from current state.
/// Called after any selection/focus change or toolbar property update.
fn rebuild_toolbar_stack(&mut self);
}

The toolbar stack is rebuilt in reduce() whenever selection or focus state changes. This is driven by the modality’s own intent handling — the modality calls rebuild_toolbar_stack() at the end of any intent that changes selection or focus:

// In Whiteboard's reduce():
WhiteboardIntent::Select(ids) => {
state.synced.selected = ids.clone();
self.rebuild_toolbar_stack(); // trait method, not ad-hoc impl
Effect::none()
}
WhiteboardIntent::Focus(id) => {
state.ephemeral.focused = Some(id.clone());
self.rebuild_toolbar_stack();
Effect::none()
}
WhiteboardIntent::Deselect => {
state.synced.selected.clear();
state.ephemeral.focused = None;
self.rebuild_toolbar_stack();
Effect::none()
}

The whiteboard implements ToolbarReducer with its own selection model. Multi-select vs. single-select logic is whiteboard-specific — not abstracted into a generic trait:

impl ToolbarReducer for Whiteboard {
fn modality_toolbar_context(&self) -> Option<ToolbarContext> {
// Slide-level properties: background color, grid settings, etc.
Some(ToolbarContext {
label: "Slide".into(),
entries: vec![/* ... */],
layer: ToolbarLayer::Modality,
})
}
fn reduce_toolbar(&mut self, key: &str, value: PropertyValue) {
// Update slide-level property
}
fn reduce_element_toolbar(&mut self, element_id: &str, key: &str, value: PropertyValue) {
// Delegates to the element's component via the Toolbar blanket impl.
// The component's reduce_toolbar() is called.
if let Some(placement) = self.state_mut().synced.placements.get_mut(element_id) {
placement.update_binding(key, value);
}
}
fn reduce_multi_select_toolbar(
&mut self, element_ids: &[String], key: &str, value: PropertyValue,
) {
// Whiteboard-specific: group/ungroup/align/distribute actions,
// or shared property update across all selected elements.
match key {
"group" => { /* group selected */ }
"align_left" | "align_center" | "align_right" => { /* align */ }
"distribute_h" | "distribute_v" => { /* distribute */ }
_ => {
// Shared property — apply to all selected elements
for id in element_ids {
self.reduce_element_toolbar(id, key, value.clone());
}
}
}
}
fn reduce_selection_toolbar(&mut self, element_id: &str, key: &str, value: PropertyValue) {
// Route to prose formatting
if let Some(prose) = self.state_mut().ephemeral.prose.get_mut(element_id) {
match key {
"bold" => prose.toggle_bold(),
"italic" => prose.toggle_italic(),
"underline" => prose.toggle_underline(),
"link" => { /* set/remove link */ }
_ => {}
}
}
}
fn rebuild_toolbar_stack(&mut self) {
let state = self.state();
let mut stack = Vec::new();
// Layer 1: Modality-level (slide properties) — always present
if let Some(ctx) = self.modality_toolbar_context() {
stack.push(ctx);
}
// Layer 2+: Selection-dependent
let selected = &state.synced.selected;
if selected.len() == 1 {
let id = &selected[0];
// Single selection → use the component's Toolbar impl (blanket)
if let Some(component) = self.get_component(id) {
if let Some(ctx) = component.toolbar_context() {
stack.push(ctx);
}
}
// Layer 3: Focus-level (prose formatting)
if let Some(ref focused_id) = state.ephemeral.focused {
if let Some(ctx) = self.focus_toolbar_context(focused_id) {
stack.push(ctx);
}
}
} else if selected.len() > 1 {
// Multi-selection → whiteboard builds this itself (not abstractable)
stack.push(self.multi_select_toolbar_context(selected));
}
self.state_mut().ephemeral.toolbar_stack = stack;
}
}

The condition for whether to build a multi-select toolbar vs. an element toolbar is whiteboard-specific logic. Other modalities may have entirely different selection models — a worksheet might not have multi-select at all.

The prose selection toolbar is a non-component toolbar layer. When a user selects text inside a focused text element, the toolbar should show formatting controls. This is built by the modality (not by the Toolbar trait) because prose selections aren’t components:

impl Whiteboard {
fn focus_toolbar_context(&self, element_id: &str) -> Option<ToolbarContext> {
let prose = self.state().ephemeral.prose.get(element_id)?;
let selection = self.state().ephemeral.text_selection.as_ref()?;
if selection.is_collapsed() {
return None;
}
let attrs = prose.attributes_at(selection);
let entries = vec![
ToolbarEntry {
schema: PropertySchema::boolean("bold", "Bold"),
value: PropertyValue::Boolean(attrs.bold),
},
ToolbarEntry {
schema: PropertySchema::boolean("italic", "Italic"),
value: PropertyValue::Boolean(attrs.italic),
},
ToolbarEntry {
schema: PropertySchema::boolean("underline", "Underline"),
value: PropertyValue::Boolean(attrs.underline),
},
ToolbarEntry {
schema: PropertySchema::text("link", "Link URL"),
value: attrs.link.map(PropertyValue::String).unwrap_or(PropertyValue::Null),
},
];
Some(ToolbarContext {
label: "Text Formatting".into(),
entries,
layer: ToolbarLayer::Selection {
element_id: element_id.to_string(),
},
})
}
}

This is one of the cases where a concrete impl Whiteboard method is appropriate — prose selection toolbars don’t generalize across modalities, and Selectable doesn’t abstract over sub-element selection.

The toolbar stack is included in the snapshot because Dart needs it to render. Since it lives in ephemeral state, the snapshot just reads it out:

pub struct WhiteboardSnapshot {
pub elements: Vec<WhiteboardElement>,
pub selected: HashSet<ElementId>,
pub focused: Option<ElementId>,
pub toolbar_stack: ToolbarStack, // read from ephemeral
// ...
}
impl Whiteboard {
fn snapshot(&self, ...) -> WhiteboardSnapshot {
WhiteboardSnapshot {
// ...
toolbar_stack: self.state().ephemeral.toolbar_stack.clone(),
}
}
}

Dart dispatches all toolbar changes through SessionIntent::Toolbar. The ToolbarLayer on the context tells Dart which layer the change targets — no switch/case routing on the Dart side:

void onToolbarChange(ToolbarContext ctx, String key, PropertyValue value) {
dispatch(SessionIntent.toolbar(
ToolbarIntent(layer: ctx.layer, key: key, value: value),
));
}

This is simpler than the previous design where Dart had to construct different modality-specific intents per layer. The session handles all routing in Rust.

For a lesson plan, the stack can be four layers deep:

  1. LessonPlan — lesson-level properties
  2. The active slide’s Whiteboard — slide-level properties
  3. The selected element’s component toolbar — element properties
  4. The focused element’s prose toolbar — text formatting

The lesson plan builds layer 1 itself. Layers 2-4 come from the child whiteboard session’s ephemeral toolbar stack. The parent session concatenates them in its rebuild_toolbar_stack:

impl ToolbarReducer for LessonPlan {
fn rebuild_toolbar_stack(&mut self) {
let mut stack = Vec::new();
// Layer 1: Lesson plan level
if let Some(ctx) = self.modality_toolbar_context() {
stack.push(ctx);
}
// Layers 2+: Delegate to the focused child (whiteboard slide)
if let Some(slide_id) = self.state().synced.selected_slide.as_ref() {
if let Some(Slot::Hot(session)) = self.state().synced.children.get(slide_id) {
let child_stack = &session.lock().state().ephemeral.toolbar_stack;
stack.extend(child_stack.iter().cloned());
}
}
self.state_mut().ephemeral.toolbar_stack = stack;
}
// ... other ToolbarReducer methods
}

Toolbar intents targeting layers 2+ are forwarded to the child session by ParentSessionIntent routing (see above).

These are entirely separate concerns:

AspectContext ToolbarTool Palette
ContentSchema-driven, changes with focus/selectionStatic set of tools
Driven byToolbarStack from ephemeral stateModality type (hardcoded)
PositionTop bar / floating / side peek (modality decides)Right side / bottom (fixed)
ExamplesFill color, stroke width, bold, italicPen, eraser, shape tool, select, undo/redo
ScopeThis RFC + the Schema-Driven Toolbar RFCNot in scope

Different modalities render the context toolbar differently, but consume the same ToolbarStack data:

ModalityRenderingNotes
WhiteboardPersistent top barAlways visible, swaps content based on active context
DocumentFloating toolbarAppears near text selection, disappears on blur
WorksheetSide peek panelOpens when a question is selected
Lesson PlanPersistent top barSame as whiteboard (the toolbar stack includes lesson + slide + element layers)

The rendering decision is purely a Dart-side concern. The Rust side provides the ToolbarStack in the snapshot; Dart chooses the widget tree.

  1. Define ToolbarEntry, ToolbarContext, ToolbarLayer, ToolbarStack in modality_core. Pure data types. Verify: cargo check --package modality_core
  2. Define Toolbar trait in modality_core. No default impl. Verify: cargo check --package modality_core
  3. Define ToolbarReducer trait in modality_core. Default no-ops for multi-select and selection layers. Verify: cargo check --package modality_core
  4. Blanket Toolbar impl for TypedComponent — schema from derive, reduce updates bindings. Verify: cargo check --package modality_core
  5. Add ToolbarIntent to SessionIntent — session-level dispatch with layer-based routing. Verify: cargo check --package modality_session
  6. Add ToolbarIntent routing to ParentSessionIntent — forward to child session for deeper layers. Verify: cargo check --package modality_session
  7. Add toolbar_stack: ToolbarStack to WhiteboardEphemeral — ephemeral state, not persisted. Verify: cargo check --package modality_whiteboard
  8. Implement ToolbarReducer for Whiteboardrebuild_toolbar_stack, single/multi-select logic, prose toolbar, modality-level toolbar. Verify: cargo test --package modality_whiteboard
  9. Add toolbar_stack to WhiteboardSnapshot — read from ephemeral. Verify: snapshot tests pass
  10. Expose ToolbarEntry, ToolbarContext, ToolbarLayer, ToolbarIntent via FRB#[frb(non_opaque)] on all types. Run flutter_rust_bridge_codegen generate
  11. Update Dart toolbar consumer — single onToolbarChange dispatches SessionIntent.toolbar(...), read toolbarStack from snapshot, render deepest layer
  12. Implement ToolbarReducer for LessonPlan — concatenate lesson-level context with child whiteboard stack, forward intents to child
  13. Extend to other modalitiesWorksheet, Document (when the Document Modality RFC lands)

Build the toolbar stack during snapshot() rather than storing it in ephemeral state and mutating in reduce().

Rejected because it creates a special-case “compute on read” path that doesn’t follow the standard reduce->state->snapshot pipeline. Storing the stack in ephemeral state means it’s governed by the same rules as everything else — mutated in response to intents, predictable, debuggable.

Ad-hoc impl Whiteboard methods (previous version of this RFC)

Section titled “Ad-hoc impl Whiteboard methods (previous version of this RFC)”

The previous revision had all toolbar logic as private impl Whiteboard methods (build_toolbar_stack, component_toolbar, multi_select_toolbar, focus_toolbar). Dart routed toolbar changes by constructing modality-specific intents per layer.

Rejected because it places logic outside the trait system — methods on a concrete type have no architectural contract, no blanket impls, and no guarantee that other modalities follow the same pattern. The trait-based approach makes routing well-defined and enables blanket impls for common cases (component toolbar from TypedComponent, single-select routing when Selectable::Item: Toolbar).

Modality-level dispatch (no session routing)

Section titled “Modality-level dispatch (no session routing)”

Have Dart construct the correct modality-specific intent per toolbar layer (e.g. WhiteboardIntent.updateProperty, WhiteboardIntent.formatText).

Rejected because it pushes routing logic to Dart, which shouldn’t know about the Rust-side hierarchy. Session-level dispatch keeps routing in Rust where the type system can enforce it. This also matches the established pattern — Undo/Redo, Export, and AI all enter at session level.

A single generic function that takes M: Modality + Selectable + Focusable + Toolbar and automatically builds the stack from trait queries.

Rejected because the selection model is modality-specific. Selectable doesn’t abstract over multi-select vs. single-select — those are different behaviors with different toolbar implications. The whiteboard decides when to show a multi-select toolbar; the worksheet may not have multi-select at all. Each modality implements its own rebuild_toolbar_stack with its own logic.

Blanket default impl on Toolbar for all Selectable types

Section titled “Blanket default impl on Toolbar for all Selectable types”

Provide a blanket Toolbar for anything that implements Selectable.

Rejected because Selectable’s associated type doesn’t necessarily implement Toolbar. A default impl would need to assume that selected items can provide toolbar context, which isn’t always true. A conditional impl is only safe if the selectable’s associated type also implements Toolbar — this is expressed as the ToolbarRouting conditional impl above.

Use Vec<PropertySchema> + HashMap<String, PropertyValue> instead of Vec<ToolbarEntry>.

Rejected because schema and value are always consumed together. Parallel collections invite mismatches (a key present in schemas but missing from values, or vice versa). Tupling them makes each entry self-contained and eliminates key-based lookup overhead on the consumer side.

Flatten all active layers into one combined toolbar — show slide properties and element properties and prose formatting simultaneously.

Rejected because it creates an overwhelming toolbar for deeply nested contexts (lesson plan + slide + element + prose = potentially 20+ controls). The “most-nested wins” model keeps the toolbar focused on the user’s current editing context. The full stack is still available if a modality wants breadcrumbs.

When multiple elements share a property but have different values (e.g. three shapes with different fill colors), the toolbar should show a “mixed” state. The ToolbarEntry value could be PropertyValue::Null as a sentinel, or a new PropertyValue::Mixed variant. TBD during implementation.

The multi-select toolbar introduces action entries for buttons that trigger commands (group, ungroup, align, distribute). This is a new PropertyType variant that renders as a button rather than a value editor. The exact semantics (one-shot vs. toggle) need to be resolved.

Currently the entire stack is rebuilt on any selection/focus change. For modalities with expensive toolbar construction (many elements, deep nesting), incremental updates might be needed. Deferred — unlikely to be a bottleneck in practice.

Should the UI show the full toolbar stack as a breadcrumb trail with the ability to click a level to jump to that toolbar? Useful for deep nesting but adds UI complexity. Deferred to implementation.

The exact set of prose formatting properties depends on the prose model (the Embedded Prose RFC) and what formatting the LoroText-based prose system supports. The mechanism is defined here; the specific entries are an implementation detail.

  • the Typed Component Properties RFC (Typed Component Properties)#[derive(ComponentProperties)] generates schemas() and typed value extraction, which the Toolbar blanket impl uses
  • the Schema-Driven Toolbar RFC (Schema-Driven Toolbar) — this RFC extends the schema-driven approach to multiple stacked layers; SchemaToolbar and PropertyEditor render each ToolbarContext
  • the Selection Trait RFC (Selection Trait)Selectable and Focusable traits inform which toolbar layers are active; conditional ToolbarRouting blanket impl uses Selectable::Item
  • the Embedded Prose RFC (Embedded Prose Reducer) — prose selection state is needed for the focus-level toolbar layer
  • the Typed Element Components RFC (Typed Element Components) — per-element-type components provide the element-level toolbar layer via the Toolbar blanket impl on TypedComponent