Skip to content

Selection Trait

Two traits — Selectable and Focusable — on Modality, each with an associated type that varies per modality. Selection is a set (multi-select), focus is singular. Session wraps both with intent variants following the same pattern as undo/export.

Consumed by:

  • Clipboard (the Session Clipboard RFC) — copy/cut operates on the current selection
  • AI — Agent impl decides whether/how to include selection in context
  • Snapshots — selection/focus included in modality snapshots for Dart rendering
  • Collaborative Presence (the Collaborative Presence RFC) — selection/focus synced to peers via Loro EphemeralStore
trait Selectable: Modality {
type Selection: Clone + Default + Send + Sync + 'static;
fn selection(&self) -> &Self::Selection;
fn set_selection(&mut self, sel: Self::Selection);
fn clear_selection(&mut self);
}

Selection is Default so clearing is always possible. Each modality defines what “selected” means:

ModalitySelection typeExample
WhiteboardHashSet<ElementId>3 shapes selected
WorksheetHashSet<QuestionId>2 questions selected
LessonPlanHashSet<SlideId>1 slide selected
DocumentTextSelection (ranges/blocks)Paragraph range
trait Focusable: Modality {
type FocusTarget: Clone + PartialEq + Send + Sync + 'static;
fn focused(&self) -> Option<&Self::FocusTarget>;
fn set_focus(&mut self, target: Self::FocusTarget);
fn blur(&mut self);
}

Focus is singular — at most one thing is focused at a time. Not every modality needs focus. Examples:

ModalityFocusTargetExample
WhiteboardElementIdOne text editor active
LessonPlanSlideIdOne slide being edited
Worksheet— (not impl)No focus concept

Separate traits because not every modality needs both. Focusable can be implemented independently of Selectable.

Focus implies select, but not vice versa. Select = operating on an element (move, resize, delete). Focus = operating inside an element (e.g. editing prose in a text box). You can select a text box and drag it around without focusing it. Focusing it means you’re inside the prose editor. Each modality’s set_focus impl should ensure the target is also added to the selection set.

Selection and focus live in M::Ephemeral — not persisted, not synced. The traits provide uniform read/write access; storage is an implementation detail.

#[derive(Default)]
pub struct WhiteboardEphemeral {
pub selected: HashSet<ElementId>,
pub focused: Option<ElementId>,
// ... other ephemeral state
}
impl Selectable for Whiteboard {
type Selection = HashSet<ElementId>;
fn selection(&self) -> &Self::Selection {
&self.state().ephemeral.selected
}
fn set_selection(&mut self, sel: Self::Selection) {
self.state_mut().ephemeral.selected = sel;
}
fn clear_selection(&mut self) {
self.state_mut().ephemeral.selected.clear();
}
}
impl Focusable for Whiteboard {
type FocusTarget = ElementId;
fn focused(&self) -> Option<&Self::FocusTarget> {
self.state().ephemeral.focused.as_ref()
}
fn set_focus(&mut self, target: Self::FocusTarget) {
self.state_mut().ephemeral.focused = Some(target);
}
fn blur(&mut self) {
self.state_mut().ephemeral.focused = None;
}
}

Session provides abstracted intent variants that delegate to the trait methods, following the undo/export pattern.

SessionIntent becomes generic over M: Modality instead of a separate intent type I. All associated types (M::Intent, M::Selection, M::FocusTarget) are derived from the single generic:

enum SessionIntent<M: Modality + Selectable + Focusable> {
Modality(M::Intent),
Ai(AiIntent),
Export(ExportFormat),
Select(M::Selection),
ClearSelection,
Focus(M::FocusTarget),
Blur,
}

No per-modality type aliases needed — SessionIntent<Whiteboard> gives you WhiteboardIntent, HashSet<ElementId>, and ElementId automatically.

impl<M> ReduceIntent<M> for SessionIntent<M>
where
M: Modality + Selectable + Focusable,
{
fn reduce(self, state: &mut SessionState<M>) -> Effect<...> {
match self {
SessionIntent::Modality(i) => { /* delegate to M */ }
SessionIntent::Select(sel) => {
state.modality.set_selection(sel);
Effect::none()
}
SessionIntent::ClearSelection => {
state.modality.clear_selection();
Effect::none()
}
SessionIntent::Focus(target) => {
state.modality.set_focus(target);
Effect::none()
}
SessionIntent::Blur => {
state.modality.blur();
Effect::none()
}
// ... Ai, Export
}
}
}

