scripts/TEXT_SELECTION_ARCHITECTURE.md
The current text selection implementation has several limitations:
clear_selection() is called every drag frame, causing flickeringBrowsers use a Range-based selection model:
Selection {
anchorNode: Node, // Where the selection started
anchorOffset: u32, // Character offset in anchor node
focusNode: Node, // Where the selection currently ends
focusOffset: u32, // Character offset in focus node
isCollapsed: bool, // true if anchor == focus (caret, no selection)
}
Key concepts:
Browsers select based on DOM tree order, not visual position:
<div style="display: flex; flex-direction: row-reverse;">
<span>First</span> <!-- Visually on right -->
<span>Second</span> <!-- Visually on left -->
</div>
When selecting from left to right visually, browsers still select "Second" before "First" because that's the DOM order. However, the highlight rectangles are computed based on visual positions.
For each text node in the selection range:
| Platform | Behavior |
|---|---|
| macOS | Selection follows DOM order; triple-click selects paragraph |
| Windows | Same as macOS; double-click selects word |
| Linux/X11 | Primary selection (middle-click paste) in addition to clipboard |
| iOS/Android | Touch-based selection with handles; word-snapping |
When the user drags to select:
Selection starts here (anchor)
|
v
+--[=====]--------+
| || | <- Line 1: selected from anchor to line end
| [=====] |
| || | <- Line 2: fully selected
| [=====] |
| || |
| [====]--+ | <- Line 3: selected from line start to focus
+----------^------+
|
Focus (current mouse)
For multi-node selection, the same logic applies but spans DOM nodes:
Node A (large text "5"):
[=] <- Partially selected
Node B (button "Increase Counter"):
[================] <- Fully selected (between anchor and focus in DOM order)
Node C (some text after button):
[===]--+ <- Partially selected up to focus
|
Focus
/// Represents an ongoing or completed text selection
pub struct TextSelection {
/// The DOM where the selection started
pub dom_id: DomId,
/// Anchor: where the selection started (fixed during drag)
pub anchor: SelectionAnchor,
/// Focus: where the selection currently ends (moves during drag)
pub focus: SelectionFocus,
/// Cached list of affected nodes with their selection ranges
/// Recomputed when focus changes
pub affected_nodes: Vec<NodeSelectionRange>,
}
pub struct SelectionAnchor {
/// The node where selection started
pub node_id: NodeId,
/// The IFC root containing this node (for text layout access)
pub ifc_root_id: NodeId,
/// Character offset in the text
pub offset: u32,
/// Visual bounds of the anchor character (for logical rect calculation)
pub char_bounds: LogicalRect,
}
pub struct SelectionFocus {
/// The node where selection currently ends (may differ from anchor)
pub node_id: NodeId,
/// The IFC root containing this node
pub ifc_root_id: NodeId,
/// Character offset in the text
pub offset: u32,
/// Current mouse position in viewport coordinates
pub viewport_position: LogicalPosition,
}
pub struct NodeSelectionRange {
pub node_id: NodeId,
pub ifc_root_id: NodeId,
/// Start character in this node (0 if fully selected from start)
pub start_offset: u32,
/// End character in this node (len if fully selected to end)
pub end_offset: u32,
/// Whether this is the anchor node, focus node, or in-between
pub selection_type: NodeSelectionType,
}
pub enum NodeSelectionType {
/// This is the anchor node (selection started here)
Anchor,
/// This is the focus node (selection currently ends here)
Focus,
/// This node is between anchor and focus (fully selected)
InBetween,
/// Anchor and focus are in same node
AnchorAndFocus,
}
SelectionAnchor with node, offset, and character boundsfocus = anchor (collapsed selection / caret)focus with new node and offsetfn compute_affected_nodes(
anchor: &SelectionAnchor,
focus: &SelectionFocus,
dom: &StyledDom,
layout_tree: &LayoutTree,
) -> Vec<NodeSelectionRange> {
// 1. Determine DOM order of anchor and focus
let (start_node, end_node, is_forward) = if anchor.node_id <= focus.node_id {
(anchor.node_id, focus.node_id, true)
} else {
(focus.node_id, anchor.node_id, false)
};
// 2. Collect all text nodes between start and end in DOM order
let nodes_in_range = collect_text_nodes_in_range(dom, start_node, end_node);
// 3. For each node, determine selection range
nodes_in_range.iter().map(|node_id| {
let text_len = get_text_length(dom, *node_id);
if *node_id == anchor.node_id && *node_id == focus.node_id {
// Same node: partial selection between anchor and focus
let (start, end) = if is_forward {
(anchor.offset, focus.offset)
} else {
(focus.offset, anchor.offset)
};
NodeSelectionRange {
node_id: *node_id,
start_offset: start.min(end),
end_offset: start.max(end),
selection_type: NodeSelectionType::AnchorAndFocus,
}
} else if *node_id == start_node {
// First node: from anchor/focus offset to end
NodeSelectionRange {
node_id: *node_id,
start_offset: if is_forward { anchor.offset } else { focus.offset },
end_offset: text_len,
selection_type: if is_forward { NodeSelectionType::Anchor } else { NodeSelectionType::Focus },
}
} else if *node_id == end_node {
// Last node: from start to anchor/focus offset
NodeSelectionRange {
node_id: *node_id,
start_offset: 0,
end_offset: if is_forward { focus.offset } else { anchor.offset },
selection_type: if is_forward { NodeSelectionType::Focus } else { NodeSelectionType::Anchor },
}
} else {
// Middle node: fully selected
NodeSelectionRange {
node_id: *node_id,
start_offset: 0,
end_offset: text_len,
selection_type: NodeSelectionType::InBetween,
}
}
}).collect()
}
When anchor and focus are in different nodes, use a "logical selection rectangle":
fn is_node_in_selection_rect(
node_bounds: LogicalRect,
anchor_char_bounds: LogicalRect,
focus_position: LogicalPosition,
) -> bool {
// Selection rectangle extends from:
// - Top: min of anchor top and focus Y
// - Bottom: max of anchor bottom and focus Y
// - Left: 0 (start of line) for multi-line, or anchor X for same-line
// - Right: viewport width for multi-line, or focus X for same-line
let selection_top = anchor_char_bounds.origin.y.min(focus_position.y);
let selection_bottom = (anchor_char_bounds.origin.y + anchor_char_bounds.size.height)
.max(focus_position.y);
// Check if node's vertical bounds intersect selection rect
let node_top = node_bounds.origin.y;
let node_bottom = node_bounds.origin.y + node_bounds.size.height;
node_top < selection_bottom && node_bottom > selection_top
}
core/src/selection.rs
TextSelection, SelectionAnchor, SelectionFocus structsSelectionState to hold TextSelection instead of per-node rangeslayout/src/managers/selection.rs (or create if doesn't exist)
compute_affected_nodes() algorithmlayout/src/window.rs
process_mouse_click_for_selection: Create anchor, don't clear existing selection yetprocess_mouse_drag_for_selection: Update focus, recompute affected nodesprocess_mouse_up_for_selection: Finalize selection or clear if neededcore/src/events.rs
drag_start_position for selection anchorlayout/src/solver3/display_list.rs
paint_selection_and_cursor: Render selection rects for all affected nodesdisplay: none or visibility: hiddenwriting-mode: vertical-* changes selection axis<span>Hel<b>lo</b> World</span> - selection crosses tag boundariescore/src/selection.rsGoal: Implement browser-style text selection that can span multiple DOM nodes.
Current Problem:
Required Changes:
Key Files:
core/src/selection.rs - Data structureslayout/src/window.rs - Mouse event handlinglayout/src/solver3/display_list.rs - Selection renderingReference: See TEXT_SELECTION_ARCHITECTURE.md for full design.