From ac9be932e715960f3f1d3c54fef304d91e0a6291 Mon Sep 17 00:00:00 2001 From: Will Temple Date: Sat, 21 Mar 2026 04:05:38 -0400 Subject: [PATCH] Scroll box example --- examples/text_paragraph_demo/src/main.rs | 984 ++++++++++++++++++++--- lib/ui/src/interaction.rs | 57 +- lib/ui/src/layout.rs | 622 ++++++++++---- lib/ui/src/lib.rs | 6 +- lib/ui/src/tree.rs | 99 ++- lib/ui_platform_wayland/src/lib.rs | 17 + 6 files changed, 1507 insertions(+), 278 deletions(-) diff --git a/examples/text_paragraph_demo/src/main.rs b/examples/text_paragraph_demo/src/main.rs index 8109fcc..a5249d9 100644 --- a/examples/text_paragraph_demo/src/main.rs +++ b/examples/text_paragraph_demo/src/main.rs @@ -2,14 +2,16 @@ use std::error::Error; use std::process::Command; use std::time::{Duration, Instant}; -use ruin_runtime::{TimeoutHandle, clear_timeout, set_timeout}; +use ruin_runtime::{ + IntervalHandle, TimeoutHandle, clear_interval, clear_timeout, set_interval, set_timeout, +}; use ruin_ui::{ BoxShadow, BoxShadowKind, Color, CursorIcon, DisplayItem, Edges, Element, ElementId, ImageFit, ImageResource, InteractionTree, KeyboardEvent, KeyboardEventKind, KeyboardKey, LayoutSnapshot, PlatformEvent, PointerButton, PointerEvent, PointerEventKind, PointerRouter, PreparedText, - Quad, RoutedPointerEventKind, SceneSnapshot, TextAlign, TextFontFamily, TextSelectionStyle, - TextSpan, TextSpanSlant, TextSpanWeight, TextStyle, TextSystem, TextWrap, UiSize, - WindowController, WindowSpec, WindowUpdate, layout_snapshot_with_text_system, + Quad, Rect, RoutedPointerEventKind, SceneSnapshot, ScrollbarStyle, TextAlign, TextFontFamily, + TextSelectionStyle, TextSpan, TextSpanSlant, TextSpanWeight, TextStyle, TextSystem, TextWrap, + UiSize, WindowController, WindowSpec, WindowUpdate, layout_snapshot_with_text_system, }; use ruin_ui_platform_wayland::start_wayland_ui; use tracing_subscriber::layer::SubscriberExt; @@ -24,6 +26,7 @@ const NEXT_CARD_ID: ElementId = ElementId::new(3); const QUOTE_CARD_ID: ElementId = ElementId::new(4); const NOTE_CARD_ID: ElementId = ElementId::new(5); const STATUS_CARD_ID: ElementId = ElementId::new(6); +const STATUS_SCROLLBOX_ID: ElementId = ElementId::new(7); const HERO_TITLE_ID: ElementId = ElementId::new(101); const HERO_BODY_ID: ElementId = ElementId::new(102); const RUST_LINK_ID: ElementId = ElementId::new(103); @@ -32,6 +35,7 @@ const INPUT_TEXT_ID: ElementId = ElementId::new(105); const SECOND_INPUT_FIELD_ID: ElementId = ElementId::new(106); const SECOND_INPUT_TEXT_ID: ElementId = ElementId::new(107); const INPUT_CARET_BLINK_INTERVAL: Duration = Duration::from_millis(500); +const SELECTION_AUTOSCROLL_INTERVAL: Duration = Duration::from_millis(16); const MULTI_CLICK_INTERVAL: Duration = Duration::from_millis(350); const MULTI_CLICK_DISTANCE_SQUARED: f32 = 36.0; const HERO_IMAGE_ASSET: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/ruin.png"); @@ -54,8 +58,21 @@ struct InFlightResize { #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct SelectionDrag { - element_id: ElementId, - anchor: usize, + group_id: ElementId, + anchor: SelectionEndpoint, +} + +#[derive(Clone, Copy, Debug)] +struct ScrollbarDrag { + start_pointer_y: f32, + start_offset_y: f32, +} + +#[derive(Clone, Copy, Debug)] +struct PendingSelectionRetarget { + drag: SelectionDrag, + position: ruin_ui::Point, + finish: bool, } #[derive(Clone, Copy, Debug)] @@ -67,10 +84,16 @@ struct ClickTracker { } #[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct TextSelection { +struct SelectionEndpoint { element_id: ElementId, - anchor: usize, - focus: usize, + offset: usize, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct TextSelection { + group_id: ElementId, + anchor: SelectionEndpoint, + focus: SelectionEndpoint, } impl TextSelection { @@ -162,11 +185,94 @@ fn next_input_focus( } } -fn selection_bounds(selection: TextSelection) -> (usize, usize) { - if selection.anchor <= selection.focus { - (selection.anchor, selection.focus) - } else { - (selection.focus, selection.anchor) +#[derive(Clone, Copy)] +struct GroupPreparedText<'a> { + element_id: ElementId, + prepared_text: &'a PreparedText, +} + +fn status_scroll_text_id(slot: u64) -> ElementId { + ElementId::new(700 + slot) +} + +fn selection_group_for_element(element_id: ElementId) -> Option { + match element_id { + HERO_BODY_ID | INPUT_TEXT_ID | SECOND_INPUT_TEXT_ID => Some(element_id), + id if id == status_scroll_text_id(1) + || id == status_scroll_text_id(2) + || id == status_scroll_text_id(3) + || id == status_scroll_text_id(4) + || id == status_scroll_text_id(5) => + { + Some(STATUS_SCROLLBOX_ID) + } + id if (11..=69).contains(&id.raw()) => Some(ElementId::new(id.raw() / 10)), + _ => None, + } +} + +fn collect_group_prepared_texts<'a>( + node: &'a ruin_ui::LayoutNode, + group_id: ElementId, + texts: &mut Vec>, +) { + if let (Some(element_id), Some(prepared_text)) = (node.element_id, node.prepared_text.as_ref()) + && prepared_text.selectable + && selection_group_for_element(element_id) == Some(group_id) + { + texts.push(GroupPreparedText { + element_id, + prepared_text, + }); + } + for child in &node.children { + collect_group_prepared_texts(child, group_id, texts); + } +} + +fn group_prepared_texts<'a>( + interaction_tree: &'a InteractionTree, + group_id: ElementId, +) -> Vec> { + let mut texts = Vec::new(); + collect_group_prepared_texts(&interaction_tree.root, group_id, &mut texts); + texts +} + +fn selection_endpoint_order( + interaction_tree: &InteractionTree, + group_id: ElementId, + start: SelectionEndpoint, + end: SelectionEndpoint, +) -> Option { + let texts = group_prepared_texts(interaction_tree, group_id); + let start_index = texts + .iter() + .position(|text| text.element_id == start.element_id)?; + let end_index = texts + .iter() + .position(|text| text.element_id == end.element_id)?; + Some( + start_index + .cmp(&end_index) + .then(start.offset.cmp(&end.offset)), + ) +} + +fn ordered_selection_endpoints( + interaction_tree: &InteractionTree, + selection: TextSelection, +) -> Option<(SelectionEndpoint, SelectionEndpoint)> { + match selection_endpoint_order( + interaction_tree, + selection.group_id, + selection.anchor, + selection.focus, + )? { + std::cmp::Ordering::Greater => Some((selection.focus, selection.anchor)), + std::cmp::Ordering::Equal | std::cmp::Ordering::Less => { + Some((selection.anchor, selection.focus)) + } } } @@ -174,11 +280,15 @@ fn selection_navigation_anchor_and_focus( selection: TextSelection, key: &KeyboardKey, ) -> (usize, usize) { - let (start, end) = selection_bounds(selection); + let (start, end) = if selection.anchor.offset <= selection.focus.offset { + (selection.anchor.offset, selection.focus.offset) + } else { + (selection.focus.offset, selection.anchor.offset) + }; match key { KeyboardKey::ArrowLeft | KeyboardKey::ArrowUp | KeyboardKey::Home => (end, start), KeyboardKey::ArrowRight | KeyboardKey::ArrowDown | KeyboardKey::End => (start, end), - _ => (selection.anchor, selection.focus), + _ => (selection.anchor.offset, selection.focus.offset), } } @@ -187,10 +297,18 @@ fn active_input_selection_bounds( input_field: &InputFieldState, ) -> Option<(usize, usize)> { let selection = selection?; - if selection.element_id != input_field.text_id || selection.is_collapsed() { + if selection.group_id != input_field.text_id + || selection.anchor.element_id != input_field.text_id + || selection.focus.element_id != input_field.text_id + || selection.is_collapsed() + { return None; } - Some(selection_bounds(selection)) + if selection.anchor.offset <= selection.focus.offset { + Some((selection.anchor.offset, selection.focus.offset)) + } else { + Some((selection.focus.offset, selection.anchor.offset)) + } } fn has_active_input_selection( @@ -204,7 +322,7 @@ fn clear_input_selection_for( selection: &mut Option, input_field: &InputFieldState, ) -> bool { - if selection.is_some_and(|current| current.element_id == input_field.text_id) { + if selection.is_some_and(|current| current.group_id == input_field.text_id) { *selection = None; return true; } @@ -253,21 +371,62 @@ fn insert_text_into_focused_input( } } +fn selection_range_for_prepared_text( + interaction_tree: &InteractionTree, + selection: TextSelection, + element_id: ElementId, +) -> Option> { + let texts = group_prepared_texts(interaction_tree, selection.group_id); + let (start, end) = ordered_selection_endpoints(interaction_tree, selection)?; + if start == end { + return None; + } + let start_index = texts + .iter() + .position(|text| text.element_id == start.element_id)?; + let end_index = texts + .iter() + .position(|text| text.element_id == end.element_id)?; + let current_index = texts + .iter() + .position(|text| text.element_id == element_id)?; + let current_text = texts.get(current_index)?.prepared_text; + if current_index < start_index || current_index > end_index { + return None; + } + let range = if start_index == end_index { + start.offset..end.offset + } else if current_index == start_index { + start.offset..current_text.text.len() + } else if current_index == end_index { + 0..end.offset + } else { + 0..current_text.text.len() + }; + (!range.is_empty()).then_some(range) +} + fn selection_text( + interaction_tree: &InteractionTree, selection: Option, - prepared_text: &PreparedText, ) -> Option { let selection = selection?; - if prepared_text.element_id != Some(selection.element_id) { - return None; + let texts = group_prepared_texts(interaction_tree, selection.group_id); + let mut fragments = Vec::new(); + for text in texts { + let Some(range) = + selection_range_for_prepared_text(interaction_tree, selection, text.element_id) + else { + continue; + }; + let Some(fragment) = text.prepared_text.text.get(range) else { + continue; + }; + if !fragment.is_empty() { + fragments.push(fragment.to_owned()); + } } - if selection.is_collapsed() { - return None; - } - prepared_text - .selected_text(selection.anchor, selection.focus) - .filter(|text| !text.is_empty()) - .map(str::to_owned) + (!fragments.is_empty()).then(|| fragments.join("\n")) } fn selection_target_prepared_text( @@ -276,10 +435,114 @@ fn selection_target_prepared_text( ) -> Option<(TextSelection, &PreparedText)> { let selection = selection?; interaction_tree - .text_for_element(selection.element_id) + .text_for_element(selection.focus.element_id) .map(|prepared_text| (selection, prepared_text)) } +fn single_text_selection(element_id: ElementId, anchor: usize, focus: usize) -> TextSelection { + TextSelection { + group_id: selection_group_for_element(element_id).unwrap_or(element_id), + anchor: SelectionEndpoint { + element_id, + offset: anchor, + }, + focus: SelectionEndpoint { + element_id, + offset: focus, + }, + } +} + +fn selection_endpoint_for_group_position( + interaction_tree: &InteractionTree, + group_id: ElementId, + point: ruin_ui::Point, +) -> Option { + let texts = group_prepared_texts(interaction_tree, group_id); + let first = texts.first()?; + let last = texts.last()?; + + let text_rect = |text: &GroupPreparedText<'_>| { + let bounds = text + .prepared_text + .bounds + .unwrap_or(UiSize::new(0.0, text.prepared_text.line_height)); + Rect::new( + text.prepared_text.origin.x, + text.prepared_text.origin.y, + bounds.width, + bounds.height, + ) + }; + + if point.y <= text_rect(first).origin.y { + return Some(SelectionEndpoint { + element_id: first.element_id, + offset: 0, + }); + } + + let last_rect = text_rect(last); + if point.y >= last_rect.origin.y + last_rect.size.height { + return Some(SelectionEndpoint { + element_id: last.element_id, + offset: last.prepared_text.text.len(), + }); + } + + for text in &texts { + let rect = text_rect(text); + if point.y < rect.origin.y { + return Some(SelectionEndpoint { + element_id: text.element_id, + offset: 0, + }); + } + if point.y <= rect.origin.y + rect.size.height { + return Some(SelectionEndpoint { + element_id: text.element_id, + offset: text.prepared_text.byte_offset_for_position(point), + }); + } + } + + Some(SelectionEndpoint { + element_id: last.element_id, + offset: last.prepared_text.text.len(), + }) +} + +fn selection_at_position( + interaction_tree: &InteractionTree, + point: ruin_ui::Point, +) -> Option<(ElementId, SelectionEndpoint)> { + let hit = interaction_tree.text_hit_test(point)?; + let element_id = hit.target.element_id?; + let group_id = selection_group_for_element(element_id)?; + selection_endpoint_for_group_position(interaction_tree, group_id, point) + .map(|endpoint| (group_id, endpoint)) +} + +fn full_group_selection( + interaction_tree: &InteractionTree, + group_id: ElementId, +) -> Option { + let texts = group_prepared_texts(interaction_tree, group_id); + let first = texts.first()?; + let last = texts.last()?; + Some(TextSelection { + group_id, + anchor: SelectionEndpoint { + element_id: first.element_id, + offset: 0, + }, + focus: SelectionEndpoint { + element_id: last.element_id, + offset: last.prepared_text.text.len(), + }, + }) +} + fn navigation_target_offset( prepared_text: &PreparedText, offset: usize, @@ -388,11 +651,17 @@ async fn main() -> Result<(), Box> { "", ), ]; + let mut status_scroll_offset = 0.0_f32; + let mut scrollbar_drag = None::; let mut caret_visible = false; let mut caret_blink_timer = None::; let mut caret_blink_token = 0_u64; + let mut selection_autoscroll_timer = None::; + let mut selection_autoscroll_token = 0_u64; let mut selection = None; let mut selection_drag = None; + let mut selection_drag_pointer = None::; + let mut pending_selection_retarget = None::; let mut click_tracker = None::; println!("Opening RUIN paragraph demo window..."); @@ -407,7 +676,7 @@ async fn main() -> Result<(), Box> { let mut resize_presented = false; let mut pointer_events = Vec::new(); let mut keyboard_events = Vec::new(); - let mut pending_blink_token = None::; + let mut pending_wake_tokens = Vec::::new(); let mut pending_clipboard_text = None::; let mut pending_primary_selection_text = None::; let mut close_requested = false; @@ -436,7 +705,7 @@ async fn main() -> Result<(), Box> { keyboard_events.push(event); } PlatformEvent::Wake { window_id, token } if window_id == window.id() => { - pending_blink_token = Some(token); + pending_wake_tokens.push(token); } PlatformEvent::ClipboardText { window_id, text } if window_id == window.id() => { pending_clipboard_text = Some(text); @@ -482,6 +751,9 @@ async fn main() -> Result<(), Box> { } let has_resize_configuration = latest_configuration.is_some(); + let pending_blink = pending_wake_tokens.contains(&caret_blink_token); + let pending_selection_autoscroll = + pending_wake_tokens.contains(&selection_autoscroll_token); if let Some(configuration) = latest_configuration { let next_viewport = configuration.actual_inner_size; let is_duplicate = @@ -528,13 +800,14 @@ async fn main() -> Result<(), Box> { version, hovered_card, &hero_image, + status_scroll_offset, &input_fields, focused_element, &mut text_system, ); if selection.is_some_and(|selection: TextSelection| { next_interaction_tree - .text_for_element(selection.element_id) + .text_for_element(selection.focus.element_id) .is_none_or(|prepared_text| !prepared_text.selectable) }) { selection = None; @@ -577,6 +850,24 @@ async fn main() -> Result<(), Box> { has_resize_configuration || pending_resize.is_some() || in_flight_resize.is_some(); if !resize_active && let Some(current_interaction_tree) = interaction_tree.as_ref() { for event in pointer_events { + if handle_status_scrollbar_drag_event( + current_interaction_tree, + event, + &mut focused_element, + &mut scrollbar_drag, + &mut status_scroll_offset, + ) { + needs_input_rebuild = true; + continue; + } + if handle_status_scroll_pointer_event( + current_interaction_tree, + event, + &mut status_scroll_offset, + ) { + needs_input_rebuild = true; + continue; + } let input_outcome = handle_input_focus_event( current_interaction_tree, event, @@ -589,6 +880,34 @@ async fn main() -> Result<(), Box> { input_outcome.caret_changed || input_outcome.selection_changed; request_primary_paste |= input_outcome.request_primary_paste; request_clipboard_paste |= input_outcome.request_clipboard_paste; + let should_retarget_after_rebuild = matches!(event.kind, PointerEventKind::Move) + || matches!( + event.kind, + PointerEventKind::Up { + button: PointerButton::Primary + } + ); + if should_retarget_after_rebuild + && handle_status_selection_autoscroll( + current_interaction_tree, + selection_drag, + event.position, + &mut status_scroll_offset, + ) + { + pending_selection_retarget = + selection_drag.map(|drag| PendingSelectionRetarget { + drag, + position: event.position, + finish: matches!( + event.kind, + PointerEventKind::Up { + button: PointerButton::Primary + } + ), + }); + needs_input_rebuild = true; + } let selection_outcome = handle_selection_event( current_interaction_tree, event, @@ -597,9 +916,26 @@ async fn main() -> Result<(), Box> { &mut click_tracker, ); needs_overlay_present |= selection_outcome.changed; - if selection_outcome.copied_text.is_some() { + if pending_selection_retarget.is_none() && selection_outcome.copied_text.is_some() { copied_primary_selection_text = selection_outcome.copied_text; } + match event.kind { + PointerEventKind::Down { + button: PointerButton::Primary, + } + | PointerEventKind::Move => { + if selection_drag.is_some() { + selection_drag_pointer = Some(event.position); + } + } + PointerEventKind::Up { + button: PointerButton::Primary, + } + | PointerEventKind::LeaveWindow => { + selection_drag_pointer = None; + } + _ => {} + } if selection_drag.is_none() { let routed = pointer_router.route(current_interaction_tree, event); @@ -635,8 +971,35 @@ async fn main() -> Result<(), Box> { } } } + if !resize_active + && pending_selection_autoscroll + && let Some(current_interaction_tree) = interaction_tree.as_ref() + && let Some(position) = selection_drag_pointer + && handle_status_selection_autoscroll( + current_interaction_tree, + selection_drag, + position, + &mut status_scroll_offset, + ) + { + pending_selection_retarget = selection_drag.map(|drag| PendingSelectionRetarget { + drag, + position, + finish: false, + }); + needs_input_rebuild = true; + } if let Some(current_interaction_tree) = interaction_tree.as_ref() { for event in keyboard_events { + if handle_status_scroll_keyboard_event( + current_interaction_tree, + event.clone(), + focused_element, + &mut status_scroll_offset, + ) { + needs_input_rebuild = true; + continue; + } let input_outcome = handle_keyboard_input_event( current_interaction_tree, event, @@ -684,9 +1047,7 @@ async fn main() -> Result<(), Box> { &mut caret_blink_token, ); } - if pending_blink_token == Some(caret_blink_token) - && focused_input(focused_element, &input_fields).is_some() - { + if pending_blink && focused_input(focused_element, &input_fields).is_some() { schedule_caret_blink(&window, &mut caret_blink_timer, caret_blink_token); if in_flight_resize.is_none() { caret_visible = !caret_visible; @@ -721,18 +1082,27 @@ async fn main() -> Result<(), Box> { version, hovered_card, &hero_image, + status_scroll_offset, &input_fields, focused_element, &mut text_system, ); if selection.is_some_and(|selection: TextSelection| { next_interaction_tree - .text_for_element(selection.element_id) + .text_for_element(selection.focus.element_id) .is_none_or(|prepared_text| !prepared_text.selectable) }) { selection = None; selection_drag = None; } + if let Some(copied_text) = retarget_selection_after_rebuild( + &next_interaction_tree, + &mut selection, + &mut selection_drag, + &mut pending_selection_retarget, + ) { + window.set_primary_selection_text(copied_text)?; + } window.replace_scene(scene_with_overlays( &scene, &next_interaction_tree, @@ -760,6 +1130,19 @@ async fn main() -> Result<(), Box> { version, ))?; } + if let Some(current_interaction_tree) = interaction_tree.as_ref() { + sync_selection_autoscroll( + &window, + current_interaction_tree, + selection_drag, + selection_drag_pointer, + &mut selection_autoscroll_timer, + &mut selection_autoscroll_token, + ); + } else if let Some(handle) = selection_autoscroll_timer.take() { + selection_autoscroll_token = selection_autoscroll_token.wrapping_add(1); + clear_interval(&handle); + } if close_requested { let _ = window.update(WindowUpdate::new().open(false)); @@ -773,11 +1156,13 @@ async fn main() -> Result<(), Box> { Ok(()) } +#[allow(clippy::too_many_arguments)] fn build_snapshot( viewport: UiSize, version: u64, hovered_card: Option, hero_image: &ImageResource, + status_scroll_offset: f32, input_fields: &[InputFieldState], focused_element: Option, text_system: &mut TextSystem, @@ -786,12 +1171,269 @@ fn build_snapshot( viewport, hovered_card, hero_image, + status_scroll_offset, input_fields, focused_element, ); layout_snapshot_with_text_system(version, viewport, &tree, text_system) } +fn scrollbox_target_at_position( + interaction_tree: &InteractionTree, + position: ruin_ui::Point, +) -> Option { + interaction_tree + .hit_path(position) + .iter() + .rev() + .find_map(|target| { + let element_id = target.element_id?; + interaction_tree + .scroll_metrics_for_element(element_id) + .map(|_| element_id) + }) +} + +fn clamp_status_scroll_offset( + interaction_tree: &InteractionTree, + offset_y: f32, + current_offset_y: f32, +) -> f32 { + interaction_tree + .scroll_metrics_for_element(STATUS_SCROLLBOX_ID) + .map(|metrics| offset_y.clamp(0.0, metrics.max_offset_y)) + .unwrap_or(current_offset_y) +} + +fn adjust_status_scroll_offset( + interaction_tree: &InteractionTree, + status_scroll_offset: &mut f32, + delta_y: f32, +) -> bool { + let next_offset = clamp_status_scroll_offset( + interaction_tree, + *status_scroll_offset + delta_y, + *status_scroll_offset, + ); + if (*status_scroll_offset - next_offset).abs() <= f32::EPSILON { + return false; + } + *status_scroll_offset = next_offset; + true +} + +fn handle_status_scroll_pointer_event( + interaction_tree: &InteractionTree, + event: PointerEvent, + status_scroll_offset: &mut f32, +) -> bool { + let PointerEventKind::Scroll { delta } = event.kind else { + return false; + }; + if scrollbox_target_at_position(interaction_tree, event.position) != Some(STATUS_SCROLLBOX_ID) { + return false; + } + adjust_status_scroll_offset(interaction_tree, status_scroll_offset, delta.y) +} + +fn handle_status_scrollbar_drag_event( + interaction_tree: &InteractionTree, + event: PointerEvent, + focused_element: &mut Option, + scrollbar_drag: &mut Option, + status_scroll_offset: &mut f32, +) -> bool { + let Some(metrics) = interaction_tree.scroll_metrics_for_element(STATUS_SCROLLBOX_ID) else { + return false; + }; + + match event.kind { + PointerEventKind::Down { + button: PointerButton::Primary, + } => { + let Some(thumb_rect) = metrics.scrollbar_thumb else { + return false; + }; + if !thumb_rect.contains(event.position) { + return false; + } + *focused_element = Some(STATUS_SCROLLBOX_ID); + *scrollbar_drag = Some(ScrollbarDrag { + start_pointer_y: event.position.y, + start_offset_y: *status_scroll_offset, + }); + true + } + PointerEventKind::Move => { + let Some(drag) = *scrollbar_drag else { + return false; + }; + let Some(track_rect) = metrics.scrollbar_track else { + return false; + }; + let Some(thumb_rect) = metrics.scrollbar_thumb else { + return false; + }; + let thumb_travel = (track_rect.size.height - thumb_rect.size.height).max(0.0); + if thumb_travel <= 0.0 || metrics.max_offset_y <= 0.0 { + return false; + } + let pointer_delta = event.position.y - drag.start_pointer_y; + let next_offset = + drag.start_offset_y + pointer_delta * (metrics.max_offset_y / thumb_travel); + let next_offset = + clamp_status_scroll_offset(interaction_tree, next_offset, *status_scroll_offset); + if (*status_scroll_offset - next_offset).abs() <= f32::EPSILON { + return true; + } + *status_scroll_offset = next_offset; + true + } + PointerEventKind::Up { + button: PointerButton::Primary, + } => scrollbar_drag.take().is_some(), + PointerEventKind::LeaveWindow => scrollbar_drag.take().is_some(), + _ => false, + } +} + +fn handle_status_scroll_keyboard_event( + interaction_tree: &InteractionTree, + event: KeyboardEvent, + focused_element: Option, + status_scroll_offset: &mut f32, +) -> bool { + if focused_element != Some(STATUS_SCROLLBOX_ID) + || event.kind != KeyboardEventKind::Pressed + || event.modifiers.control + || event.modifiers.alt + || event.modifiers.super_key + { + return false; + } + + let Some(metrics) = interaction_tree.scroll_metrics_for_element(STATUS_SCROLLBOX_ID) else { + return false; + }; + let line_step = (metrics.viewport_rect.size.height * 0.12).clamp(28.0, 52.0); + let next_offset = match event.key { + KeyboardKey::ArrowUp => *status_scroll_offset - line_step, + KeyboardKey::ArrowDown => *status_scroll_offset + line_step, + KeyboardKey::Home => 0.0, + KeyboardKey::End => metrics.max_offset_y, + _ => return false, + }; + let next_offset = + clamp_status_scroll_offset(interaction_tree, next_offset, *status_scroll_offset); + if (*status_scroll_offset - next_offset).abs() <= f32::EPSILON { + return false; + } + *status_scroll_offset = next_offset; + true +} + +fn status_selection_autoscroll_delta( + interaction_tree: &InteractionTree, + drag: Option, + position: ruin_ui::Point, +) -> Option { + if drag.is_none_or(|drag| drag.group_id != STATUS_SCROLLBOX_ID) { + return None; + } + let metrics = interaction_tree.scroll_metrics_for_element(STATUS_SCROLLBOX_ID)?; + if metrics.max_offset_y <= 0.0 { + return None; + } + + let viewport = metrics.viewport_rect; + let top = viewport.origin.y; + let bottom = viewport.origin.y + viewport.size.height; + if position.y < top && metrics.offset_y > 0.0 { + Some(-((top - position.y) * 0.6).clamp(12.0, 56.0)) + } else if position.y > bottom && metrics.offset_y < metrics.max_offset_y { + Some(((position.y - bottom) * 0.6).clamp(12.0, 56.0)) + } else { + None + } +} + +fn handle_status_selection_autoscroll( + interaction_tree: &InteractionTree, + drag: Option, + position: ruin_ui::Point, + status_scroll_offset: &mut f32, +) -> bool { + let Some(delta_y) = status_selection_autoscroll_delta(interaction_tree, drag, position) else { + return false; + }; + adjust_status_scroll_offset(interaction_tree, status_scroll_offset, delta_y) +} + +fn retarget_selection_after_rebuild( + interaction_tree: &InteractionTree, + selection: &mut Option, + selection_drag: &mut Option, + pending_selection_retarget: &mut Option, +) -> Option { + let pending = pending_selection_retarget.take()?; + let focus = selection_endpoint_for_group_position( + interaction_tree, + pending.drag.group_id, + pending.position, + )?; + let next_selection = TextSelection { + group_id: pending.drag.group_id, + anchor: pending.drag.anchor, + focus, + }; + *selection = Some(next_selection); + if pending.finish { + *selection_drag = None; + return selection_text(interaction_tree, Some(next_selection)); + } + *selection_drag = Some(pending.drag); + None +} + +fn schedule_selection_autoscroll( + window: &WindowController, + selection_autoscroll_timer: &mut Option, + selection_autoscroll_token: u64, +) { + if selection_autoscroll_timer.is_some() { + return; + } + let window = window.clone(); + *selection_autoscroll_timer = Some(set_interval(SELECTION_AUTOSCROLL_INTERVAL, move || { + let _ = window.emit_wake(selection_autoscroll_token); + })); +} + +fn sync_selection_autoscroll( + window: &WindowController, + interaction_tree: &InteractionTree, + selection_drag: Option, + selection_drag_pointer: Option, + selection_autoscroll_timer: &mut Option, + selection_autoscroll_token: &mut u64, +) { + let should_run = selection_drag_pointer + .and_then(|position| { + status_selection_autoscroll_delta(interaction_tree, selection_drag, position) + }) + .is_some(); + if should_run { + schedule_selection_autoscroll( + window, + selection_autoscroll_timer, + *selection_autoscroll_token, + ); + } else if let Some(handle) = selection_autoscroll_timer.take() { + *selection_autoscroll_token = selection_autoscroll_token.wrapping_add(1); + clear_interval(&handle); + } +} + fn handle_selection_event( interaction_tree: &InteractionTree, event: PointerEvent, @@ -804,46 +1446,45 @@ fn handle_selection_event( PointerEventKind::Down { button: PointerButton::Primary, } => { - let next_selection = interaction_tree - .text_hit_test(event.position) - .and_then(|hit| { - let element_id = hit.target.element_id?; - let prepared_text = interaction_tree.text_for_element(element_id)?; + let next_selection = selection_at_position(interaction_tree, event.position).and_then( + |(group_id, endpoint)| { + let prepared_text = interaction_tree.text_for_element(endpoint.element_id)?; let click_count = - update_click_tracker(click_tracker, element_id, event.position); + update_click_tracker(click_tracker, endpoint.element_id, event.position); Some(match click_count { 2 => { - let range = prepared_text.word_range_for_offset(hit.byte_offset); + let range = prepared_text.word_range_for_offset(endpoint.offset); TextSelection { - element_id, - anchor: range.start, - focus: range.end, + group_id, + anchor: SelectionEndpoint { + element_id: endpoint.element_id, + offset: range.start, + }, + focus: SelectionEndpoint { + element_id: endpoint.element_id, + offset: range.end, + }, } } - 3 => TextSelection { - element_id, - anchor: 0, - focus: prepared_text.text.len(), - }, + 3 => { + single_text_selection(endpoint.element_id, 0, prepared_text.text.len()) + } _ => TextSelection { - element_id, - anchor: hit.byte_offset, - focus: hit.byte_offset, + group_id, + anchor: endpoint, + focus: endpoint, }, }) - }); + }, + ); if let Some(next_selection) = next_selection { *selection_drag = next_selection.is_collapsed().then_some(SelectionDrag { - element_id: next_selection.element_id, + group_id: next_selection.group_id, anchor: next_selection.anchor, }); outcome.changed = *selection != Some(next_selection); if !next_selection.is_collapsed() { - outcome.copied_text = interaction_tree - .text_for_element(next_selection.element_id) - .and_then(|prepared_text| { - selection_text(Some(next_selection), prepared_text) - }); + outcome.copied_text = selection_text(interaction_tree, Some(next_selection)); } *selection = Some(next_selection); } else { @@ -854,12 +1495,16 @@ fn handle_selection_event( } PointerEventKind::Move => { if let Some(drag) = *selection_drag - && let Some(prepared_text) = interaction_tree.text_for_element(drag.element_id) + && let Some(focus) = selection_endpoint_for_group_position( + interaction_tree, + drag.group_id, + event.position, + ) { let next_selection = TextSelection { - element_id: drag.element_id, + group_id: drag.group_id, anchor: drag.anchor, - focus: prepared_text.byte_offset_for_position(event.position), + focus, }; outcome.changed = *selection != Some(next_selection); *selection = Some(next_selection); @@ -871,20 +1516,24 @@ fn handle_selection_event( let Some(drag) = selection_drag.take() else { return outcome; }; - let Some(prepared_text) = interaction_tree.text_for_element(drag.element_id) else { + let Some(focus) = selection_endpoint_for_group_position( + interaction_tree, + drag.group_id, + event.position, + ) else { return outcome; }; let next_selection = TextSelection { - element_id: drag.element_id, + group_id: drag.group_id, anchor: drag.anchor, - focus: prepared_text.byte_offset_for_position(event.position), + focus, }; outcome.changed = *selection != Some(next_selection); - outcome.copied_text = selection_text(Some(next_selection), prepared_text); + outcome.copied_text = selection_text(interaction_tree, Some(next_selection)); *selection = Some(next_selection); } PointerEventKind::Down { .. } | PointerEventKind::Up { .. } => {} - PointerEventKind::LeaveWindow => {} + PointerEventKind::Scroll { .. } | PointerEventKind::LeaveWindow => {} } outcome } @@ -996,11 +1645,7 @@ fn handle_keyboard_input_event( if shortcut_matches(&event, 'a') { if let Some(input_field) = focused_input_mut(*focused_element, input_fields) { let end = input_field.text.len(); - let next_selection = TextSelection { - element_id: input_field.text_id, - anchor: 0, - focus: end, - }; + let next_selection = single_text_selection(input_field.text_id, 0, end); outcome.selection_changed = *selection != Some(next_selection); *selection = Some(next_selection); if input_field.caret != end { @@ -1010,41 +1655,33 @@ fn handle_keyboard_input_event( } else if let Some((current_selection, prepared_text)) = selection_target_prepared_text(interaction_tree, *selection) { - let next_selection = TextSelection { - element_id: current_selection.element_id, - anchor: 0, - focus: prepared_text.text.len(), - }; + let next_selection = + full_group_selection(interaction_tree, current_selection.group_id) + .unwrap_or_else(|| { + single_text_selection( + current_selection.focus.element_id, + 0, + prepared_text.text.len(), + ) + }); outcome.selection_changed = *selection != Some(next_selection); *selection = Some(next_selection); } } else if shortcut_matches(&event, 'c') { if let Some(input_field) = focused_input(*focused_element, input_fields) { - outcome.copied_text = interaction_tree - .text_for_element(input_field.text_id) - .and_then(|prepared_text| { + outcome.copied_text = active_input_selection_bounds(*selection, input_field) + .and_then(|(start, end)| { selection_text( - active_input_selection_bounds(*selection, input_field).map( - |(start, end)| TextSelection { - element_id: input_field.text_id, - anchor: start, - focus: end, - }, - ), - prepared_text, + interaction_tree, + Some(single_text_selection(input_field.text_id, start, end)), ) }); - } else if let Some((current_selection, prepared_text)) = - selection_target_prepared_text(interaction_tree, *selection) - { - outcome.copied_text = selection_text(Some(current_selection), prepared_text); + } else if let Some(current_selection) = *selection { + outcome.copied_text = selection_text(interaction_tree, Some(current_selection)); } } else if shortcut_matches(&event, 'x') { if let Some(input_index) = focused_input_index(*focused_element, input_fields) { - let text_id = input_fields[input_index].text_id; - outcome.copied_text = interaction_tree - .text_for_element(text_id) - .and_then(|prepared_text| selection_text(*selection, prepared_text)); + outcome.copied_text = selection_text(interaction_tree, *selection); if let Some(range) = active_input_selection_bounds(*selection, &input_fields[input_index]) { @@ -1067,22 +1704,26 @@ fn handle_keyboard_input_event( && let Some(next_caret) = navigation_target_offset( prepared_text, selection - .filter(|current| current.element_id == text_id) + .filter(|current| { + current.group_id == text_id + && current.anchor.element_id == text_id + && current.focus.element_id == text_id + }) .map(|current| selection_navigation_anchor_and_focus(current, &event.key).1) .unwrap_or(input_fields[input_index].caret), &event.key, ) { let anchor = selection - .filter(|current| current.element_id == text_id) + .filter(|current| { + current.group_id == text_id + && current.anchor.element_id == text_id + && current.focus.element_id == text_id + }) .map_or(input_fields[input_index].caret, |current| { selection_navigation_anchor_and_focus(current, &event.key).0 }); - let next_selection = TextSelection { - element_id: text_id, - anchor, - focus: next_caret, - }; + let next_selection = single_text_selection(text_id, anchor, next_caret); outcome.selection_changed = *selection != Some(next_selection); *selection = Some(next_selection); if input_fields[input_index].caret != next_caret { @@ -1194,11 +1835,11 @@ fn handle_keyboard_input_event( &event.key, ) { - let next_selection = TextSelection { - element_id: current_selection.element_id, - anchor: selection_navigation_anchor_and_focus(current_selection, &event.key).0, - focus: next_focus, - }; + let next_selection = single_text_selection( + current_selection.focus.element_id, + selection_navigation_anchor_and_focus(current_selection, &event.key).0, + next_focus, + ); outcome.selection_changed = *selection != Some(next_selection); *selection = Some(next_selection); } else { @@ -1288,16 +1929,18 @@ fn scene_with_overlays( for item in scene.items.drain(..) { if let Some(selection) = selection && let DisplayItem::Text(prepared_text) = &item - && prepared_text.element_id == Some(selection.element_id) + && let Some(element_id) = prepared_text.element_id + && let Some(range) = + selection_range_for_prepared_text(interaction_tree, selection, element_id) { - for rect in prepared_text.selection_rects(selection.anchor, selection.focus) { + for rect in prepared_text.selection_rects(range.start, range.end) { items.push(DisplayItem::Quad(Quad::new( rect, prepared_text.selection_style.highlight_color, ))); } let mut selected_text = prepared_text.clone(); - selected_text.apply_selected_text_color(selection.anchor, selection.focus); + selected_text.apply_selected_text_color(range.start, range.end); items.push(DisplayItem::Text(selected_text)); continue; } @@ -1381,6 +2024,7 @@ fn build_document_tree( viewport: UiSize, hovered_card: Option, hero_image: &ImageResource, + status_scroll_offset: f32, input_fields: &[InputFieldState], focused_element: Option, ) -> Element { @@ -1528,16 +2172,11 @@ fn build_document_tree( }, ), rich_sidebar_card(hovered_card, gutter), - sidebar_card( - STATUS_CARD_ID, + status_scroll_card( hovered_card, - "Status", - "Static layout, responsive resize, paragraph wrapping, centered headings, line clamping, and drag-to-copy text selection all share the same UI pipeline now.", + focused_element, gutter, - SidebarCardOptions { - align: Some(TextAlign::End), - ..SidebarCardOptions::default() - }, + status_scroll_offset, ), ]), ]), @@ -1604,6 +2243,105 @@ fn sidebar_card( ]) } +fn status_scrollbar_style(active: bool) -> ScrollbarStyle { + ScrollbarStyle::new( + 15.0, + if active { + Color::rgba(0xF5, 0xD0, 0x74, 0x24) + } else { + Color::rgba(0xFF, 0xFF, 0xFF, 0x12) + }, + if active { + Color::rgba(0xF5, 0xD0, 0x74, 0xC8) + } else { + Color::rgba(0xC8, 0xD1, 0xE3, 0x90) + }, + ) + .with_corner_radius(999.0) + .with_min_thumb_size(36.0) +} + +fn status_scroll_card( + hovered_card: Option, + focused_element: Option, + gutter: f32, + status_scroll_offset: f32, +) -> Element { + let scrollbox_focused = focused_element == Some(STATUS_SCROLLBOX_ID); + let scrollbox_active = scrollbox_focused || hovered_card == Some(STATUS_CARD_ID); + + Element::column() + .id(STATUS_CARD_ID) + .padding(Edges::all(gutter * 0.9)) + .gap(gutter * 0.35) + .background(card_background(STATUS_CARD_ID, hovered_card)) + .children([ + Element::paragraph( + "Scrollable status", + TextStyle::new(18.0, card_title_color(STATUS_CARD_ID, hovered_card)) + .with_line_height(24.0), + ) + .id(card_text_id(STATUS_CARD_ID, 1)), + Element::scroll_box(status_scroll_offset) + .id(STATUS_SCROLLBOX_ID) + .height((gutter * 8.8).clamp(170.0, 230.0)) + .padding(Edges { + top: 0.0, + right: 0.0, + bottom: 0.0, + left: gutter * 0.45, + }) + .gap(gutter * 0.45) + .background(Color::rgb(0x12, 0x1A, 0x27)) + .border( + 1.0, + if scrollbox_active { + Color::rgba(0xF5, 0xD0, 0x74, 0xA8) + } else { + Color::rgba(0x8B, 0x99, 0xAD, 0x52) + }, + ) + // .corner_radius(gutter * 0.45) + .corner_radius(7.5) + .scrollbar_style(status_scrollbar_style(scrollbox_active)) + .children([ + Element::rich_paragraph( + [ + TextSpan::new("This box is clipped and "), + TextSpan::new("wheel-scrollable") + .weight(TextSpanWeight::Semibold) + .color(Color::rgb(0xF5, 0xD0, 0x74)), + TextSpan::new( + ". Click it to focus, then use the arrow keys or Home/End to move through the content.", + ), + ], + demo_body_style(15.5, Color::rgb(0xD4, 0xDB, 0xEA)).with_line_height(24.0), + ) + .id(status_scroll_text_id(1)), + Element::paragraph( + "Layout notes: the viewport reserves scrollbar gutter width up front, children are laid out in their own content space, and the renderer clips the scrolled content before painting the track and thumb.", + demo_body_style(15.5, Color::rgb(0xD4, 0xDB, 0xEA)).with_line_height(24.0), + ) + .id(status_scroll_text_id(2)), + Element::paragraph( + "Interaction notes: wheel input targets the nearest scrollbox ancestor under the pointer, while keyboard scrolling follows focus. That keeps text inputs and scroll regions from fighting each other.", + demo_body_style(15.5, Color::rgb(0xD4, 0xDB, 0xEA)).with_line_height(24.0), + ) + .id(status_scroll_text_id(3)), + Element::paragraph( + "Why this cut: reserved gutters make sizing stable, rounded clipping keeps the scrolled content inside the container shape, and the same decorated-container primitives can draw both the viewport shell and the scrollbar itself.", + demo_body_style(15.5, Color::rgb(0xD4, 0xDB, 0xEA)).with_line_height(24.0), + ) + .id(status_scroll_text_id(4)), + Element::paragraph( + "Next up after this should be richer scrollbar behavior and more generalized scroll state, but this baseline is enough to prove layout, clipping, focus, wheel input, and keyboard input all cooperate.", + demo_body_style(15.5, Color::rgb(0xD4, 0xDB, 0xEA)).with_line_height(24.0), + ) + .id(status_scroll_text_id(5)), + ]), + ]) +} + fn rich_sidebar_card(hovered_card: Option, gutter: f32) -> Element { Element::column() .id(NOTE_CARD_ID) diff --git a/lib/ui/src/interaction.rs b/lib/ui/src/interaction.rs index 7dd7692..9b809c9 100644 --- a/lib/ui/src/interaction.rs +++ b/lib/ui/src/interaction.rs @@ -7,11 +7,12 @@ pub enum PointerButton { Middle, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, PartialEq)] pub enum PointerEventKind { Move, Down { button: PointerButton }, Up { button: PointerButton }, + Scroll { delta: Point }, LeaveWindow, } @@ -32,13 +33,14 @@ impl PointerEvent { } } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, PartialEq)] pub enum RoutedPointerEventKind { Enter, Leave, Move, Down { button: PointerButton }, Up { button: PointerButton }, + Scroll { delta: Point }, } #[derive(Clone, Debug, PartialEq)] @@ -142,6 +144,16 @@ impl PointerRouter { }); } } + PointerEventKind::Scroll { delta } => { + if let Some(target) = hit_target { + routed.push(RoutedPointerEvent { + kind: RoutedPointerEventKind::Scroll { delta }, + target, + pointer_id: event.pointer_id, + position: event.position, + }); + } + } PointerEventKind::LeaveWindow => {} } @@ -165,9 +177,11 @@ mod tests { element_id: None, rect: Rect::new(0.0, 0.0, 200.0, 120.0), corner_radius: 0.0, + clip_rect: None, pointer_events: false, focusable: false, cursor: CursorIcon::Default, + scroll_metrics: None, prepared_image: None, prepared_text: None, children: vec![ @@ -176,9 +190,11 @@ mod tests { element_id: Some(ElementId::new(1)), rect: Rect::new(0.0, 0.0, 120.0, 120.0), corner_radius: 0.0, + clip_rect: None, pointer_events: true, focusable: false, cursor: CursorIcon::Default, + scroll_metrics: None, prepared_image: None, prepared_text: None, children: Vec::new(), @@ -188,9 +204,11 @@ mod tests { element_id: Some(ElementId::new(2)), rect: Rect::new(80.0, 0.0, 120.0, 120.0), corner_radius: 0.0, + clip_rect: None, pointer_events: true, focusable: false, cursor: CursorIcon::Default, + scroll_metrics: None, prepared_image: None, prepared_text: None, children: Vec::new(), @@ -207,9 +225,11 @@ mod tests { element_id: None, rect: Rect::new(0.0, 0.0, 200.0, 120.0), corner_radius: 0.0, + clip_rect: None, pointer_events: false, focusable: false, cursor: CursorIcon::Default, + scroll_metrics: None, prepared_image: None, prepared_text: None, children: vec![LayoutNode { @@ -217,9 +237,11 @@ mod tests { element_id: Some(ElementId::new(1)), rect: Rect::new(0.0, 0.0, 160.0, 120.0), corner_radius: 0.0, + clip_rect: None, pointer_events: true, focusable: false, cursor: CursorIcon::Default, + scroll_metrics: None, prepared_image: None, prepared_text: None, children: vec![LayoutNode { @@ -227,9 +249,11 @@ mod tests { element_id: Some(ElementId::new(2)), rect: Rect::new(16.0, 16.0, 80.0, 40.0), corner_radius: 0.0, + clip_rect: None, pointer_events: true, focusable: false, cursor: CursorIcon::Default, + scroll_metrics: None, prepared_image: None, prepared_text: None, children: Vec::new(), @@ -290,6 +314,35 @@ mod tests { ); } + #[test] + fn router_routes_scroll_to_deepest_hover_target() { + let mut router = PointerRouter::new(); + let tree = nested_interaction_tree(); + let _ = router.route( + &tree, + PointerEvent::new(0, Point::new(24.0, 24.0), PointerEventKind::Move), + ); + + let routed = router.route( + &tree, + PointerEvent::new( + 0, + Point::new(24.0, 24.0), + PointerEventKind::Scroll { + delta: Point::new(0.0, 18.0), + }, + ), + ); + + assert!(routed.iter().any(|event| { + event.kind + == RoutedPointerEventKind::Scroll { + delta: Point::new(0.0, 18.0), + } + && event.target.element_id == Some(ElementId::new(2)) + })); + } + #[test] fn router_tracks_hovered_ancestors_for_nested_hits() { let mut router = PointerRouter::new(); diff --git a/lib/ui/src/layout.rs b/lib/ui/src/layout.rs index 7276bd2..de37c94 100644 --- a/lib/ui/src/layout.rs +++ b/lib/ui/src/layout.rs @@ -61,14 +61,26 @@ pub struct LayoutNode { pub element_id: Option, pub rect: Rect, pub corner_radius: f32, + pub clip_rect: Option, pub pointer_events: bool, pub focusable: bool, pub cursor: CursorIcon, + pub scroll_metrics: Option, pub prepared_image: Option, pub prepared_text: Option, pub children: Vec, } +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ScrollMetrics { + pub viewport_rect: Rect, + pub content_height: f32, + pub offset_y: f32, + pub max_offset_y: f32, + pub scrollbar_track: Option, + pub scrollbar_thumb: Option, +} + #[derive(Clone, Debug, PartialEq)] pub struct TextHitTarget { pub target: HitTarget, @@ -100,6 +112,10 @@ impl InteractionTree { pub fn text_for_element(&self, element_id: ElementId) -> Option<&PreparedText> { text_for_element_node(&self.root, element_id) } + + pub fn scroll_metrics_for_element(&self, element_id: ElementId) -> Option<&ScrollMetrics> { + scroll_metrics_for_element_node(&self.root, element_id) + } } pub fn layout_snapshot(version: u64, logical_size: UiSize, root: &Element) -> LayoutSnapshot { @@ -191,9 +207,11 @@ fn layout_element( element_id: element.id, rect, corner_radius: uniform_corner_radius(&element.style, rect), + clip_rect: None, pointer_events: element.style.pointer_events, focusable: element.style.focusable, cursor, + scroll_metrics: None, prepared_image: None, prepared_text: None, children: Vec::new(), @@ -264,6 +282,98 @@ fn layout_element( return interaction; } + if let Some(scroll_box) = element.scroll_box_node() { + perf_stats.container_nodes += 1; + let content = inset_rect(rect, content_insets(&element.style)); + if content.size.width > 0.0 && content.size.height > 0.0 { + let gutter_width = scroll_box + .scrollbar + .gutter_width + .max(0.0) + .min(content.size.width); + let viewport_rect = Rect::new( + content.origin.x, + content.origin.y, + (content.size.width - gutter_width).max(0.0), + content.size.height, + ); + interaction.clip_rect = Some(viewport_rect); + + let content_size = intrinsic_container_content_size( + element, + UiSize::new( + viewport_rect.size.width.max(0.0), + viewport_rect.size.height.max(0.0), + ), + text_system, + perf_stats, + ); + let provisional_content_height = content_size.height.max(viewport_rect.size.height); + let mut offset_y = scroll_box.offset_y.max(0.0); + + if viewport_rect.size.width > 0.0 && viewport_rect.size.height > 0.0 { + scene.push_clip(viewport_rect, 0.0); + interaction.children = layout_container_children( + element, + Rect::new( + viewport_rect.origin.x, + viewport_rect.origin.y - offset_y, + viewport_rect.size.width, + provisional_content_height, + ), + &interaction.path, + scene, + text_system, + perf_stats, + ); + scene.pop_clip(); + } + + let actual_content_height = interaction + .children + .iter() + .filter_map(max_layout_node_bottom) + .fold(viewport_rect.origin.y - offset_y, f32::max) + - (viewport_rect.origin.y - offset_y); + let content_height = provisional_content_height + .max(actual_content_height.ceil()) + .max(viewport_rect.size.height); + let max_offset_y = (content_height - viewport_rect.size.height).max(0.0); + offset_y = offset_y.min(max_offset_y); + interaction.scroll_metrics = Some(ScrollMetrics { + viewport_rect, + content_height, + offset_y, + max_offset_y, + scrollbar_track: None, + scrollbar_thumb: None, + }); + + let (scrollbar_track, scrollbar_thumb) = scrollbar_geometry( + content, + scroll_box.scrollbar, + content_height, + viewport_rect.size.height, + offset_y, + ); + if let Some(scroll_metrics) = interaction.scroll_metrics.as_mut() { + scroll_metrics.scrollbar_track = scrollbar_track; + scroll_metrics.scrollbar_thumb = scrollbar_thumb; + } + paint_scrollbar( + scene, + scroll_box.scrollbar, + scrollbar_track, + scrollbar_thumb, + perf_stats, + ); + } + if pushed_clip { + scene.pop_clip(); + } + return interaction; + } + perf_stats.container_nodes += 1; if element.children.is_empty() { @@ -274,90 +384,14 @@ fn layout_element( if content.size.width <= 0.0 || content.size.height <= 0.0 { return interaction; } - - let gap_count = element.children.len().saturating_sub(1) as f32; - let total_gap = element.style.gap * gap_count; - let available_main = main_axis_size(content.size, element.style.direction).max(0.0) - total_gap; - let available_main = available_main.max(0.0); - let available_cross = cross_axis_size(content.size, element.style.direction).max(0.0); - - let mut measured_children = Vec::with_capacity(element.children.len()); - let mut fixed_total = 0.0; - let mut flex_total = 0.0; - for child in &element.children { - let cross = child_cross_size(child, element.style.direction) - .unwrap_or(available_cross) - .clamp(0.0, available_cross); - let explicit_main = - child_main_size(child, element.style.direction).map(|main| main.max(0.0)); - let is_flex = explicit_main.is_none() && child.style.flex_grow > 0.0; - let measured_main = explicit_main.unwrap_or_else(|| { - if is_flex { - 0.0 - } else { - perf_stats.intrinsic_calls += 1; - if child.text_node().is_some() { - perf_stats.intrinsic_text_calls += 1; - } else { - perf_stats.intrinsic_container_calls += 1; - } - let intrinsic_started = perf_stats.enabled.then(Instant::now); - let intrinsic = intrinsic_main_size( - child, - element.style.direction, - cross, - available_main, - text_system, - perf_stats, - ); - if let Some(intrinsic_started) = intrinsic_started { - perf_stats.intrinsic_ms += intrinsic_started.elapsed().as_secs_f64() * 1_000.0; - } - intrinsic - } - }); - if is_flex { - flex_total += child_flex_weight(child); - } else { - fixed_total += measured_main; - } - measured_children.push(MeasuredChild { - cross, - main: measured_main, - is_flex, - }); - } - - let remaining_main = (available_main - fixed_total).max(0.0); - let mut cursor = main_axis_origin(content, element.style.direction); - - for (index, (child, measured)) in element.children.iter().zip(measured_children).enumerate() { - let child_main = if measured.is_flex { - if flex_total <= 0.0 { - 0.0 - } else { - remaining_main * (child_flex_weight(child) / flex_total) - } - } else { - measured.main - }; - let child_rect = child_rect( - content, - element.style.direction, - cursor, - child_main.max(0.0), - measured.cross, - ); - interaction.children.push(layout_element( - child, - child_rect, - interaction.path.child(index), - scene, - text_system, - perf_stats, - )); - cursor += child_main.max(0.0) + element.style.gap; - } + interaction.children = layout_container_children( + element, + content, + &interaction.path, + scene, + text_system, + perf_stats, + ); if pushed_clip { scene.pop_clip(); @@ -371,18 +405,23 @@ fn hit_path_node(node: &LayoutNode, point: crate::scene::Point) -> Option Option Option<&Pr None } +fn scroll_metrics_for_element_node( + node: &LayoutNode, + element_id: ElementId, +) -> Option<&ScrollMetrics> { + if node.element_id == Some(element_id) + && let Some(scroll_metrics) = node.scroll_metrics.as_ref() + { + return Some(scroll_metrics); + } + + for child in &node.children { + if let Some(scroll_metrics) = scroll_metrics_for_element_node(child, element_id) { + return Some(scroll_metrics); + } + } + + None +} + fn point_hits_node_shape(node: &LayoutNode, point: crate::scene::Point) -> bool { node.rect.contains(point) && (node.corner_radius <= 0.0 @@ -496,6 +559,104 @@ struct MeasuredChild { is_flex: bool, } +fn layout_container_children( + element: &Element, + content: Rect, + path: &LayoutPath, + scene: &mut SceneSnapshot, + text_system: &mut TextSystem, + perf_stats: &mut LayoutPerfStats, +) -> Vec { + if element.children.is_empty() || content.size.width <= 0.0 || content.size.height <= 0.0 { + return Vec::new(); + } + + let gap_count = element.children.len().saturating_sub(1) as f32; + let total_gap = element.style.gap * gap_count; + let available_main = + (main_axis_size(content.size, element.style.direction).max(0.0) - total_gap).max(0.0); + let available_cross = cross_axis_size(content.size, element.style.direction).max(0.0); + + let mut measured_children = Vec::with_capacity(element.children.len()); + let mut fixed_total = 0.0; + let mut flex_total = 0.0; + for child in &element.children { + let cross = child_cross_size(child, element.style.direction) + .unwrap_or(available_cross) + .clamp(0.0, available_cross); + let explicit_main = + child_main_size(child, element.style.direction).map(|main| main.max(0.0)); + let is_flex = explicit_main.is_none() && child.style.flex_grow > 0.0; + let measured_main = explicit_main.unwrap_or_else(|| { + if is_flex { + 0.0 + } else { + perf_stats.intrinsic_calls += 1; + if child.text_node().is_some() { + perf_stats.intrinsic_text_calls += 1; + } else { + perf_stats.intrinsic_container_calls += 1; + } + let intrinsic_started = perf_stats.enabled.then(Instant::now); + let intrinsic = intrinsic_main_size( + child, + element.style.direction, + cross, + available_main, + text_system, + perf_stats, + ); + if let Some(intrinsic_started) = intrinsic_started { + perf_stats.intrinsic_ms += intrinsic_started.elapsed().as_secs_f64() * 1_000.0; + } + intrinsic + } + }); + if is_flex { + flex_total += child_flex_weight(child); + } else { + fixed_total += measured_main; + } + measured_children.push(MeasuredChild { + cross, + main: measured_main, + is_flex, + }); + } + + let remaining_main = (available_main - fixed_total).max(0.0); + let mut cursor = main_axis_origin(content, element.style.direction); + let mut children = Vec::with_capacity(element.children.len()); + for (index, (child, measured)) in element.children.iter().zip(measured_children).enumerate() { + let child_main = if measured.is_flex { + if flex_total <= 0.0 { + 0.0 + } else { + remaining_main * (child_flex_weight(child) / flex_total) + } + } else { + measured.main + }; + let child_rect = child_rect( + content, + element.style.direction, + cursor, + child_main.max(0.0), + measured.cross, + ); + children.push(layout_element( + child, + child_rect, + path.child(index), + scene, + text_system, + perf_stats, + )); + cursor += child_main.max(0.0) + element.style.gap; + } + children +} + fn intrinsic_main_size( child: &Element, direction: FlexDirection, @@ -530,58 +691,18 @@ fn intrinsic_main_size( ) } -fn intrinsic_size( +fn intrinsic_container_content_size( element: &Element, - available_size: UiSize, + content_size: UiSize, text_system: &mut TextSystem, perf_stats: &mut LayoutPerfStats, ) -> UiSize { - perf_stats.intrinsic_size_calls += 1; - let insets = content_insets(&element.style); - if let Some(text) = element.text_node() { - let measured = text_system.measure_spans( - &text.spans, - &text.style, - Some(available_size.width.max(0.0)), - Some(available_size.height.max(0.0)), - ); - return UiSize::new( - element - .style - .width - .unwrap_or(measured.width + horizontal_insets(insets)), - element - .style - .height - .unwrap_or(measured.height + vertical_insets(insets)), - ); - } - - if let Some(image) = element.image_node() { - let resolved = resolve_image_element_size(element, image.resource.size()); - return UiSize::new( - resolved.width + horizontal_insets(insets), - resolved.height + vertical_insets(insets), - ); - } - - let explicit_width = element.style.width; - let explicit_height = element.style.height; - let content_width = - explicit_width.unwrap_or(available_size.width).max(0.0) - horizontal_insets(insets); - let content_height = - explicit_height.unwrap_or(available_size.height).max(0.0) - vertical_insets(insets); - let content_size = UiSize::new(content_width.max(0.0), content_height.max(0.0)); - if element.children.is_empty() { - return UiSize::new( - explicit_width.unwrap_or(horizontal_insets(insets)), - explicit_height.unwrap_or(vertical_insets(insets)), - ); + return UiSize::new(0.0, 0.0); } let gap_total = element.style.gap * element.children.len().saturating_sub(1) as f32; - let (intrinsic_content_width, intrinsic_content_height) = match element.style.direction { + match element.style.direction { FlexDirection::Column => { let mut width: f32 = 0.0; let mut height = gap_total; @@ -601,7 +722,7 @@ fn intrinsic_size( height += child.style.height.unwrap_or(child_size.height); } } - (width, height) + UiSize::new(width, height) } FlexDirection::Row => { let mut width = gap_total; @@ -653,13 +774,74 @@ fn intrinsic_size( } height = height.max(child.style.height.unwrap_or(child_size.height)); } - (width, height) + UiSize::new(width, height) } - }; + } +} + +fn intrinsic_size( + element: &Element, + available_size: UiSize, + text_system: &mut TextSystem, + perf_stats: &mut LayoutPerfStats, +) -> UiSize { + perf_stats.intrinsic_size_calls += 1; + let insets = content_insets(&element.style); + if let Some(text) = element.text_node() { + let measured = text_system.measure_spans( + &text.spans, + &text.style, + Some(available_size.width.max(0.0)), + Some(available_size.height.max(0.0)), + ); + return UiSize::new( + element + .style + .width + .unwrap_or(measured.width + horizontal_insets(insets)), + element + .style + .height + .unwrap_or(measured.height + vertical_insets(insets)), + ); + } + + if let Some(image) = element.image_node() { + let resolved = resolve_image_element_size(element, image.resource.size()); + return UiSize::new( + resolved.width + horizontal_insets(insets), + resolved.height + vertical_insets(insets), + ); + } + + let explicit_width = element.style.width; + let explicit_height = element.style.height; + let scroll_gutter = element + .scroll_box_node() + .map_or(0.0, |scroll_box| scroll_box.scrollbar.gutter_width.max(0.0)); + let content_width = + explicit_width.unwrap_or(available_size.width).max(0.0) - horizontal_insets(insets); + let content_height = + explicit_height.unwrap_or(available_size.height).max(0.0) - vertical_insets(insets); + let content_size = UiSize::new( + (content_width - scroll_gutter).max(0.0), + content_height.max(0.0), + ); + + if element.children.is_empty() { + return UiSize::new( + explicit_width.unwrap_or(horizontal_insets(insets) + scroll_gutter), + explicit_height.unwrap_or(vertical_insets(insets)), + ); + } + + let intrinsic_content = + intrinsic_container_content_size(element, content_size, text_system, perf_stats); UiSize::new( - explicit_width.unwrap_or(intrinsic_content_width + horizontal_insets(insets)), - explicit_height.unwrap_or(intrinsic_content_height + vertical_insets(insets)), + explicit_width + .unwrap_or(intrinsic_content.width + horizontal_insets(insets) + scroll_gutter), + explicit_height.unwrap_or(intrinsic_content.height + vertical_insets(insets)), ) } @@ -759,6 +941,116 @@ fn resolve_image_element_size(element: &Element, intrinsic: UiSize) -> UiSize { } } +fn scrollbar_geometry( + content_rect: Rect, + scrollbar: crate::ScrollbarStyle, + content_height: f32, + viewport_height: f32, + offset_y: f32, +) -> (Option, Option) { + let gutter_width = scrollbar.gutter_width.max(0.0).min(content_rect.size.width); + if gutter_width <= 0.0 || content_rect.size.height <= 0.0 { + return (None, None); + } + + let gutter_rect = Rect::new( + content_rect.origin.x + content_rect.size.width - gutter_width, + content_rect.origin.y, + gutter_width, + content_rect.size.height, + ); + let track_rect = inset_rect( + gutter_rect, + Edges { + top: 0.0, + right: 0.0, + bottom: 0.0, + left: (gutter_width * 0.16).clamp(1.0, gutter_width * 0.5), + }, + ); + if track_rect.size.width <= 0.0 || track_rect.size.height <= 0.0 { + return (None, None); + } + + let max_offset_y = (content_height - viewport_height).max(0.0); + if max_offset_y <= 0.0 { + return (Some(track_rect), None); + } + + let thumb_height = ((viewport_height / content_height.max(viewport_height)) + * track_rect.size.height) + .max(scrollbar.min_thumb_size.max(0.0)) + .min(track_rect.size.height); + let thumb_range = (track_rect.size.height - thumb_height).max(0.0); + let thumb_origin_y = track_rect.origin.y + thumb_range * (offset_y / max_offset_y.max(1.0)); + let thumb_rect = Rect::new( + track_rect.origin.x, + thumb_origin_y, + track_rect.size.width, + thumb_height, + ); + (Some(track_rect), Some(thumb_rect)) +} + +fn paint_scrollbar( + scene: &mut SceneSnapshot, + scrollbar: crate::ScrollbarStyle, + track_rect: Option, + thumb_rect: Option, + perf_stats: &mut LayoutPerfStats, +) { + let radius_for = |rect: Rect| { + scrollbar + .corner_radius + .max(0.0) + .min(rect.size.width * 0.5) + .min(rect.size.height * 0.5) + }; + + if let Some(track_rect) = track_rect { + perf_stats.background_quads += 1; + scene.push_rounded_rect(RoundedRect { + rect: track_rect, + fill: Some(scrollbar.track_color), + border_color: None, + border_width: 0.0, + radius: radius_for(track_rect), + }); + } + + if let Some(thumb_rect) = thumb_rect { + perf_stats.background_quads += 1; + scene.push_rounded_rect(RoundedRect { + rect: thumb_rect, + fill: Some(scrollbar.thumb_color), + border_color: None, + border_width: 0.0, + radius: radius_for(thumb_rect), + }); + } +} + +fn max_layout_node_bottom(node: &LayoutNode) -> Option { + let mut max_bottom = + (node.rect.size.height > 0.0).then_some(node.rect.origin.y + node.rect.size.height); + if let Some(prepared_text) = node.prepared_text.as_ref() + && let Some(bounds) = prepared_text.bounds + { + let text_bottom = prepared_text.origin.y + bounds.height; + max_bottom = Some(max_bottom.map_or(text_bottom, |current| current.max(text_bottom))); + } + if let Some(prepared_image) = node.prepared_image.as_ref() { + let image_bottom = prepared_image.rect.origin.y + prepared_image.rect.size.height; + max_bottom = Some(max_bottom.map_or(image_bottom, |current| current.max(image_bottom))); + } + for child in &node.children { + if let Some(child_bottom) = max_layout_node_bottom(child) { + max_bottom = Some(max_bottom.map_or(child_bottom, |current| current.max(child_bottom))); + } + } + max_bottom +} + fn push_box_shadows( scene: &mut SceneSnapshot, rect: Rect, @@ -1270,6 +1562,42 @@ mod tests { ); } + #[test] + fn scroll_box_exposes_scroll_metrics_and_reserves_scrollbar_gutter() { + let scrollbox_id = ElementId::new(9); + let root = Element::column().child( + Element::scroll_box(48.0) + .id(scrollbox_id) + .width(180.0) + .height(96.0) + .padding(Edges::all(8.0)) + .children([ + Element::paragraph( + "Scroll boxes should clip oversized content and reserve space for a scrollbar.", + TextStyle::new(16.0, Color::rgb(0xFF, 0xFF, 0xFF)).with_line_height(22.0), + ), + Element::paragraph( + "This extra paragraph forces overflow so the layout exposes max offset and scrollbar geometry.", + TextStyle::new(16.0, Color::rgb(0xFF, 0xFF, 0xFF)).with_line_height(22.0), + ), + Element::paragraph( + "Keyboard and wheel handling use these metrics to clamp scrolling in the demo.", + TextStyle::new(16.0, Color::rgb(0xFF, 0xFF, 0xFF)).with_line_height(22.0), + ), + ]), + ); + + let snapshot = layout_snapshot(1, UiSize::new(240.0, 220.0), &root); + let scroll_metrics = snapshot + .interaction_tree + .scroll_metrics_for_element(scrollbox_id) + .expect("scroll box should expose scroll metrics"); + assert!(scroll_metrics.max_offset_y > 0.0); + assert!(scroll_metrics.viewport_rect.size.width < 180.0); + assert!(scroll_metrics.scrollbar_track.is_some()); + assert!(scroll_metrics.scrollbar_thumb.is_some()); + } + #[test] fn interaction_tree_hit_test_returns_deepest_pointer_target() { let root = Element::column() diff --git a/lib/ui/src/lib.rs b/lib/ui/src/lib.rs index 4be9176..071e98e 100644 --- a/lib/ui/src/lib.rs +++ b/lib/ui/src/lib.rs @@ -28,8 +28,8 @@ pub use interaction::{ }; pub use keyboard::{KeyboardEvent, KeyboardEventKind, KeyboardKey, KeyboardModifiers}; pub use layout::{ - HitTarget, InteractionTree, LayoutNode, LayoutPath, LayoutSnapshot, TextHitTarget, - layout_snapshot, layout_snapshot_with_text_system, + HitTarget, InteractionTree, LayoutNode, LayoutPath, LayoutSnapshot, ScrollMetrics, + TextHitTarget, layout_snapshot, layout_snapshot_with_text_system, }; pub use layout::{layout_scene, layout_scene_with_text_system}; pub use platform::{ @@ -47,7 +47,7 @@ pub use text::{ }; pub use tree::{ Border, BoxShadow, BoxShadowKind, CornerRadius, CursorIcon, Edges, Element, ElementId, - FlexDirection, Style, + FlexDirection, ScrollbarStyle, Style, }; pub use window::{ DecorationMode, WindowConfigured, WindowId, WindowLifecycle, WindowSpec, WindowUpdate, diff --git a/lib/ui/src/tree.rs b/lib/ui/src/tree.rs index 3c32645..807a378 100644 --- a/lib/ui/src/tree.rs +++ b/lib/ui/src/tree.rs @@ -134,6 +134,47 @@ impl BoxShadow { } } +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ScrollbarStyle { + pub gutter_width: f32, + pub track_color: Color, + pub thumb_color: Color, + pub corner_radius: f32, + pub min_thumb_size: f32, +} + +impl ScrollbarStyle { + pub const fn new(gutter_width: f32, track_color: Color, thumb_color: Color) -> Self { + Self { + gutter_width, + track_color, + thumb_color, + corner_radius: 999.0, + min_thumb_size: 28.0, + } + } + + pub const fn with_corner_radius(mut self, corner_radius: f32) -> Self { + self.corner_radius = corner_radius; + self + } + + pub const fn with_min_thumb_size(mut self, min_thumb_size: f32) -> Self { + self.min_thumb_size = min_thumb_size; + self + } +} + +impl Default for ScrollbarStyle { + fn default() -> Self { + Self::new( + 14.0, + Color::rgba(0xFF, 0xFF, 0xFF, 0x12), + Color::rgba(0xD8, 0xDE, 0xEA, 0x88), + ) + } +} + #[derive(Clone, Debug, PartialEq)] pub struct Style { pub direction: FlexDirection, @@ -174,6 +215,7 @@ impl Default for Style { #[derive(Clone, Debug, PartialEq)] enum ElementContent { Container, + ScrollBox(ScrollBoxNode), Image(ImageNode), Text(TextNode), } @@ -190,6 +232,12 @@ pub(crate) struct ImageNode { pub fit: ImageFit, } +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct ScrollBoxNode { + pub offset_y: f32, + pub scrollbar: ScrollbarStyle, +} + #[derive(Clone, Debug, PartialEq)] pub struct Element { pub id: Option, @@ -240,6 +288,21 @@ impl Element { } } + pub fn scroll_box(offset_y: f32) -> Self { + Self { + id: None, + style: Style { + focusable: true, + ..Style::default() + }, + children: Vec::new(), + content: ElementContent::ScrollBox(ScrollBoxNode { + offset_y: offset_y.max(0.0), + scrollbar: ScrollbarStyle::default(), + }), + } + } + pub fn rich_paragraph(spans: impl IntoIterator, style: TextStyle) -> Self { Self::spans(spans, style.with_wrap(TextWrap::Word)) } @@ -330,6 +393,22 @@ impl Element { self } + pub fn scroll_offset(mut self, offset_y: f32) -> Self { + let ElementContent::ScrollBox(scroll_box) = &mut self.content else { + panic!("only scroll box elements can set scroll_offset"); + }; + scroll_box.offset_y = offset_y.max(0.0); + self + } + + pub fn scrollbar_style(mut self, scrollbar: ScrollbarStyle) -> Self { + let ElementContent::ScrollBox(scroll_box) = &mut self.content else { + panic!("only scroll box elements can set scrollbar_style"); + }; + scroll_box.scrollbar = scrollbar; + self + } + pub fn child(mut self, child: Element) -> Self { self.assert_container(); self.children.push(child); @@ -345,20 +424,34 @@ impl Element { pub(crate) fn text_node(&self) -> Option<&TextNode> { match &self.content { ElementContent::Text(text) => Some(text), - ElementContent::Container | ElementContent::Image(_) => None, + ElementContent::Container | ElementContent::ScrollBox(_) | ElementContent::Image(_) => { + None + } } } pub(crate) fn image_node(&self) -> Option<&ImageNode> { match &self.content { ElementContent::Image(image) => Some(image), - ElementContent::Container | ElementContent::Text(_) => None, + ElementContent::Container | ElementContent::ScrollBox(_) | ElementContent::Text(_) => { + None + } + } + } + + pub(crate) fn scroll_box_node(&self) -> Option<&ScrollBoxNode> { + match &self.content { + ElementContent::ScrollBox(scroll_box) => Some(scroll_box), + ElementContent::Container | ElementContent::Image(_) | ElementContent::Text(_) => None, } } fn assert_container(&self) { assert!( - matches!(self.content, ElementContent::Container), + matches!( + self.content, + ElementContent::Container | ElementContent::ScrollBox(_) + ), "non-container elements cannot contain children" ); } diff --git a/lib/ui_platform_wayland/src/lib.rs b/lib/ui_platform_wayland/src/lib.rs index a11c4a1..8a25a8b 100644 --- a/lib/ui_platform_wayland/src/lib.rs +++ b/lib/ui_platform_wayland/src/lib.rs @@ -1556,6 +1556,23 @@ impl Dispatch for State { .pending_pointer_events .push(PointerEvent::new(0, position, kind)); } + wl_pointer::Event::Axis { axis, value, .. } => { + let Some(position) = state.pointer_position else { + return; + }; + let delta = match axis { + WEnum::Value(wl_pointer::Axis::VerticalScroll) => Point::new(0.0, value as f32), + WEnum::Value(wl_pointer::Axis::HorizontalScroll) => { + Point::new(value as f32, 0.0) + } + WEnum::Value(_) | WEnum::Unknown(_) => return, + }; + state.pending_pointer_events.push(PointerEvent::new( + 0, + position, + PointerEventKind::Scroll { delta }, + )); + } _ => {} } }