//! Minimal app/runtime glue for RUIN application experiments. //! //! This crate is intentionally low-level. It is the substrate that a future proc-macro-driven //! component system can expand to, not the final ergonomic authoring API. extern crate self as ruin_app; use std::any::{Any, TypeId, type_name}; use std::cell::{Cell as StdCell, RefCell}; use std::collections::HashMap; use std::error::Error; use std::future::Future; use std::iter; use std::time::Instant; use std::marker::PhantomData; use std::rc::Rc; use ruin_reactivity::effect; use ruin_runtime::queue_future; use ruin_ui::{ Border, Color, CursorIcon, DisplayItem, Edges, Element, ElementId, HitTarget, InteractionTree, KeyboardEvent, KeyboardEventKind, KeyboardKey, LayoutCache, LayoutSnapshot, PlatformEvent, PointerButton, PointerEvent, PointerEventKind, PointerRouter, Quad, RoutedPointerEvent, RoutedPointerEventKind, ScrollbarStyle, TextFontFamily, TextSpan, TextSpanWeight, TextStyle, TextSystem, TextWrap, UiSize, WindowController, WindowSpec, WindowUpdate, layout_snapshot_with_cache, }; use ruin_ui_platform_wayland::start_wayland_ui; pub use ResourceState::{Pending, Ready}; pub use ruin_app_proc_macros::{component, context_provider, view}; pub type Result = std::result::Result>; #[derive(Clone, Debug)] pub struct Window { spec: WindowSpec, } impl Window { pub fn new() -> Self { Self { spec: WindowSpec::new("RUIN App"), } } pub fn title(mut self, title: impl Into) -> Self { self.spec.title = title.into(); self } pub fn app_id(mut self, app_id: impl Into) -> Self { self.spec = self.spec.app_id(app_id); self } pub fn size(mut self, width: f32, height: f32) -> Self { self.spec = self.spec.requested_inner_size(UiSize::new(width, height)); self } } impl Default for Window { fn default() -> Self { Self::new() } } pub struct App { window: Window, } impl App { pub fn new() -> Self { Self { window: Window::new(), } } pub fn window(mut self, window: Window) -> Self { self.window = window; self } pub fn mount(self, root: M) -> MountedApp { MountedApp { window: self.window, root: Rc::new(root), render_state: Rc::new(RenderState::default()), } } } impl Default for App { fn default() -> Self { Self::new() } } #[doc(hidden)] pub fn __render_mountable_for_test(mountable: &M) -> View { render_with_context(Rc::new(RenderState::default()), || mountable.render()).view } pub trait Mountable: 'static { fn render(&self) -> View; } pub trait Component: 'static { type Builder; fn builder() -> Self::Builder; fn render(&self) -> View; } pub trait ContextKey: 'static { type Value: Clone + 'static; } impl Mountable for T { fn render(&self) -> View { Component::render(self) } } impl Mountable for View { fn render(&self) -> View { self.clone() } } impl Mountable for Element { fn render(&self) -> View { View::from_element(self.clone()) } } pub struct MountedApp { window: Window, root: Rc, render_state: Rc, } impl MountedApp { pub async fn run(self) -> Result<()> { let MountedApp { window: app_window, root, render_state, } = self; let mut ui = start_wayland_ui(); let window = ui.create_window(app_window.spec.clone())?; let initial_viewport = app_window .spec .requested_inner_size .unwrap_or(UiSize::new(960.0, 640.0)); let viewport = ruin_reactivity::cell(initial_viewport); let scene_version = StdCell::new(0_u64); let text_system = Rc::new(RefCell::new(TextSystem::new())); let layout_cache = Rc::new(RefCell::new(LayoutCache::new())); let interaction_tree = Rc::new(RefCell::new(None::)); let bindings = Rc::new(RefCell::new(EventBindings::default())); let shortcuts = Rc::new(RefCell::new(Vec::::new())); let current_title = Rc::new(RefCell::new(None::)); let mut input_state = InputState::new(); let mut pointer_router = PointerRouter::new(); let _scene_effect = effect({ let window = window.clone(); let viewport = viewport.clone(); let text_system = Rc::clone(&text_system); let layout_cache = Rc::clone(&layout_cache); let interaction_tree = Rc::clone(&interaction_tree); let bindings = Rc::clone(&bindings); let shortcuts = Rc::clone(&shortcuts); let current_title = Rc::clone(¤t_title); let root = Rc::clone(&root); let render_state = Rc::clone(&render_state); let text_selection = Rc::clone(&input_state.text_selection); move || { let viewport = viewport.get(); let version = scene_version.get().wrapping_add(1); scene_version.set(version); let _ = text_selection.version.get(); let t_effect = Instant::now(); let render_output = render_with_context(Rc::clone(&render_state), || root.render()); if render_output.side_effects.window_title != *current_title.borrow() { if let Some(title) = &render_output.side_effects.window_title { window .update(WindowUpdate::new().title(title.clone())) .expect("window should remain alive while the app is running"); } *current_title.borrow_mut() = render_output.side_effects.window_title.clone(); } let render_us = t_effect.elapsed().as_micros(); let t_layout = Instant::now(); let LayoutSnapshot { mut scene, interaction_tree: next_interaction_tree, } = layout_snapshot_with_cache( version, viewport, render_output.view.element(), &mut text_system.borrow_mut(), &mut layout_cache.borrow_mut(), ); let layout_us = t_layout.elapsed().as_micros(); let effect_us = t_effect.elapsed().as_micros(); tracing::debug!( target: "ruin_app::resize", version, width = viewport.width, height = viewport.height, render_us, layout_us, effect_us, "scene effect complete, sending ReplaceScene" ); apply_text_selection_overlay(&mut scene, *text_selection.selection.borrow()); *interaction_tree.borrow_mut() = Some(next_interaction_tree); *bindings.borrow_mut() = render_output.view.bindings; *shortcuts.borrow_mut() = render_output.side_effects.shortcuts.clone(); window .replace_scene(scene) .expect("window should remain alive while the app is running"); } }); loop { let Some(event) = ui.next_event().await else { break; }; for event in iter::once(event).chain(ui.take_pending_events()) { match event { PlatformEvent::Configured { window_id, configuration, } if window_id == window.id() => { tracing::debug!( target: "ruin_app::resize", width = configuration.actual_inner_size.width, height = configuration.actual_inner_size.height, "app received Configured, queuing layout effect" ); let _ = viewport.set(configuration.actual_inner_size); } PlatformEvent::Pointer { window_id, event } if window_id == window.id() => { Self::handle_pointer_event( &window, &interaction_tree, &bindings, &mut pointer_router, &mut input_state, event, )?; } PlatformEvent::Keyboard { window_id, event } if window_id == window.id() => { Self::handle_keyboard_event( &interaction_tree, &bindings, &shortcuts, &input_state, event, )?; } PlatformEvent::CloseRequested { window_id } if window_id == window.id() => { let _ = window.update(WindowUpdate::new().open(false)); } PlatformEvent::Closed { window_id } if window_id == window.id() => { ui.shutdown()?; return Ok(()); } _ => {} } } } ui.shutdown()?; Ok(()) } fn handle_pointer_event( window: &WindowController, interaction_tree: &RefCell>, bindings: &RefCell, pointer_router: &mut PointerRouter, input_state: &mut InputState, event: PointerEvent, ) -> Result<()> { if matches!( event.kind, PointerEventKind::Down { button: PointerButton::Primary | PointerButton::Middle, } ) { let interaction_tree = interaction_tree.borrow(); if let Some(interaction_tree) = interaction_tree.as_ref() { input_state.focused_element = focused_element_for_pointer(interaction_tree, &event); } } let routed = { let interaction_tree = interaction_tree.borrow(); let Some(interaction_tree) = interaction_tree.as_ref() else { return Ok(()); }; pointer_router.route(interaction_tree, event) }; { let interaction_tree = interaction_tree.borrow(); let Some(interaction_tree) = interaction_tree.as_ref() else { return Ok(()); }; let hovered_targets = pointer_router.hovered_targets(); for routed_event in &routed { bindings .borrow() .dispatch(routed_event, interaction_tree, hovered_targets); Self::handle_text_selection_event( window, interaction_tree, routed_event, &input_state.text_selection, )?; } } let next_cursor = pointer_router .hovered_targets() .last() .map(|target| target.cursor) .unwrap_or(CursorIcon::Default); if next_cursor != input_state.current_cursor { input_state.current_cursor = next_cursor; window.set_cursor_icon(next_cursor)?; } Ok(()) } fn handle_keyboard_event( interaction_tree: &RefCell>, bindings: &RefCell, shortcuts: &RefCell>, input_state: &InputState, event: KeyboardEvent, ) -> Result<()> { let interaction_tree = interaction_tree.borrow(); let Some(interaction_tree) = interaction_tree.as_ref() else { return Ok(()); }; let shortcut_bindings = shortcuts.borrow().clone(); let mut consumed = false; for shortcut in shortcut_bindings { if shortcut.matches(&event, input_state.focused_element, interaction_tree) { shortcut.trigger(interaction_tree); consumed = true; } } if consumed { return Ok(()); } bindings .borrow() .dispatch_key(input_state.focused_element, &event, interaction_tree); Ok(()) } fn handle_text_selection_event( window: &WindowController, interaction_tree: &InteractionTree, event: &RoutedPointerEvent, text_selection: &TextSelectionState, ) -> Result<()> { let mut selection_changed = false; match event.kind { RoutedPointerEventKind::Down { button: PointerButton::Primary, } => { if let Some(hit) = interaction_tree.text_hit_test(event.position) && let Some(element_id) = hit.target.element_id { let next = Some(TextSelection { element_id, anchor: hit.byte_offset, focus: hit.byte_offset, }); if *text_selection.selection.borrow() != next { *text_selection.selection.borrow_mut() = next; selection_changed = true; } *text_selection.drag.borrow_mut() = Some(TextSelectionDrag { element_id, anchor: hit.byte_offset, }); } else { selection_changed = text_selection.selection.borrow_mut().take().is_some(); let _ = text_selection.drag.borrow_mut().take(); } } RoutedPointerEventKind::Move => { let Some(drag) = *text_selection.drag.borrow() else { return Ok(()); }; let Some(text) = interaction_tree.text_for_element(drag.element_id) else { return Ok(()); }; let next = Some(TextSelection { element_id: drag.element_id, anchor: drag.anchor, focus: text.byte_offset_for_position(event.position), }); if *text_selection.selection.borrow() != next { *text_selection.selection.borrow_mut() = next; selection_changed = true; } } RoutedPointerEventKind::Up { button: PointerButton::Primary, } => { if text_selection.drag.borrow_mut().take().is_some() { selection_changed = true; } } _ => {} } if selection_changed { text_selection.version.update(|value| *value += 1); sync_primary_selection(window, interaction_tree, *text_selection.selection.borrow())?; } Ok(()) } } #[derive(Clone, Default)] pub struct View { element: Element, bindings: EventBindings, } impl View { pub fn from_element(element: Element) -> Self { Self { element, bindings: EventBindings::default(), } } pub fn element(&self) -> &Element { &self.element } fn with_press_handler(mut self, element_id: ElementId, handler: PressHandler) -> Self { self.bindings.on_press.insert(element_id, handler); self } fn with_scroll_handler(mut self, element_id: ElementId, handler: ScrollHandler) -> Self { self.bindings.on_scroll.insert(element_id, handler); self } fn with_key_handler(mut self, element_id: ElementId, handler: KeyHandler) -> Self { self.bindings.on_key.insert(element_id, handler); self } fn from_container(element: Element, children: Vec) -> Self { let mut composed = Self::from_element(element); let mut element = composed.element; for child in children { element = element.child(child.element); composed.bindings.extend(child.bindings); } composed.element = element; composed } } impl From for View { fn from(element: Element) -> Self { Self::from_element(element) } } pub trait IntoView { fn into_view(self) -> View; } impl IntoView for View { fn into_view(self) -> View { self } } impl IntoView for Element { fn into_view(self) -> View { View::from_element(self) } } impl IntoView for T { fn into_view(self) -> View { self.render() } } pub trait Children { fn into_views(self) -> Vec; } #[derive(Clone, Default)] pub struct ChildViews(Vec); impl ChildViews { pub fn from_children(children: impl Children) -> Self { Self(children.into_views()) } pub fn into_vec(self) -> Vec { self.0 } } impl Children for () { fn into_views(self) -> Vec { Vec::new() } } impl Children for T { fn into_views(self) -> Vec { vec![self.into_view()] } } impl Children for Vec { fn into_views(self) -> Vec { self.into_iter().map(IntoView::into_view).collect() } } impl Children for ChildViews { fn into_views(self) -> Vec { self.0 } } macro_rules! impl_children_tuple { ($($name:ident),+ $(,)?) => { #[allow(non_camel_case_types)] impl<$($name: IntoView),+> Children for ($($name,)+) { fn into_views(self) -> Vec { let ($($name,)+) = self; vec![$($name.into_view(),)+] } } }; } impl_children_tuple!(a, b); impl_children_tuple!(a, b, c); impl_children_tuple!(a, b, c, d); impl_children_tuple!(a, b, c, d, e); impl_children_tuple!(a, b, c, d, e, f); impl_children_tuple!(a, b, c, d, e, f, g); impl_children_tuple!(a, b, c, d, e, f, g, h); pub trait TextChildren { fn into_text(self) -> String; } #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct TextValue(String); impl TextValue { pub fn from_text(children: impl TextChildren) -> Self { Self(children.into_text()) } pub fn into_string(self) -> String { self.0 } } impl TextChildren for TextValue { fn into_text(self) -> String { self.0 } } impl TextChildren for &'static str { fn into_text(self) -> String { self.to_string() } } impl TextChildren for String { fn into_text(self) -> String { self } } impl TextChildren for i32 { fn into_text(self) -> String { self.to_string() } } impl TextChildren for i64 { fn into_text(self) -> String { self.to_string() } } impl TextChildren for usize { fn into_text(self) -> String { self.to_string() } } impl TextChildren for u64 { fn into_text(self) -> String { self.to_string() } } impl TextChildren for f32 { fn into_text(self) -> String { self.to_string() } } impl TextChildren for Signal { fn into_text(self) -> String { self.with(|value| value.to_string()) } } impl TextChildren for Memo { fn into_text(self) -> String { self.with(|value| value.to_string()) } } macro_rules! impl_text_children_tuple { ($($name:ident),+ $(,)?) => { #[allow(non_camel_case_types)] impl<$($name: TextChildren),+> TextChildren for ($($name,)+) { fn into_text(self) -> String { let ($($name,)+) = self; let mut text = String::new(); $(text.push_str(&$name.into_text());)+ text } } }; } impl_text_children_tuple!(a, b); impl_text_children_tuple!(a, b, c); impl_text_children_tuple!(a, b, c, d); impl_text_children_tuple!(a, b, c, d, e); impl_text_children_tuple!(a, b, c, d, e, f); pub trait IntoEdges { fn into_edges(self) -> Edges; } impl IntoEdges for Edges { fn into_edges(self) -> Edges { self } } impl IntoEdges for f32 { fn into_edges(self) -> Edges { Edges::all(self) } } pub trait IntoBorder { fn into_border(self) -> Border; } impl IntoBorder for Border { fn into_border(self) -> Border { self } } impl IntoBorder for (f32, Color) { fn into_border(self) -> Border { Border::new(self.0, self.1) } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum TextRole { Body, Heading(u8), } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum FontWeight { Normal, Medium, Semibold, Bold, } impl FontWeight { const fn to_text_span_weight(self) -> TextSpanWeight { match self { Self::Normal => TextSpanWeight::Normal, Self::Medium => TextSpanWeight::Medium, Self::Semibold => TextSpanWeight::Semibold, Self::Bold => TextSpanWeight::Bold, } } } pub mod colors { use ruin_ui::Color; pub const fn text() -> Color { Color::rgb(0xFF, 0xFF, 0xFF) } pub const fn muted() -> Color { Color::rgb(0xB6, 0xC2, 0xD9) } pub const fn danger() -> Color { Color::rgb(0xFF, 0x7B, 0x72) } } pub mod surfaces { use ruin_ui::Color; pub const fn canvas() -> Color { Color::rgb(0x0F, 0x16, 0x25) } pub const fn raised() -> Color { Color::rgb(0x1B, 0x26, 0x3D) } pub const fn interactive() -> Color { Color::rgb(0x2B, 0x3A, 0x67) } pub const fn interactive_muted() -> Color { Color::rgb(0x3D, 0x4B, 0x72) } } pub fn column() -> ContainerBuilder { ContainerBuilder { element: Element::column().background(surfaces::canvas()), widget_ref: None, } } pub fn row() -> ContainerBuilder { ContainerBuilder { element: Element::row(), widget_ref: None, } } pub fn block() -> ContainerBuilder { ContainerBuilder { element: Element::column(), widget_ref: None, } } pub fn text() -> TextBuilder { TextBuilder::default() } pub fn button() -> ButtonBuilder { ButtonBuilder::default() } pub fn scroll_box() -> ScrollBoxBuilder { ScrollBoxBuilder { element: Element::scroll_box(0.0), offset_y: None, drag: with_hook_slot(|| Signal::new(None::), |drag| drag.clone()), widget_ref: None, } } pub struct ContainerBuilder { element: Element, widget_ref: Option>>, } impl ContainerBuilder { pub fn gap(mut self, gap: f32) -> Self { self.element = self.element.gap(gap); self } pub fn padding(mut self, padding: impl IntoEdges) -> Self { self.element = self.element.padding(padding.into_edges()); self } pub fn background(mut self, color: Color) -> Self { self.element = self.element.background(color); self } pub fn border(mut self, border: impl IntoBorder) -> Self { let border = border.into_border(); self.element = self.element.border(border.width, border.color); self } pub fn border_radius(mut self, radius: f32) -> Self { self.element = self.element.corner_radius(radius); self } pub fn width(mut self, width: f32) -> Self { self.element = self.element.width(width); self } pub fn height(mut self, height: f32) -> Self { self.element = self.element.height(height); self } pub fn flex(mut self, flex: f32) -> Self { self.element = self.element.flex(flex); self } pub fn widget_ref(mut self, widget_ref: WidgetRef) -> Self { self.widget_ref = Some(widget_ref.element_id.clone()); self } pub fn children(mut self, children: impl Children) -> View { if let Some(widget_ref) = &self.widget_ref { let element_id = allocate_element_id(); self.element = self.element.id(element_id); let _ = widget_ref.set(Some(element_id)); } View::from_container(self.element, children.into_views()) } } pub struct ScrollBoxBuilder { element: Element, offset_y: Option>, drag: Signal>, widget_ref: Option>>, } impl ScrollBoxBuilder { pub fn padding(mut self, padding: impl IntoEdges) -> Self { self.element = self.element.padding(padding.into_edges()); self } pub fn background(mut self, color: Color) -> Self { self.element = self.element.background(color); self } pub fn border(mut self, border: impl IntoBorder) -> Self { let border = border.into_border(); self.element = self.element.border(border.width, border.color); self } pub fn border_radius(mut self, radius: f32) -> Self { self.element = self.element.corner_radius(radius); self } pub fn width(mut self, width: f32) -> Self { self.element = self.element.width(width); self } pub fn height(mut self, height: f32) -> Self { self.element = self.element.height(height); self } pub fn flex(mut self, flex: f32) -> Self { self.element = self.element.flex(flex); self } pub fn scrollbar_style(mut self, style: ScrollbarStyle) -> Self { self.element = self.element.scrollbar_style(style); self } pub fn offset_y(mut self, offset_y: Signal) -> Self { self.element = self.element.scroll_offset(offset_y.get()); self.offset_y = Some(offset_y); self } pub fn widget_ref(mut self, widget_ref: WidgetRef) -> Self { self.widget_ref = Some(widget_ref.element_id.clone()); self } pub fn children(mut self, children: impl Children) -> View { let element_id = allocate_element_id(); self.element = self.element.id(element_id); if let Some(widget_ref) = &self.widget_ref { let _ = widget_ref.set(Some(element_id)); } let mut view = View::from_container(self.element, children.into_views()); if let Some(offset_y) = self.offset_y { let drag = self.drag; let offset_y_for_pointer = offset_y.clone(); let offset_y_for_keys = offset_y.clone(); view = view.with_scroll_handler( element_id, Rc::new(move |event, interaction_tree| { let Some(metrics) = interaction_tree.scroll_metrics_for_element(element_id) else { return; }; match event.kind { RoutedPointerEventKind::Scroll { delta } => { offset_y_for_pointer.update(|value| { *value = clamp_scroll_offset(*value + delta.y, metrics.max_offset_y); }); } RoutedPointerEventKind::Down { button: PointerButton::Primary, } => { let Some(thumb_rect) = metrics.scrollbar_thumb else { return; }; if !thumb_rect.contains(event.position) { return; } let _ = drag.set(Some(ScrollbarDrag { start_pointer_y: event.position.y, start_offset_y: offset_y_for_pointer.get(), })); } RoutedPointerEventKind::Move => { let Some(drag_state) = drag.get() else { return; }; let Some(track_rect) = metrics.scrollbar_track else { return; }; let Some(thumb_rect) = metrics.scrollbar_thumb else { return; }; 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; } let pointer_delta = event.position.y - drag_state.start_pointer_y; let next_offset = drag_state.start_offset_y + pointer_delta * (metrics.max_offset_y / thumb_travel); offset_y_for_pointer.update(|value| { *value = clamp_scroll_offset(next_offset, metrics.max_offset_y); }); } RoutedPointerEventKind::Up { button: PointerButton::Primary, } => { let _ = drag.set(None); } _ => {} } }), ); view = view.with_key_handler( element_id, Rc::new(move |event, interaction_tree| { if 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(element_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 => offset_y_for_keys.get() - line_step, KeyboardKey::ArrowDown => offset_y_for_keys.get() + line_step, KeyboardKey::Home => 0.0, KeyboardKey::End => metrics.max_offset_y, _ => return false, }; let next_offset = clamp_scroll_offset(next_offset, metrics.max_offset_y); let changed = (offset_y_for_keys.get() - next_offset).abs() > f32::EPSILON; if changed { let _ = offset_y_for_keys.set(next_offset); } changed }), ); } view } } #[derive(Default)] pub struct TextBuilder { role: Option, size: Option, weight: Option, color: Option, font_family: Option, wrap: Option, } impl TextBuilder { pub fn role(mut self, role: TextRole) -> Self { self.role = Some(role); self } pub fn size(mut self, size: f32) -> Self { self.size = Some(size); self } pub fn weight(mut self, weight: FontWeight) -> Self { self.weight = Some(weight); self } pub fn color(mut self, color: Color) -> Self { self.color = Some(color); self } pub fn font_family(mut self, family: TextFontFamily) -> Self { self.font_family = Some(family); self } pub fn wrap(mut self, wrap: TextWrap) -> Self { self.wrap = Some(wrap); self } pub fn children(self, children: impl TextChildren) -> View { let size = self .size .unwrap_or_else(|| match self.role.unwrap_or(TextRole::Body) { TextRole::Body => 18.0, TextRole::Heading(1) => 32.0, TextRole::Heading(2) => 28.0, TextRole::Heading(_) => 24.0, }); let color = self.color.unwrap_or(colors::text()); let weight = self .weight .unwrap_or_else(|| match self.role.unwrap_or(TextRole::Body) { TextRole::Body => FontWeight::Normal, TextRole::Heading(_) => FontWeight::Semibold, }); let mut style = TextStyle::new(size, color) .with_line_height(size * 1.2) .with_wrap(self.wrap.unwrap_or(TextWrap::None)); if let Some(font_family) = self.font_family { style = style.with_font_family(font_family); } let span = TextSpan::new(children.into_text()) .color(color) .weight(weight.to_text_span_weight()); View::from_element(Element::spans([span], style).id(allocate_element_id())) } } #[derive(Default)] pub struct ButtonBuilder { on_press: Option, } impl ButtonBuilder { pub fn on_press(mut self, handler: impl Fn(&RoutedPointerEvent) + 'static) -> Self { self.on_press = Some(Rc::new(handler)); self } pub fn children(self, children: impl TextChildren) -> View { let id = allocate_element_id(); let label = children.into_text(); let view = View::from_element( Element::column() .id(id) .padding(Edges::symmetric(14.0, 10.0)) .background(surfaces::interactive()) .corner_radius(10.0) .cursor(CursorIcon::Pointer) .focusable(true) .child( Element::spans( [TextSpan::new(label).weight(TextSpanWeight::Medium)], TextStyle::new(18.0, colors::text()).with_line_height(21.6), ) .pointer_events(false) .focusable(false), ), ); match self.on_press { Some(handler) => view.with_press_handler(id, handler), None => view, } } } pub struct Signal { inner: Rc>, } impl Clone for Signal { fn clone(&self) -> Self { Self { inner: Rc::clone(&self.inner), } } } impl Signal { fn new(initial: T) -> Self { Self { inner: Rc::new(SignalInner { cell: ruin_reactivity::cell(initial), }), } } pub fn with(&self, f: impl FnOnce(&T) -> R) -> R { self.inner.cell.with(f) } pub fn replace(&self, value: T) -> T { self.inner.cell.replace(value) } pub fn update(&self, f: impl FnOnce(&mut T) -> R) -> R { self.inner.cell.update(f) } } impl Signal { pub fn get(&self) -> T { self.inner.cell.get() } } impl Signal { pub fn set(&self, value: T) -> Option { self.inner.cell.set(value) } } pub struct Memo { compute: Rc T>, } impl Clone for Memo { fn clone(&self) -> Self { Self { compute: Rc::clone(&self.compute), } } } impl Memo { pub fn with(&self, f: impl FnOnce(&T) -> R) -> R { let value = (self.compute)(); f(&value) } } impl Memo { pub fn get(&self) -> T { (self.compute)() } } #[derive(Clone)] struct ContextEntry { key: TypeId, value: Rc, } impl ContextEntry { fn new(value: C::Value) -> Self { Self { key: TypeId::of::(), value: Rc::new(value), } } } pub fn use_signal(initial: impl FnOnce() -> T) -> Signal { with_hook_slot(|| Signal::new(initial()), |signal| signal.clone()) } pub fn use_context() -> C::Value { with_render_context_state(|context| { context .context_entries .iter() .rev() .find_map(|entry| { (entry.key == TypeId::of::()) .then(|| entry.value.downcast_ref::()) .flatten() .cloned() }) .unwrap_or_else(|| { panic!( "missing context provider for {} while rendering", type_name::() ) }) }) } pub fn provide(value: C::Value, render: impl FnOnce() -> View) -> View { with_render_context_state(|context| { let mut context_entries = (*context.context_entries).clone(); context_entries.push(ContextEntry::new::(value)); with_render_context( context.with_context_entries(Rc::new(context_entries)), render, ) }) } pub fn use_memo(compute: impl Fn() -> T + 'static) -> Memo { let compute: Rc T>>> = Rc::new(RefCell::new(Box::new(compute) as Box T>)); with_hook_slot( { let compute = Rc::clone(&compute); move || MemoSlot::new(compute) }, |slot: &mut MemoSlot| { slot.replace_compute(Rc::clone(&compute)); slot.handle.clone() }, ) } pub fn use_effect(effect: impl Fn() + 'static) { with_hook_slot(|| ruin_reactivity::effect(effect), |_| ()); } pub fn use_window_title(compute: impl FnOnce() -> String) { with_render_context_state(|context| { context.side_effects.borrow_mut().window_title = Some(compute()); }); } #[derive(Clone, Debug, Eq, PartialEq)] pub enum Key { Character(char), Enter, ArrowUp, ArrowDown, Home, End, } impl Key { fn matches(&self, event: &KeyboardEvent) -> bool { match (self, &event.key) { (Self::Character(expected), KeyboardKey::Character(actual)) => actual .chars() .next() .is_some_and(|actual| actual.eq_ignore_ascii_case(expected)), (Self::Enter, KeyboardKey::Enter) => true, (Self::ArrowUp, KeyboardKey::ArrowUp) => true, (Self::ArrowDown, KeyboardKey::ArrowDown) => true, (Self::Home, KeyboardKey::Home) => true, (Self::End, KeyboardKey::End) => true, _ => false, } } } #[derive(Clone, Debug, Eq, PartialEq)] pub struct Shortcut { key: Key, control: bool, shift: bool, alt: bool, super_key: bool, } impl Shortcut { pub fn new(key: Key) -> Self { Self { key, control: false, shift: false, alt: false, super_key: false, } } pub fn with_ctrl(mut self) -> Self { self.control = true; self } pub fn with_shift(mut self) -> Self { self.shift = true; self } fn matches(&self, event: &KeyboardEvent) -> bool { event.kind == KeyboardEventKind::Pressed && self.key.matches(event) && event.modifiers.control == self.control && event.modifiers.shift == self.shift && event.modifiers.alt == self.alt && event.modifiers.super_key == self.super_key } } #[derive(Clone)] pub struct FocusScope { element_id: Signal>, } #[derive(Clone)] pub enum ShortcutScope { Application, FocusedWithin(FocusScope), } impl ShortcutScope { fn matches( &self, focused_element: Option, interaction_tree: &InteractionTree, ) -> bool { match self { Self::Application => true, Self::FocusedWithin(scope) => { let Some(scope_element) = scope.element_id.get() else { return false; }; let Some(focused_element) = focused_element else { return false; }; element_contains_element(interaction_tree, scope_element, focused_element) } } } } pub struct WidgetRef { element_id: Signal>, _marker: PhantomData T>, } impl Clone for WidgetRef { fn clone(&self) -> Self { Self { element_id: self.element_id.clone(), _marker: PhantomData, } } } impl WidgetRef { fn new() -> Self { Self { element_id: Signal::new(None), _marker: PhantomData, } } pub fn element_id(&self) -> Option { self.element_id.get() } pub fn focus_scope(&self) -> FocusScope { FocusScope { element_id: self.element_id.clone(), } } } pub struct ScrollBoxWidget; pub struct BlockWidget; pub fn use_widget_ref() -> WidgetRef { with_hook_slot(WidgetRef::new, |widget_ref: &mut WidgetRef| { widget_ref.clone() }) } pub fn use_shortcut(shortcut: Shortcut, scope: ShortcutScope, action: impl Fn() + 'static) { use_shortcut_with_context(shortcut, scope, move |_| action()); } pub fn use_shortcut_with_context( shortcut: Shortcut, scope: ShortcutScope, action: impl Fn(&InteractionTree) + 'static, ) { with_render_context_state(|context| { context .side_effects .borrow_mut() .shortcuts .push(ShortcutBinding { shortcut, scope, action: Rc::new(action), }); }); } #[derive(Clone)] pub struct Resource { state: Signal>, } impl Resource { pub fn read(&self) -> ResourceState { self.state.get() } } #[derive(Clone)] pub enum ResourceState { Pending, Ready(std::result::Result), } pub fn use_resource(factory: F) -> Resource where T: Clone + 'static, E: Clone + 'static, Fut: Future> + 'static, F: Fn() -> Fut + 'static, { with_hook_slot( || { let resource = Resource { state: Signal::new(ResourceState::Pending), }; let generation = Rc::new(StdCell::new(0_u64)); let _effect = ruin_reactivity::effect({ let resource = resource.clone(); let generation = Rc::clone(&generation); move || { let next_generation = generation.get().wrapping_add(1); generation.set(next_generation); let _ = resource.state.replace(ResourceState::Pending); let resource = resource.clone(); let future = factory(); let generation = Rc::clone(&generation); std::mem::drop(queue_future(async move { let result = future.await; if generation.get() == next_generation { let _ = resource.state.replace(ResourceState::Ready(result)); } })); } }); ResourceSlot { resource, _effect } }, |slot: &mut ResourceSlot| slot.resource.clone(), ) } #[derive(Default)] struct RenderState { hooks: RefCell>>, element_ids: RefCell>, next_element_id: StdCell, } #[derive(Clone)] struct ShortcutBinding { shortcut: Shortcut, scope: ShortcutScope, action: Rc, } impl ShortcutBinding { fn matches( &self, event: &KeyboardEvent, focused_element: Option, interaction_tree: &InteractionTree, ) -> bool { self.shortcut.matches(event) && self.scope.matches(focused_element, interaction_tree) } fn trigger(&self, interaction_tree: &InteractionTree) { (self.action)(interaction_tree); } } #[derive(Clone, Default)] struct RenderSideEffects { window_title: Option, shortcuts: Vec, } #[derive(Clone)] struct RenderContext { state: Rc, hook_index: Rc>, element_index: Rc>, side_effects: Rc>, context_entries: Rc>, } impl RenderContext { fn with_context_entries(&self, context_entries: Rc>) -> Self { Self { state: Rc::clone(&self.state), hook_index: Rc::clone(&self.hook_index), element_index: Rc::clone(&self.element_index), side_effects: Rc::clone(&self.side_effects), context_entries, } } } struct RenderOutput { view: View, side_effects: RenderSideEffects, } thread_local! { static CURRENT_RENDER_CONTEXT: RefCell> = const { RefCell::new(None) }; } fn render_with_context(state: Rc, render: impl FnOnce() -> View) -> RenderOutput { let context = RenderContext { state, hook_index: Rc::new(StdCell::new(0)), element_index: Rc::new(StdCell::new(0)), side_effects: Rc::new(RefCell::new(RenderSideEffects::default())), context_entries: Rc::new(Vec::new()), }; let view = with_render_context(context.clone(), render); let side_effects = context.side_effects.borrow().clone(); RenderOutput { view, side_effects } } fn with_render_context(context: RenderContext, render: impl FnOnce() -> View) -> View { CURRENT_RENDER_CONTEXT.with(|slot| { let previous = slot.replace(Some(context.clone())); struct Guard<'a> { slot: &'a RefCell>, previous: Option, } impl Drop for Guard<'_> { fn drop(&mut self) { let _ = self.slot.replace(self.previous.take()); } } let _guard = Guard { slot, previous }; render() }) } fn with_render_context_state(f: impl FnOnce(&RenderContext) -> R) -> R { CURRENT_RENDER_CONTEXT.with(|slot| { let context = slot .borrow() .clone() .expect("ruin_app hooks can only run while rendering a mounted component"); f(&context) }) } fn with_hook_slot(init: impl FnOnce() -> T, f: impl FnOnce(&mut T) -> R) -> R { with_render_context_state(|context| { let index = context.hook_index.get(); context.hook_index.set(index + 1); let mut hooks = context.state.hooks.borrow_mut(); if hooks.len() == index { hooks.push(Box::new(init())); } let slot = hooks[index] .downcast_mut::() .expect("ruin_app hook call order changed between renders"); f(slot) }) } fn allocate_element_id() -> ElementId { with_render_context_state(|context| { let index = context.element_index.get(); context.element_index.set(index + 1); let mut element_ids = context.state.element_ids.borrow_mut(); if element_ids.len() == index { let next = context.state.next_element_id.get().wrapping_add(1); context.state.next_element_id.set(next); element_ids.push(ElementId::new(next)); } element_ids[index] }) } struct MemoSlot { compute: Rc T>>>, handle: Memo, } struct ResourceSlot { resource: Resource, _effect: ruin_reactivity::EffectHandle, } impl MemoSlot { fn new(compute: Rc T>>>) -> Self { let handle = Memo { compute: Rc::new({ let compute = Rc::clone(&compute); move || { let compute = compute.borrow(); (compute.as_ref())() } }), }; Self { compute, handle } } fn replace_compute(&mut self, compute: Rc T>>>) { self.compute = Rc::clone(&compute); self.handle.compute = Rc::new({ let compute = Rc::clone(&compute); move || { let compute = compute.borrow(); (compute.as_ref())() } }); } } type PressHandler = Rc; type ScrollHandler = Rc; type KeyHandler = Rc bool + 'static>; #[derive(Clone, Default)] struct EventBindings { on_press: HashMap, on_scroll: HashMap, on_key: HashMap, } impl EventBindings { fn extend(&mut self, other: EventBindings) { self.on_press.extend(other.on_press); self.on_scroll.extend(other.on_scroll); self.on_key.extend(other.on_key); } fn dispatch( &self, event: &RoutedPointerEvent, interaction_tree: &InteractionTree, hovered_targets: &[HitTarget], ) { match event.kind { RoutedPointerEventKind::Up { button: PointerButton::Primary, } => { if let Some(element_id) = event.target.element_id && let Some(handler) = self.on_press.get(&element_id) { handler(event); } if let Some(handler) = scroll_handler_for_event( &self.on_scroll, event.target.element_id, hovered_targets, ) { handler(event, interaction_tree); } } RoutedPointerEventKind::Down { button: PointerButton::Primary, } | RoutedPointerEventKind::Move | RoutedPointerEventKind::Scroll { .. } | RoutedPointerEventKind::Leave => { if let Some(handler) = scroll_handler_for_event( &self.on_scroll, event.target.element_id, hovered_targets, ) { handler(event, interaction_tree); } } _ => {} } } fn dispatch_key( &self, focused_element: Option, event: &KeyboardEvent, interaction_tree: &InteractionTree, ) { let Some(handler) = key_handler_for_focus(&self.on_key, focused_element, interaction_tree) else { return; }; let _ = handler(event, interaction_tree); } } #[derive(Clone, Copy, PartialEq)] struct ScrollbarDrag { start_pointer_y: f32, start_offset_y: f32, } #[derive(Clone, Copy, PartialEq)] struct TextSelection { element_id: ElementId, anchor: usize, focus: usize, } #[derive(Clone, Copy, PartialEq)] struct TextSelectionDrag { element_id: ElementId, anchor: usize, } struct TextSelectionState { selection: RefCell>, drag: RefCell>, version: Signal, } impl TextSelectionState { fn new() -> Self { Self { selection: RefCell::new(None), drag: RefCell::new(None), version: Signal::new(0), } } } struct InputState { current_cursor: CursorIcon, focused_element: Option, text_selection: Rc, } impl InputState { fn new() -> Self { Self { current_cursor: CursorIcon::Default, focused_element: None, text_selection: Rc::new(TextSelectionState::new()), } } } fn clamp_scroll_offset(offset_y: f32, max_offset_y: f32) -> f32 { offset_y.clamp(0.0, max_offset_y.max(0.0)) } fn apply_text_selection_overlay( scene: &mut ruin_ui::SceneSnapshot, selection: Option, ) { let Some(selection) = selection else { return; }; let mut next_items = Vec::with_capacity(scene.items.len()); for item in scene.items.drain(..) { match item { DisplayItem::Text(mut text) if text.element_id == Some(selection.element_id) => { for rect in text.selection_rects(selection.anchor, selection.focus) { next_items.push(DisplayItem::Quad(Quad::new( rect, text.selection_style.highlight_color, ))); } text.apply_selected_text_color(selection.anchor, selection.focus); next_items.push(DisplayItem::Text(text)); } other => next_items.push(other), } } scene.items = next_items; } fn sync_primary_selection( window: &WindowController, interaction_tree: &InteractionTree, selection: Option, ) -> Result<()> { let Some(selection) = selection else { window.set_primary_selection_text(String::new())?; return Ok(()); }; let Some(text) = interaction_tree.text_for_element(selection.element_id) else { return Ok(()); }; let copied = text .selected_text(selection.anchor, selection.focus) .unwrap_or_default() .to_owned(); window.set_primary_selection_text(copied)?; Ok(()) } fn scroll_handler_for_event<'a>( handlers: &'a HashMap, direct_target: Option, hovered_targets: &[HitTarget], ) -> Option<&'a ScrollHandler> { if let Some(element_id) = direct_target && let Some(handler) = handlers.get(&element_id) { return Some(handler); } hovered_targets .iter() .rev() .filter_map(|target| target.element_id) .find_map(|element_id| handlers.get(&element_id)) } fn focused_element_for_pointer( interaction_tree: &InteractionTree, event: &PointerEvent, ) -> Option { let hit_path = interaction_tree.hit_path(event.position); hit_path .iter() .rev() .find_map(|target| target.focusable.then_some(target.element_id).flatten()) .or_else(|| hit_path.iter().rev().find_map(|target| target.element_id)) } fn element_contains_element( interaction_tree: &InteractionTree, ancestor: ElementId, descendant: ElementId, ) -> bool { fn contains_descendant(node: &ruin_ui::LayoutNode, descendant: ElementId) -> bool { if node.element_id == Some(descendant) { return true; } node.children .iter() .any(|child| contains_descendant(child, descendant)) } fn ancestor_contains( node: &ruin_ui::LayoutNode, ancestor: ElementId, descendant: ElementId, ) -> bool { if node.element_id == Some(ancestor) { return contains_descendant(node, descendant); } node.children .iter() .any(|child| ancestor_contains(child, ancestor, descendant)) } ancestor_contains(&interaction_tree.root, ancestor, descendant) } fn key_handler_for_focus<'a>( handlers: &'a HashMap, focused_element: Option, interaction_tree: &InteractionTree, ) -> Option<&'a KeyHandler> { let focused_element = focused_element?; focused_ancestor_chain(&interaction_tree.root, focused_element)? .into_iter() .find_map(|element_id| handlers.get(&element_id)) } fn focused_ancestor_chain( node: &ruin_ui::LayoutNode, focused_element: ElementId, ) -> Option> { let mut chain = Vec::new(); if build_focused_ancestor_chain(node, focused_element, &mut chain) { Some(chain) } else { None } } fn build_focused_ancestor_chain( node: &ruin_ui::LayoutNode, focused_element: ElementId, chain: &mut Vec, ) -> bool { if node.element_id == Some(focused_element) { if let Some(element_id) = node.element_id { chain.push(element_id); } return true; } for child in &node.children { if build_focused_ancestor_chain(child, focused_element, chain) { if let Some(element_id) = node.element_id { chain.push(element_id); } return true; } } false } pub mod prelude { pub use crate::{ App, BlockWidget, ButtonBuilder, ChildViews, Children, Component, ContainerBuilder, ContextKey, FocusScope, FontWeight, IntoBorder, IntoEdges, IntoView, Key, Memo, Mountable, Pending, Ready, Resource, ResourceState, Result, ScrollBoxBuilder, ScrollBoxWidget, Shortcut, ShortcutScope, Signal, TextBuilder, TextChildren, TextRole, TextValue, View, WidgetRef, Window, block, button, colors, column, component, context_provider, provide, row, scroll_box, surfaces, text, use_context, use_effect, use_memo, use_resource, use_shortcut, use_shortcut_with_context, use_signal, use_widget_ref, use_window_title, view, }; pub use ruin_ui::{ Border, Color, CursorIcon, Edges, Element, ElementId, InteractionTree, PointerButton, PointerEventKind, RoutedPointerEvent, RoutedPointerEventKind, ScrollbarStyle, TextFontFamily, TextStyle, TextWrap, UiSize, }; } struct SignalInner { cell: ruin_reactivity::Cell, } #[cfg(test)] mod tests { use super::*; use ruin_ui::{KeyboardModifiers, Point, UiRuntime, WindowSpec}; #[derive(Clone, Debug, PartialEq, Eq)] struct NamedValue(&'static str); struct OuterContext; struct InnerContext; impl ContextKey for OuterContext { type Value = NamedValue; } impl ContextKey for InnerContext { type Value = NamedValue; } #[component] fn ContractProbe(label: TextValue, actions: ChildViews, children: ChildViews) -> impl IntoView { column().children(( text().children(label), row().children(actions), block().children(children), )) } #[test] fn use_context_distinguishes_marker_types_for_same_value_type() { let seen_outer = Rc::new(RefCell::new(None::)); let seen_inner = Rc::new(RefCell::new(None::)); let _ = render_with_context(Rc::new(RenderState::default()), { let seen_outer = Rc::clone(&seen_outer); let seen_inner = Rc::clone(&seen_inner); move || { provide::(NamedValue("outer"), || { provide::(NamedValue("inner"), || { *seen_outer.borrow_mut() = Some(use_context::()); *seen_inner.borrow_mut() = Some(use_context::()); View::from_element(Element::column()) }) }) } }); assert_eq!(*seen_outer.borrow(), Some(NamedValue("outer"))); assert_eq!(*seen_inner.borrow(), Some(NamedValue("inner"))); } #[test] fn nearer_provider_shadows_outer_provider_of_same_marker() { let seen_value = Rc::new(RefCell::new(None::)); let _ = render_with_context(Rc::new(RenderState::default()), { let seen_value = Rc::clone(&seen_value); move || { provide::(NamedValue("outer"), || { provide::(NamedValue("inner"), || { *seen_value.borrow_mut() = Some(use_context::()); View::from_element(Element::column()) }) }) } }); assert_eq!(*seen_value.borrow(), Some(NamedValue("inner"))); } #[test] fn components_accept_child_contracts_and_child_like_slot_props() { let render = render_with_context(Rc::new(RenderState::default()), || { IntoView::into_view(view! { ContractProbe( label = "slot label", actions = ( text().children("action a"), text().children("action b"), ) ) { text() { "body child" } } }) }); let debug = format!("{:?}", render.view.element()); assert!(debug.contains("slot label"), "{debug}"); assert!(debug.contains("action a"), "{debug}"); assert!(debug.contains("action b"), "{debug}"); assert!(debug.contains("body child"), "{debug}"); } #[test] fn key_dispatch_prefers_the_nearest_focused_ancestor_handler() { let outer_id = ElementId::new(41); let inner_id = ElementId::new(42); let root = Element::column().pointer_events(false).child( Element::column() .id(outer_id) .width(160.0) .height(120.0) .focusable(true) .child( Element::column() .id(inner_id) .width(120.0) .height(80.0) .focusable(true), ), ); let snapshot = ruin_ui::layout_snapshot(1, UiSize::new(200.0, 120.0), &root); let outer_hits = Rc::new(StdCell::new(0usize)); let inner_hits = Rc::new(StdCell::new(0usize)); let mut handlers = HashMap::::new(); handlers.insert( outer_id, Rc::new({ let outer_hits = Rc::clone(&outer_hits); move |_, _| { outer_hits.set(outer_hits.get() + 1); true } }), ); handlers.insert( inner_id, Rc::new({ let inner_hits = Rc::clone(&inner_hits); move |_, _| { inner_hits.set(inner_hits.get() + 1); true } }), ); let handler = key_handler_for_focus(&handlers, Some(inner_id), &snapshot.interaction_tree) .expect("focused element should resolve a key handler"); let _ = handler( &KeyboardEvent::new( 0, KeyboardEventKind::Pressed, KeyboardKey::ArrowDown, KeyboardModifiers::default(), None, ), &snapshot.interaction_tree, ); assert_eq!(inner_hits.get(), 1); assert_eq!(outer_hits.get(), 0); } #[test] fn scroll_box_arrow_keys_work_after_clicking_text_content() { let offset_slot = Rc::new(RefCell::new(None::>)); let render = render_with_context(Rc::new(RenderState::default()), { let offset_slot = Rc::clone(&offset_slot); move || { let offset = use_signal(|| 0.0_f32); *offset_slot.borrow_mut() = Some(offset.clone()); scroll_box() .height(120.0) .offset_y(offset) .children(text().children( "line 01\nline 02\nline 03\nline 04\nline 05\nline 06\nline 07\nline 08\nline 09\nline 10\nline 11\nline 12", )) } }); let offset = offset_slot .borrow() .clone() .expect("scroll signal should have been captured"); let scrollbox_id = render .view .element .id .expect("scroll box should receive an element id"); let snapshot = ruin_ui::layout_snapshot(1, UiSize::new(260.0, 160.0), render.view.element()); let focused = focused_element_for_pointer( &snapshot.interaction_tree, &PointerEvent::new( 1, Point::new(12.0, 12.0), PointerEventKind::Down { button: PointerButton::Primary, }, ), ); assert_eq!(focused, Some(scrollbox_id)); render.view.bindings.dispatch_key( focused, &KeyboardEvent::new( 0, KeyboardEventKind::Pressed, KeyboardKey::ArrowDown, KeyboardModifiers::default(), None, ), &snapshot.interaction_tree, ); assert!(offset.get() > 0.0); } #[test] fn scroll_box_thumb_drag_updates_offset_signal() { let offset_slot = Rc::new(RefCell::new(None::>)); let render = render_with_context(Rc::new(RenderState::default()), { let offset_slot = Rc::clone(&offset_slot); move || { let offset = use_signal(|| 0.0_f32); *offset_slot.borrow_mut() = Some(offset.clone()); scroll_box() .height(120.0) .offset_y(offset) .children(text().children( "line 01\nline 02\nline 03\nline 04\nline 05\nline 06\nline 07\nline 08\nline 09\nline 10\nline 11\nline 12", )) } }); let offset = offset_slot .borrow() .clone() .expect("scroll signal should have been captured"); let scrollbox_id = render .view .element .id .expect("scroll box should receive an element id"); let snapshot = ruin_ui::layout_snapshot(1, UiSize::new(260.0, 160.0), render.view.element()); let metrics = snapshot .interaction_tree .scroll_metrics_for_element(scrollbox_id) .expect("scroll metrics should exist for the scroll box"); let thumb = metrics .scrollbar_thumb .expect("overflowing scroll box should expose a scrollbar thumb"); let thumb_center = Point::new( thumb.origin.x + (thumb.size.width * 0.5), thumb.origin.y + (thumb.size.height * 0.5), ); let hovered_targets = snapshot.interaction_tree.hit_path(thumb_center); let handler = scroll_handler_for_event( &render.view.bindings.on_scroll, Some(scrollbox_id), &hovered_targets, ) .expect("scroll box should resolve its scroll handler") .clone(); handler( &RoutedPointerEvent { kind: RoutedPointerEventKind::Down { button: PointerButton::Primary, }, target: hovered_targets .last() .cloned() .expect("thumb center should hit the scroll box"), pointer_id: 1, position: thumb_center, }, &snapshot.interaction_tree, ); handler( &RoutedPointerEvent { kind: RoutedPointerEventKind::Move, target: hovered_targets .last() .cloned() .expect("thumb center should hit the scroll box"), pointer_id: 1, position: Point::new(thumb_center.x, thumb_center.y + 24.0), }, &snapshot.interaction_tree, ); assert!(offset.get() > 0.0); } #[test] fn live_input_path_scrolls_a_scroll_box_rendered_inside_a_branch() { let offset_slot = Rc::new(RefCell::new(None::>)); let render = render_with_context(Rc::new(RenderState::default()), { let offset_slot = Rc::clone(&offset_slot); move || match true { true => { let offset = use_signal(|| 0.0_f32); *offset_slot.borrow_mut() = Some(offset.clone()); column() .background(surfaces::raised()) .gap(10.0) .children(( text().children(("bytes = ", 4096)), scroll_box() .height(420.0) .offset_y(offset.clone()) .padding(12.0) .background(surfaces::canvas()) .border_radius(10.0) .border((2.0, colors::muted())) .children( text() .color(colors::muted()) .font_family(TextFontFamily::Monospace) .children( "line 01\nline 02\nline 03\nline 04\nline 05\nline 06\nline 07\nline 08\nline 09\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21\nline 22\nline 23\nline 24", ), ), )) } false => View::from_element(Element::column()), } }); let offset = offset_slot .borrow() .clone() .expect("scroll signal should have been captured"); let snapshot = ruin_ui::layout_snapshot(1, UiSize::new(1080.0, 760.0), render.view.element()); let interaction_tree = RefCell::new(Some(snapshot.interaction_tree.clone())); let bindings = RefCell::new(render.view.bindings.clone()); let mut pointer_router = PointerRouter::new(); let mut input_state = InputState::new(); let window = UiRuntime::headless() .create_window(WindowSpec::new("scrollbox-test")) .expect("headless window should be created"); MountedApp::::handle_pointer_event( &window, &interaction_tree, &bindings, &mut pointer_router, &mut input_state, PointerEvent::new( 1, Point::new(24.0, 64.0), PointerEventKind::Down { button: PointerButton::Primary, }, ), ) .expect("pointer down should succeed"); MountedApp::::handle_keyboard_event( &interaction_tree, &bindings, &RefCell::new(Vec::new()), &input_state, KeyboardEvent::new( 0, KeyboardEventKind::Pressed, KeyboardKey::ArrowDown, KeyboardModifiers::default(), None, ), ) .expect("keyboard event should succeed"); assert!(offset.get() > 0.0); } #[test] fn scroll_box_stays_interactive_when_it_appears_on_a_later_render() { let state = Rc::new(RenderState::default()); let ready_slot = Rc::new(RefCell::new(None::>)); let offset_slot = Rc::new(RefCell::new(None::>)); let render_once = |state: Rc, ready_slot: Rc>>>, offset_slot: Rc>>>| { render_with_context(state, move || { let ready = use_signal(|| false); let offset = use_signal(|| 0.0_f32); *ready_slot.borrow_mut() = Some(ready.clone()); *offset_slot.borrow_mut() = Some(offset.clone()); if ready.get() { column() .background(surfaces::raised()) .gap(10.0) .children(( text().children(("bytes = ", 4096)), scroll_box() .height(420.0) .offset_y(offset.clone()) .padding(12.0) .background(surfaces::canvas()) .border_radius(10.0) .border((2.0, colors::muted())) .children( text() .color(colors::muted()) .font_family(TextFontFamily::Monospace) .children( "line 01\nline 02\nline 03\nline 04\nline 05\nline 06\nline 07\nline 08\nline 09\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21\nline 22\nline 23\nline 24", ), ), )) } else { text().children("Loading file contents...") } }) }; let _initial = render_once( Rc::clone(&state), Rc::clone(&ready_slot), Rc::clone(&offset_slot), ); let ready = ready_slot .borrow() .clone() .expect("ready signal should have been captured"); let offset = offset_slot .borrow() .clone() .expect("offset signal should have been captured"); let _ = ready.set(true); let render = render_once(state, ready_slot, Rc::clone(&offset_slot)); let snapshot = ruin_ui::layout_snapshot(1, UiSize::new(1080.0, 760.0), render.view.element()); let interaction_tree = RefCell::new(Some(snapshot.interaction_tree.clone())); let bindings = RefCell::new(render.view.bindings.clone()); let mut pointer_router = PointerRouter::new(); let mut input_state = InputState::new(); let window = UiRuntime::headless() .create_window(WindowSpec::new("scrollbox-transition-test")) .expect("headless window should be created"); MountedApp::::handle_pointer_event( &window, &interaction_tree, &bindings, &mut pointer_router, &mut input_state, PointerEvent::new( 1, Point::new(24.0, 64.0), PointerEventKind::Down { button: PointerButton::Primary, }, ), ) .expect("pointer down should succeed after branch switch"); MountedApp::::handle_keyboard_event( &interaction_tree, &bindings, &RefCell::new(Vec::new()), &input_state, KeyboardEvent::new( 0, KeyboardEventKind::Pressed, KeyboardKey::ArrowDown, KeyboardModifiers::default(), None, ), ) .expect("keyboard event should succeed after branch switch"); assert!(offset.get() > 0.0); } #[test] fn live_input_path_scrolls_with_real_cargo_lock_contents() { let offset_slot = Rc::new(RefCell::new(None::>)); let render = render_with_context(Rc::new(RenderState::default()), { let offset_slot = Rc::clone(&offset_slot); move || { let offset = use_signal(|| 0.0_f32); *offset_slot.borrow_mut() = Some(offset.clone()); column().background(surfaces::raised()).gap(10.0).children(( text().children(("bytes = ", include_str!("../../../Cargo.lock").len())), scroll_box() .height(420.0) .offset_y(offset.clone()) .padding(12.0) .background(surfaces::canvas()) .border_radius(10.0) .border((2.0, colors::muted())) .children( text() .color(colors::muted()) .font_family(TextFontFamily::Monospace) .children(include_str!("../../../Cargo.lock")), ), )) } }); let offset = offset_slot .borrow() .clone() .expect("scroll signal should have been captured"); let snapshot = ruin_ui::layout_snapshot(1, UiSize::new(1080.0, 760.0), render.view.element()); let interaction_tree = RefCell::new(Some(snapshot.interaction_tree.clone())); let bindings = RefCell::new(render.view.bindings.clone()); let mut pointer_router = PointerRouter::new(); let mut input_state = InputState::new(); let window = UiRuntime::headless() .create_window(WindowSpec::new("scrollbox-cargo-lock-test")) .expect("headless window should be created"); MountedApp::::handle_pointer_event( &window, &interaction_tree, &bindings, &mut pointer_router, &mut input_state, PointerEvent::new( 1, Point::new(24.0, 64.0), PointerEventKind::Scroll { delta: Point::new(0.0, 48.0), }, ), ) .expect("wheel event should succeed"); assert!(offset.get() > 0.0); } #[test] fn rerendered_scroll_box_element_carries_the_updated_offset() { let state = Rc::new(RenderState::default()); let offset_slot = Rc::new(RefCell::new(None::>)); let render_once = |state: Rc, offset_slot: Rc>>>| { render_with_context(state, move || { let offset = use_signal(|| 0.0_f32); *offset_slot.borrow_mut() = Some(offset.clone()); scroll_box() .height(120.0) .offset_y(offset.clone()) .children( text().children("line 01\nline 02\nline 03\nline 04\nline 05\nline 06"), ) }) }; let _initial = render_once(Rc::clone(&state), Rc::clone(&offset_slot)); let offset = offset_slot .borrow() .clone() .expect("offset signal should have been captured"); let _ = offset.set(96.0); let render = render_once(state, offset_slot); let debug = format!("{:?}", render.view.element()); assert!(debug.contains("offset_y: 96.0"), "{debug}"); } }