From bc287f615d4b7ceebd99faff0944e57f956939ad Mon Sep 17 00:00:00 2001 From: Will Temple Date: Sat, 21 Mar 2026 23:25:28 -0400 Subject: [PATCH] Example 02 realized --- lib/ruin_app/Cargo.toml | 4 + .../example/02_widget_refs_and_commands.rs | 496 ++++++++++++++++++ lib/ruin_app/src/lib.rs | 333 +++++++++++- lib/ruin_app_proc_macros/src/lib.rs | 1 + lib/ui/src/layout.rs | 65 ++- lib/ui_renderer_wgpu/src/lib.rs | 151 +++++- 6 files changed, 1016 insertions(+), 34 deletions(-) create mode 100644 lib/ruin_app/example/02_widget_refs_and_commands.rs diff --git a/lib/ruin_app/Cargo.toml b/lib/ruin_app/Cargo.toml index 7db9920..04746de 100644 --- a/lib/ruin_app/Cargo.toml +++ b/lib/ruin_app/Cargo.toml @@ -21,3 +21,7 @@ path = "example/00_bootstrap_and_counter.rs" [[example]] name = "01_async_data_and_effects" path = "example/01_async_data_and_effects.rs" + +[[example]] +name = "02_widget_refs_and_commands" +path = "example/02_widget_refs_and_commands.rs" diff --git a/lib/ruin_app/example/02_widget_refs_and_commands.rs b/lib/ruin_app/example/02_widget_refs_and_commands.rs new file mode 100644 index 0000000..a170485 --- /dev/null +++ b/lib/ruin_app/example/02_widget_refs_and_commands.rs @@ -0,0 +1,496 @@ +use ruin_app::prelude::*; + +#[ruin_runtime::async_main] +async fn main() -> ruin_app::Result<()> { + App::new() + .window( + Window::new() + .title("RUIN Widget Refs and Commands") + .app_id("dev.ruin.widget-refs") + .size(1100.0, 760.0), + ) + .mount(view! { + WidgetRefsAndCommandsDemo() {} + }) + .run() + .await +} + +#[component] +fn WidgetRefsAndCommandsDemo() -> impl IntoView { + let selected_index = use_signal(|| 0_usize); + let last_command = use_signal(|| "No command has been run yet.".to_string()); + let results_scroll = use_signal(|| 0.0_f32); + let results_list = use_widget_ref::(); + + use_window_title({ + let selected_index = selected_index.clone(); + move || format!("RUIN Commands ({})", COMMANDS[selected_index.get()].title) + }); + + use_shortcut( + Shortcut::new(Key::Character('k')).with_ctrl(), + ShortcutScope::Application, + { + let last_command = last_command.clone(); + move || { + let _ = last_command.set( + "Application shortcut fired. Click inside the command list, then use \ + ArrowUp / ArrowDown / Enter." + .to_string(), + ); + } + }, + ); + + use_shortcut( + Shortcut::new(Key::Enter), + ShortcutScope::FocusedWithin(results_list.focus_scope()), + { + let selected_index = selected_index.clone(); + let last_command = last_command.clone(); + move || { + let command = &COMMANDS[selected_index.get()]; + let _ = last_command.set(format!("Executed: {}", command.title)); + } + }, + ); + + let selected = &COMMANDS[selected_index.get()]; + + view! { + column(gap = 16.0, padding = 24.0) { + text(role = TextRole::Heading(1), size = 32.0, weight = FontWeight::Semibold) { + "Widget refs and commands" + } + + text(color = colors::muted(), wrap = TextWrap::Word) { + "This is a smaller truthful slice of example 02: the results list installs a widget \ + ref-backed focus scope, typed shortcuts are explicit about application vs focused-within \ + behavior, and the scroll box keeps its own default arrow-key scrolling." + } + + block( + padding = 16.0, + gap = 10.0, + background = surfaces::raised(), + border_radius = 12.0, + ) { + text(size = 18.0, weight = FontWeight::Semibold) { "Shortcuts" } + text(color = colors::muted(), wrap = TextWrap::Word) { + "Ctrl+K writes a global status message. Click any command row below to focus the \ + list, then use ArrowUp / ArrowDown / Enter. The explicit shortcuts now override \ + the scroll box's default arrow-key scrolling in this focused scope." + } + } + + row(gap = 16.0) { + block( + flex = 1.0, + padding = 16.0, + gap = 10.0, + background = surfaces::raised(), + border_radius = 12.0, + ) { + text(size = 18.0, weight = FontWeight::Semibold) { "Commands" } + + button( + on_press = { + let selected_index = selected_index.clone(); + let last_command = last_command.clone(); + move |_| { + let command = &COMMANDS[selected_index.get()]; + let _ = last_command.set(format!("Executed: {}", command.title)); + } + }, + ) { + "Run selected command" + } + + scroll_box( + widget_ref = results_list.clone(), + offset_y = results_scroll.clone(), + height = 420.0, + padding = 12.0, + background = surfaces::canvas(), + border_radius = 10.0, + border = (2.0, colors::muted()), + ) { + column(gap = 8.0) { + CommandRows( + selected_index = selected_index.clone(), + results_list = results_list.clone(), + results_scroll = results_scroll.clone(), + ) {} + } + } + } + + block( + width = 320.0, + padding = 16.0, + gap = 10.0, + background = surfaces::raised(), + border_radius = 12.0, + ) { + text(size = 18.0, weight = FontWeight::Semibold) { "Selection" } + text() { "title = "; selected.title } + text(color = colors::muted(), wrap = TextWrap::Word) { selected.subtitle } + + block( + padding = 12.0, + gap = 8.0, + background = surfaces::canvas(), + border_radius = 10.0, + ) { + text(size = 16.0, weight = FontWeight::Semibold) { "Last command" } + text(color = colors::muted(), wrap = TextWrap::Word) { last_command.clone() } + } + } + } + } + } +} + +#[component] +fn CommandRows( + selected_index: Signal, + results_list: WidgetRef, + results_scroll: Signal, +) -> impl IntoView { + let row0 = use_widget_ref::(); + let row1 = use_widget_ref::(); + let row2 = use_widget_ref::(); + let row3 = use_widget_ref::(); + let row4 = use_widget_ref::(); + let row5 = use_widget_ref::(); + let row6 = use_widget_ref::(); + let row7 = use_widget_ref::(); + let row8 = use_widget_ref::(); + let row9 = use_widget_ref::(); + + use_shortcut_with_context( + Shortcut::new(Key::ArrowDown), + ShortcutScope::FocusedWithin(results_list.focus_scope()), + { + let selected_index = selected_index.clone(); + let results_list = results_list.clone(); + let results_scroll = results_scroll.clone(); + let row0 = row0.clone(); + let row1 = row1.clone(); + let row2 = row2.clone(); + let row3 = row3.clone(); + let row4 = row4.clone(); + let row5 = row5.clone(); + let row6 = row6.clone(); + let row7 = row7.clone(); + let row8 = row8.clone(); + let row9 = row9.clone(); + move |interaction_tree| { + let next_index = (selected_index.get() + 1).min(COMMANDS.len().saturating_sub(1)); + let _ = selected_index.set(next_index); + match next_index { + 0 => scroll_widget_into_view( + interaction_tree, + &results_list, + &row0, + &results_scroll, + ), + 1 => scroll_widget_into_view( + interaction_tree, + &results_list, + &row1, + &results_scroll, + ), + 2 => scroll_widget_into_view( + interaction_tree, + &results_list, + &row2, + &results_scroll, + ), + 3 => scroll_widget_into_view( + interaction_tree, + &results_list, + &row3, + &results_scroll, + ), + 4 => scroll_widget_into_view( + interaction_tree, + &results_list, + &row4, + &results_scroll, + ), + 5 => scroll_widget_into_view( + interaction_tree, + &results_list, + &row5, + &results_scroll, + ), + 6 => scroll_widget_into_view( + interaction_tree, + &results_list, + &row6, + &results_scroll, + ), + 7 => scroll_widget_into_view( + interaction_tree, + &results_list, + &row7, + &results_scroll, + ), + 8 => scroll_widget_into_view( + interaction_tree, + &results_list, + &row8, + &results_scroll, + ), + 9 => scroll_widget_into_view( + interaction_tree, + &results_list, + &row9, + &results_scroll, + ), + _ => {} + } + } + }, + ); + + use_shortcut_with_context( + Shortcut::new(Key::ArrowUp), + ShortcutScope::FocusedWithin(results_list.focus_scope()), + { + let selected_index = selected_index.clone(); + let results_list = results_list.clone(); + let results_scroll = results_scroll.clone(); + let row0 = row0.clone(); + let row1 = row1.clone(); + let row2 = row2.clone(); + let row3 = row3.clone(); + let row4 = row4.clone(); + let row5 = row5.clone(); + let row6 = row6.clone(); + let row7 = row7.clone(); + let row8 = row8.clone(); + let row9 = row9.clone(); + move |interaction_tree| { + let next_index = selected_index.get().saturating_sub(1); + let _ = selected_index.set(next_index); + match next_index { + 0 => scroll_widget_into_view( + interaction_tree, + &results_list, + &row0, + &results_scroll, + ), + 1 => scroll_widget_into_view( + interaction_tree, + &results_list, + &row1, + &results_scroll, + ), + 2 => scroll_widget_into_view( + interaction_tree, + &results_list, + &row2, + &results_scroll, + ), + 3 => scroll_widget_into_view( + interaction_tree, + &results_list, + &row3, + &results_scroll, + ), + 4 => scroll_widget_into_view( + interaction_tree, + &results_list, + &row4, + &results_scroll, + ), + 5 => scroll_widget_into_view( + interaction_tree, + &results_list, + &row5, + &results_scroll, + ), + 6 => scroll_widget_into_view( + interaction_tree, + &results_list, + &row6, + &results_scroll, + ), + 7 => scroll_widget_into_view( + interaction_tree, + &results_list, + &row7, + &results_scroll, + ), + 8 => scroll_widget_into_view( + interaction_tree, + &results_list, + &row8, + &results_scroll, + ), + 9 => scroll_widget_into_view( + interaction_tree, + &results_list, + &row9, + &results_scroll, + ), + _ => {} + } + } + }, + ); + + view! { + column(gap = 8.0) { + column(gap = 8.0) { + CommandRow0(selected_index = selected_index.clone(), row_ref = row0.clone()) {} + CommandRow1(selected_index = selected_index.clone(), row_ref = row1.clone()) {} + CommandRow2(selected_index = selected_index.clone(), row_ref = row2.clone()) {} + CommandRow3(selected_index = selected_index.clone(), row_ref = row3.clone()) {} + CommandRow4(selected_index = selected_index.clone(), row_ref = row4.clone()) {} + } + + column(gap = 8.0) { + CommandRow5(selected_index = selected_index.clone(), row_ref = row5.clone()) {} + CommandRow6(selected_index = selected_index.clone(), row_ref = row6.clone()) {} + CommandRow7(selected_index = selected_index.clone(), row_ref = row7.clone()) {} + CommandRow8(selected_index = selected_index.clone(), row_ref = row8.clone()) {} + CommandRow9(selected_index = selected_index.clone(), row_ref = row9.clone()) {} + } + } + } +} + +macro_rules! command_row_component { + ($name:ident, $index:expr) => { + #[component] + fn $name(selected_index: Signal, row_ref: WidgetRef) -> impl IntoView { + let command = &COMMANDS[$index]; + let prefix = if selected_index.get() == $index { + "> " + } else { + " " + }; + let background = if selected_index.get() == $index { + surfaces::interactive() + } else { + surfaces::interactive_muted() + }; + view! { + block( + widget_ref = row_ref, + padding = 12.0, + gap = 6.0, + background = background, + border_radius = 10.0, + ) { + text(weight = FontWeight::Semibold) { + prefix; + command.title + } + + text(color = colors::muted(), wrap = TextWrap::Word) { + command.subtitle + } + } + } + } + }; +} + +command_row_component!(CommandRow0, 0); +command_row_component!(CommandRow1, 1); +command_row_component!(CommandRow2, 2); +command_row_component!(CommandRow3, 3); +command_row_component!(CommandRow4, 4); +command_row_component!(CommandRow5, 5); +command_row_component!(CommandRow6, 6); +command_row_component!(CommandRow7, 7); +command_row_component!(CommandRow8, 8); +command_row_component!(CommandRow9, 9); + +struct Command { + title: &'static str, + subtitle: &'static str, +} + +static COMMANDS: &[Command] = &[ + Command { + title: "Open workspace search", + subtitle: "Jump into a project-wide search panel with the current repository root prefilled.", + }, + Command { + title: "Show diagnostics", + subtitle: "Collect the latest compile, lint, and runtime diagnostics into a single review list.", + }, + Command { + title: "Reload shaders", + subtitle: "Force a renderer-side asset refresh without restarting the process.", + }, + Command { + title: "Toggle layout bounds", + subtitle: "Render debug outlines for container and text layout boxes.", + }, + Command { + title: "Profile text layout", + subtitle: "Capture a narrow text-layout performance sample for the active window.", + }, + Command { + title: "Dump interaction tree", + subtitle: "Serialize the current focus and hit-test tree for debugging pointer routing.", + }, + Command { + title: "Clear transient overlays", + subtitle: "Dismiss temporary banners, confirmations, and other transient UI state.", + }, + Command { + title: "Restart background tasks", + subtitle: "Cancel and requeue currently tracked background work items.", + }, + Command { + title: "Copy scene summary", + subtitle: "Place a concise scene-graph summary onto the clipboard for issue reports.", + }, + Command { + title: "Open command help", + subtitle: "Explain the currently wired shortcut scopes and default list interactions.", + }, +]; + +fn scroll_widget_into_view( + interaction_tree: &InteractionTree, + scroll_box: &WidgetRef, + item: &WidgetRef, + scroll_offset: &Signal, +) { + let Some(scroll_box_id) = scroll_box.element_id() else { + return; + }; + let Some(item_id) = item.element_id() else { + return; + }; + let Some(metrics) = interaction_tree.scroll_metrics_for_element(scroll_box_id) else { + return; + }; + let Some(item_rect) = interaction_tree.rect_for_element(item_id) else { + return; + }; + + let viewport_top = metrics.viewport_rect.origin.y; + let viewport_bottom = viewport_top + metrics.viewport_rect.size.height; + let item_top = item_rect.origin.y; + let item_bottom = item_rect.origin.y + item_rect.size.height; + + scroll_offset.update(|offset| { + let mut next_offset = *offset; + if item_top < viewport_top { + next_offset -= viewport_top - item_top; + } else if item_bottom > viewport_bottom { + next_offset += item_bottom - viewport_bottom; + } + *offset = next_offset.clamp(0.0, metrics.max_offset_y); + }); +} diff --git a/lib/ruin_app/src/lib.rs b/lib/ruin_app/src/lib.rs index 4fb6a8d..a5300e2 100644 --- a/lib/ruin_app/src/lib.rs +++ b/lib/ruin_app/src/lib.rs @@ -9,6 +9,7 @@ use std::collections::HashMap; use std::error::Error; use std::future::Future; use std::iter; +use std::marker::PhantomData; use std::rc::Rc; use ruin_reactivity::effect; @@ -148,6 +149,7 @@ impl MountedApp { let text_system = Rc::new(RefCell::new(TextSystem::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(); @@ -158,6 +160,7 @@ impl MountedApp { let text_system = Rc::clone(&text_system); 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); @@ -193,6 +196,7 @@ impl MountedApp { *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"); @@ -226,6 +230,7 @@ impl MountedApp { Self::handle_keyboard_event( &interaction_tree, &bindings, + &shortcuts, &input_state, event, )?; @@ -309,6 +314,7 @@ impl MountedApp { fn handle_keyboard_event( interaction_tree: &RefCell>, bindings: &RefCell, + shortcuts: &RefCell>, input_state: &InputState, event: KeyboardEvent, ) -> Result<()> { @@ -316,6 +322,17 @@ impl MountedApp { 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); @@ -676,18 +693,21 @@ pub mod surfaces { 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, } } @@ -704,11 +724,13 @@ pub fn scroll_box() -> 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 { @@ -753,7 +775,17 @@ impl ContainerBuilder { self } - pub fn children(self, children: impl Children) -> View { + 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()) } } @@ -762,6 +794,7 @@ pub struct ScrollBoxBuilder { element: Element, offset_y: Option>, drag: Signal>, + widget_ref: Option>>, } impl ScrollBoxBuilder { @@ -812,9 +845,17 @@ impl ScrollBoxBuilder { 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 { @@ -1129,6 +1170,169 @@ pub fn use_window_title(compute: impl FnOnce() -> String) { }); } +#[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>, @@ -1190,9 +1394,32 @@ struct RenderState { next_element_id: StdCell, } -#[derive(Clone, Default, PartialEq)] +#[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)] @@ -1384,10 +1611,8 @@ impl EventBindings { event: &KeyboardEvent, interaction_tree: &InteractionTree, ) { - let Some(element_id) = focused_element else { - return; - }; - let Some(handler) = self.on_key.get(&element_id) else { + let Some(handler) = key_handler_for_focus(&self.on_key, focused_element, interaction_tree) + else { return; }; let _ = handler(event, interaction_tree); @@ -1526,18 +1751,98 @@ fn focused_element_for_pointer( .find_map(|target| target.focusable.then_some(target.element_id).flatten()) } +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() + .rev() + .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, ButtonBuilder, Children, Component, ContainerBuilder, FontWeight, IntoBorder, - IntoEdges, IntoView, Memo, Mountable, Pending, Ready, Resource, ResourceState, Result, - ScrollBoxBuilder, Signal, TextBuilder, TextChildren, TextRole, View, Window, block, button, - colors, column, component, row, scroll_box, surfaces, text, use_effect, use_memo, - use_resource, use_signal, use_window_title, view, + App, BlockWidget, ButtonBuilder, Children, Component, ContainerBuilder, FocusScope, + FontWeight, IntoBorder, IntoEdges, IntoView, Key, Memo, Mountable, Pending, Ready, + Resource, ResourceState, Result, ScrollBoxBuilder, ScrollBoxWidget, Shortcut, + ShortcutScope, Signal, TextBuilder, TextChildren, TextRole, View, WidgetRef, Window, block, + button, colors, column, component, row, scroll_box, surfaces, text, 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, PointerButton, PointerEventKind, - RoutedPointerEvent, RoutedPointerEventKind, ScrollbarStyle, TextFontFamily, TextStyle, - TextWrap, UiSize, + Border, Color, CursorIcon, Edges, Element, ElementId, InteractionTree, PointerButton, + PointerEventKind, RoutedPointerEvent, RoutedPointerEventKind, ScrollbarStyle, + TextFontFamily, TextStyle, TextWrap, UiSize, }; } struct SignalInner { diff --git a/lib/ruin_app_proc_macros/src/lib.rs b/lib/ruin_app_proc_macros/src/lib.rs index 2770078..d9c8b89 100644 --- a/lib/ruin_app_proc_macros/src/lib.rs +++ b/lib/ruin_app_proc_macros/src/lib.rs @@ -251,6 +251,7 @@ fn expand_component(mut function: ItemFn) -> Result { Ok(quote! { #function + #[allow(non_snake_case)] #component_tokens }) } diff --git a/lib/ui/src/layout.rs b/lib/ui/src/layout.rs index de37c94..b142186 100644 --- a/lib/ui/src/layout.rs +++ b/lib/ui/src/layout.rs @@ -116,6 +116,10 @@ impl InteractionTree { pub fn scroll_metrics_for_element(&self, element_id: ElementId) -> Option<&ScrollMetrics> { scroll_metrics_for_element_node(&self.root, element_id) } + + pub fn rect_for_element(&self, element_id: ElementId) -> Option { + rect_for_element_node(&self.root, element_id) + } } pub fn layout_snapshot(version: u64, logical_size: UiSize, root: &Element) -> LayoutSnapshot { @@ -309,7 +313,9 @@ fn layout_element( 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); + let provisional_max_offset_y = + (provisional_content_height - viewport_rect.size.height).max(0.0); + let mut offset_y = scroll_box.offset_y.max(0.0).min(provisional_max_offset_y); if viewport_rect.size.width > 0.0 && viewport_rect.size.height > 0.0 { scene.push_clip(viewport_rect, 0.0); @@ -509,6 +515,18 @@ fn scroll_metrics_for_element_node( None } +fn rect_for_element_node(node: &LayoutNode, element_id: ElementId) -> Option { + if node.element_id == Some(element_id) { + return Some(node.rect); + } + for child in &node.children { + if let Some(rect) = rect_for_element_node(child, element_id) { + return Some(rect); + } + } + None +} + fn point_hits_node_shape(node: &LayoutNode, point: crate::scene::Point) -> bool { node.rect.contains(point) && (node.corner_radius <= 0.0 @@ -1598,6 +1616,51 @@ mod tests { assert!(scroll_metrics.scrollbar_thumb.is_some()); } + #[test] + fn scroll_box_clamps_stale_offset_before_laying_out_reflowed_content() { + let scrollbox_id = ElementId::new(19); + let root = Element::column().child( + Element::scroll_box(240.0) + .id(scrollbox_id) + .width(320.0) + .height(120.0) + .padding(Edges::all(8.0)) + .child(Element::paragraph( + "When the viewport becomes wider, wrapped scroll-box content can get much \ + shorter. Layout should clamp any now-invalid stale offset before positioning \ + the children so the viewport does not open up an empty gap at the bottom.", + TextStyle::new(16.0, Color::rgb(0xFF, 0xFF, 0xFF)) + .with_line_height(22.0) + .with_wrap(TextWrap::Word), + )), + ); + + let snapshot = layout_snapshot(1, UiSize::new(360.0, 220.0), &root); + let scroll_metrics = snapshot + .interaction_tree + .scroll_metrics_for_element(scrollbox_id) + .expect("scroll box should expose scroll metrics"); + let visible_text = snapshot + .scene + .items + .iter() + .find_map(|item| match item { + DisplayItem::Text(text) => Some(text), + _ => None, + }) + .expect("scroll box should still emit text"); + + let text_bounds = visible_text + .bounds + .expect("prepared text should expose bounds for wrapped content"); + let text_bottom = visible_text.origin.y + text_bounds.height; + let viewport_bottom = + scroll_metrics.viewport_rect.origin.y + scroll_metrics.viewport_rect.size.height; + + assert!(scroll_metrics.offset_y <= scroll_metrics.max_offset_y); + assert!(text_bottom >= viewport_bottom - 1.0); + } + #[test] fn interaction_tree_hit_test_returns_deepest_pointer_target() { let root = Element::column() diff --git a/lib/ui_renderer_wgpu/src/lib.rs b/lib/ui_renderer_wgpu/src/lib.rs index 83b259a..25bae34 100644 --- a/lib/ui_renderer_wgpu/src/lib.rs +++ b/lib/ui_renderer_wgpu/src/lib.rs @@ -43,10 +43,20 @@ struct TextVertex { #[derive(Clone, Copy, Debug, Default)] struct ActiveClip { + rect_active: bool, rect: Option, rounded: Option<(Rect, f32)>, } +impl ActiveClip { + fn resolved_rect(self) -> Option { + if !self.rect_active { + return None; + } + Some(self.rect.unwrap_or_else(empty_clip_rect)) + } +} + #[derive(Clone, Copy, Debug)] struct PixelRect { left: i32, @@ -995,8 +1005,20 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { let text_bounds_clip = text.bounds.map(|bounds| { Rect::new(text.origin.x, text.origin.y, bounds.width, bounds.height) }); - let clip_rect = - intersect_rects(active_clip.rect, text_bounds_clip).map(rect_to_pixel_rect); + let logical_clip_rect = + match (active_clip.rect_active, active_clip.rect, text_bounds_clip) { + (true, Some(active_clip_rect), Some(text_bounds_rect)) => { + intersect_rects(Some(active_clip_rect), Some(text_bounds_rect)) + } + (true, Some(active_clip_rect), None) => Some(active_clip_rect), + (true, None, _) => None, + (false, _, Some(text_bounds_rect)) => Some(text_bounds_rect), + (false, _, None) => None, + }; + if active_clip.rect_active && logical_clip_rect.is_none() { + continue; + } + let clip_rect = logical_clip_rect.map(rect_to_pixel_rect); for glyph in &text.glyphs { let Some(cache_key) = glyph.cache_key else { @@ -1173,7 +1195,7 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { .image_cache .get(&key) .expect("image cache entry should exist after insertion"); - let vertices = build_image_vertices(image.rect, image.uv_rect, logical_size, clip); + let vertices = build_image_vertices(image.rect, image.uv_rect, logical_size, clip)?; let vertex_buffer = self .device .create_buffer_init(&wgpu::util::BufferInitDescriptor { @@ -1210,7 +1232,7 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { text.origin.x + cached.origin_offset.x, text.origin.y + cached.origin_offset.y, ); - let vertices = build_text_vertices(origin, cached.size, logical_size, clip); + let vertices = build_text_vertices(origin, cached.size, logical_size, clip)?; let vertex_buffer = self .device .create_buffer_init(&wgpu::util::BufferInitDescriptor { @@ -1635,7 +1657,7 @@ fn build_text_vertices( size: UiSize, logical_size: UiSize, clip: ActiveClip, -) -> [TextVertex; 6] { +) -> Option<[TextVertex; 6]> { let rect = Rect::new(origin.x, origin.y, size.width, size.height); build_textured_vertices( rect, @@ -1651,7 +1673,7 @@ fn build_image_vertices( uv_rect: (f32, f32, f32, f32), logical_size: UiSize, clip: ActiveClip, -) -> [TextVertex; 6] { +) -> Option<[TextVertex; 6]> { build_textured_vertices(rect, uv_rect, [1.0, 1.0, 1.0, 1.0], logical_size, clip) } @@ -1661,7 +1683,8 @@ fn build_textured_vertices( color: [f32; 4], logical_size: UiSize, clip: ActiveClip, -) -> [TextVertex; 6] { +) -> Option<[TextVertex; 6]> { + let (rect, (u0, v0, u1, v1)) = clip_textured_rect(rect, uv_rect, clip.resolved_rect())?; let left = to_ndc_x(rect.origin.x, logical_size.width.max(1.0)); let right = to_ndc_x(rect.origin.x + rect.size.width, logical_size.width.max(1.0)); let top = to_ndc_y(rect.origin.y, logical_size.height.max(1.0)); @@ -1669,11 +1692,10 @@ fn build_textured_vertices( rect.origin.y + rect.size.height, logical_size.height.max(1.0), ); - let (u0, v0, u1, v1) = uv_rect; - let clip_rect = clip_rect_array(clip.rect); + let clip_rect = clip_rect_array(clip); let (rounded_clip_rect, clip_params) = rounded_clip_arrays(clip); - [ + Some([ TextVertex { position: [left, top], world_position: [rect.origin.x, rect.origin.y], @@ -1731,7 +1753,7 @@ fn build_textured_vertices( rounded_clip_rect, clip_params, }, - ] + ]) } #[allow(dead_code)] @@ -1754,7 +1776,7 @@ fn push_glyph_vertices( let bottom = to_ndc_y(dest_rect.bottom as f32, logical_size.height.max(1.0)); let color = color_to_f32(color); - let clip_rect = clip_rect_array(clip.rect); + let clip_rect = clip_rect_array(clip); let (rounded_clip_rect, clip_params) = rounded_clip_arrays(clip); vertices.extend_from_slice(&[ TextVertex { @@ -1959,7 +1981,7 @@ fn push_shape_vertices( let rect_data = rect_to_array(shader_rect); let fill_color = fill_color.map_or([0.0, 0.0, 0.0, 0.0], color_to_f32); let border_color = border_color.map_or([0.0, 0.0, 0.0, 0.0], color_to_f32); - let clip_rect = clip_rect_array(clip.rect); + let clip_rect = clip_rect_array(clip); let (rounded_clip_rect, clip_params) = rounded_clip_arrays(clip); let shadow_base_rect = shadow_base_rect.map_or([0.0, 0.0, 0.0, 0.0], rect_to_array); @@ -2091,8 +2113,9 @@ fn shadow_blur_extent(blur: f32) -> f32 { blur.max(0.0) * 2.0 } -fn clip_rect_array(rect: Option) -> [f32; 4] { - rect.map_or([0.0, 0.0, 0.0, 0.0], rect_to_array) +fn clip_rect_array(clip: ActiveClip) -> [f32; 4] { + clip.resolved_rect() + .map_or([0.0, 0.0, 0.0, 0.0], rect_to_array) } fn rounded_clip_arrays(clip: ActiveClip) -> ([f32; 4], [f32; 2]) { @@ -2100,7 +2123,7 @@ fn rounded_clip_arrays(clip: ActiveClip) -> ([f32; 4], [f32; 2]) { .rounded .map_or([0.0, 0.0, 0.0, 0.0], |(rect, _)| rect_to_array(rect)); let clip_params = [ - if clip.rect.is_some() { 1.0 } else { 0.0 }, + if clip.rect_active { 1.0 } else { 0.0 }, clip.rounded.map_or(0.0, |(_, radius)| radius), ]; (rounded_clip_rect, clip_params) @@ -2111,7 +2134,12 @@ fn push_clip_state(stack: &mut Vec, active: &mut ActiveClip, region: if region.radius > 0.0 { active.rounded = Some((region.rect, region.radius)); } - active.rect = Some(intersect_rects(active.rect, Some(region.rect)).unwrap_or(region.rect)); + active.rect = if active.rect_active { + intersect_rects(active.rect, Some(region.rect)) + } else { + Some(region.rect) + }; + active.rect_active = true; } fn pop_clip_state(stack: &mut Vec, active: &mut ActiveClip) { @@ -2136,6 +2164,10 @@ fn intersect_rects(first: Option, second: Option) -> Option { } } +fn empty_clip_rect() -> Rect { + Rect::new(1.0, 1.0, -1.0, -1.0) +} + fn to_ndc_x(x: f32, width: f32) -> f32 { (x / width) * 2.0 - 1.0 } @@ -2183,6 +2215,43 @@ fn clipped_glyph_quad( Some((clipped, (u0, v0, u1, v1))) } +fn clip_textured_rect( + rect: Rect, + uv_rect: (f32, f32, f32, f32), + clip_rect: Option, +) -> Option<(Rect, (f32, f32, f32, f32))> { + let clipped = if let Some(clip) = clip_rect { + let left = rect.origin.x.max(clip.origin.x); + let top = rect.origin.y.max(clip.origin.y); + let right = (rect.origin.x + rect.size.width).min(clip.origin.x + clip.size.width); + let bottom = (rect.origin.y + rect.size.height).min(clip.origin.y + clip.size.height); + if right <= left || bottom <= top { + return None; + } + Rect::new(left, top, right - left, bottom - top) + } else { + rect + }; + + if rect.size.width <= 0.0 || rect.size.height <= 0.0 { + return None; + } + + let (u0, v0, u1, v1) = uv_rect; + let clipped_u0 = u0 + (u1 - u0) * ((clipped.origin.x - rect.origin.x) / rect.size.width); + let clipped_v0 = v0 + (v1 - v0) * ((clipped.origin.y - rect.origin.y) / rect.size.height); + let clipped_u1 = u1 + - (u1 - u0) + * (((rect.origin.x + rect.size.width) - (clipped.origin.x + clipped.size.width)) + / rect.size.width); + let clipped_v1 = v1 + - (v1 - v0) + * (((rect.origin.y + rect.size.height) - (clipped.origin.y + clipped.size.height)) + / rect.size.height); + + Some((clipped, (clipped_u0, clipped_v0, clipped_u1, clipped_v1))) +} + fn text_texture_key(text: &PreparedText) -> TextTextureKey { TextTextureKey { text: text.text.clone(), @@ -2208,9 +2277,13 @@ fn text_texture_key(text: &PreparedText) -> TextTextureKey { #[cfg(test)] mod tests { - use super::{blend_rgba, build_vertices, text_texture_key}; + use super::{ + ActiveClip, blend_rgba, build_text_vertices, build_vertices, clip_rect_array, + push_clip_state, rounded_clip_arrays, text_texture_key, + }; use ruin_ui::{ - Color, GlyphInstance, Point, PreparedText, Rect, SceneSnapshot, TextSelectionStyle, UiSize, + ClipRegion, Color, GlyphInstance, Point, PreparedText, Rect, SceneSnapshot, + TextSelectionStyle, UiSize, }; #[test] @@ -2284,4 +2357,44 @@ mod tests { assert_eq!(text_texture_key(&first), text_texture_key(&second)); } + + #[test] + fn nested_clip_with_empty_intersection_stays_clipped() { + let mut clip_stack = Vec::new(); + let mut active_clip = ActiveClip::default(); + + push_clip_state( + &mut clip_stack, + &mut active_clip, + ClipRegion { + rect: Rect::new(0.0, 0.0, 100.0, 100.0), + radius: 0.0, + }, + ); + push_clip_state( + &mut clip_stack, + &mut active_clip, + ClipRegion { + rect: Rect::new(150.0, 150.0, 50.0, 50.0), + radius: 8.0, + }, + ); + + assert!(active_clip.rect_active); + assert_eq!(active_clip.rect, None); + let clip_rect = clip_rect_array(active_clip); + assert!(clip_rect[0] > clip_rect[2]); + assert!(clip_rect[1] > clip_rect[3]); + let (_, clip_params) = rounded_clip_arrays(active_clip); + assert_eq!(clip_params[0], 1.0); + assert!( + build_text_vertices( + Point::new(160.0, 160.0), + UiSize::new(24.0, 12.0), + UiSize::new(400.0, 400.0), + active_clip, + ) + .is_none() + ); + } }