From 341d07d82fbf49e2fa37cd70010a2e368d7a0a62 Mon Sep 17 00:00:00 2001 From: Will Temple Date: Wed, 25 Mar 2026 22:32:11 -0400 Subject: [PATCH] Refactor ruin_app, add some README files, minor usability in reactivity --- lib/reactivity/README.md | 152 ++ lib/reactivity/examples/readme_md_01.rs | 31 + lib/reactivity/examples/readme_md_02.rs | 49 + lib/reactivity/examples/readme_md_03.rs | 29 + lib/reactivity/src/effect.rs | 12 + lib/reactivity/src/event.rs | 20 +- .../example/00_bootstrap_and_counter.rs | 19 +- lib/ruin_app/src/app.rs | 466 ++++ lib/ruin_app/src/colors.rs | 13 + lib/ruin_app/src/context.rs | 187 ++ lib/ruin_app/src/converters.rs | 43 + lib/ruin_app/src/hooks.rs | 374 +++ lib/ruin_app/src/input.rs | 344 +++ lib/ruin_app/src/lib.rs | 2182 +---------------- lib/ruin_app/src/primitives/button.rs | 61 + lib/ruin_app/src/primitives/container.rs | 147 ++ lib/ruin_app/src/primitives/mod.rs | 63 + lib/ruin_app/src/primitives/scroll_box.rs | 160 ++ lib/ruin_app/src/primitives/text.rs | 129 + lib/ruin_app/src/surfaces.rs | 17 + lib/ruin_app/src/text_style.rs | 61 + lib/ruin_app/src/view.rs | 239 ++ lib/ruin_app_proc_macros/src/lib.rs | 30 +- lib/runtime/README.md | 0 lib/runtime/src/lib.rs | 3 + .../src/platform/linux_x86_64/runtime.rs | 17 +- lib/ui/src/layout.rs | 20 +- lib/ui/src/scene.rs | 32 +- 28 files changed, 2782 insertions(+), 2118 deletions(-) create mode 100644 lib/reactivity/README.md create mode 100644 lib/reactivity/examples/readme_md_01.rs create mode 100644 lib/reactivity/examples/readme_md_02.rs create mode 100644 lib/reactivity/examples/readme_md_03.rs create mode 100644 lib/ruin_app/src/app.rs create mode 100644 lib/ruin_app/src/colors.rs create mode 100644 lib/ruin_app/src/context.rs create mode 100644 lib/ruin_app/src/converters.rs create mode 100644 lib/ruin_app/src/hooks.rs create mode 100644 lib/ruin_app/src/input.rs create mode 100644 lib/ruin_app/src/primitives/button.rs create mode 100644 lib/ruin_app/src/primitives/container.rs create mode 100644 lib/ruin_app/src/primitives/mod.rs create mode 100644 lib/ruin_app/src/primitives/scroll_box.rs create mode 100644 lib/ruin_app/src/primitives/text.rs create mode 100644 lib/ruin_app/src/surfaces.rs create mode 100644 lib/ruin_app/src/text_style.rs create mode 100644 lib/ruin_app/src/view.rs create mode 100644 lib/runtime/README.md diff --git a/lib/reactivity/README.md b/lib/reactivity/README.md new file mode 100644 index 0000000..79e75d5 --- /dev/null +++ b/lib/reactivity/README.md @@ -0,0 +1,152 @@ +# RUIN Reactivity - Fine-Grained Reactor + +Automatic thread-stack-based reactivity for RUIN-apps. + +RUIN Reactivity provides an implementation of fine-grained reactivity primitives +for dependency tracking and incremental computation. Those primitives are: + +- `Cell`: a tracked-state value cell, primitively observable (sometimes + called a "signal" in other reactor implementations). +- `effect`: a primitive observer that runs once, observes its dependencies, and + runs again whenever its dependencies change. +- `Thunk`: a tracked-state recomputable value defined by a closure. + Invalidated if any of its dependencies change. A `Thunk` is both an observer + and an observable. +- `Event`: a push-style source of events of type `T`. Supports subscription + and cancellation of interest in events. + +RUIN reactivity requires the RUIN runtime to function, and cannot be used on +threads not managed by the RUIN runtime. + +RUIN reactivity does not function across thread boundaries. It tracks +dependencies between entities on the same thread only. + +## Examples + +### Observe a cell using an effect + +```rs +use std::time::Duration; + +use ruin_reactivity::{cell, effect}; +use ruin_runtime::{main, set_timeout}; + +#[main] +fn main() { + // Creates an observable value. Calling `.get` from within an observer will create a dependency, and calling `.set` + // will trigger updates to any dependent observers. + let v = cell(5); + + // Creates an observer that prints the value of `v` whenever it changes. + // Calling `.leak()` on the effect handle allows it to run for the lifetime of the program without automatically + // disposing when dropped. + effect({ + let v = v.clone(); + move || { + println!("v is: {}", v.get()); + } + }) + .leak(); + + // Queue a future to wait 5 seconds and then update `v`. This will trigger + // the effect to run again and print the new value. + set_timeout(Duration::from_secs(5), { + let v = v.clone(); + move || { + v.set(v.get() + 20); + } + }); +} +``` + +### Observe a thunk using an effect + +```rs +use std::time::Duration; + +use ruin_reactivity::{cell, effect, thunk}; +use ruin_runtime::{clear_interval, main, set_interval, set_timeout}; + +#[main] +fn main() { + // Two primitive observable values. + let x = cell(5); + let y = cell(10); + + // A derived observable value that depends on `x` and `y`. The closure will only run when `x` or `y` change, and the + // result will be cached until then. + let z = thunk({ + let x = x.clone(); + let y = y.clone(); + move || { + println!("calculating z..."); + x.get() + y.get() + } + }); + + // The effect observes `z`, so it will run whenever `z` changes. Because `z` depends on `x` and `y`, the effect will + // run whenever `x` or `y` change. + effect({ + let z = z.clone(); + move || { + println!("z is: {}", z.get()); + } + }) + .leak(); + + // Update `x` and `y` every second. This will trigger the effect to run and print the new value of `z`. + let interval = set_interval(Duration::from_secs(1), { + let x = x.clone(); + let y = y.clone(); + move || { + x.update(|value| *value += 1); + y.update(|value| *value += 2); + } + }); + + // After 10 seconds, clear the interval to stop updating `x` and `y`. Once the interval is cleared, the queue will + // empty and the program will exit since there are no more pending tasks. + set_timeout(Duration::from_secs(10), move || { + println!("clearing interval..."); + clear_interval(&interval); + }); +} +``` + +### Use an event to handle intra-thread messaging + +```rs +use std::{cell::Cell, rc::Rc, time::Duration}; + +use ruin_reactivity::event; +use ruin_runtime::{main, queue_future, set_interval, time::sleep}; + +#[main] +fn main() { + let my_event = event::(); + + my_event.subscribe(|message| { + println!("got event with message: {message}"); + }); + + // Emit an event every 250ms with an incrementing count. + let interval = set_interval(Duration::from_millis(250), { + let counter = Rc::new(Cell::new(0)); + move || { + let count = counter.get(); + my_event.emit(format!("the count is {}", count)); + counter.set(count + 1); + } + }); + + // After 5 seconds, clear the interval to stop emitting events. + queue_future(async move { + sleep(Duration::from_secs(5)).await; + interval.clear(); + }); +} +``` + +## License + +Licensed under the [MIT License](../../LICENSE.txt). diff --git a/lib/reactivity/examples/readme_md_01.rs b/lib/reactivity/examples/readme_md_01.rs new file mode 100644 index 0000000..7a78442 --- /dev/null +++ b/lib/reactivity/examples/readme_md_01.rs @@ -0,0 +1,31 @@ +use std::time::Duration; + +use ruin_reactivity::{cell, effect}; +use ruin_runtime::{main, set_timeout}; + +#[main] +fn main() { + // Creates an observable value. Calling `.get` from within an observer will create a dependency, and calling `.set` + // will trigger updates to any dependent observers. + let v = cell(5); + + // Creates an observer that prints the value of `v` whenever it changes. + // Calling `.leak()` on the effect handle allows it to run for the lifetime of the program without automatically + // disposing when dropped. + effect({ + let v = v.clone(); + move || { + println!("v is: {}", v.get()); + } + }) + .leak(); + + // Queue a future to wait 5 seconds and then update `v`. This will trigger + // the effect to run again and print the new value. + set_timeout(Duration::from_secs(5), { + let v = v.clone(); + move || { + v.set(v.get() + 20); + } + }); +} diff --git a/lib/reactivity/examples/readme_md_02.rs b/lib/reactivity/examples/readme_md_02.rs new file mode 100644 index 0000000..8e6021f --- /dev/null +++ b/lib/reactivity/examples/readme_md_02.rs @@ -0,0 +1,49 @@ +use std::time::Duration; + +use ruin_reactivity::{cell, effect, thunk}; +use ruin_runtime::{clear_interval, main, set_interval, set_timeout}; + +#[main] +fn main() { + // Two primitive observable values. + let x = cell(5); + let y = cell(10); + + // A derived observable value that depends on `x` and `y`. The closure will only run when `x` or `y` change, and the + // result will be cached until then. + let z = thunk({ + let x = x.clone(); + let y = y.clone(); + move || { + println!("calculating z..."); + x.get() + y.get() + } + }); + + // The effect observes `z`, so it will run whenever `z` changes. Because `z` depends on `x` and `y`, the effect will + // run whenever `x` or `y` change. + effect({ + let z = z.clone(); + move || { + println!("z is: {}", z.get()); + } + }) + .leak(); + + // Update `x` and `y` every second. This will trigger the effect to run and print the new value of `z`. + let interval = set_interval(Duration::from_secs(1), { + let x = x.clone(); + let y = y.clone(); + move || { + x.update(|value| *value += 1); + y.update(|value| *value += 2); + } + }); + + // After 10 seconds, clear the interval to stop updating `x` and `y`. Once the interval is cleared, the queue will + // empty and the program will exit since there are no more pending tasks. + set_timeout(Duration::from_secs(10), move || { + println!("clearing interval..."); + clear_interval(&interval); + }); +} diff --git a/lib/reactivity/examples/readme_md_03.rs b/lib/reactivity/examples/readme_md_03.rs new file mode 100644 index 0000000..5e9a22b --- /dev/null +++ b/lib/reactivity/examples/readme_md_03.rs @@ -0,0 +1,29 @@ +use std::{cell::Cell, rc::Rc, time::Duration}; + +use ruin_reactivity::event; +use ruin_runtime::{main, queue_future, set_interval, time::sleep}; + +#[main] +fn main() { + let my_event = event::(); + + my_event.subscribe(|message| { + println!("got event with message: {message}"); + }); + + // Emit an event every 250ms with an incrementing count. + let interval = set_interval(Duration::from_millis(250), { + let counter = Rc::new(Cell::new(0)); + move || { + let count = counter.get(); + my_event.emit(format!("the count is {}", count)); + counter.set(count + 1); + } + }); + + // After 5 seconds, clear the interval to stop emitting events. + queue_future(async move { + sleep(Duration::from_secs(5)).await; + interval.clear(); + }); +} diff --git a/lib/reactivity/src/effect.rs b/lib/reactivity/src/effect.rs index 4e9ef53..2e2d23a 100644 --- a/lib/reactivity/src/effect.rs +++ b/lib/reactivity/src/effect.rs @@ -5,6 +5,9 @@ use crate::reactor::ObserverHook; use crate::{NodeId, Reactor, current, trace_targets}; /// Creates an effect in the current thread's default reactor. +/// +/// The effect is scheduled immediately and then re-scheduled whenever one of its dependencies +/// changes. pub fn effect(f: impl Fn() + 'static) -> EffectHandle { current().effect(f) } @@ -16,6 +19,7 @@ pub fn effect_in(reactor: &Reactor, f: impl Fn() + 'static) -> EffectHandle { /// Disposable handle for a reactive effect. #[derive(Clone)] +#[must_use = "effects are automatically disposed when dropped, so you must use the handle or explicitly leak it"] pub struct EffectHandle { inner: Rc, } @@ -55,6 +59,14 @@ impl EffectHandle { Self { inner } } + /// Consumes the effect and leaks it, allowing it to run for the remainder of the program without automatically + /// disposing when dropped. Use this for effects that you want to run for the lifetime of the program. You CANNOT + /// recover an EffectHandle after calling this method, so be sure to call it on an EffectHandle that you don't need + /// to later dispose of. + pub fn leak(self) { + std::mem::forget(self); + } + /// Disposes the effect immediately. pub fn dispose(&self) { self.inner.dispose(); diff --git a/lib/reactivity/src/event.rs b/lib/reactivity/src/event.rs index 7c6333b..ff5e941 100644 --- a/lib/reactivity/src/event.rs +++ b/lib/reactivity/src/event.rs @@ -1,5 +1,5 @@ use std::cell::{Cell, RefCell}; -use std::collections::BTreeMap; +use std::collections::HashMap; use std::rc::Rc; use crate::{NodeId, Reactor, current, trace_targets}; @@ -17,11 +17,13 @@ pub fn event_in(reactor: &Reactor) -> Event { } /// Creates a reactive draining subscription for `event` in the current reactor. +#[must_use = "subscriptions created with `on` must be used to stay active"] pub fn on(event: &Event, handler: impl Fn(&T) + 'static) -> Subscription { current().on(event, handler) } /// Creates a reactive draining subscription for `event` associated with `reactor`. +#[must_use = "subscriptions created with `on_in` must be used to stay active"] pub fn on_in( reactor: &Reactor, event: &Event, @@ -49,6 +51,7 @@ impl Reactor { } /// Creates a reactive draining subscription for `event`. + #[must_use = "subscriptions created with `on` must be used to stay active"] pub fn on( &self, event: &Event, @@ -103,7 +106,7 @@ impl Event { reactor, id, next_subscriber: Cell::new(1), - subscribers: RefCell::new(BTreeMap::new()), + subscribers: RefCell::new(Default::default()), }), } } @@ -204,7 +207,7 @@ struct EventInner { reactor: Reactor, id: NodeId, next_subscriber: Cell, - subscribers: RefCell>>>, + subscribers: RefCell>>>, } struct SubscriptionInner { @@ -227,11 +230,12 @@ impl SubscriptionInner { } } -impl Drop for SubscriptionInner { - fn drop(&mut self) { - self.unsubscribe(); - } -} +// TODO: it's actually kind of hard to use events if subcscriptions have to be manually managed. +// impl Drop for SubscriptionInner { +// fn drop(&mut self) { +// self.unsubscribe(); +// } +// } #[cfg(test)] mod tests { diff --git a/lib/ruin_app/example/00_bootstrap_and_counter.rs b/lib/ruin_app/example/00_bootstrap_and_counter.rs index 9b639cd..1dff49f 100644 --- a/lib/ruin_app/example/00_bootstrap_and_counter.rs +++ b/lib/ruin_app/example/00_bootstrap_and_counter.rs @@ -1,4 +1,4 @@ -use ruin_app::prelude::*; +use ruin_app::{PartialTextStyle, prelude::*}; use ruin_ui::{BoxShadow, BoxShadowKind, Point}; #[ruin_runtime::async_main] @@ -34,8 +34,14 @@ fn CounterApp(title: &'static str) -> impl IntoView { move || format!("{title} ({})", count.get()) }); + let text_style = PartialTextStyle { + color: Some(Color::BLACK), + weight: Some(FontWeight::Bold), + ..Default::default() + }; + view! { - column(gap = 16.0, padding = 24.0) { + column(gap = 16.0, padding = 24.0, text_style = text_style) { text(role = TextRole::Heading(1), size = 32.0, weight = FontWeight::Semibold, color = Color::rgba(0, 0, 0, 255)) { title } @@ -63,7 +69,6 @@ fn CounterActions(count: Signal) -> impl IntoView { row(gap = 16.0) { button( background = Color::rgba(255, 255, 255, 90), - color = Color::rgba(0, 0, 0, 255), on_press = { let count = count.clone(); move |_| { @@ -71,12 +76,11 @@ fn CounterActions(count: Signal) -> impl IntoView { } }, ) { - "-1" + text(selectable = false, pointer_events = false) { "-1" } } button( background = Color::rgba(255, 255, 255, 90), - color = Color::rgba(0, 0, 0, 255), on_press = { let count = count.clone(); move |_| { @@ -84,12 +88,11 @@ fn CounterActions(count: Signal) -> impl IntoView { } }, ) { - "Reset" + text(selectable = false, pointer_events = false) { "Reset" } } button( background = Color::rgba(255, 255, 255, 90), - color = Color::rgba(0, 0, 0, 255), on_press = { let count = count.clone(); move |_| { @@ -97,7 +100,7 @@ fn CounterActions(count: Signal) -> impl IntoView { } }, ) { - "+1" + text(selectable = false, pointer_events = false) { "+1" } } } } diff --git a/lib/ruin_app/src/app.rs b/lib/ruin_app/src/app.rs new file mode 100644 index 0000000..613f5a6 --- /dev/null +++ b/lib/ruin_app/src/app.rs @@ -0,0 +1,466 @@ +use std::cell::Cell as StdCell; +use std::cell::RefCell; +use std::iter; +use std::rc::Rc; +use std::time::Instant; + +use ruin_reactivity::effect; +use ruin_ui::{ + Color, Element, InteractionTree, KeyboardEvent, LayoutCache, LayoutSnapshot, PlatformEvent, + PointerButton, PointerEvent, PointerEventKind, PointerRouter, RoutedPointerEvent, + RoutedPointerEventKind, TextSystem, UiSize, WindowController, WindowSpec, WindowUpdate, + layout_snapshot_with_cache, +}; +use ruin_ui_platform_wayland::start_wayland_ui; + +use crate::Result; +use crate::context::{RenderState, render_with_context}; +use crate::input::{ + EventBindings, InputState, ShortcutBinding, TextSelection, TextSelectionDrag, + TextSelectionState, apply_text_selection_overlay, focused_element_for_pointer, + sync_primary_selection, +}; +use crate::view::View; + +#[derive(Clone, Debug)] +pub struct Window { + pub(crate) 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 + } + + pub fn min_size(mut self, min_width: f32, min_height: f32) -> Self { + self.spec = self.spec.min_inner_size(UiSize::new(min_width, min_height)); + self + } + + pub fn base_color(mut self, color: Color) -> Self { + self.spec = self.spec.base_color(color); + 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() + } +} + +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 { + pub(crate) window: Window, + pub(crate) root: Rc, + pub(crate) 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 last_min_size = Rc::new(RefCell::new(None::)); + let explicit_min_size = app_window.spec.min_inner_size; + 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 last_min_size = Rc::clone(&last_min_size); + 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, + root_min_size, + } = 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(); + + // Compute and send min-size hint: max of layout min and explicit window min. + let desired_min = UiSize::new( + root_min_size + .width + .max(explicit_min_size.map_or(0.0, |s| s.width)), + root_min_size + .height + .max(explicit_min_size.map_or(0.0, |s| s.height)), + ); + let current_min = *last_min_size.borrow(); + if current_min != Some(desired_min) { + let _ = window.update(WindowUpdate::new().min_inner_size(Some(desired_min))); + *last_min_size.borrow_mut() = Some(desired_min); + } + 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(()) + } + + pub(crate) 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(ruin_ui::CursorIcon::Default); + if next_cursor != input_state.current_cursor { + input_state.current_cursor = next_cursor; + window.set_cursor_icon(next_cursor)?; + } + + Ok(()) + } + + pub(crate) 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(()) + } +} + +#[doc(hidden)] +pub fn __render_mountable_for_test(mountable: &M) -> View { + render_with_context(Rc::new(RenderState::default()), || mountable.render()).view +} diff --git a/lib/ruin_app/src/colors.rs b/lib/ruin_app/src/colors.rs new file mode 100644 index 0000000..72c08ef --- /dev/null +++ b/lib/ruin_app/src/colors.rs @@ -0,0 +1,13 @@ +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) +} diff --git a/lib/ruin_app/src/context.rs b/lib/ruin_app/src/context.rs new file mode 100644 index 0000000..619d6e7 --- /dev/null +++ b/lib/ruin_app/src/context.rs @@ -0,0 +1,187 @@ +use std::any::{Any, TypeId}; +use std::cell::{Cell as StdCell, RefCell}; +use std::rc::Rc; + +use ruin_ui::ElementId; + +use crate::ContextKey; +use crate::view::View; + +#[derive(Clone)] +pub(crate) struct ContextEntry { + pub(crate) key: TypeId, + pub(crate) value: Rc, +} + +impl ContextEntry { + pub(crate) fn new(value: C::Value) -> Self { + Self { + key: TypeId::of::(), + value: Rc::new(value), + } + } +} + +#[derive(Default)] +pub(crate) struct RenderState { + pub(crate) hooks: RefCell>>, + pub(crate) element_ids: RefCell>, + pub(crate) next_element_id: StdCell, +} + +#[derive(Clone, Default)] +pub(crate) struct RenderSideEffects { + pub(crate) window_title: Option, + pub(crate) shortcuts: Vec, +} + +#[derive(Clone)] +pub(crate) struct RenderContext { + pub(crate) state: Rc, + pub(crate) hook_index: Rc>, + pub(crate) element_index: Rc>, + pub(crate) side_effects: Rc>, + pub(crate) context_entries: Rc>, +} + +impl RenderContext { + pub(crate) 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, + } + } +} + +pub(crate) struct RenderOutput { + pub(crate) view: View, + pub(crate) side_effects: RenderSideEffects, +} + +thread_local! { + pub(crate) static CURRENT_RENDER_CONTEXT: RefCell> = const { RefCell::new(None) }; +} + +pub(crate) 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 } +} + +pub(crate) 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() + }) +} + +pub(crate) 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) + }) +} + +pub(crate) 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) + }) +} + +pub(crate) 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] + }) +} + +pub(crate) struct MemoSlot { + pub(crate) compute: Rc T>>>, + pub(crate) handle: crate::hooks::Memo, +} + +pub(crate) struct ResourceSlot { + pub(crate) resource: crate::hooks::Resource, + pub(crate) _effect: ruin_reactivity::EffectHandle, +} + +impl MemoSlot { + pub(crate) fn new(compute: Rc T>>>) -> Self { + let handle = crate::hooks::Memo { + compute: Rc::new({ + let compute = Rc::clone(&compute); + move || { + let compute = compute.borrow(); + (compute.as_ref())() + } + }), + }; + Self { compute, handle } + } + + pub(crate) 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())() + } + }); + } +} diff --git a/lib/ruin_app/src/converters.rs b/lib/ruin_app/src/converters.rs new file mode 100644 index 0000000..dd22842 --- /dev/null +++ b/lib/ruin_app/src/converters.rs @@ -0,0 +1,43 @@ +use ruin_ui::{Border, BoxShadow, Color, Edges}; + +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) + } +} + +pub trait IntoShadow { + fn into_shadow(self) -> BoxShadow; +} + +impl IntoShadow for BoxShadow { + fn into_shadow(self) -> BoxShadow { + self + } +} diff --git a/lib/ruin_app/src/hooks.rs b/lib/ruin_app/src/hooks.rs new file mode 100644 index 0000000..e87c533 --- /dev/null +++ b/lib/ruin_app/src/hooks.rs @@ -0,0 +1,374 @@ +use std::any::type_name; +use std::any::TypeId; +use std::cell::Cell as StdCell; +use std::future::Future; +use std::marker::PhantomData; +use std::rc::Rc; + +use ruin_ui::{ElementId, InteractionTree, KeyboardEvent, KeyboardEventKind, KeyboardKey}; + +use crate::ContextKey; +use crate::context::{ + ContextEntry, MemoSlot, ResourceSlot, with_hook_slot, + with_render_context, with_render_context_state, +}; +use crate::input::ShortcutBinding; + +pub struct SignalInner { + pub(crate) cell: ruin_reactivity::Cell, +} + +pub struct Signal { + pub(crate) inner: Rc>, +} + +impl Clone for Signal { + fn clone(&self) -> Self { + Self { + inner: Rc::clone(&self.inner), + } + } +} + +impl Signal { + pub(crate) 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 { + pub(crate) 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)() + } +} + +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() -> crate::view::View) -> crate::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 { + use std::cell::RefCell; + 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 { + pub(crate) 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 { + pub(crate) key: Key, + pub(crate) control: bool, + pub(crate) shift: bool, + pub(crate) alt: bool, + pub(crate) 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 + } + + pub(crate) 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 { + pub(crate) element_id: Signal>, +} + +#[derive(Clone)] +pub enum ShortcutScope { + Application, + FocusedWithin(FocusScope), +} + +impl ShortcutScope { + pub(crate) 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; + }; + crate::input::element_contains_element( + interaction_tree, + scope_element, + focused_element, + ) + } + } + } +} + +pub struct WidgetRef { + pub(crate) element_id: Signal>, + pub(crate) _marker: PhantomData T>, +} + +impl Clone for WidgetRef { + fn clone(&self) -> Self { + Self { + element_id: self.element_id.clone(), + _marker: PhantomData, + } + } +} + +impl WidgetRef { + pub(crate) 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 { + pub(crate) 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(ruin_runtime::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(), + ) +} + diff --git a/lib/ruin_app/src/input.rs b/lib/ruin_app/src/input.rs new file mode 100644 index 0000000..5e04d09 --- /dev/null +++ b/lib/ruin_app/src/input.rs @@ -0,0 +1,344 @@ +use std::cell::RefCell; +use std::collections::HashMap; +use std::rc::Rc; + +use ruin_ui::{ + CursorIcon, DisplayItem, ElementId, HitTarget, InteractionTree, KeyboardEvent, + PointerButton, PointerEvent, Quad, RoutedPointerEvent, + RoutedPointerEventKind, WindowController, +}; + +use crate::hooks::{Shortcut, ShortcutScope, Signal}; +use crate::view::View; + +pub(crate) type PressHandler = Rc; +pub(crate) type ScrollHandler = Rc; +pub(crate) type KeyHandler = Rc bool + 'static>; + +#[derive(Clone, Default)] +pub(crate) struct EventBindings { + pub(crate) on_press: HashMap, + pub(crate) on_scroll: HashMap, + pub(crate) on_key: HashMap, +} + +impl EventBindings { + pub(crate) 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); + } + + pub(crate) 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); + } + } + _ => {} + } + } + + pub(crate) 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)] +pub(crate) struct ScrollbarDrag { + pub(crate) start_pointer_y: f32, + pub(crate) start_offset_y: f32, +} + +#[derive(Clone, Copy, PartialEq)] +pub(crate) struct TextSelection { + pub(crate) element_id: ElementId, + pub(crate) anchor: usize, + pub(crate) focus: usize, +} + +#[derive(Clone, Copy, PartialEq)] +pub(crate) struct TextSelectionDrag { + pub(crate) element_id: ElementId, + pub(crate) anchor: usize, +} + +pub(crate) struct TextSelectionState { + pub(crate) selection: RefCell>, + pub(crate) drag: RefCell>, + pub(crate) version: Signal, +} + +impl TextSelectionState { + pub(crate) fn new() -> Self { + Self { + selection: RefCell::new(None), + drag: RefCell::new(None), + version: Signal::new(0), + } + } +} + +pub(crate) struct InputState { + pub(crate) current_cursor: CursorIcon, + pub(crate) focused_element: Option, + pub(crate) text_selection: Rc, +} + +impl InputState { + pub(crate) fn new() -> Self { + Self { + current_cursor: CursorIcon::Default, + focused_element: None, + text_selection: Rc::new(TextSelectionState::new()), + } + } +} + +#[derive(Clone)] +pub(crate) struct ShortcutBinding { + pub(crate) shortcut: Shortcut, + pub(crate) scope: ShortcutScope, + pub(crate) action: Rc, +} + +impl ShortcutBinding { + pub(crate) fn matches( + &self, + event: &KeyboardEvent, + focused_element: Option, + interaction_tree: &InteractionTree, + ) -> bool { + self.shortcut.matches(event) && self.scope.matches(focused_element, interaction_tree) + } + + pub(crate) fn trigger(&self, interaction_tree: &InteractionTree) { + (self.action)(interaction_tree); + } +} + +pub(crate) fn clamp_scroll_offset(offset_y: f32, max_offset_y: f32) -> f32 { + offset_y.clamp(0.0, max_offset_y.max(0.0)) +} + +pub(crate) 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; +} + +pub(crate) fn sync_primary_selection( + window: &WindowController, + interaction_tree: &InteractionTree, + selection: Option, +) -> crate::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(()) +} + +pub(crate) 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)) +} + +pub(crate) 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)) +} + +pub(crate) 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) +} + +pub(crate) 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)) +} + +pub(crate) 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 + } +} + +pub(crate) 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 +} + +// View helper methods for attaching handlers — used by primitives +impl View { + pub(crate) fn with_press_handler(mut self, element_id: ElementId, handler: PressHandler) -> Self { + self.bindings.on_press.insert(element_id, handler); + self + } + + pub(crate) fn with_scroll_handler( + mut self, + element_id: ElementId, + handler: ScrollHandler, + ) -> Self { + self.bindings.on_scroll.insert(element_id, handler); + self + } + + pub(crate) fn with_key_handler( + mut self, + element_id: ElementId, + handler: KeyHandler, + ) -> Self { + self.bindings.on_key.insert(element_id, handler); + self + } +} diff --git a/lib/ruin_app/src/lib.rs b/lib/ruin_app/src/lib.rs index acfc719..affdab2 100644 --- a/lib/ruin_app/src/lib.rs +++ b/lib/ruin_app/src/lib.rs @@ -5,2068 +5,57 @@ 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::marker::PhantomData; -use std::rc::Rc; -use std::time::Instant; -use ruin_reactivity::effect; -use ruin_runtime::queue_future; -use ruin_ui::{ - Border, BoxShadow, 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, +pub mod app; +pub mod colors; +pub mod converters; +mod context; +mod hooks; +mod input; +pub mod primitives; +pub mod surfaces; +pub mod text_style; +pub mod view; + +// Re-export public items from each module. + +pub use app::{App, Component, ContextKey, MountedApp, Mountable, Window, __render_mountable_for_test}; +pub use converters::{IntoBorder, IntoEdges, IntoShadow}; +pub use hooks::{ + BlockWidget, FocusScope, Key, Memo, Resource, ResourceState, ScrollBoxWidget, Shortcut, + ShortcutScope, Signal, WidgetRef, provide, use_context, use_effect, use_memo, use_resource, + use_shortcut, use_shortcut_with_context, use_signal, use_widget_ref, use_window_title, }; -use ruin_ui_platform_wayland::start_wayland_ui; +pub use primitives::{ + ButtonBuilder, ContainerBuilder, ContainerProps, FontWeight, ScrollBoxBuilder, TextBuilder, + TextRole, block, button, column, row, scroll_box, text, +}; +pub use text_style::PartialTextStyle; +pub use view::{ + ChildViews, Children, IntoView, LazyChildren, TextChildren, TextValue, View, +}; + +// Bare string literals as container children produce a text element with context-aware styling. +// These impls live here (not in view.rs) so they can call primitives::text(). +impl Children for &str { + fn into_views(self) -> Vec { + let s = self.to_string(); + vec![primitives::text().children(s)] + } +} + +impl Children for String { + fn into_views(self) -> Vec { + vec![primitives::text().children(self)] + } +} 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 - } - - pub fn min_size(mut self, min_width: f32, min_height: f32) -> Self { - self.spec = self.spec.min_inner_size(UiSize::new(min_width, min_height)); - self - } - - pub fn base_color(mut self, color: Color) -> Self { - self.spec = self.spec.base_color(color); - 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 last_min_size = Rc::new(RefCell::new(None::)); - let explicit_min_size = app_window.spec.min_inner_size; - 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 last_min_size = Rc::clone(&last_min_size); - 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, - root_min_size, - } = 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(); - - // Compute and send min-size hint: max of layout min and explicit window min. - let desired_min = UiSize::new( - root_min_size - .width - .max(explicit_min_size.map_or(0.0, |s| s.width)), - root_min_size - .height - .max(explicit_min_size.map_or(0.0, |s| s.height)), - ); - let current_min = *last_min_size.borrow(); - if current_min != Some(desired_min) { - let _ = window.update(WindowUpdate::new().min_inner_size(Some(desired_min))); - *last_min_size.borrow_mut() = Some(desired_min); - } - 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) - } -} - -pub trait IntoShadow { - fn into_shadow(self) -> BoxShadow; -} - -impl IntoShadow for BoxShadow { - fn into_shadow(self) -> BoxShadow { - self - } -} - -#[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(), - 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 shadow(mut self, shadow: impl IntoShadow) -> Self { - self.element = self.element.shadow(shadow.into_shadow()); - self - } - - pub fn min_width(mut self, min_width: f32) -> Self { - self.element = self.element.min_width(min_width); - self - } - - pub fn min_height(mut self, min_height: f32) -> Self { - self.element = self.element.min_height(min_height); - self - } - - pub fn min_size(self, min_width: f32, min_height: f32) -> Self { - self.min_width(min_width).min_height(min_height) - } - - 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 min_width(mut self, min_width: f32) -> Self { - self.element = self.element.min_width(min_width); - self - } - - pub fn min_height(mut self, min_height: f32) -> Self { - self.element = self.element.min_height(min_height); - self - } - - pub fn min_size(self, min_width: f32, min_height: f32) -> Self { - self.min_width(min_width).min_height(min_height) - } - - 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, - background: Option, - color: 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 background(mut self, color: Color) -> Self { - self.background = Some(color); - self - } - - pub fn color(mut self, color: Color) -> Self { - self.color = Some(color); - self - } - - pub fn children(self, children: impl TextChildren) -> View { - let id = allocate_element_id(); - let label = children.into_text(); - let view = View::from_element({ - let elt = Element::column() - .id(id) - .padding(Edges::symmetric(14.0, 10.0)); - - let elt = if let Some(background) = self.background { - elt.background(background) - } else { - elt.background(surfaces::interactive()) - }; - - elt.corner_radius(10.0) - .cursor(CursorIcon::Pointer) - .focusable(true) - .child( - Element::spans( - [TextSpan::new(label).weight(TextSpanWeight::Medium)], - TextStyle::new(18.0, self.color.unwrap_or(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, @@ -2085,14 +74,20 @@ pub mod prelude { }; } -struct SignalInner { - cell: ruin_reactivity::Cell, -} - #[cfg(test)] mod tests { + use std::cell::Cell as StdCell; + use std::cell::RefCell; + use std::collections::HashMap; + use std::rc::Rc; + + use ruin_ui::{KeyboardModifiers, Point, WindowSpec}; + use super::*; - use ruin_ui::{KeyboardModifiers, Point, UiRuntime, WindowSpec}; + use crate::context::{RenderState, render_with_context}; + use crate::input::{ + InputState, KeyHandler, focused_element_for_pointer, scroll_handler_for_event, + }; #[derive(Clone, Debug, PartialEq, Eq)] struct NamedValue(&'static str); @@ -2130,7 +125,7 @@ mod tests { provide::(NamedValue("inner"), || { *seen_outer.borrow_mut() = Some(use_context::()); *seen_inner.borrow_mut() = Some(use_context::()); - View::from_element(Element::column()) + View::from_element(ruin_ui::Element::column()) }) }) } @@ -2150,7 +145,7 @@ mod tests { provide::(NamedValue("outer"), || { provide::(NamedValue("inner"), || { *seen_value.borrow_mut() = Some(use_context::()); - View::from_element(Element::column()) + View::from_element(ruin_ui::Element::column()) }) }) } @@ -2184,23 +179,25 @@ mod tests { #[test] fn key_dispatch_prefers_the_nearest_focused_ancestor_handler() { + use ruin_ui::{ElementId, KeyboardEvent, KeyboardEventKind, KeyboardKey}; + let outer_id = ElementId::new(41); let inner_id = ElementId::new(42); - let root = Element::column().pointer_events(false).child( - Element::column() + let root = ruin_ui::Element::column().pointer_events(false).child( + ruin_ui::Element::column() .id(outer_id) .width(160.0) .height(120.0) .focusable(true) .child( - Element::column() + ruin_ui::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 snapshot = ruin_ui::layout_snapshot(1, ruin_ui::UiSize::new(200.0, 120.0), &root); let outer_hits = Rc::new(StdCell::new(0usize)); let inner_hits = Rc::new(StdCell::new(0usize)); @@ -2226,9 +223,8 @@ mod tests { }), ); - let handler = key_handler_for_focus(&handlers, Some(inner_id), &snapshot.interaction_tree) - .expect("focused element should resolve a key handler"); - let _ = handler( + crate::input::key_handler_for_focus(&handlers, Some(inner_id), &snapshot.interaction_tree) + .unwrap()( &KeyboardEvent::new( 0, KeyboardEventKind::Pressed, @@ -2245,6 +241,8 @@ mod tests { #[test] fn scroll_box_arrow_keys_work_after_clicking_text_content() { + use ruin_ui::{KeyboardEvent, KeyboardEventKind, KeyboardKey, PointerEvent, PointerEventKind, PointerButton}; + let offset_slot = Rc::new(RefCell::new(None::>)); let render = render_with_context(Rc::new(RenderState::default()), { let offset_slot = Rc::clone(&offset_slot); @@ -2269,7 +267,7 @@ mod tests { .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()); + ruin_ui::layout_snapshot(1, ruin_ui::UiSize::new(260.0, 160.0), render.view.element()); let focused = focused_element_for_pointer( &snapshot.interaction_tree, &PointerEvent::new( @@ -2300,6 +298,8 @@ mod tests { #[test] fn scroll_box_thumb_drag_updates_offset_signal() { + use ruin_ui::{PointerButton, RoutedPointerEvent, RoutedPointerEventKind}; + let offset_slot = Rc::new(RefCell::new(None::>)); let render = render_with_context(Rc::new(RenderState::default()), { let offset_slot = Rc::clone(&offset_slot); @@ -2324,7 +324,7 @@ mod tests { .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()); + ruin_ui::layout_snapshot(1, ruin_ui::UiSize::new(260.0, 160.0), render.view.element()); let metrics = snapshot .interaction_tree .scroll_metrics_for_element(scrollbox_id) @@ -2377,6 +377,8 @@ mod tests { #[test] fn live_input_path_scrolls_a_scroll_box_rendered_inside_a_branch() { + use ruin_ui::{PointerEvent, PointerEventKind, PointerButton}; + let offset_slot = Rc::new(RefCell::new(None::>)); let render = render_with_context(Rc::new(RenderState::default()), { let offset_slot = Rc::clone(&offset_slot); @@ -2399,14 +401,14 @@ mod tests { .children( text() .color(colors::muted()) - .font_family(TextFontFamily::Monospace) + .font_family(ruin_ui::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()), + false => View::from_element(ruin_ui::Element::column()), } }); let offset = offset_slot @@ -2414,12 +416,12 @@ mod tests { .clone() .expect("scroll signal should have been captured"); let snapshot = - ruin_ui::layout_snapshot(1, UiSize::new(1080.0, 760.0), render.view.element()); + ruin_ui::layout_snapshot(1, ruin_ui::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 pointer_router = ruin_ui::PointerRouter::new(); let mut input_state = InputState::new(); - let window = UiRuntime::headless() + let window = ruin_ui::UiRuntime::headless() .create_window(WindowSpec::new("scrollbox-test")) .expect("headless window should be created"); @@ -2443,10 +445,10 @@ mod tests { &bindings, &RefCell::new(Vec::new()), &input_state, - KeyboardEvent::new( + ruin_ui::KeyboardEvent::new( 0, - KeyboardEventKind::Pressed, - KeyboardKey::ArrowDown, + ruin_ui::KeyboardEventKind::Pressed, + ruin_ui::KeyboardKey::ArrowDown, KeyboardModifiers::default(), None, ), @@ -2458,6 +460,8 @@ mod tests { #[test] fn scroll_box_stays_interactive_when_it_appears_on_a_later_render() { + use ruin_ui::{PointerEvent, PointerEventKind, PointerButton}; + let state = Rc::new(RenderState::default()); let ready_slot = Rc::new(RefCell::new(None::>)); let offset_slot = Rc::new(RefCell::new(None::>)); @@ -2488,7 +492,7 @@ mod tests { .children( text() .color(colors::muted()) - .font_family(TextFontFamily::Monospace) + .font_family(ruin_ui::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", ), @@ -2517,12 +521,12 @@ mod tests { 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()); + ruin_ui::layout_snapshot(1, ruin_ui::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 pointer_router = ruin_ui::PointerRouter::new(); let mut input_state = InputState::new(); - let window = UiRuntime::headless() + let window = ruin_ui::UiRuntime::headless() .create_window(WindowSpec::new("scrollbox-transition-test")) .expect("headless window should be created"); @@ -2546,10 +550,10 @@ mod tests { &bindings, &RefCell::new(Vec::new()), &input_state, - KeyboardEvent::new( + ruin_ui::KeyboardEvent::new( 0, - KeyboardEventKind::Pressed, - KeyboardKey::ArrowDown, + ruin_ui::KeyboardEventKind::Pressed, + ruin_ui::KeyboardKey::ArrowDown, KeyboardModifiers::default(), None, ), @@ -2561,6 +565,8 @@ mod tests { #[test] fn live_input_path_scrolls_with_real_cargo_lock_contents() { + use ruin_ui::{PointerEvent, PointerEventKind}; + let offset_slot = Rc::new(RefCell::new(None::>)); let render = render_with_context(Rc::new(RenderState::default()), { let offset_slot = Rc::clone(&offset_slot); @@ -2579,7 +585,7 @@ mod tests { .children( text() .color(colors::muted()) - .font_family(TextFontFamily::Monospace) + .font_family(ruin_ui::TextFontFamily::Monospace) .children(include_str!("../../../Cargo.lock")), ), )) @@ -2590,12 +596,12 @@ mod tests { .clone() .expect("scroll signal should have been captured"); let snapshot = - ruin_ui::layout_snapshot(1, UiSize::new(1080.0, 760.0), render.view.element()); + ruin_ui::layout_snapshot(1, ruin_ui::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 pointer_router = ruin_ui::PointerRouter::new(); let mut input_state = InputState::new(); - let window = UiRuntime::headless() + let window = ruin_ui::UiRuntime::headless() .create_window(WindowSpec::new("scrollbox-cargo-lock-test")) .expect("headless window should be created"); diff --git a/lib/ruin_app/src/primitives/button.rs b/lib/ruin_app/src/primitives/button.rs new file mode 100644 index 0000000..3d669a3 --- /dev/null +++ b/lib/ruin_app/src/primitives/button.rs @@ -0,0 +1,61 @@ +use std::rc::Rc; + +use ruin_ui::{CursorIcon, Edges, Element, RoutedPointerEvent}; + +use crate::context::allocate_element_id; +use crate::input::PressHandler; +use crate::primitives::ContainerProps; +use crate::text_style::{pop_text_style, push_text_style}; +use crate::view::{Children, View}; +use crate::surfaces; + +pub struct ButtonBuilder { + pub(crate) props: ContainerProps, + pub(crate) on_press: Option, +} + +pub fn button() -> ButtonBuilder { + ButtonBuilder { + props: ContainerProps::new( + Element::column() + .padding(Edges::symmetric(14.0, 10.0)) + .background(surfaces::interactive()) + .corner_radius(10.0) + .cursor(CursorIcon::Pointer) + .focusable(true), + ), + on_press: None, + } +} + +impl ButtonBuilder { + impl_props_methods!(); + + pub fn on_press(mut self, handler: impl Fn(&RoutedPointerEvent) + 'static) -> Self { + self.on_press = Some(Rc::new(handler)); + self + } + + pub fn text_style(mut self, style: crate::text_style::PartialTextStyle) -> Self { + self.props = self.props.text_style(style); + self + } + + pub fn children(mut self, children: impl Children) -> View { + let id = allocate_element_id(); + self.props.element = self.props.element.id(id); + let had_style = self.props.partial_text_style.is_some(); + if let Some(style) = self.props.partial_text_style.take() { + push_text_style(style); + } + let children_views = children.into_views(); + if had_style { + pop_text_style(); + } + let view = View::from_container(self.props.element, children_views); + match self.on_press { + Some(handler) => view.with_press_handler(id, handler), + None => view, + } + } +} diff --git a/lib/ruin_app/src/primitives/container.rs b/lib/ruin_app/src/primitives/container.rs new file mode 100644 index 0000000..a65397a --- /dev/null +++ b/lib/ruin_app/src/primitives/container.rs @@ -0,0 +1,147 @@ +use ruin_ui::{Color, Element, ElementId}; + +use crate::context::allocate_element_id; +use crate::converters::{IntoBorder, IntoEdges, IntoShadow}; +use crate::hooks::{Signal, WidgetRef}; +use crate::text_style::{PartialTextStyle, pop_text_style, push_text_style}; +use crate::view::{Children, View}; + +/// Shared box-model properties common to all container-like builders. +pub struct ContainerProps { + pub(crate) element: Element, + pub(crate) widget_ref: Option>>, + pub(crate) partial_text_style: Option, +} + +impl ContainerProps { + pub(crate) fn new(element: Element) -> Self { + Self { + element, + widget_ref: None, + partial_text_style: None, + } + } + + pub fn text_style(mut self, style: PartialTextStyle) -> Self { + self.partial_text_style = Some(style); + self + } + + 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 min_width(mut self, min_width: f32) -> Self { + self.element = self.element.min_width(min_width); + self + } + + pub fn min_height(mut self, min_height: f32) -> Self { + self.element = self.element.min_height(min_height); + self + } + + pub fn min_size(self, w: f32, h: f32) -> Self { + self.min_width(w).min_height(h) + } + + pub fn flex(mut self, flex: f32) -> Self { + self.element = self.element.flex(flex); + self + } + + pub fn shadow(mut self, shadow: impl IntoShadow) -> Self { + self.element = self.element.shadow(shadow.into_shadow()); + self + } + + pub fn widget_ref(mut self, widget_ref: WidgetRef) -> Self { + self.widget_ref = Some(widget_ref.element_id.clone()); + self + } +} + +pub struct ContainerBuilder { + pub(crate) props: ContainerProps, +} + +pub fn column() -> ContainerBuilder { + ContainerBuilder { + props: ContainerProps::new(Element::column()), + } +} + +pub fn row() -> ContainerBuilder { + ContainerBuilder { + props: ContainerProps::new(Element::row()), + } +} + +pub fn block() -> ContainerBuilder { + ContainerBuilder { + props: ContainerProps::new(Element::column()), + } +} + +impl ContainerBuilder { + impl_props_methods!(); + + pub fn shadow(mut self, shadow: impl IntoShadow) -> Self { + self.props = self.props.shadow(shadow); + self + } + + pub fn text_style(mut self, style: PartialTextStyle) -> Self { + self.props = self.props.text_style(style); + self + } + + pub fn children(mut self, children: impl Children) -> View { + if let Some(widget_ref) = &self.props.widget_ref { + let element_id = allocate_element_id(); + self.props.element = self.props.element.id(element_id); + let _ = widget_ref.set(Some(element_id)); + } + let had_style = self.props.partial_text_style.is_some(); + if let Some(style) = self.props.partial_text_style.take() { + push_text_style(style); + } + let children_views = children.into_views(); + if had_style { + pop_text_style(); + } + View::from_container(self.props.element, children_views) + } +} diff --git a/lib/ruin_app/src/primitives/mod.rs b/lib/ruin_app/src/primitives/mod.rs new file mode 100644 index 0000000..acfa8aa --- /dev/null +++ b/lib/ruin_app/src/primitives/mod.rs @@ -0,0 +1,63 @@ +/// Generates builder methods that delegate to a `props: ContainerProps` field. +/// Must be invoked inside an `impl SomeBuilder { ... }` block. +macro_rules! impl_props_methods { + () => { + pub fn gap(mut self, gap: f32) -> Self { + self.props = self.props.gap(gap); + self + } + pub fn padding(mut self, padding: impl $crate::converters::IntoEdges) -> Self { + self.props = self.props.padding(padding); + self + } + pub fn background(mut self, color: ruin_ui::Color) -> Self { + self.props = self.props.background(color); + self + } + pub fn border(mut self, border: impl $crate::converters::IntoBorder) -> Self { + self.props = self.props.border(border); + self + } + pub fn border_radius(mut self, radius: f32) -> Self { + self.props = self.props.border_radius(radius); + self + } + pub fn width(mut self, width: f32) -> Self { + self.props = self.props.width(width); + self + } + pub fn height(mut self, height: f32) -> Self { + self.props = self.props.height(height); + self + } + pub fn min_width(mut self, min_width: f32) -> Self { + self.props = self.props.min_width(min_width); + self + } + pub fn min_height(mut self, min_height: f32) -> Self { + self.props = self.props.min_height(min_height); + self + } + pub fn min_size(self, w: f32, h: f32) -> Self { + self.min_width(w).min_height(h) + } + pub fn flex(mut self, flex: f32) -> Self { + self.props = self.props.flex(flex); + self + } + pub fn widget_ref(mut self, widget_ref: $crate::hooks::WidgetRef) -> Self { + self.props = self.props.widget_ref(widget_ref); + self + } + }; +} + +pub mod button; +pub mod container; +pub mod scroll_box; +pub mod text; + +pub use button::{ButtonBuilder, button}; +pub use container::{ContainerBuilder, ContainerProps, block, column, row}; +pub use scroll_box::{ScrollBoxBuilder, scroll_box}; +pub use text::{FontWeight, TextBuilder, TextRole, text}; diff --git a/lib/ruin_app/src/primitives/scroll_box.rs b/lib/ruin_app/src/primitives/scroll_box.rs new file mode 100644 index 0000000..13da319 --- /dev/null +++ b/lib/ruin_app/src/primitives/scroll_box.rs @@ -0,0 +1,160 @@ +use std::rc::Rc; + +use ruin_ui::{Element, KeyboardEventKind, PointerButton, RoutedPointerEventKind, ScrollbarStyle}; + +use crate::context::{allocate_element_id, with_hook_slot}; +use crate::hooks::Signal; +use crate::input::{ScrollbarDrag, clamp_scroll_offset}; +use crate::primitives::ContainerProps; +use crate::text_style::{pop_text_style, push_text_style}; +use crate::view::{Children, View}; + +pub struct ScrollBoxBuilder { + pub(crate) props: ContainerProps, + pub(crate) offset_y: Option>, + pub(crate) drag: Signal>, +} + +pub fn scroll_box() -> ScrollBoxBuilder { + ScrollBoxBuilder { + props: ContainerProps::new(Element::scroll_box(0.0)), + offset_y: None, + drag: with_hook_slot(|| Signal::new(None::), |drag| drag.clone()), + } +} + +impl ScrollBoxBuilder { + impl_props_methods!(); + + pub fn text_style(mut self, style: crate::text_style::PartialTextStyle) -> Self { + self.props = self.props.text_style(style); + self + } + + pub fn scrollbar_style(mut self, style: ScrollbarStyle) -> Self { + self.props.element = self.props.element.scrollbar_style(style); + self + } + + pub fn offset_y(mut self, offset_y: Signal) -> Self { + self.props.element = self.props.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.props.element = self.props.element.id(element_id); + if let Some(widget_ref) = &self.props.widget_ref { + let _ = widget_ref.set(Some(element_id)); + } + + let had_style = self.props.partial_text_style.is_some(); + if let Some(style) = self.props.partial_text_style.take() { + push_text_style(style); + } + let children_views = children.into_views(); + if had_style { + pop_text_style(); + } + let mut view = View::from_container(self.props.element, children_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 { + ruin_ui::KeyboardKey::ArrowUp => offset_y_for_keys.get() - line_step, + ruin_ui::KeyboardKey::ArrowDown => offset_y_for_keys.get() + line_step, + ruin_ui::KeyboardKey::Home => 0.0, + ruin_ui::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 + } +} diff --git a/lib/ruin_app/src/primitives/text.rs b/lib/ruin_app/src/primitives/text.rs new file mode 100644 index 0000000..1e4a4aa --- /dev/null +++ b/lib/ruin_app/src/primitives/text.rs @@ -0,0 +1,129 @@ +use ruin_ui::{Color, Element, TextFontFamily, TextSpan, TextSpanWeight, TextStyle, TextWrap}; + +use crate::colors; +use crate::context::allocate_element_id; +use crate::text_style::current_text_style; +use crate::view::{TextChildren, View}; + +#[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 { + pub(crate) 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, + } + } +} + +#[derive(Default)] +pub struct TextBuilder { + pub(crate) role: Option, + pub(crate) size: Option, + pub(crate) weight: Option, + pub(crate) color: Option, + pub(crate) font_family: Option, + pub(crate) wrap: Option, + pub(crate) selectable: Option, + pub(crate) pointer_events: Option, +} + +pub fn text() -> TextBuilder { + TextBuilder::default() +} + +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 selectable(mut self, selectable: bool) -> Self { + self.selectable = Some(selectable); + self + } + + pub fn pointer_events(mut self, pointer_events: bool) -> Self { + self.pointer_events = Some(pointer_events); + self + } + + pub fn children(self, children: impl TextChildren) -> View { + let inherited = current_text_style(); + + let role = self.role.unwrap_or(TextRole::Body); + let size = self.size.or(inherited.size).unwrap_or(match role { + TextRole::Body => 18.0, + TextRole::Heading(1) => 32.0, + TextRole::Heading(2) => 28.0, + TextRole::Heading(_) => 24.0, + }); + let color = self.color.or(inherited.color).unwrap_or(colors::text()); + let weight = self.weight.or(inherited.weight).unwrap_or(match role { + TextRole::Body => FontWeight::Normal, + TextRole::Heading(_) => FontWeight::Semibold, + }); + let wrap = self.wrap.or(inherited.wrap).unwrap_or(TextWrap::None); + let line_height = inherited.line_height.unwrap_or(size * 1.2); + + let mut style = TextStyle::new(size, color) + .with_line_height(line_height) + .with_wrap(wrap); + + let font_family = self.font_family.or(inherited.font_family); + if let Some(font_family) = 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.with_selectable(self.selectable.unwrap_or(true)), + ) + .pointer_events(self.pointer_events.unwrap_or(true)) + .id(allocate_element_id()), + ) + } +} diff --git a/lib/ruin_app/src/surfaces.rs b/lib/ruin_app/src/surfaces.rs new file mode 100644 index 0000000..a9928e2 --- /dev/null +++ b/lib/ruin_app/src/surfaces.rs @@ -0,0 +1,17 @@ +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) +} diff --git a/lib/ruin_app/src/text_style.rs b/lib/ruin_app/src/text_style.rs new file mode 100644 index 0000000..417b197 --- /dev/null +++ b/lib/ruin_app/src/text_style.rs @@ -0,0 +1,61 @@ +use std::cell::RefCell; + +use ruin_ui::{Color, TextFontFamily, TextWrap}; + +use crate::primitives::FontWeight; + +/// Partially-specified text style for context-based inheritance. +/// +/// Set on a container via `.text_style(...)`. All descendant `text()` nodes inherit +/// any fields that are `Some`, falling back to their own defaults for fields that are `None`. +/// Inner (closer) ancestors take precedence over outer ancestors. +#[derive(Clone, Default)] +pub struct PartialTextStyle { + pub size: Option, + pub color: Option, + pub weight: Option, + pub font_family: Option, + pub wrap: Option, + pub line_height: Option, +} + +impl PartialTextStyle { + /// Merge `other` on top of `self` — `other`'s `Some` fields win. + fn merge_over(self, other: &PartialTextStyle) -> Self { + Self { + size: other.size.or(self.size), + color: other.color.or(self.color), + weight: other.weight.or(self.weight), + font_family: other.font_family.clone().or(self.font_family), + wrap: other.wrap.or(self.wrap), + line_height: other.line_height.or(self.line_height), + } + } +} + +thread_local! { + static TEXT_STYLE_STACK: RefCell> = const { RefCell::new(Vec::new()) }; +} + +/// Push a partial style onto the context stack. Must be paired with `pop_text_style`. +pub(crate) fn push_text_style(style: PartialTextStyle) { + TEXT_STYLE_STACK.with(|stack| stack.borrow_mut().push(style)); +} + +/// Pop the top partial style from the context stack. +pub(crate) fn pop_text_style() { + TEXT_STYLE_STACK.with(|stack| { + stack.borrow_mut().pop(); + }); +} + +/// Returns the effective `PartialTextStyle` at the current render position by merging +/// all layers from outermost to innermost (innermost wins). +pub(crate) fn current_text_style() -> PartialTextStyle { + TEXT_STYLE_STACK.with(|stack| { + stack + .borrow() + .iter() + .fold(PartialTextStyle::default(), |acc, layer| acc.merge_over(layer)) + }) +} diff --git a/lib/ruin_app/src/view.rs b/lib/ruin_app/src/view.rs new file mode 100644 index 0000000..02d3e46 --- /dev/null +++ b/lib/ruin_app/src/view.rs @@ -0,0 +1,239 @@ +use ruin_ui::Element; + +use crate::Component; +use crate::input::EventBindings; + +#[derive(Clone, Default)] +pub struct View { + pub(crate) element: Element, + pub(crate) bindings: EventBindings, +} + +impl View { + pub fn from_element(element: Element) -> Self { + Self { + element, + bindings: EventBindings::default(), + } + } + + pub fn element(&self) -> &Element { + &self.element + } + + pub 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(pub(crate) 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 + } +} + +/// Wraps a lazy closure so it can be used as container children. +/// +/// The closure is called exactly once, inside the builder's `.children()` method, +/// after any `PartialTextStyle` context has been pushed. This enables text style +/// inheritance from ancestor containers. +/// +/// The `view!` macro automatically generates `LazyChildren(|| vec![...])` for +/// primitive container nodes so that style context propagates correctly. +pub struct LazyChildren Vec>(pub F); + +impl Vec> Children for LazyChildren { + 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(pub(crate) 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 crate::hooks::Signal { + fn into_text(self) -> String { + self.with(|value| value.to_string()) + } +} + +impl TextChildren for crate::hooks::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); diff --git a/lib/ruin_app_proc_macros/src/lib.rs b/lib/ruin_app_proc_macros/src/lib.rs index e007c1b..2574445 100644 --- a/lib/ruin_app_proc_macros/src/lib.rs +++ b/lib/ruin_app_proc_macros/src/lib.rs @@ -686,23 +686,49 @@ fn expand_node(node: &Node) -> proc_macro2::TokenStream { let value = &property.value; quote! { .#name(#value) } }); - let children = expand_children(&node.children); if is_component_path(path) { + // Component nodes: eager children so #[component] builder impls keep working. + let children = expand_children(&node.children); quote! { #path::builder() #(#prop_calls)* .children(#children) } - } else { + } else if is_text_path(path) { + // text(): children are TextChildren (string/value), not Views — keep eager. + let children = expand_children(&node.children); quote! { ::ruin_app::#path() #(#prop_calls)* .children(#children) } + } else { + // Primitive container nodes (column, row, block, button, scroll_box, …): + // Wrap children in a LazyChildren closure so that PartialTextStyle context is + // pushed BEFORE children run, enabling correct text style inheritance. + // + // Each child is spread via Children::into_views(), which handles: + // - Child::Node results (View) via impl Children for T + // - &str literals via Children for &str + // - ChildViews (slot values) via Children for ChildViews (flattens) + let items = node.children.iter().map(expand_child); + quote! { + ::ruin_app::#path() + #(#prop_calls)* + .children(::ruin_app::LazyChildren(|| { + let mut __v: ::std::vec::Vec<::ruin_app::View> = ::std::vec::Vec::new(); + #(__v.extend(::ruin_app::Children::into_views(#items));)* + __v + })) + } } } +fn is_text_path(path: &Path) -> bool { + path.segments.len() == 1 && path.segments[0].ident == "text" +} + fn expand_provider_node(node: &Node) -> proc_macro2::TokenStream { let path = &node.path; let value = match node.props.as_slice() { diff --git a/lib/runtime/README.md b/lib/runtime/README.md new file mode 100644 index 0000000..e69de29 diff --git a/lib/runtime/src/lib.rs b/lib/runtime/src/lib.rs index 2417077..ca2aad5 100644 --- a/lib/runtime/src/lib.rs +++ b/lib/runtime/src/lib.rs @@ -29,7 +29,10 @@ pub(crate) mod trace_targets { pub const DRIVER: &str = "ruin_runtime::driver"; pub const RUNTIME: &str = "ruin_runtime::runtime"; pub const SCHEDULER: &str = "ruin_runtime::scheduler"; + + #[cfg(debug_assertions)] pub const TIMER: &str = "ruin_runtime::timer"; + #[cfg(debug_assertions)] pub const ASYNC: &str = "ruin_runtime::async"; } diff --git a/lib/runtime/src/platform/linux_x86_64/runtime.rs b/lib/runtime/src/platform/linux_x86_64/runtime.rs index 6bbcac7..1129aab 100644 --- a/lib/runtime/src/platform/linux_x86_64/runtime.rs +++ b/lib/runtime/src/platform/linux_x86_64/runtime.rs @@ -54,6 +54,12 @@ pub struct TimeoutHandle { _local: Rc<()>, } +impl TimeoutHandle { + pub fn clear(&self) { + clear_timeout(self); + } +} + #[derive(Clone)] /// Handle returned by [`set_interval`]. pub struct IntervalHandle { @@ -62,6 +68,12 @@ pub struct IntervalHandle { _local: Rc<()>, } +impl IntervalHandle { + pub fn clear(&self) { + clear_interval(self); + } +} + /// Handle returned by [`queue_future`]. /// /// Awaiting a join handle yields the output of the queued future. @@ -72,9 +84,10 @@ pub struct JoinHandle { /// Future returned by [`yield_now`]. /// /// Awaiting this future will immediately yield control back to the runtime scheduler, allowing other queued microtasks -/// to run before the current task continues executing. Note that continuation of futures runs as a microtask, so this +/// to run before the current task continues executing. Note that continuations of futures run as microtasks, so this /// can only yield to other microtasks and not to macrotasks (driver events such as file or network I/O, timers, or -/// channel messages). +/// channel messages). To yield to macrotasks, you must allow the flow of execution to return to the runtime event loop +/// and flush the full microtask queue, for example by awaiting a timer. pub struct YieldNow { yielded: bool, } diff --git a/lib/ui/src/layout.rs b/lib/ui/src/layout.rs index efb8564..df19ee9 100644 --- a/lib/ui/src/layout.rs +++ b/lib/ui/src/layout.rs @@ -257,6 +257,7 @@ pub fn layout_snapshot_with_cache( } } +#[allow(clippy::too_many_arguments)] fn layout_element( element: &Element, rect: Rect, @@ -797,6 +798,7 @@ struct MeasuredChild { is_flex: bool, } +#[allow(clippy::too_many_arguments)] fn layout_container_children( element: &Element, content: Rect, @@ -996,8 +998,13 @@ fn intrinsic_container_content_size( .height .map(|h| h + vertical_insets(child_insets)) .unwrap_or(content_size.height); - let child_size = - intrinsic_size(child, UiSize::new(offered_w, offered_h), text_system, perf_stats, layout_cache); + let child_size = intrinsic_size( + child, + UiSize::new(offered_w, offered_h), + text_system, + perf_stats, + layout_cache, + ); // child_size is always outer; accumulate directly. width = width.max(child_size.width); if !skip_main { @@ -1030,8 +1037,13 @@ fn intrinsic_container_content_size( .height .map(|h| h + vertical_insets(child_insets)) .unwrap_or(content_size.height); - let child_size = - intrinsic_size(child, UiSize::new(offered_w, offered_h), text_system, perf_stats, layout_cache); + let child_size = intrinsic_size( + child, + UiSize::new(offered_w, offered_h), + text_system, + perf_stats, + layout_cache, + ); // child_size.width is outer; use directly as the fixed main contribution. let child_main = child_size.width; fixed_main += child_main; diff --git a/lib/ui/src/scene.rs b/lib/ui/src/scene.rs index 0594e04..1becf27 100644 --- a/lib/ui/src/scene.rs +++ b/lib/ui/src/scene.rs @@ -80,6 +80,9 @@ impl Color { /// safe to reserve as a sentinel. No user-facing API sets this value directly. pub const SENTINEL: Self = Self::rgba(0, 0, 0, 0); + pub const BLACK: Self = Self::rgb(0, 0, 0); + pub const WHITE: Self = Self::rgb(255, 255, 255); + pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self { Self { r, g, b, a } } @@ -219,6 +222,7 @@ impl Deref for PreparedText { impl PreparedText { /// Construct a `PreparedText` from shaped data. Called by `TextSystem::prepare_spans`. + #[allow(clippy::too_many_arguments)] pub(crate) fn from_layout( element_id: Option, text: String, @@ -623,10 +627,18 @@ impl DisplayItem { /// Return a copy of this item with all positions shifted by `offset`. pub fn translated(&self, offset: Point) -> Self { fn translate_rect(r: Rect, o: Point) -> Rect { - Rect::new(r.origin.x + o.x, r.origin.y + o.y, r.size.width, r.size.height) + Rect::new( + r.origin.x + o.x, + r.origin.y + o.y, + r.size.width, + r.size.height, + ) } match self { - Self::Quad(q) => Self::Quad(Quad { rect: translate_rect(q.rect, offset), ..*q }), + Self::Quad(q) => Self::Quad(Quad { + rect: translate_rect(q.rect, offset), + ..*q + }), Self::RoundedRect(r) => Self::RoundedRect(RoundedRect { rect: translate_rect(r.rect, offset), ..*r @@ -838,8 +850,8 @@ mod tests { #[test] fn prepared_text_vertical_offset_moves_between_lines() { - use std::sync::Arc; use super::{PreparedTextLine, TextLayoutData}; + use std::sync::Arc; let mut text = PreparedText::monospace( "abcdwxyz", @@ -873,7 +885,11 @@ mod tests { } } let orig_size = text.layout.size; - text.layout = Arc::new(TextLayoutData { lines, glyphs, size: orig_size }); + text.layout = Arc::new(TextLayoutData { + lines, + glyphs, + size: orig_size, + }); assert_eq!(text.vertical_offset(2, 1), Some(6)); assert_eq!(text.vertical_offset(6, -1), Some(2)); @@ -892,8 +908,12 @@ mod tests { let target_line = &text.lines[1]; // Lines store LOCAL coords; add text.origin to get absolute window coords for the query. let y = text.origin.y + target_line.rect.origin.y + target_line.rect.size.height * 0.5; - let start = text.byte_offset_for_position(Point::new(text.origin.x + target_line.rect.origin.x, y)); - let end = text.byte_offset_for_position(Point::new(text.origin.x + target_line.rect.origin.x + 16.0, y)); + let start = + text.byte_offset_for_position(Point::new(text.origin.x + target_line.rect.origin.x, y)); + let end = text.byte_offset_for_position(Point::new( + text.origin.x + target_line.rect.origin.x + 16.0, + y, + )); let rects = text.selection_rects(start, end); assert_eq!(rects.len(), 1);