Session knows when selection changes, so it can trigger recompile/snapshot/callbacks in the dispatch lifecycle — same as any other intent.

Not every modality implements both traits. When a modality only implements Selectable, SessionIntent can be bounded on M: Selectable alone — the Focus/Blur variants would live in a separate FocusableSessionIntent<M> or be gated behind a where clause. Exact conditional ergonomics TBD at implementation time, but the principle holds: one generic M, all types derived from it.

Decision: Agent impl controls it. No new trait or generic machinery. Each modality’s Agent implementation decides whether and how to include selection/focus in the AI context.

impl Agent for WhiteboardAgentContext {
fn preamble(&self) -> String {
let mut prompt = "You are editing a whiteboard...".to_string();
// Whiteboard decides: tell AI what's selected
let selected = self.modality.selection();
if !selected.is_empty() {
prompt.push_str("\n\nCurrently selected elements:\n");
for id in selected {
if let Some(el) = self.modality.element(id) {
prompt.push_str(&format!("- {}: {}\n", id, el.describe()));
}
}
}
if let Some(id) = self.modality.focused() {
prompt.push_str(&format!("\nFocused element: {}\n", id));
}
prompt
}
}
// Worksheet: no selection in AI context
impl Agent for WorksheetAgentContext {
fn preamble(&self) -> String {
"You are editing a worksheet...".to_string()
}
}

No modality is forced to include selection in its AI context. The Selectable/Focusable trait bounds give the Agent impl access to query selection if it wants to, but the decision is purely per-modality.

Clipboard Integration (the Session Clipboard RFC)

Section titled “Clipboard Integration (the Session Clipboard RFC)”

Clipboard operates on the current selection. With the trait bound, clipboard logic can be generic:

fn copy<M: Modality + Selectable>(state: &SessionState<M>) -> ClipboardPayload {
let selection = state.modality.selection();
// serialize selected items into clipboard payload
M::serialize_selection(selection, &state.modality)
}

This suggests Selectable may also need a serialization method for clipboard. Deferred to the Session Clipboard RFC.

Undo Integration (the Session Undo/Redo RFC)

Section titled “Undo Integration (the Session Undo/Redo RFC)”

Selection is ephemeral — not undo-tracked. Selection state stays as-is after undo/redo. This matches user expectations: undo affects content, not cursor/selection state.

Selection and focus are included in modality snapshots so Dart can render selection indicators:

impl Modality for Whiteboard {
fn snapshot(&self, ai: &AiState, export: &ExportState, version: u64) -> WhiteboardSnapshot {
WhiteboardSnapshot {
elements: self.compile_elements(),
selected: self.selection().clone(),
focused: self.focused().cloned(),
// ...
}
}
}
ModalitySelectableFocusableAI uses selection
WhiteboardHashSet<ElementId>ElementIdYes
WorksheetHashSet<QuestionId>No
LessonPlanHashSet<SlideId>SlideIdSlide name only
DocumentTextSelectionTBD (the Document Modality RFC)
  • Clipboard serialization — does Selectable need a serialize_selection() method, or is that clipboard’s concern? Deferred to the Session Clipboard RFC.
  • Multi-focus — some future modality might need multiple focused items (e.g. split-view editor). Current design is singular focus. Cross that bridge if we get there.
  • Selection changed callback — should Session emit a separate event when selection changes, or is the normal snapshot emission sufficient? Likely sufficient.
  • Document selectionTextSelection shape depends on the Document Modality RFC (Document Modality). Placeholder until that’s settled.
  • the Session Clipboard RFC (Clipboard) — depends on this RFC for selection-aware copy/cut
  • the Document Modality RFC (Document) — Document’s Selection type depends on document model
  • the Collaborative Presence RFC (Collaborative Presence) — builds on this RFC to sync selection/focus via Loro EphemeralStore