diff --git a/lib/ruin_app/Cargo.toml b/lib/ruin_app/Cargo.toml index c133dfd..7db9920 100644 --- a/lib/ruin_app/Cargo.toml +++ b/lib/ruin_app/Cargo.toml @@ -17,3 +17,7 @@ path = "example/00_bootstrap_and_counter_raw.rs" [[example]] name = "00_bootstrap_and_counter" path = "example/00_bootstrap_and_counter.rs" + +[[example]] +name = "01_async_data_and_effects" +path = "example/01_async_data_and_effects.rs" diff --git a/lib/ruin_app/example/00_bootstrap_and_counter.rs b/lib/ruin_app/example/00_bootstrap_and_counter.rs index 4ff00dd..162af9c 100644 --- a/lib/ruin_app/example/00_bootstrap_and_counter.rs +++ b/lib/ruin_app/example/00_bootstrap_and_counter.rs @@ -1,5 +1,4 @@ use ruin_app::prelude::*; -use ruin_ui::Border; #[ruin_runtime::async_main] async fn main() -> ruin_app::Result<()> { diff --git a/lib/ruin_app/example/01_async_data_and_effects.rs b/lib/ruin_app/example/01_async_data_and_effects.rs new file mode 100644 index 0000000..fd13d55 --- /dev/null +++ b/lib/ruin_app/example/01_async_data_and_effects.rs @@ -0,0 +1,162 @@ +use ruin_app::prelude::*; + +#[ruin_runtime::async_main] +async fn main() -> ruin_app::Result<()> { + App::new() + .window( + Window::new() + .title("RUIN Async Data and Effects") + .app_id("dev.ruin.async-data") + .size(1080.0, 760.0), + ) + .mount(view! { + AsyncDataAndEffectsDemo() {} + }) + .run() + .await +} + +#[component] +fn AsyncDataAndEffectsDemo() -> impl IntoView { + let selected_path = use_signal(|| "Cargo.toml".to_string()); + let reload_nonce = use_signal(|| 0_u64); + let preview_scroll = use_signal(|| 0.0_f32); + + let file_contents = use_resource({ + let selected_path = selected_path.clone(); + let reload_nonce = reload_nonce.clone(); + move || { + let path = selected_path.get(); + let _ = reload_nonce.get(); + async move { + ruin_runtime::fs::read_to_string(&path) + .await + .map_err(|error| error.to_string()) + } + } + }); + + use_effect({ + let selected_path = selected_path.clone(); + let reload_nonce = reload_nonce.clone(); + move || { + eprintln!( + "async resource dependencies changed: path={}, reload={}", + selected_path.get(), + reload_nonce.get() + ); + } + }); + + use_effect({ + let selected_path = selected_path.clone(); + let reload_nonce = reload_nonce.clone(); + let preview_scroll = preview_scroll.clone(); + move || { + let _ = selected_path.get(); + let _ = reload_nonce.get(); + let _ = preview_scroll.set(0.0); + } + }); + + use_window_title({ + let selected_path = selected_path.clone(); + move || format!("RUIN Async Demo ({})", selected_path.get()) + }); + + view! { + column(gap = 16.0, padding = 24.0) { + text(role = TextRole::Heading(1), size = 32.0, weight = FontWeight::Semibold) { + "Async data and effects" + } + + text(color = colors::muted(), wrap = TextWrap::Word) { + "This is a smaller truthful slice of example 01: local signals drive a real async \ + resource, and an effect logs the dependency changes." + } + + FileControls(selected_path = selected_path.clone(), reload_nonce = reload_nonce.clone()) {} + + block( + padding = 16.0, + gap = 10.0, + background = surfaces::raised(), + border_radius = 12.0, + ) { + text(size = 18.0) { "selected = "; selected_path.clone() } + + match file_contents.read() { + Pending => view! { + text(color = colors::muted()) { + "Loading file contents..." + } + }, + Ready(Ok(contents)) => view! { + column(background = surfaces::raised(), gap = 10.0) { + text() { "bytes = "; contents.len() } + + scroll_box( + height = 420.0, + offset_y = preview_scroll.clone(), + padding = 12.0, + background = surfaces::canvas(), + border_radius = 10.0, + border = (2.0, colors::muted()), + ) { + text( + color = colors::muted(), + font_family = TextFontFamily::Monospace, + ) { + contents + } + } + } + }, + Ready(Err(error)) => view! { + text(color = colors::danger()) { error.to_string() } + }, + } + } + } + } +} + +#[component] +fn FileControls(selected_path: Signal, reload_nonce: Signal) -> impl IntoView { + view! { + row(gap = 8.0) { + button( + on_press = { + let selected_path = selected_path.clone(); + move |_| { + let _ = selected_path.set("Cargo.toml".to_string()); + } + }, + ) { + "Cargo.toml" + } + + button( + on_press = { + let selected_path = selected_path.clone(); + move |_| { + let _ = selected_path.set("Cargo.lock".to_string()); + } + }, + ) { + "Cargo.lock" + } + + button( + on_press = { + let reload_nonce = reload_nonce.clone(); + move |_| { + reload_nonce.update(|value| *value += 1); + } + }, + ) { + "Reload" + } + } + } +} diff --git a/lib/ruin_app/src/lib.rs b/lib/ruin_app/src/lib.rs index bee95fa..4fb6a8d 100644 --- a/lib/ruin_app/src/lib.rs +++ b/lib/ruin_app/src/lib.rs @@ -7,18 +7,23 @@ use std::any::Any; use std::cell::{Cell as StdCell, RefCell}; use std::collections::HashMap; use std::error::Error; +use std::future::Future; use std::iter; use std::rc::Rc; use ruin_reactivity::effect; +use ruin_runtime::queue_future; use ruin_ui::{ - Border, Color, CursorIcon, Edges, Element, ElementId, InteractionTree, LayoutSnapshot, - PlatformEvent, PointerButton, PointerEvent, PointerRouter, RoutedPointerEvent, - RoutedPointerEventKind, TextFontFamily, TextSpan, TextSpanWeight, TextStyle, TextSystem, - UiSize, WindowController, WindowSpec, WindowUpdate, layout_snapshot_with_text_system, + Border, Color, CursorIcon, DisplayItem, Edges, Element, ElementId, HitTarget, InteractionTree, + KeyboardEvent, KeyboardEventKind, KeyboardKey, LayoutSnapshot, PlatformEvent, PointerButton, + PointerEvent, PointerEventKind, PointerRouter, Quad, RoutedPointerEvent, + RoutedPointerEventKind, ScrollbarStyle, TextFontFamily, TextSpan, TextSpanWeight, TextStyle, + TextSystem, TextWrap, UiSize, WindowController, WindowSpec, WindowUpdate, + layout_snapshot_with_text_system, }; use ruin_ui_platform_wayland::start_wayland_ui; +pub use ResourceState::{Pending, Ready}; pub use ruin_app_proc_macros::{component, view}; pub type Result = std::result::Result>; @@ -144,8 +149,8 @@ impl MountedApp { let interaction_tree = Rc::new(RefCell::new(None::)); let bindings = Rc::new(RefCell::new(EventBindings::default())); let current_title = Rc::new(RefCell::new(None::)); + let mut input_state = InputState::new(); let mut pointer_router = PointerRouter::new(); - let mut current_cursor = CursorIcon::Default; let _scene_effect = effect({ let window = window.clone(); @@ -156,10 +161,12 @@ impl MountedApp { 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 render_output = render_with_context(Rc::clone(&render_state), || root.render()); @@ -173,7 +180,7 @@ impl MountedApp { } let LayoutSnapshot { - scene, + mut scene, interaction_tree: next_interaction_tree, } = layout_snapshot_with_text_system( version, @@ -182,6 +189,8 @@ impl MountedApp { &mut text_system.borrow_mut(), ); + 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; window @@ -209,7 +218,15 @@ impl MountedApp { &interaction_tree, &bindings, &mut pointer_router, - &mut current_cursor, + &mut input_state, + event, + )?; + } + PlatformEvent::Keyboard { window_id, event } if window_id == window.id() => { + Self::handle_keyboard_event( + &interaction_tree, + &bindings, + &input_state, event, )?; } @@ -234,9 +251,21 @@ impl MountedApp { interaction_tree: &RefCell>, bindings: &RefCell, pointer_router: &mut PointerRouter, - current_cursor: &mut CursorIcon, + 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 { @@ -245,8 +274,23 @@ impl MountedApp { pointer_router.route(interaction_tree, event) }; - for routed_event in &routed { - bindings.borrow().dispatch_press(routed_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 @@ -254,13 +298,97 @@ impl MountedApp { .last() .map(|target| target.cursor) .unwrap_or(CursorIcon::Default); - if next_cursor != *current_cursor { - *current_cursor = next_cursor; + 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, + input_state: &InputState, + event: KeyboardEvent, + ) -> Result<()> { + let interaction_tree = interaction_tree.borrow(); + let Some(interaction_tree) = interaction_tree.as_ref() else { + 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)] @@ -286,6 +414,16 @@ impl View { 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; @@ -458,6 +596,22 @@ impl IntoEdges for f32 { } } +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, @@ -493,6 +647,10 @@ pub mod colors { pub const fn muted() -> Color { Color::rgb(0xB6, 0xC2, 0xD9) } + + pub const fn danger() -> Color { + Color::rgb(0xFF, 0x7B, 0x72) + } } pub mod surfaces { @@ -541,6 +699,14 @@ 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()), + } +} + pub struct ContainerBuilder { element: Element, } @@ -561,13 +727,14 @@ impl ContainerBuilder { self } - pub fn border_radius(mut self, radius: f32) -> Self { - self.element = self.element.corner_radius(radius); + 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(mut self, border: Border) -> Self { - self.element = self.element.border(border.width, border.color); + pub fn border_radius(mut self, radius: f32) -> Self { + self.element = self.element.corner_radius(radius); self } @@ -591,6 +758,166 @@ impl ContainerBuilder { } } +pub struct ScrollBoxBuilder { + element: Element, + offset_y: Option>, + drag: Signal>, +} + +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 children(mut self, children: impl Children) -> View { + let element_id = allocate_element_id(); + self.element = self.element.id(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, @@ -598,6 +925,7 @@ pub struct TextBuilder { weight: Option, color: Option, font_family: Option, + wrap: Option, } impl TextBuilder { @@ -626,6 +954,11 @@ impl TextBuilder { 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 @@ -643,7 +976,9 @@ impl TextBuilder { TextRole::Heading(_) => FontWeight::Semibold, }); - let mut style = TextStyle::new(size, color).with_line_height(size * 1.2); + 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); } @@ -651,7 +986,7 @@ impl TextBuilder { let span = TextSpan::new(children.into_text()) .color(color) .weight(weight.to_text_span_weight()); - View::from_element(Element::spans([span], style)) + View::from_element(Element::spans([span], style).id(allocate_element_id())) } } @@ -784,12 +1119,70 @@ pub fn use_memo(compute: impl Fn() -> T + 'static) -> Memo { ) } +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)] +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>>, @@ -895,6 +1288,11 @@ struct MemoSlot { handle: Memo, } +struct ResourceSlot { + resource: Resource, + _effect: ruin_reactivity::EffectHandle, +} + impl MemoSlot { fn new(compute: Rc T>>>) -> Self { let handle = Memo { @@ -922,46 +1320,224 @@ impl MemoSlot { } 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_press(&self, event: &RoutedPointerEvent) { - let Some(element_id) = event.target.element_id else { + 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(element_id) = focused_element else { return; }; - if !matches!( - event.kind, - RoutedPointerEventKind::Up { - button: PointerButton::Primary - } - ) { + let Some(handler) = self.on_key.get(&element_id) else { return; - } + }; + let _ = handler(event, interaction_tree); + } +} - if let Some(handler) = self.on_press.get(&element_id) { - handler(event); +#[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 { + interaction_tree + .hit_path(event.position) + .iter() + .rev() + .find_map(|target| target.focusable.then_some(target.element_id).flatten()) +} + pub mod prelude { pub use crate::{ - App, ButtonBuilder, Children, Component, ContainerBuilder, FontWeight, IntoEdges, IntoView, - Memo, Mountable, Result, Signal, TextBuilder, TextChildren, TextRole, View, Window, block, - button, colors, column, component, row, surfaces, text, use_memo, use_signal, - use_window_title, view, + 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, }; pub use ruin_ui::{ - Color, CursorIcon, Edges, Element, ElementId, PointerButton, PointerEventKind, - RoutedPointerEvent, RoutedPointerEventKind, TextFontFamily, TextStyle, TextWrap, UiSize, + Border, Color, CursorIcon, Edges, Element, ElementId, 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 3a2bc8c..2770078 100644 --- a/lib/ruin_app_proc_macros/src/lib.rs +++ b/lib/ruin_app_proc_macros/src/lib.rs @@ -434,7 +434,9 @@ fn looks_like_node(input: ParseStream<'_>) -> Result { } let props_content; parenthesized!(props_content in fork); - let _ = Punctuated::::parse_terminated(&props_content)?; + if Punctuated::::parse_terminated(&props_content).is_err() { + return Ok(false); + } Ok(fork.peek(syn::token::Brace)) } diff --git a/lib/ui/src/interaction.rs b/lib/ui/src/interaction.rs index 9b809c9..0370daa 100644 --- a/lib/ui/src/interaction.rs +++ b/lib/ui/src/interaction.rs @@ -114,7 +114,7 @@ impl PointerRouter { match event.kind { PointerEventKind::Move => { - if let Some(target) = hit_target.clone() { + if let Some(target) = self.pressed.clone().or(hit_target.clone()) { routed.push(RoutedPointerEvent { kind: RoutedPointerEventKind::Move, target, @@ -314,6 +314,36 @@ mod tests { ); } + #[test] + fn router_prefers_pressed_target_on_pointer_move() { + let mut router = PointerRouter::new(); + let tree = interaction_tree(); + let _ = router.route( + &tree, + PointerEvent::new( + 0, + Point::new(20.0, 20.0), + PointerEventKind::Down { + button: PointerButton::Primary, + }, + ), + ); + let routed = router.route( + &tree, + PointerEvent::new(0, Point::new(160.0, 20.0), PointerEventKind::Move), + ); + + assert!( + routed + .iter() + .any(|event| event.kind == RoutedPointerEventKind::Move) + ); + assert_eq!( + routed.last().unwrap().target.element_id, + Some(ElementId::new(1)) + ); + } + #[test] fn router_routes_scroll_to_deepest_hover_target() { let mut router = PointerRouter::new(); diff --git a/lib/ui/src/scene.rs b/lib/ui/src/scene.rs index 636ca5b..6d6edf3 100644 --- a/lib/ui/src/scene.rs +++ b/lib/ui/src/scene.rs @@ -585,6 +585,7 @@ impl SceneSnapshot { mod tests { use super::{Color, Point, PreparedText, Rect}; use crate::TextSelectionStyle; + use crate::{TextStyle, TextSystem, TextWrap}; #[test] fn prepared_text_hit_testing_clamps_to_nearest_cluster_boundary() { @@ -703,4 +704,22 @@ mod tests { assert_eq!(text.line_start_offset(6), Some(4)); assert_eq!(text.line_end_offset(2), Some(4)); } + + #[test] + fn prepared_text_multiline_selection_stays_on_target_line() { + let mut text_system = TextSystem::new(); + let style = TextStyle::new(16.0, Color::rgb(0xFF, 0xFF, 0xFF)).with_wrap(TextWrap::None); + let text = text_system.prepare("ab\ncd\nef", Point::new(10.0, 20.0), &style); + + assert_eq!(text.lines.len(), 3); + + let target_line = &text.lines[1]; + let y = target_line.rect.origin.y + target_line.rect.size.height * 0.5; + let start = text.byte_offset_for_position(Point::new(target_line.rect.origin.x, y)); + let end = text.byte_offset_for_position(Point::new(target_line.rect.origin.x + 16.0, y)); + let rects = text.selection_rects(start, end); + + assert_eq!(rects.len(), 1); + assert_eq!(rects[0].origin.y, target_line.rect.origin.y); + } } diff --git a/lib/ui/src/text.rs b/lib/ui/src/text.rs index 8efdd6b..983b90f 100644 --- a/lib/ui/src/text.rs +++ b/lib/ui/src/text.rs @@ -418,6 +418,7 @@ impl TextSystem { } self.frame_stats.buffer_build_ms += buffer_build_started.elapsed().as_secs_f64() * 1_000.0; + let line_starts = line_start_offsets(&combined_text(spans)); let mut measured_width: f32 = 0.0; let mut measured_height: f32 = 0.0; let mut lines = Vec::new(); @@ -430,6 +431,7 @@ impl TextSystem { measured_width = measured_width.max(run.line_w); measured_height = measured_height.max(run.line_top + run.line_height); let x_offset = aligned_line_offset(style.align, width, run.line_w); + let line_offset = line_starts.get(run.line_i).copied().unwrap_or(0); let glyph_start = glyphs.len(); glyphs.extend(run.glyphs.iter().map(move |glyph| { let physical = glyph.physical((x_offset, run.line_y), 1.0); @@ -438,13 +440,19 @@ impl TextSystem { advance: glyph.w, color: glyph.color_opt.map_or(style.color, color_from_cosmic), cache_key: Some(physical.cache_key), - text_start: glyph.start, - text_end: glyph.end, + text_start: line_offset + glyph.start, + text_end: line_offset + glyph.end, } })); let glyph_end = glyphs.len(); - let text_start = run.glyphs.first().map_or(0, |glyph| glyph.start); - let text_end = run.glyphs.last().map_or(text_start, |glyph| glyph.end); + let text_start = run + .glyphs + .first() + .map_or(line_offset, |glyph| line_offset + glyph.start); + let text_end = run + .glyphs + .last() + .map_or(text_start, |glyph| line_offset + glyph.end); lines.push(PreparedTextLine { rect: Rect::new(x_offset, run.line_top, run.line_w.max(0.0), run.line_height), text_start, @@ -504,6 +512,28 @@ fn combined_text(spans: &[TextSpan]) -> String { text } +fn line_start_offsets(text: &str) -> Vec { + let mut starts = vec![0]; + let bytes = text.as_bytes(); + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + b'\n' => starts.push(index + 1), + b'\r' => { + if bytes.get(index + 1) == Some(&b'\n') { + starts.push(index + 2); + index += 1; + } else { + starts.push(index + 1); + } + } + _ => {} + } + index += 1; + } + starts +} + fn layout_cache_key( spans: &[TextSpan], style: &TextStyle,