scripts/report-selection.md
The current text selection implementation has an architectural issue: the inline_layout_result is stored on the IFC root (container) node, but the hit-test tags are assigned based on DOM node relationships, not layout tree relationships.
This creates a mismatch:
UnifiedLayout (containing hittest_cursor()) is stored on the container <p> node::text nodes (DOM children), which don't have inline_layout_resultDOM Tree: Layout Tree:
┌─────────────────┐ ┌──────────────────────────┐
│ <p> (node 1) │ → │ LayoutNode (IFC root) │
├─────────────────┤ │ dom_node_id: Some(1) │
│ ::text (node 2) │ → │ inline_layout_result: │
│ "Hello world" │ │ UnifiedLayout {...} │◄── Layout stored here
└─────────────────┘ │ children: [...] │
└──────────────────────────┘
│
┌─────────┴──────────┐
▼ ▼
┌───────────────┐ ┌───────────────┐
│ LayoutNode │ │ (maybe more │
│ dom_node_id:2 │ │ inline items)│
│ inline_layout │ └───────────────┘
│ _result: None │◄── No layout here!
└───────────────┘
The UnifiedLayout represents the complete inline formatting context, including:
It cannot be split per-DOM-node because:
<span>A</span><span>B</span> may break mid-word<span style="display:inline-block"> participates in the same IFCWhen a click occurs:
(tag_id, point_relative_to_item)TagIdToNodeIdMappingUnifiedLayout to call hittest_cursor(point)Currently, the code tags the container node (that has text children), which is correct for WebRender's perspective, but:
used_size may be 0x0 (text doesn't contribute to box size directly)inline_layout_result.bounds()text3 already has a mechanism to track where each shaped item came from:
/// A stable, logical pointer to an item within the original `InlineContent` array.
pub struct ContentIndex {
/// The index of the `InlineContent` run in the original input array.
pub run_index: u32,
/// The byte index of the character or item *within* that run's string.
pub item_index: u32,
}
/// Each shaped cluster references its source
pub struct ShapedCluster {
pub source_content_index: ContentIndex,
// ...
}
This ContentIndex is used during layout to map back to the original InlineContent items.
/// Unique identifier for an Inline Formatting Context
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct IfcId(pub u32);
/// Maps IFC runs to their source DOM nodes
pub struct IfcDomMapping {
/// The DOM node that is the IFC root (the container)
pub ifc_root_dom_id: NodeId,
/// Map from run_index to the DOM node ID of the original text/inline node
pub run_to_dom: Vec<Option<NodeId>>,
}
/// Extended LayoutNode with IFC tracking
pub struct LayoutNode {
// ... existing fields ...
/// If this node participates in an IFC (is inline content),
/// this stores the IFC ID and which run within that IFC this node represents.
pub ifc_membership: Option<IfcMembership>,
}
pub struct IfcMembership {
/// Which IFC this node's content was laid out in
pub ifc_id: IfcId,
/// Which run index within the IFC corresponds to this node's text
pub run_index: u32,
}
During collect_and_measure_inline_content:
fn collect_and_measure_inline_content(...) -> Result<(Vec<InlineContent>, IfcDomMapping)> {
let ifc_id = IfcId::next(); // Generate unique IFC ID
let mut run_to_dom = Vec::new();
for child in children {
if let NodeType::Text(_) = node_data.get_node_type() {
run_to_dom.push(Some(child_dom_id));
// Also store on the child LayoutNode:
child_layout_node.ifc_membership = Some(IfcMembership {
ifc_id,
run_index: run_to_dom.len() as u32 - 1,
});
}
}
Ok((content, IfcDomMapping { ifc_root_dom_id, run_to_dom }))
}
pub fn process_mouse_click_for_selection(&mut self, position: LogicalPosition, ...) {
// 1. Get hit-test from WebRender (via HoverManager)
let (tag_id, point_relative_to_item) = hover_manager.get_hit_at(position)?;
// 2. Map tag to DOM node
let hit_dom_id = tag_mapping.get(tag_id)?;
// 3. Find the LayoutNode for this DOM ID
let layout_node_idx = layout_tree.dom_to_layout.get(&hit_dom_id)?[0];
let layout_node = layout_tree.get(layout_node_idx)?;
// 4. Get the IFC and its UnifiedLayout
let (ifc_layout, run_filter) = if let Some(cached) = &layout_node.inline_layout_result {
// This is an IFC root - use whole layout
(cached.layout.clone(), None)
} else if let Some(membership) = &layout_node.ifc_membership {
// This node is INSIDE an IFC - find the IFC root
let ifc_root_idx = find_ifc_root(layout_tree, membership.ifc_id)?;
let ifc_root = layout_tree.get(ifc_root_idx)?;
let layout = ifc_root.inline_layout_result.as_ref()?.layout.clone();
(layout, Some(membership.run_index))
} else {
return None; // No text content
};
// 5. Hit-test the cursor within the IFC layout
let cursor = if let Some(run_idx) = run_filter {
// Only consider items from this specific run
ifc_layout.hittest_cursor_in_run(point_relative_to_item, run_idx)
} else {
ifc_layout.hittest_cursor(point_relative_to_item)
}?;
// 6. Create selection
// ...
}
Instead of copying the layout, store a reference:
pub struct LayoutNode {
// For IFC roots:
pub inline_layout_result: Option<CachedInlineLayout>,
// For text nodes inside an IFC:
pub inline_layout_ref: Option<InlineLayoutRef>,
}
pub struct InlineLayoutRef {
/// Index of the IFC root LayoutNode that has the actual layout
pub ifc_root_index: usize,
/// Which run within the IFC this text corresponds to
pub run_index: u32,
/// Byte range within the run (for partial text nodes if needed)
pub byte_range: Range<u32>,
}
This avoids duplicating the layout and provides a clear path from text node → IFC layout.
When rendering selection, we need to:
SelectionRange (start cursor, end cursor)impl UnifiedLayout {
pub fn get_selection_rectangles(&self, range: &SelectionRange) -> Vec<Rect> {
let mut rects = Vec::new();
for item in &self.items {
if let ShapedItem::Cluster(cluster) = &item.item {
if is_cluster_in_range(cluster, range) {
rects.push(Rect {
x: item.position.x,
y: item.position.y,
width: cluster.advance,
height: cluster.line_height,
});
}
}
}
// Merge adjacent rectangles on same line
merge_horizontal_rects(&mut rects);
rects
}
}
Selection rectangles should be rendered:
::selection pseudo-element color (default: system highlight color)// In display_list.rs
if let Some(selection) = get_selection_for_node(dom_id) {
for rect in ifc_layout.get_selection_rectangles(&selection) {
// Translate to absolute coordinates
let abs_rect = rect.translate(node_absolute_pos);
push_rect(display_list, abs_rect, selection_background_color);
}
}
// Then push the text itself
push_text(display_list, ...);
::selection Pseudo-ElementThe ::selection pseudo-element should support:
color: Text color when selectedbackground-color: Selection highlight colortext-shadow: Shadow on selected text (rare)/// In styled_dom.rs or prop_cache.rs
pub fn get_selection_style(&self, node_id: NodeId) -> SelectionStyle {
// Check if there's a ::selection rule for this node
if let Some(style) = self.pseudo_element_styles.get(&(node_id, PseudoElement::Selection)) {
SelectionStyle {
color: style.color.unwrap_or(system_selection_text_color()),
background: style.background_color.unwrap_or(system_selection_background()),
}
} else {
// Default system colors
SelectionStyle {
color: system_selection_text_color(),
background: system_selection_background(),
}
}
}
An IFC can contain items that are NOT text:
| Item Type | Selectable? | Notes |
|---|---|---|
| Text runs | Yes | Primary selection target |
| Inline-blocks | No* | Selected as atomic unit |
| Images | No* | Selected as atomic unit |
| Markers (::marker) | No | List bullets shouldn't be selected |
| Line breaks | No | Control characters |
*Inline-blocks and images can be "selected" in the sense that they're included in a copy/paste, but you can't place a cursor inside them.
When hit-testing, we should:
ShapedItem::Cluster (text)Object (inline-block), treat it as an atomic unitIfcId type with atomic counter that resets per layout passIfcMembership struct with ifc_id, ifc_root_layout_index, run_indexifc_id field to LayoutNode for IFC rootsifc_membership field to LayoutNode for participating text nodescollect_and_measure_inline_contentlayout_documentprocess_mouse_click_for_selection to use IFC membershipinline_layout_result first (IFC root), then ifc_membership (text node)ifc_root_layout_indexinline_layout_result)The debug server currently only modifies mouse_state without triggering the full
event processing pipeline. We attempted to fix this by:
get_mouse_position_with_fallback() to use mouse_state when EventData is Noneprocess_text_selection_click call from debug servermouse_stateCurrent Status - BLOCKED:
The selection IS being set correctly (verified by debug logs):
[DEBUG] process_mouse_click_for_selection: position=(58.0,28.0), time_ms=0
[DEBUG] HoverManager has hit test with 1 doms
[DEBUG] Setting selection on dom_id=DomId { inner: 0 }, node_id=NodeId(1)
But GetSelectionState returns empty selections. The issue is that:
Text nodes have no rect in hit-testing: When querying layout, text nodes return rect: null
{"node_id": 2, "tag": "text", "rect": null}
This is correct - text nodes are inline content, not block boxes.
Click handlers confirm the issue: When adding click handlers:
[CLICK] Paragraph 1 was clicked!Selection is set on correct node: The IFC root (paragraph div, NodeId 1) has the
inline_layout_result, and the selection is correctly stored there.
But selection disappears: Between Setting selection and GetSelectionState,
the selection is lost. Possible causes:
LayoutWindow instances between timer callback invocationsInvestigation Needed:
The flow is:
process_callback_result_v2mouse_state_changed = true → calls process_window_events_recursive_v2TextClick internal eventprocess_mouse_click_for_selection is called → sets selection on self.selection_managerRefreshDomcallback_info.get_layout_window().selection_managerThe question: Is step 6 reading from the same LayoutWindow as step 4 wrote to?
UnifiedLayout::get_selection_rectangles()::selection pseudo-element styling| Issue | Current State | Proposed Solution |
|---|---|---|
| Layout stored on wrong node | IFC root has layout, text nodes don't | Add ifc_membership to text nodes |
| Hit-test requires fallback | Manual search through all nodes | Direct lookup via IFC membership |
| Selection rendering | Not implemented | get_selection_rectangles() in display list |
| Multi-IFC selection | Not handled | Selection manager tracks per-DOM-ID |
::selection styling | Not implemented | Pseudo-element style lookup |
| Debug API selection | ⚠️ Selection set but not persisted | Investigate LayoutWindow identity |
tests/e2e/selection.shtests/e2e/selection.c with click handlers on paragraph div and text nodeText nodes don't have hit-test rects:
{"node_id": 1, "tag": "div", "rect": {"x": 8.0, "y": 8.0, "width": 816.7, "height": 121.6}}
{"node_id": 2, "tag": "text", "rect": null}
Click handlers reveal hit-test target:
on_p1_click (div) fires: YESon_p1_text_click (text) fires: NOSelection IS being set (from logs):
[DEBUG] process_mouse_click_for_selection: position=(58.0,28.0), time_ms=0
[DEBUG] HoverManager has hit test with 1 doms
[DEBUG] Setting selection on dom_id=DomId { inner: 0 }, node_id=NodeId(1)
But GetSelectionState returns empty:
{"has_selection": false, "selection_count": 0, "selections": []}
core/src/events.rs: Added get_mouse_position_with_fallback() that reads from
mouse_state.cursor_position when EventData::Mouse is not available.
debug_server.rs: Removed manual process_text_selection_click call from MouseDown
handler - now relies on normal event pipeline.
GetSelectionState handler to see if selection_manager is emptycallback_info.get_layout_window() returns the persistent LayoutWindowShould IFC ID be global or per-layout-pass?
layout_document() entryHow to handle contenteditable?
<input type=text> implementationPerformance of multi-IFC selection?
ifc_root_layout_indexRTL/Bidi selection rectangles?
Debug Server LayoutWindow identity?
callback_info.get_layout_window() returns the actual
window's LayoutWindow, not a copyWhy do text nodes not participate in hit-testing?
rect: null because they're inline content