From ad5c233b15d36dd9d70dd96fc64bb68dedd5fd8c Mon Sep 17 00:00:00 2001 From: Will Temple Date: Mon, 23 Mar 2026 10:11:51 -0400 Subject: [PATCH 1/9] Fix lints, undo broken set_immediate_interval --- .../src/platform/linux_x86_64/runtime.rs | 76 +++++---- lib/ui/src/layout.rs | 161 +++++++++++++----- lib/ui/src/lib.rs | 1 + lib/ui_renderer_wgpu/src/lib.rs | 24 ++- 4 files changed, 174 insertions(+), 88 deletions(-) diff --git a/lib/runtime/src/platform/linux_x86_64/runtime.rs b/lib/runtime/src/platform/linux_x86_64/runtime.rs index 039a4b5..6bbcac7 100644 --- a/lib/runtime/src/platform/linux_x86_64/runtime.rs +++ b/lib/runtime/src/platform/linux_x86_64/runtime.rs @@ -217,17 +217,19 @@ where ); if delay.is_zero() { + unimplemented!("Zero-delay intervals are not yet implemented.") + // TODO: vibeshit got this completely wrong // Zero-delay intervals never touch the timer heap or arm an io_uring // timer (a past-deadline kernel timer would fire on every event loop // iteration, spinning the runtime at 100 % CPU). Instead the callback // is stored in `immediate_intervals` and self-schedules as a macrotask // each turn, mirroring JS `setInterval(f, 0)` semantics. - let callback = Rc::new(RefCell::new(Box::new(callback) as Box)); - current_thread() - .immediate_intervals - .borrow_mut() - .insert(id, Rc::clone(&callback)); - schedule_immediate_interval(owner, id); + // let callback = Rc::new(RefCell::new(Box::new(callback) as Box)); + // current_thread() + // .immediate_intervals + // .borrow_mut() + // .insert(id, Rc::clone(&callback)); + // schedule_immediate_interval(owner, id); } else { let deadline = deadline_from_now(delay); #[cfg(debug_assertions)] @@ -571,6 +573,8 @@ impl Future for YieldNow { } } +type ImmediateIntervals = RefCell>>>>; + struct ThreadState { driver: Driver, shared: Arc, @@ -580,7 +584,7 @@ struct ThreadState { timers: RefCell, /// Zero-delay intervals bypasses the timer heap entirely. Each entry /// re-enqueues itself as a macrotask on every turn. - immediate_intervals: RefCell>>>>, + immediate_intervals: ImmediateIntervals, next_timer_id: Cell, children: RefCell>, } @@ -1162,27 +1166,32 @@ fn clear_timer(owner: *const ThreadState, id: usize) { } } -/// Enqueues one macrotask turn for a zero-delay interval. -/// -/// The macrotask checks whether the interval is still live (not cleared), fires -/// the callback, and re-enqueues itself for the next turn. -fn schedule_immediate_interval(owner: *const ThreadState, id: usize) { - push_local_macrotask(Box::new(move || { - let callback = current_thread() - .immediate_intervals - .borrow() - .get(&id) - .map(Rc::clone); - let Some(callback) = callback else { - return; // interval was cleared before this turn ran - }; - (callback.borrow_mut())(); - // Re-enqueue for the next turn if still live. - if current_thread().immediate_intervals.borrow().contains_key(&id) { - schedule_immediate_interval(owner, id); - } - })); -} +// TODO: vibeshit got this completely wrong +// /// Enqueues one macrotask turn for a zero-delay interval. +// /// +// /// The macrotask checks whether the interval is still live (not cleared), fires +// /// the callback, and re-enqueues itself for the next turn. +// fn schedule_immediate_interval(owner: *const ThreadState, id: usize) { +// push_local_macrotask(Box::new(move || { +// let callback = current_thread() +// .immediate_intervals +// .borrow() +// .get(&id) +// .map(Rc::clone); +// let Some(callback) = callback else { +// return; // interval was cleared before this turn ran +// }; +// (callback.borrow_mut())(); +// // Re-enqueue for the next turn if still live. +// if current_thread() +// .immediate_intervals +// .borrow() +// .contains_key(&id) +// { +// schedule_immediate_interval(owner, id); +// } +// })); +// } fn dispatch_expired_timers() { let now = deadline_from_now(Duration::ZERO); @@ -1207,12 +1216,8 @@ fn dispatch_expired_timers() { .deadline .checked_add(interval) .unwrap_or(Duration::MAX); - let next_timer = TimerNode::interval( - timer.id, - next_deadline, - interval, - Rc::clone(&callback), - ); + let next_timer = + TimerNode::interval(timer.id, next_deadline, interval, Rc::clone(&callback)); current_thread().timers.borrow_mut().insert(next_timer); push_local_macrotask(Box::new(move || { @@ -1394,8 +1399,7 @@ mod tests { // turns by awaiting a sleep between checks. let count = Rc::new(Cell::new(0usize)); let count_clone = Rc::clone(&count); - let handle_slot: Rc>> = - Rc::new(RefCell::new(None)); + let handle_slot: Rc>> = Rc::new(RefCell::new(None)); let handle_slot_clone = Rc::clone(&handle_slot); let handle = set_interval(Duration::ZERO, move || { diff --git a/lib/ui/src/layout.rs b/lib/ui/src/layout.rs index 51289ee..e9637cb 100644 --- a/lib/ui/src/layout.rs +++ b/lib/ui/src/layout.rs @@ -128,7 +128,12 @@ impl LayoutNode { /// Return a copy of this node (and all descendants) 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, + ) } let rect = translate_rect(self.rect, offset); let clip_rect = self.clip_rect.map(|r| translate_rect(r, offset)); @@ -143,7 +148,11 @@ impl LayoutNode { rect: translate_rect(img.rect, offset), ..img }); - let children = self.children.into_iter().map(|c| c.translated(offset)).collect(); + let children = self + .children + .into_iter() + .map(|c| c.translated(offset)) + .collect(); LayoutNode { rect, clip_rect, @@ -256,24 +265,24 @@ fn layout_element( perf_stats.nodes += 1; // Viewport culling: skip fully off-screen elements inside a scroll box. - if let Some(clip) = clip_rect { - if !rects_overlap(rect, clip) { - perf_stats.viewport_culled += 1; - return LayoutNode { - path, - element_id: element.id, - rect, - corner_radius: 0.0, - clip_rect: None, - pointer_events: element.style.pointer_events, - focusable: element.style.focusable, - cursor: element.style.cursor.unwrap_or(CursorIcon::Default), - scroll_metrics: None, - prepared_image: None, - prepared_text: None, - children: Vec::new(), - }; - } + if let Some(clip) = clip_rect + && !rects_overlap(rect, clip) + { + perf_stats.viewport_culled += 1; + return LayoutNode { + path, + element_id: element.id, + rect, + corner_radius: 0.0, + clip_rect: None, + pointer_events: element.style.pointer_events, + focusable: element.style.focusable, + cursor: element.style.cursor.unwrap_or(CursorIcon::Default), + scroll_metrics: None, + prepared_image: None, + prepared_text: None, + children: Vec::new(), + }; } // Incremental layout cache check. @@ -368,7 +377,13 @@ fn layout_element( if pushed_clip { scene.pop_clip(); } - cache_layout(layout_cache, cache_key, &interaction, &scene.items[scene_start..], rect.origin); + cache_layout( + layout_cache, + cache_key, + &interaction, + &scene.items[scene_start..], + rect.origin, + ); return interaction; } @@ -382,7 +397,13 @@ fn layout_element( if pushed_clip { scene.pop_clip(); } - cache_layout(layout_cache, cache_key, &interaction, &scene.items[scene_start..], rect.origin); + cache_layout( + layout_cache, + cache_key, + &interaction, + &scene.items[scene_start..], + rect.origin, + ); return interaction; } @@ -508,7 +529,13 @@ fn layout_element( if pushed_clip { scene.pop_clip(); } - cache_layout(layout_cache, cache_key, &interaction, &scene.items[scene_start..], rect.origin); + cache_layout( + layout_cache, + cache_key, + &interaction, + &scene.items[scene_start..], + rect.origin, + ); return interaction; } @@ -518,7 +545,13 @@ fn layout_element( if pushed_clip { scene.pop_clip(); } - cache_layout(layout_cache, cache_key, &interaction, &scene.items[scene_start..], rect.origin); + cache_layout( + layout_cache, + cache_key, + &interaction, + &scene.items[scene_start..], + rect.origin, + ); return interaction; } @@ -527,7 +560,13 @@ fn layout_element( if pushed_clip { scene.pop_clip(); } - cache_layout(layout_cache, cache_key, &interaction, &scene.items[scene_start..], rect.origin); + cache_layout( + layout_cache, + cache_key, + &interaction, + &scene.items[scene_start..], + rect.origin, + ); return interaction; } interaction.children = layout_container_children( @@ -545,7 +584,13 @@ fn layout_element( scene.pop_clip(); } - cache_layout(layout_cache, cache_key, &interaction, &scene.items[scene_start..], rect.origin); + cache_layout( + layout_cache, + cache_key, + &interaction, + &scene.items[scene_start..], + rect.origin, + ); interaction } @@ -558,10 +603,13 @@ fn cache_layout( origin: Point, ) { let neg = Point::new(-origin.x, -origin.y); - cache.results.insert(key, CachedLayout { - interaction_node: interaction.clone().translated(neg), - scene_items: scene_items.iter().map(|i| i.translated(neg)).collect(), - }); + cache.results.insert( + key, + CachedLayout { + interaction_node: interaction.clone().translated(neg), + scene_items: scene_items.iter().map(|i| i.translated(neg)).collect(), + }, + ); } fn hit_path_node(node: &LayoutNode, point: crate::scene::Point) -> Option> { @@ -986,7 +1034,14 @@ fn intrinsic_size( return cached; } let insets = content_insets(&element.style); - let result = intrinsic_size_inner(element, available_size, insets, text_system, perf_stats, layout_cache); + let result = intrinsic_size_inner( + element, + available_size, + insets, + text_system, + perf_stats, + layout_cache, + ); layout_cache.intrinsic_cache.insert(cache_key, result); result } @@ -1047,8 +1102,13 @@ fn intrinsic_size_inner( ); } - let intrinsic_content = - intrinsic_container_content_size(element, content_size, text_system, perf_stats, layout_cache); + let intrinsic_content = intrinsic_container_content_size( + element, + content_size, + text_system, + perf_stats, + layout_cache, + ); UiSize::new( explicit_width @@ -1539,9 +1599,9 @@ fn child_rect( mod tests { use super::{LayoutCache, layout_scene, layout_snapshot, layout_snapshot_with_cache}; use crate::scene::{Color, DisplayItem, Point, Quad, Rect, UiSize}; + use crate::text::TextSystem; use crate::text::{TextStyle, TextWrap}; use crate::tree::{Edges, Element, ElementId, FlexDirection}; - use crate::text::TextSystem; #[test] fn row_layout_apportions_fixed_and_flex_children() { @@ -2156,10 +2216,12 @@ mod tests { let size = UiSize::new(400.0, 600.0); let snap1 = layout_snapshot_with_cache(1, size, &root, &mut text_system, &mut layout_cache); let snap2 = layout_snapshot_with_cache(2, size, &root, &mut text_system, &mut layout_cache); - assert_eq!(snap1.scene.items, snap2.scene.items, "scene items should be identical"); assert_eq!( - snap1.interaction_tree.root, - snap2.interaction_tree.root, + snap1.scene.items, snap2.scene.items, + "scene items should be identical" + ); + assert_eq!( + snap1.interaction_tree.root, snap2.interaction_tree.root, "interaction trees should be identical" ); } @@ -2198,12 +2260,14 @@ mod tests { let item_height = 40.0; let viewport_height = 200.0; let n = 100; - let mut scroll_box = Element::scroll_box(0.0).width(400.0).height(viewport_height); + let mut scroll_box = Element::scroll_box(0.0) + .width(400.0) + .height(viewport_height); for i in 0..n { scroll_box = scroll_box.child( Element::new() .height(item_height) - .child(Element::text(format!("item {i}"), text_style())) + .child(Element::text(format!("item {i}"), text_style())), ); } let root = Element::new().child(scroll_box); @@ -2214,11 +2278,20 @@ mod tests { let snap = layout_snapshot_with_cache(1, size, &root, &mut text_system, &mut layout_cache); // Only text items within the 200px viewport should appear in the scene. - let text_items = snap.scene.items.iter().filter(|item| { - matches!(item, DisplayItem::Text(_)) - }).count(); + let text_items = snap + .scene + .items + .iter() + .filter(|item| matches!(item, DisplayItem::Text(_))) + .count(); // At most 6 text items should be visible (5 fit + 1 partial). - assert!(text_items <= 6, "expected <= 6 text items in scene, got {text_items} (culling not working)"); - assert!(text_items >= 4, "expected >= 4 text items visible, got {text_items}"); + assert!( + text_items <= 6, + "expected <= 6 text items in scene, got {text_items} (culling not working)" + ); + assert!( + text_items >= 4, + "expected >= 4 text items visible, got {text_items}" + ); } } diff --git a/lib/ui/src/lib.rs b/lib/ui/src/lib.rs index dfb0155..69e6bd1 100644 --- a/lib/ui/src/lib.rs +++ b/lib/ui/src/lib.rs @@ -6,6 +6,7 @@ //! sibling crates. pub(crate) mod trace_targets { + #![allow(dead_code)] pub const PLATFORM: &str = "ruin_ui::platform"; pub const SCENE: &str = "ruin_ui::scene"; pub const TEXT_PERF: &str = "ruin_ui::text_perf"; diff --git a/lib/ui_renderer_wgpu/src/lib.rs b/lib/ui_renderer_wgpu/src/lib.rs index f58e8bd..08edc27 100644 --- a/lib/ui_renderer_wgpu/src/lib.rs +++ b/lib/ui_renderer_wgpu/src/lib.rs @@ -990,7 +990,9 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { bottom: physical.y - image.placement.top + height, }, content: image.content, - color: glyph.color_opt.map_or(text.default_color, color_from_cosmic), + color: glyph + .color_opt + .map_or(text.default_color, color_from_cosmic), data: image.data.clone(), }); } @@ -1470,7 +1472,11 @@ fn create_glyph_atlas( /// Resolve `Color::SENTINEL` to `default_color`; return other colors unchanged. #[inline] fn resolve_glyph_color(color: Color, default_color: Color) -> Color { - if color == Color::SENTINEL { default_color } else { color } + if color == Color::SENTINEL { + default_color + } else { + color + } } fn color_from_cosmic(color: cosmic_text::Color) -> Color { @@ -2297,7 +2303,7 @@ fn clip_textured_rect( /// return only those glyph indices. This turns O(all_glyphs) iteration into /// O(log(lines) + visible_glyphs) — critical for large text nodes in scroll /// boxes where only a few lines are on screen at once. -fn clip_visible_glyphs<'a>(text: &'a PreparedText, clip: Option) -> &'a [GlyphInstance] { +fn clip_visible_glyphs(text: &PreparedText, clip: Option) -> &[GlyphInstance] { let Some(clip) = clip else { return &text.glyphs; }; @@ -2332,7 +2338,12 @@ fn text_texture_key(text: &PreparedText) -> TextTextureKey { .map(|bounds| (bounds.width.to_bits(), bounds.height.to_bits())), font_size_bits: text.font_size.to_bits(), line_height_bits: text.line_height.to_bits(), - color: (text.default_color.r, text.default_color.g, text.default_color.b, text.default_color.a), + color: ( + text.default_color.r, + text.default_color.g, + text.default_color.b, + text.default_color.a, + ), glyphs: text .glyphs .iter() @@ -2354,10 +2365,7 @@ mod tests { ActiveClip, blend_rgba, build_text_vertices, build_vertices, clip_rect_array, push_clip_state, rounded_clip_arrays, text_texture_key, }; - use ruin_ui::{ - ClipRegion, Color, GlyphInstance, Point, PreparedText, Rect, SceneSnapshot, - TextSelectionStyle, UiSize, - }; + use ruin_ui::{ClipRegion, Color, Point, PreparedText, Rect, SceneSnapshot, UiSize}; #[test] fn quad_scenes_expand_to_six_vertices_per_quad() { From d961dd7491a6d04aa749eee9d5faaa433ef2bd20 Mon Sep 17 00:00:00 2001 From: Will Temple Date: Mon, 23 Mar 2026 10:36:33 -0400 Subject: [PATCH 2/9] Bound zxdg_decoration_manager_v1 for compositors that support server-side decorations. Fixed a resize deadlock. Fixed scroll box list rendering. --- lib/ui/src/layout.rs | 8 +++++++- lib/ui_platform_wayland/src/lib.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/lib/ui/src/layout.rs b/lib/ui/src/layout.rs index e9637cb..45ccba2 100644 --- a/lib/ui/src/layout.rs +++ b/lib/ui/src/layout.rs @@ -577,7 +577,13 @@ fn layout_element( text_system, perf_stats, layout_cache, - clip_rect, + // Do NOT propagate clip_rect into children: the scroll-box PushClip already clips + // them visually, and propagating the clip causes the layout cache to bake in + // culling results for a specific scroll position. When the scroll changes, the + // same cache entry would replay with stale "empty" children that were culled at + // the previous offset. Culling applies only at the direct-child level of the + // scroll box (which passes Some(viewport_rect) to layout_container_children). + None, ); if pushed_clip { diff --git a/lib/ui_platform_wayland/src/lib.rs b/lib/ui_platform_wayland/src/lib.rs index b6ba4c0..030e0ff 100644 --- a/lib/ui_platform_wayland/src/lib.rs +++ b/lib/ui_platform_wayland/src/lib.rs @@ -40,6 +40,9 @@ use wayland_client::{ use wayland_protocols::wp::cursor_shape::v1::client::{ wp_cursor_shape_device_v1, wp_cursor_shape_manager_v1, }; +use wayland_protocols::xdg::decoration::zv1::client::{ + zxdg_decoration_manager_v1, zxdg_toplevel_decoration_v1, +}; use wayland_protocols::wp::primary_selection::zv1::client::{ zwp_primary_selection_device_manager_v1, zwp_primary_selection_device_v1, zwp_primary_selection_offer_v1, zwp_primary_selection_source_v1, @@ -156,6 +159,8 @@ struct State { clipboard_device: Option, cursor_shape_manager: Option, cursor_shape_device: Option, + /// Server-side decoration handle. Kept alive so the compositor maintains decorations. + _decoration: Option, primary_selection_manager: Option, primary_selection_device: Option, @@ -318,6 +323,8 @@ impl WaylandWindow { manager.get_device(&seat, &qh, ()) }, ); + let decoration_manager: Option = + globals.bind(&qh, 1..=1, ()).ok(); let surface = compositor.create_surface(&qh, ()); let xdg_surface = wm_base.get_xdg_surface(&surface, &qh, ()); let toplevel = xdg_surface.get_toplevel(&qh, ()); @@ -325,6 +332,11 @@ impl WaylandWindow { if let Some(app_id) = spec.app_id.as_ref() { toplevel.set_app_id(app_id.clone()); } + let decoration = decoration_manager.as_ref().map(|dm| { + let deco = dm.get_toplevel_decoration(&toplevel, &qh, ()); + deco.set_mode(zxdg_toplevel_decoration_v1::Mode::ServerSide); + deco + }); apply_size_constraints(&toplevel, &spec); if spec.maximized { @@ -395,6 +407,7 @@ impl WaylandWindow { last_selection_serial: None, last_pointer_enter_serial: None, cursor_icon: CursorIcon::Default, + _decoration: decoration, }, }) } @@ -1355,6 +1368,19 @@ fn spawn_window_worker( ); state_ref.pending_viewport = Some(current_viewport); state_ref.pending_viewport_since.get_or_insert_with(Instant::now); + // Deadlock prevention: if the app responded to the + // configure we sent (scene matches in_flight) but the + // viewport has since moved on, clear the in_flight + // marker so we can immediately send the next configure. + // Without this, viewport_request_in_flight stays set + // to the old size forever and the app is never told + // about the new viewport. + if state_ref.viewport_request_in_flight + == Some(scene.logical_size) + { + state_ref.viewport_request_in_flight = None; + maybe_request_pending_viewport(&mut state_ref); + } state_ref.window.request_redraw(); } else if !state_ref.window.presentation_ready() { // Correct scene is ready but the compositor @@ -1582,6 +1608,8 @@ delegate_noop!( State: ignore zwp_primary_selection_device_manager_v1::ZwpPrimarySelectionDeviceManagerV1 ); +delegate_noop!(State: ignore zxdg_decoration_manager_v1::ZxdgDecorationManagerV1); +delegate_noop!(State: ignore zxdg_toplevel_decoration_v1::ZxdgToplevelDecorationV1); impl Dispatch for State { fn event( state: &mut Self, From 6079117f2c6cf44241409393a846dfe3ab3c2e3a Mon Sep 17 00:00:00 2001 From: Will Temple Date: Mon, 23 Mar 2026 15:45:58 -0400 Subject: [PATCH 3/9] Pixel-grid snapping and corrected border box sizing --- lib/ruin_app/example/03_fine_grained_list.rs | 3 +- lib/ui/src/layout.rs | 166 +++++++++++++------ 2 files changed, 116 insertions(+), 53 deletions(-) diff --git a/lib/ruin_app/example/03_fine_grained_list.rs b/lib/ruin_app/example/03_fine_grained_list.rs index 1cfa8e9..6b18733 100644 --- a/lib/ruin_app/example/03_fine_grained_list.rs +++ b/lib/ruin_app/example/03_fine_grained_list.rs @@ -4,8 +4,7 @@ use tracing_subscriber::util::SubscriberInitExt; use tracing_subscriber::{EnvFilter, fmt}; fn install_tracing() { - let filter = EnvFilter::try_from_default_env() - .unwrap_or_else(|_| EnvFilter::new("warn")); + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn")); let fmt_layer = fmt::layer() .with_target(true) .with_thread_ids(true) diff --git a/lib/ui/src/layout.rs b/lib/ui/src/layout.rs index 45ccba2..02e8df9 100644 --- a/lib/ui/src/layout.rs +++ b/lib/ui/src/layout.rs @@ -263,6 +263,7 @@ fn layout_element( clip_rect: Option, ) -> LayoutNode { perf_stats.nodes += 1; + let rect = snap_rect(rect); // Viewport culling: skip fully off-screen elements inside a scroll box. if let Some(clip) = clip_rect @@ -813,11 +814,16 @@ fn layout_container_children( let mut fixed_total = 0.0; let mut flex_total = 0.0; for child in &element.children { + let child_insets = content_insets(&child.style); + let main_inset = main_axis_padding(child_insets, element.style.direction); + let cross_inset = cross_axis_padding(child_insets, element.style.direction); + // style.width/height are content-box; add insets to get the outer (allocated) size. let cross = child_cross_size(child, element.style.direction) + .map(|c| (c + cross_inset).max(0.0)) .unwrap_or(available_cross) .clamp(0.0, available_cross); - let explicit_main = - child_main_size(child, element.style.direction).map(|main| main.max(0.0)); + let explicit_main = child_main_size(child, element.style.direction) + .map(|main| (main + main_inset).max(0.0)); let is_flex = explicit_main.is_none() && child.style.flex_grow > 0.0; let measured_main = explicit_main.unwrap_or_else(|| { if is_flex { @@ -902,13 +908,22 @@ fn intrinsic_main_size( layout_cache: &mut LayoutCache, ) -> f32 { if let Some(text) = child.text_node() { + // cross_size and available_main are outer sizes; subtract insets to get content dimensions + // for text measurement so wrapping is constrained to the actual content area. + let child_insets = content_insets(&child.style); let constraints = match direction { - FlexDirection::Row => (Some(available_main.max(0.0)), Some(cross_size.max(0.0))), - FlexDirection::Column => (Some(cross_size.max(0.0)), None), + FlexDirection::Row => ( + Some((available_main - main_axis_padding(child_insets, direction)).max(0.0)), + Some((cross_size - cross_axis_padding(child_insets, direction)).max(0.0)), + ), + FlexDirection::Column => ( + Some((cross_size - cross_axis_padding(child_insets, direction)).max(0.0)), + None, + ), }; let content = text_system.measure_spans(&text.spans, &text.style, constraints.0, constraints.1); - let padding = main_axis_padding(content_insets(&child.style), direction); + let padding = main_axis_padding(child_insets, direction); return main_axis_size(content, direction) + padding; } @@ -945,19 +960,24 @@ fn intrinsic_container_content_size( let mut height = gap_total; for child in &element.children { let skip_main = child.style.flex_grow > 0.0 && child.style.height.is_none(); - let child_size = intrinsic_size( - child, - UiSize::new( - child.style.width.unwrap_or(content_size.width), - child.style.height.unwrap_or(content_size.height), - ), - text_system, - perf_stats, - layout_cache, - ); - width = width.max(child.style.width.unwrap_or(child_size.width)); + let child_insets = content_insets(&child.style); + // Offer outer size: content-box style.w/h + insets, or parent's content width. + let offered_w = child + .style + .width + .map(|w| w + horizontal_insets(child_insets)) + .unwrap_or(content_size.width); + let offered_h = child + .style + .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); + // child_size is always outer; accumulate directly. + width = width.max(child_size.width); if !skip_main { - height += child.style.height.unwrap_or(child_size.height); + height += child_size.height; } } UiSize::new(width, height) @@ -975,17 +995,21 @@ fn intrinsic_container_content_size( child_main_sizes.push(None); continue; } - let child_size = intrinsic_size( - child, - UiSize::new( - child.style.width.unwrap_or(content_size.width), - child.style.height.unwrap_or(content_size.height), - ), - text_system, - perf_stats, - layout_cache, - ); - let child_main = child.style.width.unwrap_or(child_size.width); + let child_insets = content_insets(&child.style); + let offered_w = child + .style + .width + .map(|w| w + horizontal_insets(child_insets)) + .unwrap_or(content_size.width); + let offered_h = child + .style + .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); + // child_size.width is outer; use directly as the fixed main contribution. + let child_main = child_size.width; fixed_main += child_main; child_main_sizes.push(Some(child_main)); } @@ -999,12 +1023,15 @@ fn intrinsic_container_content_size( remaining_main * (child_flex_weight(child) / flex_total) } }); + let child_insets = content_insets(&child.style); + let offered_h = child + .style + .height + .map(|h| h + vertical_insets(child_insets)) + .unwrap_or(content_size.height); let child_size = intrinsic_size( child, - UiSize::new( - child_main, - child.style.height.unwrap_or(content_size.height), - ), + UiSize::new(child_main, offered_h), text_system, perf_stats, layout_cache, @@ -1012,7 +1039,7 @@ fn intrinsic_container_content_size( if !skip_main { width += child_main; } - height = height.max(child.style.height.unwrap_or(child_size.height)); + height = height.max(child_size.height); } UiSize::new(width, height) } @@ -1060,22 +1087,30 @@ fn intrinsic_size_inner( perf_stats: &mut LayoutPerfStats, layout_cache: &mut LayoutCache, ) -> UiSize { + // style.width/height are content-box (content dimensions). + // available_size is the outer size the parent is offering. + // All returned sizes are outer (content + insets). + if let Some(text) = element.text_node() { + // Measure at content dimensions: use explicit if set, else available minus insets. + let content_w = element + .style + .width + .unwrap_or_else(|| (available_size.width - horizontal_insets(insets)).max(0.0)); + let content_h = element + .style + .height + .unwrap_or_else(|| (available_size.height - vertical_insets(insets)).max(0.0)); let measured = text_system.measure_spans( &text.spans, &text.style, - Some(available_size.width.max(0.0)), - Some(available_size.height.max(0.0)), + Some(content_w.max(0.0)), + Some(content_h.max(0.0)), ); + // Return outer: explicit (content) + insets, or measured + insets for auto. return UiSize::new( - element - .style - .width - .unwrap_or(measured.width + horizontal_insets(insets)), - element - .style - .height - .unwrap_or(measured.height + vertical_insets(insets)), + element.style.width.unwrap_or(measured.width) + horizontal_insets(insets), + element.style.height.unwrap_or(measured.height) + vertical_insets(insets), ); } @@ -1092,10 +1127,13 @@ fn intrinsic_size_inner( let scroll_gutter = element .scroll_box_node() .map_or(0.0, |scroll_box| scroll_box.scrollbar.gutter_width.max(0.0)); - let content_width = - explicit_width.unwrap_or(available_size.width).max(0.0) - horizontal_insets(insets); - let content_height = - explicit_height.unwrap_or(available_size.height).max(0.0) - vertical_insets(insets); + // Content dimensions: explicit = already content; auto = available_outer - insets. + let content_width = explicit_width + .map(|w| w.max(0.0)) + .unwrap_or_else(|| (available_size.width - horizontal_insets(insets)).max(0.0)); + let content_height = explicit_height + .map(|h| h.max(0.0)) + .unwrap_or_else(|| (available_size.height - vertical_insets(insets)).max(0.0)); let content_size = UiSize::new( (content_width - scroll_gutter).max(0.0), content_height.max(0.0), @@ -1103,8 +1141,12 @@ fn intrinsic_size_inner( if element.children.is_empty() { return UiSize::new( - explicit_width.unwrap_or(horizontal_insets(insets) + scroll_gutter), - explicit_height.unwrap_or(vertical_insets(insets)), + explicit_width + .map(|w| w + horizontal_insets(insets) + scroll_gutter) + .unwrap_or(horizontal_insets(insets) + scroll_gutter), + explicit_height + .map(|h| h + vertical_insets(insets)) + .unwrap_or(vertical_insets(insets)), ); } @@ -1118,8 +1160,11 @@ fn intrinsic_size_inner( UiSize::new( explicit_width + .map(|w| w + horizontal_insets(insets) + scroll_gutter) .unwrap_or(intrinsic_content.width + horizontal_insets(insets) + scroll_gutter), - explicit_height.unwrap_or(intrinsic_content.height + vertical_insets(insets)), + explicit_height + .map(|h| h + vertical_insets(insets)) + .unwrap_or(intrinsic_content.height + vertical_insets(insets)), ) } @@ -1567,6 +1612,26 @@ fn main_axis_padding(edges: Edges, direction: FlexDirection) -> f32 { } } +fn cross_axis_padding(edges: Edges, direction: FlexDirection) -> f32 { + match direction { + FlexDirection::Row => edges.top + edges.bottom, + FlexDirection::Column => edges.left + edges.right, + } +} + +/// Snap a rect to the integer pixel grid. +/// +/// Origin and far edge are each rounded independently so that adjacent +/// elements (where one's far edge equals the other's near edge) always +/// butt flush against each other with no gap or overlap. +fn snap_rect(r: Rect) -> Rect { + let x0 = r.origin.x.round(); + let y0 = r.origin.y.round(); + let x1 = (r.origin.x + r.size.width).round(); + let y1 = (r.origin.y + r.size.height).round(); + Rect::new(x0, y0, (x1 - x0).max(0.0), (y1 - y0).max(0.0)) +} + fn main_axis_size(size: UiSize, direction: FlexDirection) -> f32 { match direction { FlexDirection::Row => size.width, @@ -2258,7 +2323,6 @@ mod tests { #[test] fn scroll_culling_skips_off_screen_children() { - use crate::tree::ScrollbarStyle; let mut text_system = TextSystem::new(); let mut layout_cache = LayoutCache::new(); From e327516e5ef3a1c52423bfe5221f5c46f9e95911 Mon Sep 17 00:00:00 2001 From: Will Temple Date: Mon, 23 Mar 2026 16:33:19 -0400 Subject: [PATCH 4/9] Opacity compositing --- examples/reactive_layout_demo/src/main.rs | 1 + examples/wayland_wgpu_demo/src/main.rs | 1 + lib/ruin_app/src/lib.rs | 7 ++++- lib/ui/src/window.rs | 9 ++++++- lib/ui_platform_wayland/src/lib.rs | 1 + lib/ui_renderer_wgpu/src/lib.rs | 33 +++++++++++++++++------ 6 files changed, 42 insertions(+), 10 deletions(-) diff --git a/examples/reactive_layout_demo/src/main.rs b/examples/reactive_layout_demo/src/main.rs index f725a72..aad2c25 100644 --- a/examples/reactive_layout_demo/src/main.rs +++ b/examples/reactive_layout_demo/src/main.rs @@ -101,6 +101,7 @@ async fn run_demo() -> Result<(), Box> { window.surface_target(), initial_size.width as u32, initial_size.height as u32, + ruin_ui::Color::rgb(0x08, 0x0C, 0x14), ) .expect("wgpu renderer should initialize"); let state = Rc::new(RefCell::new(WorkerState { diff --git a/examples/wayland_wgpu_demo/src/main.rs b/examples/wayland_wgpu_demo/src/main.rs index 1185ce0..0069f4d 100644 --- a/examples/wayland_wgpu_demo/src/main.rs +++ b/examples/wayland_wgpu_demo/src/main.rs @@ -36,6 +36,7 @@ fn main() -> Result<(), Box> { window.surface_target(), scene.logical_size.width as u32, scene.logical_size.height as u32, + ruin_ui::Color::rgb(0x08, 0x0C, 0x14), )?; println!("Opening RUIN Wayland / wgpu demo window..."); diff --git a/lib/ruin_app/src/lib.rs b/lib/ruin_app/src/lib.rs index f52af66..7eafeae 100644 --- a/lib/ruin_app/src/lib.rs +++ b/lib/ruin_app/src/lib.rs @@ -58,6 +58,11 @@ impl Window { self.spec = self.spec.requested_inner_size(UiSize::new(width, height)); self } + + pub fn base_color(mut self, color: Color) -> Self { + self.spec = self.spec.base_color(color); + self + } } impl Default for Window { @@ -774,7 +779,7 @@ pub mod surfaces { pub fn column() -> ContainerBuilder { ContainerBuilder { - element: Element::column().background(surfaces::canvas()), + element: Element::column(), widget_ref: None, } } diff --git a/lib/ui/src/window.rs b/lib/ui/src/window.rs index 96a09cb..9072de7 100644 --- a/lib/ui/src/window.rs +++ b/lib/ui/src/window.rs @@ -1,6 +1,6 @@ //! Common window model types. -use crate::scene::UiSize; +use crate::scene::{Color, UiSize}; #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] pub struct WindowId(u64); @@ -55,6 +55,7 @@ pub struct WindowSpec { pub maximized: bool, pub fullscreen: bool, pub resizable: bool, + pub base_color: Color, } impl WindowSpec { @@ -72,9 +73,15 @@ impl WindowSpec { maximized: false, fullscreen: false, resizable: true, + base_color: Color::rgb(0x08, 0x0C, 0x14), } } + pub fn base_color(mut self, color: Color) -> Self { + self.base_color = color; + self + } + pub fn key(mut self, key: impl Into) -> Self { self.key = Some(key.into()); self diff --git a/lib/ui_platform_wayland/src/lib.rs b/lib/ui_platform_wayland/src/lib.rs index 030e0ff..30ce8b8 100644 --- a/lib/ui_platform_wayland/src/lib.rs +++ b/lib/ui_platform_wayland/src/lib.rs @@ -1096,6 +1096,7 @@ fn spawn_window_worker( spec.requested_inner_size .unwrap_or_else(|| UiSize::new(800.0, 500.0)) .height as u32, + spec.base_color, ) { Ok(renderer) => renderer, Err(_) => { diff --git a/lib/ui_renderer_wgpu/src/lib.rs b/lib/ui_renderer_wgpu/src/lib.rs index 08edc27..aa34238 100644 --- a/lib/ui_renderer_wgpu/src/lib.rs +++ b/lib/ui_renderer_wgpu/src/lib.rs @@ -330,6 +330,7 @@ pub struct WgpuSceneRenderer { image_cache_order: VecDeque, #[allow(dead_code)] glyph_atlas: GlyphAtlas, + base_color: Color, } const MAX_TEXT_CACHE_ENTRIES: usize = 64; @@ -343,6 +344,7 @@ impl WgpuSceneRenderer { target: impl HasDisplayHandle + HasWindowHandle + Send + Sync + 'static, width: u32, height: u32, + base_color: Color, ) -> Result> { let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); let surface = instance.create_surface(target)?; @@ -361,6 +363,15 @@ impl WgpuSceneRenderer { .copied() .find(wgpu::TextureFormat::is_srgb) .unwrap_or(caps.formats[0]); + let alpha_mode = if base_color.a < 255 + && caps + .alpha_modes + .contains(&wgpu::CompositeAlphaMode::PreMultiplied) + { + wgpu::CompositeAlphaMode::PreMultiplied + } else { + caps.alpha_modes[0] + }; let config = wgpu::SurfaceConfiguration { usage: wgpu::TextureUsages::RENDER_ATTACHMENT, format, @@ -368,7 +379,7 @@ impl WgpuSceneRenderer { height: height.max(1), present_mode: wgpu::PresentMode::AutoVsync, desired_maximum_frame_latency: 2, - alpha_mode: caps.alpha_modes[0], + alpha_mode, view_formats: vec![], }; surface.configure(&device, &config); @@ -415,7 +426,7 @@ fn sd_round_rect(point: vec2, rect: vec4, radius: f32) -> f32 { fn rounded_rect_alpha(point: vec2, rect: vec4, radius: f32) -> f32 { let distance = sd_round_rect(point, rect, radius); - return 1.0 - smoothstep(0.0, 1.0, distance); + return 1.0 - smoothstep(-0.5, 0.5, distance); } fn blurred_round_rect_alpha(point: vec2, rect: vec4, radius: f32, blur: f32) -> f32 { @@ -553,7 +564,7 @@ fn sd_round_rect(point: vec2, rect: vec4, radius: f32) -> f32 { fn rounded_rect_alpha(point: vec2, rect: vec4, radius: f32) -> f32 { let distance = sd_round_rect(point, rect, radius); - return 1.0 - smoothstep(0.0, 1.0, distance); + return 1.0 - smoothstep(-0.5, 0.5, distance); } fn apply_clip_alpha(point: vec2, clip_rect: vec4, rounded_clip_rect: vec4, clip_params: vec2) -> f32 { @@ -710,6 +721,7 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { image_cache: HashMap::new(), image_cache_order: VecDeque::new(), glyph_atlas, + base_color, }) } @@ -792,11 +804,16 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { depth_slice: None, resolve_target: None, ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color { - r: 0.03, - g: 0.04, - b: 0.08, - a: 1.0, + load: wgpu::LoadOp::Clear({ + let a = self.base_color.a as f64 / 255.0; + // Premultiplied: multiply RGB by alpha so the compositor + // can blend correctly when alpha_mode is PreMultiplied. + wgpu::Color { + r: (self.base_color.r as f64 / 255.0) * a, + g: (self.base_color.g as f64 / 255.0) * a, + b: (self.base_color.b as f64 / 255.0) * a, + a, + } }), store: wgpu::StoreOp::Store, }, From 5f67c060833d775e8301605720b094d392e74e68 Mon Sep 17 00:00:00 2001 From: Will Temple Date: Mon, 23 Mar 2026 19:43:14 -0400 Subject: [PATCH 5/9] Min size layout constraints --- examples/text_paragraph_demo/src/main.rs | 2 + .../example/00_bootstrap_and_counter.rs | 22 +- lib/ruin_app/src/lib.rs | 121 +++++++-- lib/ui/src/layout.rs | 242 ++++++++++++++++-- lib/ui/src/lib.rs | 3 +- lib/ui/src/tree.rs | 20 ++ 6 files changed, 365 insertions(+), 45 deletions(-) diff --git a/examples/text_paragraph_demo/src/main.rs b/examples/text_paragraph_demo/src/main.rs index a5249d9..6f639cf 100644 --- a/examples/text_paragraph_demo/src/main.rs +++ b/examples/text_paragraph_demo/src/main.rs @@ -795,6 +795,7 @@ async fn main() -> Result<(), Box> { let LayoutSnapshot { scene, interaction_tree: next_interaction_tree, + .. } = build_snapshot( viewport, version, @@ -1077,6 +1078,7 @@ async fn main() -> Result<(), Box> { let LayoutSnapshot { scene, interaction_tree: next_interaction_tree, + .. } = build_snapshot( viewport, version, diff --git a/lib/ruin_app/example/00_bootstrap_and_counter.rs b/lib/ruin_app/example/00_bootstrap_and_counter.rs index 162af9c..9b639cd 100644 --- a/lib/ruin_app/example/00_bootstrap_and_counter.rs +++ b/lib/ruin_app/example/00_bootstrap_and_counter.rs @@ -1,4 +1,5 @@ use ruin_app::prelude::*; +use ruin_ui::{BoxShadow, BoxShadowKind, Point}; #[ruin_runtime::async_main] async fn main() -> ruin_app::Result<()> { @@ -9,6 +10,8 @@ async fn main() -> ruin_app::Result<()> { Window::new() .title(title) .app_id("dev.ruin.counter") + .base_color(Color::rgba(255, 255, 255, 80)) + .min_size(320.0, 240.0) .size(960.0, 640.0), ) .mount(view! { @@ -33,7 +36,7 @@ fn CounterApp(title: &'static str) -> impl IntoView { view! { column(gap = 16.0, padding = 24.0) { - text(role = TextRole::Heading(1), size = 32.0, weight = FontWeight::Semibold) { + text(role = TextRole::Heading(1), size = 32.0, weight = FontWeight::Semibold, color = Color::rgba(0, 0, 0, 255)) { title } @@ -42,12 +45,13 @@ fn CounterApp(title: &'static str) -> impl IntoView { block( padding = 16.0, gap = 8.0, - background = surfaces::raised(), + background = Color::rgba(255, 255, 255, 200), border_radius = 12.0, - border = Border::new(2.0, Color::rgb(0, 0, 0)) + border = Border::new(2.0, Color::rgba(0, 0, 0, 120)), + shadow = BoxShadow::new(Point::new(2.0, 2.0), 8.0, 2.0, Color::rgba(0, 0, 0, 60), BoxShadowKind::Outer), ) { - text(size = 18.0) { "count = "; count.clone() } - text(color = colors::muted()) { "double = "; doubled.clone() } + text(size = 18.0, color = Color::rgb(0, 0, 0)) { "count = "; count.clone() } + text(color = Color::rgba(0, 0, 0, 200)) { "double = "; doubled.clone() } } } } @@ -56,8 +60,10 @@ fn CounterApp(title: &'static str) -> impl IntoView { #[component] fn CounterActions(count: Signal) -> impl IntoView { view! { - row(gap = 8.0) { + 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 |_| { @@ -69,6 +75,8 @@ fn CounterActions(count: Signal) -> impl IntoView { } button( + background = Color::rgba(255, 255, 255, 90), + color = Color::rgba(0, 0, 0, 255), on_press = { let count = count.clone(); move |_| { @@ -80,6 +88,8 @@ fn CounterActions(count: Signal) -> impl IntoView { } button( + background = Color::rgba(255, 255, 255, 90), + color = Color::rgba(0, 0, 0, 255), on_press = { let count = count.clone(); move |_| { diff --git a/lib/ruin_app/src/lib.rs b/lib/ruin_app/src/lib.rs index 7eafeae..acfc719 100644 --- a/lib/ruin_app/src/lib.rs +++ b/lib/ruin_app/src/lib.rs @@ -11,19 +11,19 @@ use std::collections::HashMap; use std::error::Error; use std::future::Future; use std::iter; -use std::time::Instant; use std::marker::PhantomData; use std::rc::Rc; +use std::time::Instant; use ruin_reactivity::effect; use ruin_runtime::queue_future; use ruin_ui::{ - Border, Color, CursorIcon, DisplayItem, Edges, Element, ElementId, HitTarget, InteractionTree, - KeyboardEvent, KeyboardEventKind, KeyboardKey, LayoutCache, LayoutSnapshot, PlatformEvent, - PointerButton, PointerEvent, PointerEventKind, PointerRouter, Quad, RoutedPointerEvent, - RoutedPointerEventKind, ScrollbarStyle, TextFontFamily, TextSpan, TextSpanWeight, TextStyle, - TextSystem, TextWrap, UiSize, WindowController, WindowSpec, WindowUpdate, - layout_snapshot_with_cache, + 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, }; use ruin_ui_platform_wayland::start_wayland_ui; @@ -59,6 +59,11 @@ impl Window { 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 @@ -169,6 +174,8 @@ impl MountedApp { 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(); @@ -181,6 +188,7 @@ impl MountedApp { 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); @@ -208,6 +216,7 @@ impl MountedApp { let LayoutSnapshot { mut scene, interaction_tree: next_interaction_tree, + root_min_size, } = layout_snapshot_with_cache( version, viewport, @@ -216,6 +225,21 @@ impl MountedApp { &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!( @@ -716,6 +740,16 @@ impl IntoBorder for (f32, Color) { } } +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, @@ -862,6 +896,25 @@ impl ContainerBuilder { 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 @@ -921,6 +974,20 @@ impl ScrollBoxBuilder { 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 @@ -1121,6 +1188,8 @@ impl TextBuilder { #[derive(Default)] pub struct ButtonBuilder { on_press: Option, + background: Option, + color: Option, } impl ButtonBuilder { @@ -1129,26 +1198,43 @@ impl ButtonBuilder { 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( - Element::column() + let view = View::from_element({ + let elt = Element::column() .id(id) - .padding(Edges::symmetric(14.0, 10.0)) - .background(surfaces::interactive()) - .corner_radius(10.0) + .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, colors::text()).with_line_height(21.6), + 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), @@ -1988,8 +2074,9 @@ pub mod prelude { Pending, Ready, Resource, ResourceState, Result, ScrollBoxBuilder, ScrollBoxWidget, Shortcut, ShortcutScope, Signal, TextBuilder, TextChildren, TextRole, TextValue, View, WidgetRef, Window, block, button, colors, column, component, context_provider, provide, - row, scroll_box, surfaces, text, use_context, use_effect, use_memo, use_resource, use_shortcut, - use_shortcut_with_context, use_signal, use_widget_ref, use_window_title, view, + row, scroll_box, surfaces, text, use_context, use_effect, use_memo, use_resource, + use_shortcut, use_shortcut_with_context, use_signal, use_widget_ref, use_window_title, + view, }; pub use ruin_ui::{ Border, Color, CursorIcon, Edges, Element, ElementId, InteractionTree, PointerButton, diff --git a/lib/ui/src/layout.rs b/lib/ui/src/layout.rs index 02e8df9..efb8564 100644 --- a/lib/ui/src/layout.rs +++ b/lib/ui/src/layout.rs @@ -27,6 +27,11 @@ pub fn layout_scene_with_text_system( pub struct LayoutSnapshot { pub scene: SceneSnapshot, pub interaction_tree: InteractionTree, + /// Minimum size the root element needs in logical units. Accounts for + /// `min_width`/`min_height` constraints and the natural minimum of fixed- + /// size content. Scroll boxes contribute only their own explicit/min size + /// on the scroll axis, not their content. + pub root_min_size: UiSize, } #[derive(Clone, Debug, Eq, Hash, PartialEq)] @@ -242,11 +247,13 @@ pub fn layout_snapshot_with_cache( "layout snapshot perf" ); } + let root_min_size = element_min_size(root); LayoutSnapshot { scene, interaction_tree: InteractionTree { root: interaction_root, }, + root_min_size, } } @@ -817,17 +824,26 @@ fn layout_container_children( let child_insets = content_insets(&child.style); let main_inset = main_axis_padding(child_insets, element.style.direction); let cross_inset = cross_axis_padding(child_insets, element.style.direction); - // style.width/height are content-box; add insets to get the outer (allocated) size. - let cross = child_cross_size(child, element.style.direction) + // min-size constraints (content-box) converted to outer. + let min_cross_outer = child_min_cross_size(child, element.style.direction) .map(|c| (c + cross_inset).max(0.0)) - .unwrap_or(available_cross) - .clamp(0.0, available_cross); + .unwrap_or(0.0); + let min_main_outer = child_min_main_size(child, element.style.direction) + .map(|m| (m + main_inset).max(0.0)) + .unwrap_or(0.0); + // style.width/height are content-box; add insets to get the outer (allocated) size. + // min-cross acts as floor; allow exceeding available_cross only when min demands it. + let cross = child_cross_size(child, element.style.direction) + .map(|c| (c + cross_inset).max(0.0).max(min_cross_outer)) + .unwrap_or(available_cross.max(min_cross_outer)) + .min(available_cross.max(min_cross_outer)); let explicit_main = child_main_size(child, element.style.direction) - .map(|main| (main + main_inset).max(0.0)); + .map(|main| (main + main_inset).max(0.0).max(min_main_outer)); let is_flex = explicit_main.is_none() && child.style.flex_grow > 0.0; let measured_main = explicit_main.unwrap_or_else(|| { if is_flex { - 0.0 + // Flex items get their share of remaining space later; record min floor now. + min_main_outer } else { perf_stats.intrinsic_calls += 1; if child.text_node().is_some() { @@ -848,7 +864,8 @@ fn layout_container_children( if let Some(intrinsic_started) = intrinsic_started { perf_stats.intrinsic_ms += intrinsic_started.elapsed().as_secs_f64() * 1_000.0; } - intrinsic + // Apply min-main floor to intrinsic result. + intrinsic.max(min_main_outer) } }); if is_flex { @@ -868,11 +885,18 @@ fn layout_container_children( let mut children = Vec::with_capacity(element.children.len()); for (index, (child, measured)) in element.children.iter().zip(measured_children).enumerate() { let child_main = if measured.is_flex { - if flex_total <= 0.0 { + let allocated = if flex_total <= 0.0 { 0.0 } else { remaining_main * (child_flex_weight(child) / flex_total) - } + }; + // Apply min-main floor to flex allocation. + let child_insets = content_insets(&child.style); + let main_inset = main_axis_padding(child_insets, element.style.direction); + let min_main_outer = child_min_main_size(child, element.style.direction) + .map(|m| (m + main_inset).max(0.0)) + .unwrap_or(0.0); + allocated.max(min_main_outer) } else { measured.main }; @@ -1091,6 +1115,17 @@ fn intrinsic_size_inner( // available_size is the outer size the parent is offering. // All returned sizes are outer (content + insets). + let min_outer_w = element + .style + .min_width + .map(|mw| mw + horizontal_insets(insets)) + .unwrap_or(0.0); + let min_outer_h = element + .style + .min_height + .map(|mh| mh + vertical_insets(insets)) + .unwrap_or(0.0); + if let Some(text) = element.text_node() { // Measure at content dimensions: use explicit if set, else available minus insets. let content_w = element @@ -1109,16 +1144,18 @@ fn intrinsic_size_inner( ); // Return outer: explicit (content) + insets, or measured + insets for auto. return UiSize::new( - element.style.width.unwrap_or(measured.width) + horizontal_insets(insets), - element.style.height.unwrap_or(measured.height) + vertical_insets(insets), + (element.style.width.unwrap_or(measured.width) + horizontal_insets(insets)) + .max(min_outer_w), + (element.style.height.unwrap_or(measured.height) + vertical_insets(insets)) + .max(min_outer_h), ); } if let Some(image) = element.image_node() { let resolved = resolve_image_element_size(element, image.resource.size()); return UiSize::new( - resolved.width + horizontal_insets(insets), - resolved.height + vertical_insets(insets), + (resolved.width + horizontal_insets(insets)).max(min_outer_w), + (resolved.height + vertical_insets(insets)).max(min_outer_h), ); } @@ -1141,12 +1178,14 @@ fn intrinsic_size_inner( if element.children.is_empty() { return UiSize::new( - explicit_width + (explicit_width .map(|w| w + horizontal_insets(insets) + scroll_gutter) - .unwrap_or(horizontal_insets(insets) + scroll_gutter), - explicit_height + .unwrap_or(horizontal_insets(insets) + scroll_gutter)) + .max(min_outer_w), + (explicit_height .map(|h| h + vertical_insets(insets)) - .unwrap_or(vertical_insets(insets)), + .unwrap_or(vertical_insets(insets))) + .max(min_outer_h), ); } @@ -1159,12 +1198,159 @@ fn intrinsic_size_inner( ); UiSize::new( - explicit_width + (explicit_width .map(|w| w + horizontal_insets(insets) + scroll_gutter) - .unwrap_or(intrinsic_content.width + horizontal_insets(insets) + scroll_gutter), - explicit_height + .unwrap_or(intrinsic_content.width + horizontal_insets(insets) + scroll_gutter)) + .max(min_outer_w), + (explicit_height .map(|h| h + vertical_insets(insets)) - .unwrap_or(intrinsic_content.height + vertical_insets(insets)), + .unwrap_or(intrinsic_content.height + vertical_insets(insets))) + .max(min_outer_h), + ) +} + +/// Computes the minimum logical size that the element's content requires. +/// +/// Unlike [`intrinsic_size`], this function is designed for window minimum-size +/// propagation. The key difference is scroll boxes: a vertically-scrolling box +/// does not propagate its content height to the window minimum — only its own +/// explicit `height` or `min_height` contributes. Flex children without an +/// explicit size or `min_*` constraint contribute 0 on the main axis (they +/// can shrink). Text nodes with no explicit size contribute 0 (they wrap). +/// +/// The result is in the same coordinate space as [`intrinsic_size`] (outer / +/// including padding and border insets). +pub fn element_min_size(element: &Element) -> UiSize { + let insets = content_insets(&element.style); + let min_outer_w = element + .style + .min_width + .map(|mw| (mw + horizontal_insets(insets)).max(0.0)) + .unwrap_or(0.0); + let min_outer_h = element + .style + .min_height + .map(|mh| (mh + vertical_insets(insets)).max(0.0)) + .unwrap_or(0.0); + + // Text: can wrap to any width; only explicit size or min_* constrain the window. + if element.text_node().is_some() { + let w = element + .style + .width + .map(|w| w + horizontal_insets(insets)) + .unwrap_or(0.0) + .max(min_outer_w); + let h = element + .style + .height + .map(|h| h + vertical_insets(insets)) + .unwrap_or(0.0) + .max(min_outer_h); + return UiSize::new(w, h); + } + + // Image: natural size counts as a minimum. + if let Some(image) = element.image_node() { + let resolved = resolve_image_element_size(element, image.resource.size()); + return UiSize::new( + (resolved.width + horizontal_insets(insets)).max(min_outer_w), + (resolved.height + vertical_insets(insets)).max(min_outer_h), + ); + } + + let explicit_w = element.style.width; + let explicit_h = element.style.height; + + // Scroll box: content scrolls on the vertical axis; use only explicit/min height. + // Width still accumulates child widths (no horizontal scroll yet). + if element.scroll_box_node().is_some() { + let scroll_gutter = element + .scroll_box_node() + .map_or(0.0, |sb| sb.scrollbar.gutter_width.max(0.0)); + let w = explicit_w + .map(|w| w + horizontal_insets(insets) + scroll_gutter) + .unwrap_or(horizontal_insets(insets) + scroll_gutter) + .max(min_outer_w); + let h = explicit_h + .map(|h| h + vertical_insets(insets)) + .unwrap_or(vertical_insets(insets)) + .max(min_outer_h); + return UiSize::new(w, h); + } + + // Regular container: sum/max children's minimums. + if element.children.is_empty() { + return UiSize::new( + explicit_w + .map(|w| w + horizontal_insets(insets)) + .unwrap_or(horizontal_insets(insets)) + .max(min_outer_w), + explicit_h + .map(|h| h + vertical_insets(insets)) + .unwrap_or(vertical_insets(insets)) + .max(min_outer_h), + ); + } + + let gap_total = element.style.gap * element.children.len().saturating_sub(1) as f32; + let children_min = match element.style.direction { + FlexDirection::Column => { + let mut width: f32 = 0.0; + let mut height = gap_total; + for child in &element.children { + let child_min = element_min_size(child); + // Flex children with no explicit height contribute their min_height only; + // if that is also unset they contribute 0 (they can shrink freely). + let skip_main = child.style.flex_grow > 0.0 && child.style.height.is_none(); + width = width.max(child_min.width); + if !skip_main { + height += child_min.height; + } else { + // Still count the min_height floor even for flex children. + let child_insets = content_insets(&child.style); + let min_h = child + .style + .min_height + .map(|mh| mh + vertical_insets(child_insets)) + .unwrap_or(0.0); + height += min_h; + } + } + UiSize::new(width, height) + } + FlexDirection::Row => { + let mut width = gap_total; + let mut height: f32 = 0.0; + for child in &element.children { + let child_min = element_min_size(child); + let skip_main = child.style.flex_grow > 0.0 && child.style.width.is_none(); + if !skip_main { + width += child_min.width; + } else { + let child_insets = content_insets(&child.style); + let min_w = child + .style + .min_width + .map(|mw| mw + horizontal_insets(child_insets)) + .unwrap_or(0.0); + width += min_w; + } + height = height.max(child_min.height); + } + UiSize::new(width, height) + } + }; + + UiSize::new( + explicit_w + .map(|w| w + horizontal_insets(insets)) + .unwrap_or(children_min.width + horizontal_insets(insets)) + .max(min_outer_w), + explicit_h + .map(|h| h + vertical_insets(insets)) + .unwrap_or(children_min.height + vertical_insets(insets)) + .max(min_outer_h), ) } @@ -1597,6 +1783,20 @@ fn child_cross_size(child: &Element, direction: FlexDirection) -> Option { } } +fn child_min_main_size(child: &Element, direction: FlexDirection) -> Option { + match direction { + FlexDirection::Row => child.style.min_width, + FlexDirection::Column => child.style.min_height, + } +} + +fn child_min_cross_size(child: &Element, direction: FlexDirection) -> Option { + match direction { + FlexDirection::Row => child.style.min_height, + FlexDirection::Column => child.style.min_width, + } +} + fn child_flex_weight(child: &Element) -> f32 { if child.style.flex_grow > 0.0 { child.style.flex_grow diff --git a/lib/ui/src/lib.rs b/lib/ui/src/lib.rs index 69e6bd1..adff8aa 100644 --- a/lib/ui/src/lib.rs +++ b/lib/ui/src/lib.rs @@ -31,7 +31,8 @@ pub use interaction::{ pub use keyboard::{KeyboardEvent, KeyboardEventKind, KeyboardKey, KeyboardModifiers}; pub use layout::{ HitTarget, InteractionTree, LayoutCache, LayoutNode, LayoutPath, LayoutSnapshot, ScrollMetrics, - TextHitTarget, layout_snapshot, layout_snapshot_with_cache, layout_snapshot_with_text_system, + TextHitTarget, element_min_size, layout_snapshot, layout_snapshot_with_cache, + layout_snapshot_with_text_system, }; pub use layout::{layout_scene, layout_scene_with_text_system}; pub use platform::{ diff --git a/lib/ui/src/tree.rs b/lib/ui/src/tree.rs index 719ecb5..bfde6d8 100644 --- a/lib/ui/src/tree.rs +++ b/lib/ui/src/tree.rs @@ -182,6 +182,8 @@ pub struct Style { pub direction: FlexDirection, pub width: Option, pub height: Option, + pub min_width: Option, + pub min_height: Option, pub flex_grow: f32, pub gap: f32, pub padding: Edges, @@ -200,6 +202,8 @@ impl Default for Style { direction: FlexDirection::Column, width: None, height: None, + min_width: None, + min_height: None, flex_grow: 0.0, gap: 0.0, padding: Edges::ZERO, @@ -332,6 +336,20 @@ impl Element { self } + pub fn min_width(mut self, min_width: f32) -> Self { + self.style.min_width = Some(min_width.max(0.0)); + self + } + + pub fn min_height(mut self, min_height: f32) -> Self { + self.style.min_height = Some(min_height.max(0.0)); + self + } + + pub fn min_size(self, min_width: f32, min_height: f32) -> Self { + self.min_width(min_width).min_height(min_height) + } + pub fn flex(mut self, flex_grow: f32) -> Self { self.style.flex_grow = flex_grow.max(0.0); self @@ -532,6 +550,8 @@ impl Hash for Style { self.direction.hash(state); self.width.map(f32::to_bits).hash(state); self.height.map(f32::to_bits).hash(state); + self.min_width.map(f32::to_bits).hash(state); + self.min_height.map(f32::to_bits).hash(state); self.flex_grow.to_bits().hash(state); self.gap.to_bits().hash(state); self.padding.hash(state); From 341d07d82fbf49e2fa37cd70010a2e368d7a0a62 Mon Sep 17 00:00:00 2001 From: Will Temple Date: Wed, 25 Mar 2026 22:32:11 -0400 Subject: [PATCH 6/9] 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); From cd55cf14c5fc640c78b0b116de42e3669bf7361a Mon Sep 17 00:00:00 2001 From: Will Temple Date: Wed, 25 Mar 2026 22:32:25 -0400 Subject: [PATCH 7/9] typical stuff --- LICENSE.txt | 18 ++++++++++++++++++ README.md | 0 2 files changed, 18 insertions(+) create mode 100644 LICENSE.txt create mode 100644 README.md diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..d70d600 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,18 @@ +Copyright 2026 Will Temple + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the “Software”), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 From e2c2563b7e4f6432bc1f59759e3ea47a48ebfd1e Mon Sep 17 00:00:00 2001 From: Will Temple Date: Wed, 25 Mar 2026 22:57:58 -0400 Subject: [PATCH 8/9] Some more layout features, align/stretch. --- .../example/00_bootstrap_and_counter.rs | 3 +- lib/ruin_app/src/lib.rs | 6 +- lib/ruin_app/src/primitives/container.rs | 22 ++++++- lib/ui/src/layout.rs | 61 +++++++++++++++++-- lib/ui/src/lib.rs | 4 +- lib/ui/src/tree.rs | 32 ++++++++++ 6 files changed, 115 insertions(+), 13 deletions(-) diff --git a/lib/ruin_app/example/00_bootstrap_and_counter.rs b/lib/ruin_app/example/00_bootstrap_and_counter.rs index 1dff49f..37fa129 100644 --- a/lib/ruin_app/example/00_bootstrap_and_counter.rs +++ b/lib/ruin_app/example/00_bootstrap_and_counter.rs @@ -41,7 +41,7 @@ fn CounterApp(title: &'static str) -> impl IntoView { }; view! { - column(gap = 16.0, padding = 24.0, text_style = text_style) { + column(gap = 16.0, padding = 24.0, align_items = AlignItems::Center, text_style = text_style) { text(role = TextRole::Heading(1), size = 32.0, weight = FontWeight::Semibold, color = Color::rgba(0, 0, 0, 255)) { title } @@ -51,6 +51,7 @@ fn CounterApp(title: &'static str) -> impl IntoView { block( padding = 16.0, gap = 8.0, + align_self = AlignItems::Stretch, background = Color::rgba(255, 255, 255, 200), border_radius = 12.0, border = Border::new(2.0, Color::rgba(0, 0, 0, 120)), diff --git a/lib/ruin_app/src/lib.rs b/lib/ruin_app/src/lib.rs index affdab2..86febdd 100644 --- a/lib/ruin_app/src/lib.rs +++ b/lib/ruin_app/src/lib.rs @@ -68,9 +68,9 @@ pub mod prelude { view, }; pub use ruin_ui::{ - Border, Color, CursorIcon, Edges, Element, ElementId, InteractionTree, PointerButton, - PointerEventKind, RoutedPointerEvent, RoutedPointerEventKind, ScrollbarStyle, - TextFontFamily, TextStyle, TextWrap, UiSize, + AlignItems, Border, Color, CursorIcon, Edges, Element, ElementId, InteractionTree, + PointerButton, PointerEventKind, RoutedPointerEvent, RoutedPointerEventKind, + ScrollbarStyle, TextFontFamily, TextStyle, TextWrap, UiSize, }; } diff --git a/lib/ruin_app/src/primitives/container.rs b/lib/ruin_app/src/primitives/container.rs index a65397a..148f71f 100644 --- a/lib/ruin_app/src/primitives/container.rs +++ b/lib/ruin_app/src/primitives/container.rs @@ -1,4 +1,4 @@ -use ruin_ui::{Color, Element, ElementId}; +use ruin_ui::{AlignItems, Color, Element, ElementId}; use crate::context::allocate_element_id; use crate::converters::{IntoBorder, IntoEdges, IntoShadow}; @@ -82,6 +82,16 @@ impl ContainerProps { self } + pub fn align_items(mut self, align: AlignItems) -> Self { + self.element = self.element.align_items(align); + self + } + + pub fn align_self(mut self, align: AlignItems) -> Self { + self.element = self.element.align_self(align); + self + } + pub fn shadow(mut self, shadow: impl IntoShadow) -> Self { self.element = self.element.shadow(shadow.into_shadow()); self @@ -118,6 +128,16 @@ pub fn block() -> ContainerBuilder { impl ContainerBuilder { impl_props_methods!(); + pub fn align_items(mut self, align: AlignItems) -> Self { + self.props = self.props.align_items(align); + self + } + + pub fn align_self(mut self, align: AlignItems) -> Self { + self.props = self.props.align_self(align); + self + } + pub fn shadow(mut self, shadow: impl IntoShadow) -> Self { self.props = self.props.shadow(shadow); self diff --git a/lib/ui/src/layout.rs b/lib/ui/src/layout.rs index df19ee9..bc6158f 100644 --- a/lib/ui/src/layout.rs +++ b/lib/ui/src/layout.rs @@ -7,7 +7,7 @@ use crate::scene::{ UiSize, }; use crate::text::TextSystem; -use crate::tree::{CursorIcon, Edges, Element, ElementId, FlexDirection, ImageNode, Style}; +use crate::tree::{AlignItems, CursorIcon, Edges, Element, ElementId, FlexDirection, ImageNode, Style}; pub fn layout_scene(version: u64, logical_size: UiSize, root: &Element) -> SceneSnapshot { let mut text_system = TextSystem::new(); @@ -793,7 +793,11 @@ fn point_in_rounded_rect(point: crate::scene::Point, rect: Rect, radius: f32) -> #[derive(Clone, Copy, Debug)] struct MeasuredChild { + /// Offered cross-axis size (full available_cross for stretch; explicit value otherwise). cross: f32, + /// Natural cross-axis size: set when parent uses non-stretch alignment, child has no + /// explicit cross size, and the child is not flex. `None` means use `cross`. + natural_cross: Option, main: f32, is_flex: bool, } @@ -870,6 +874,31 @@ fn layout_container_children( intrinsic.max(min_main_outer) } }); + // For non-stretch alignment, measure the child's natural cross size so it is not + // forcibly stretched to fill the container's cross axis. Only done for non-flex + // children with no explicit cross-axis style, since flex main size is unknown here. + // align_self on the child overrides the parent's align_items for this child. + let effective_align = child.style.align_self.unwrap_or(element.style.align_items); + let natural_cross = + if effective_align != AlignItems::Stretch + && !is_flex + && child_cross_size(child, element.style.direction).is_none() + { + let offered = match element.style.direction { + FlexDirection::Row => UiSize::new(measured_main, available_cross), + FlexDirection::Column => UiSize::new(available_cross, measured_main), + }; + let natural = + intrinsic_size(child, offered, text_system, perf_stats, layout_cache); + Some( + cross_axis_size(natural, element.style.direction) + .max(min_cross_outer) + .min(available_cross.max(min_cross_outer)), + ) + } else { + None + }; + if is_flex { flex_total += child_flex_weight(child); } else { @@ -877,6 +906,7 @@ fn layout_container_children( } measured_children.push(MeasuredChild { cross, + natural_cross, main: measured_main, is_flex, }); @@ -902,12 +932,24 @@ fn layout_container_children( } else { measured.main }; + // For non-stretch alignment, use the child's natural cross size; otherwise stretch. + // align_self on the child overrides the parent's align_items for this child. + let effective_align = child.style.align_self.unwrap_or(element.style.align_items); + let actual_cross = measured.natural_cross.unwrap_or(measured.cross); + let cross_origin = { + let base = cross_axis_origin(content, element.style.direction); + match effective_align { + AlignItems::Stretch | AlignItems::Start => base, + AlignItems::Center => base + (available_cross - actual_cross) * 0.5, + AlignItems::End => base + available_cross - actual_cross, + } + }; let child_rect = child_rect( - content, + cross_origin, element.style.direction, cursor, child_main.max(0.0), - measured.cross, + actual_cross, ); children.push(layout_element( child, @@ -1865,16 +1907,23 @@ fn main_axis_origin(rect: Rect, direction: FlexDirection) -> f32 { } } +fn cross_axis_origin(content: Rect, direction: FlexDirection) -> f32 { + match direction { + FlexDirection::Row => content.origin.y, + FlexDirection::Column => content.origin.x, + } +} + fn child_rect( - content: Rect, + cross_origin: f32, direction: FlexDirection, main_origin: f32, main_size: f32, cross_size: f32, ) -> Rect { match direction { - FlexDirection::Row => Rect::new(main_origin, content.origin.y, main_size, cross_size), - FlexDirection::Column => Rect::new(content.origin.x, main_origin, cross_size, main_size), + FlexDirection::Row => Rect::new(main_origin, cross_origin, main_size, cross_size), + FlexDirection::Column => Rect::new(cross_origin, main_origin, cross_size, main_size), } } diff --git a/lib/ui/src/lib.rs b/lib/ui/src/lib.rs index adff8aa..f453fda 100644 --- a/lib/ui/src/lib.rs +++ b/lib/ui/src/lib.rs @@ -50,8 +50,8 @@ pub use text::{ TextStyle, TextSystem, TextWrap, }; pub use tree::{ - Border, BoxShadow, BoxShadowKind, CornerRadius, CursorIcon, Edges, Element, ElementId, - FlexDirection, ScrollbarStyle, Style, + AlignItems, Border, BoxShadow, BoxShadowKind, CornerRadius, CursorIcon, Edges, Element, + ElementId, FlexDirection, ScrollbarStyle, Style, }; pub use window::{ DecorationMode, WindowConfigured, WindowId, WindowLifecycle, WindowSpec, WindowUpdate, diff --git a/lib/ui/src/tree.rs b/lib/ui/src/tree.rs index bfde6d8..2848a44 100644 --- a/lib/ui/src/tree.rs +++ b/lib/ui/src/tree.rs @@ -23,6 +23,15 @@ pub enum FlexDirection { Column, } +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum AlignItems { + #[default] + Stretch, + Start, + Center, + End, +} + #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum CursorIcon { Default, @@ -180,6 +189,9 @@ impl Default for ScrollbarStyle { #[derive(Clone, Debug, PartialEq)] pub struct Style { pub direction: FlexDirection, + pub align_items: AlignItems, + /// Per-element override of the parent container's `align_items`. `None` means inherit. + pub align_self: Option, pub width: Option, pub height: Option, pub min_width: Option, @@ -200,6 +212,8 @@ impl Default for Style { fn default() -> Self { Self { direction: FlexDirection::Column, + align_items: AlignItems::Stretch, + align_self: None, width: None, height: None, min_width: None, @@ -400,6 +414,16 @@ impl Element { self } + pub fn align_items(mut self, align: AlignItems) -> Self { + self.style.align_items = align; + self + } + + pub fn align_self(mut self, align: AlignItems) -> Self { + self.style.align_self = Some(align); + self + } + pub fn cursor(mut self, cursor: CursorIcon) -> Self { self.style.cursor = Some(cursor); self @@ -545,9 +569,17 @@ impl Hash for ScrollbarStyle { } } +impl Hash for AlignItems { + fn hash(&self, state: &mut H) { + (*self as u8).hash(state); + } +} + impl Hash for Style { fn hash(&self, state: &mut H) { self.direction.hash(state); + self.align_items.hash(state); + self.align_self.hash(state); self.width.map(f32::to_bits).hash(state); self.height.map(f32::to_bits).hash(state); self.min_width.map(f32::to_bits).hash(state); From 6dcc2d0d00d5355c91acea2590ef7682676bb189 Mon Sep 17 00:00:00 2001 From: Will Temple Date: Sat, 16 May 2026 16:18:55 -0400 Subject: [PATCH 9/9] Lots of owork on a calculator example, transparency, etc. --- Cargo.lock | 85 +- README.md | 14 + examples/calculator/Cargo.toml | 15 + examples/calculator/src/components.rs | 375 +++ examples/calculator/src/main.rs | 161 ++ examples/calculator/src/state.rs | 198 ++ lib/reactivity/Cargo.toml | 9 +- lib/reactivity/src/cell.rs | 6 +- lib/reactivity/src/effect.rs | 7 +- lib/reactivity/src/event.rs | 9 +- lib/reactivity/src/lib.rs | 7 + lib/reactivity/src/reactor.rs | 129 +- lib/reactivity/src/source.rs | 70 + lib/reactivity/src/thunk.rs | 15 +- lib/reactivity_parsing/Cargo.toml | 13 + .../examples/json_reactive_parser.rs | 745 ++++++ .../examples/tokentree_reactive_parser.rs | 207 ++ lib/reactivity_parsing/src/delta.rs | 152 ++ lib/reactivity_parsing/src/lib.rs | 24 + lib/reactivity_parsing/src/metrics.rs | 139 ++ lib/reactivity_parsing/src/parse.rs | 162 ++ lib/reactivity_parsing/src/rope.rs | 720 ++++++ lib/reactivity_parsing/src/tokentree.rs | 2050 +++++++++++++++++ lib/ruin_app/src/app.rs | 88 +- lib/ruin_app/src/context.rs | 17 + lib/ruin_app/src/hooks.rs | 56 +- lib/ruin_app/src/input.rs | 6 + lib/ruin_app/src/lib.rs | 73 +- lib/ruin_app/src/primitives/button.rs | 8 + lib/ruin_app/src/primitives/container.rs | 10 - lib/ruin_app/src/primitives/mod.rs | 8 + lib/runtime/README.md | 4 + lib/runtime/src/fs.rs | 3 +- lib/runtime/src/lib.rs | 1 + lib/runtime/src/net.rs | 9 +- .../src/platform/linux_x86_64/driver.rs | 13 +- lib/runtime/src/stdio.rs | 191 ++ lib/runtime/src/time.rs | 16 +- lib/ui/src/interaction.rs | 24 +- lib/ui_platform_wayland/src/lib.rs | 69 +- 40 files changed, 5783 insertions(+), 125 deletions(-) create mode 100644 examples/calculator/Cargo.toml create mode 100644 examples/calculator/src/components.rs create mode 100644 examples/calculator/src/main.rs create mode 100644 examples/calculator/src/state.rs create mode 100644 lib/reactivity/src/source.rs create mode 100644 lib/reactivity_parsing/Cargo.toml create mode 100644 lib/reactivity_parsing/examples/json_reactive_parser.rs create mode 100644 lib/reactivity_parsing/examples/tokentree_reactive_parser.rs create mode 100644 lib/reactivity_parsing/src/delta.rs create mode 100644 lib/reactivity_parsing/src/lib.rs create mode 100644 lib/reactivity_parsing/src/metrics.rs create mode 100644 lib/reactivity_parsing/src/parse.rs create mode 100644 lib/reactivity_parsing/src/rope.rs create mode 100644 lib/reactivity_parsing/src/tokentree.rs create mode 100644 lib/runtime/src/stdio.rs diff --git a/Cargo.lock b/Cargo.lock index 28c3bd6..800e55e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -150,7 +150,7 @@ dependencies = [ "anyhow", "arrayvec", "log", - "nom", + "nom 8.0.0", "num-rational", "v_frame", ] @@ -253,6 +253,17 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "calculator" +version = "0.1.0" +dependencies = [ + "meval", + "ruin-runtime", + "ruin_app", + "ruin_ui", + "tracing-subscriber", +] + [[package]] name = "cast" version = "0.3.0" @@ -606,6 +617,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -834,6 +851,17 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + [[package]] name = "hermit-abi" version = "0.5.2" @@ -1165,6 +1193,16 @@ dependencies = [ "libc", ] +[[package]] +name = "meval" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f79496a5651c8d57cd033c5add8ca7ee4e3d5f7587a4777484640d9cb60392d9" +dependencies = [ + "fnv", + "nom 1.2.4", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1226,6 +1264,12 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nom" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5b8c256fd9471521bcb84c3cdba98921497f1a331cbc15b8030fc63b82050ce" + [[package]] name = "nom" version = "8.0.0" @@ -1241,6 +1285,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -1793,6 +1846,14 @@ dependencies = [ "syn", ] +[[package]] +name = "ruin-reactivity-parsing" +version = "0.1.0" +dependencies = [ + "ruin-runtime", + "ruin_reactivity", +] + [[package]] name = "ruin-runtime" version = "0.1.0" @@ -1831,6 +1892,7 @@ dependencies = [ name = "ruin_reactivity" version = "0.1.0" dependencies = [ + "hashbrown 0.17.0", "ruin-runtime", "tracing", "tracing-subscriber", @@ -2257,6 +2319,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", ] [[package]] @@ -2266,12 +2340,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "matchers", + "nu-ansi-term", "once_cell", "regex-automata", "sharded-slab", + "smallvec", "thread_local", "tracing", "tracing-core", + "tracing-log", ] [[package]] @@ -2336,6 +2413,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "version_check" version = "0.9.5" diff --git a/README.md b/README.md index e69de29..5560378 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,14 @@ +# RUIN - Rust Native Application Framework + +A layered architecture for fast, native, cross-platform applications. + +RUIN is a modern asynchronous runtime for interactive Rust applications on personal computers. It has a simple +threading and scheduling model, fast asynchronous I/O (io_uring on Linux, kqueue on macOS, and IOCP on windows), +and several libraries to enable building modern, reactive applications. + +## Runtime + +RUIN's core runtime is provided in [`ruin-runtime`](./lib/runtime/). + +The runtime is non-workstealing with no threadpool. When a RUIN application starts, it runs a simple event loop on the +main thread. \ No newline at end of file diff --git a/examples/calculator/Cargo.toml b/examples/calculator/Cargo.toml new file mode 100644 index 0000000..61b12ca --- /dev/null +++ b/examples/calculator/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "calculator" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "calculator" +path = "src/main.rs" + +[dependencies] +ruin_app = { path = "../../lib/ruin_app" } +ruin_ui = { path = "../../lib/ui" } +ruin-runtime = { path = "../../lib/runtime" } +meval = "0.2" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/examples/calculator/src/components.rs b/examples/calculator/src/components.rs new file mode 100644 index 0000000..e4d81ad --- /dev/null +++ b/examples/calculator/src/components.rs @@ -0,0 +1,375 @@ +use ruin_app::prelude::*; +use ruin_ui::{BoxShadow, BoxShadowKind, Point, TextFontFamily}; + +use crate::state::{CalcMode, CalcState}; + +// --------------------------------------------------------------------------- +// Display panel +// --------------------------------------------------------------------------- + +#[component] +pub fn Display(state: CalcState) -> impl IntoView { + let expression = state.expression.clone(); + let is_error = state.is_error.clone(); + let history = state.history.clone(); + + let result_color = use_memo({ + let is_error = is_error.clone(); + move || { + if is_error.get() { + Color::rgba(200, 30, 30, 245) + } else { + Color::rgba(20, 140, 20, 245) + } + } + }); + + let result_text = use_memo({ + let result = state.result.clone(); + move || { + let r = result.get(); + if r.is_empty() { " ".to_string() } else { format!("= {r}") } + } + }); + + view! { + block( + padding = 12.0, + gap = 8.0, + // min_height (content) sized to cover scroll_box + expression + result lines + // so the window auto-sizes correctly despite text min-size being 0. + // Content: scroll_box(100) + expr_text(31) + result_text(20) + 2×gap(16) = 167 + min_height = 167.0, + align_self = AlignItems::Stretch, + align_items = AlignItems::End, + background = Color::rgba(255, 255, 255, 200), + border_radius = 12.0, + border = (1.0, Color::rgba(0, 0, 0, 60)), + shadow = BoxShadow::new(Point::new(2.0, 2.0), 8.0, 2.0, Color::rgba(0, 0, 0, 40), BoxShadowKind::Outer), + ) { + block(flex = 1.0, min_height = 0.0) {} + + // History (oldest at top, newest at bottom, scrollable) + scroll_box( + height = 100.0, + offset_y = state.history_scroll.clone(), + align_self = AlignItems::Stretch, + ) { + column(padding = 4.0, gap = 2.0, align_items = AlignItems::End) { + history.with(|entries| { + entries + .iter() + .rev() + .map(|e| { + let expr = e.expression.clone(); + let res = e.result.clone(); + let s = state.clone(); + let res_for_press = res.clone(); + view! { + button( + align_self = AlignItems::End, + padding = 4.0, + background = Color::rgba(0, 0, 0, 0), + border_radius = 4.0, + on_press = move |_| s.use_history_result(res_for_press.clone()), + ) { + row(gap = 6.0, align_self = AlignItems::End) { + text(size = 12.0, color = Color::rgba(0, 0, 0, 200), font_family = TextFontFamily::Monospace, wrap = TextWrap::None, weight = FontWeight::Normal) { + expr.clone() + } + text(size = 12.0, color = Color::rgba(20, 130, 20, 230), font_family = TextFontFamily::Monospace, wrap = TextWrap::None, weight = FontWeight::Normal) { + format!("= {res}") + } + } + } + } + }) + .collect::>() + }) + } + } + + // Current expression + text( + size = 26.0, + font_family = TextFontFamily::Monospace, + color = Color::rgba(0, 0, 0, 245), + wrap = TextWrap::None, + weight = FontWeight::Normal, + pointer_events = false, + ) { + expression.get() + } + + // Result / error line + if is_error.get() { + view! { + text( + size = 16.0, + font_family = TextFontFamily::Monospace, + color = result_color.get(), + wrap = TextWrap::None, + weight = FontWeight::Normal, + pointer_events = false, + ) { + result_text.get() + } + } + } else { + view! { + block(min_height = 0.0) {} + } + } + } + } +} + +// --------------------------------------------------------------------------- +// Mode toggle +// --------------------------------------------------------------------------- + +#[component] +pub fn ModeToggle(state: CalcState) -> impl IntoView { + let mode = state.mode.clone(); + + view! { + row(gap = 6.0, align_self = AlignItems::Stretch) { + toggle_button("Basic", mode.get() == CalcMode::Basic, { + let state = state.clone(); + move || state.toggle_mode() + }) + toggle_button("Scientific", mode.get() == CalcMode::Scientific, { + let state = state.clone(); + move || state.toggle_mode() + }) + } + } +} + +// --------------------------------------------------------------------------- +// Button helpers +// --------------------------------------------------------------------------- + +fn calc_button(label: String, bg: Color, on_press: impl Fn() + 'static) -> View { + let widget_ref = use_widget_ref::(); + let interaction = use_interaction_state(widget_ref.clone()); + + button() + .widget_ref(widget_ref) + .flex(1.0) + .min_width(54.0) + // min_height = content height for 20px bold text (20 * 1.2 = 24). + // This matches the actual rendered line height so window sizing is accurate + // and the text is visually centered by the symmetric 8px top/bottom padding. + .min_height(24.0) + .padding(Edges::symmetric(8.0, 12.0)) + .align_items(AlignItems::Center) + .background(interactive_background(bg, interaction)) + .border_radius(8.0) + .border((0.5, interactive_border(interaction))) + .shadow(interactive_shadow(interaction)) + .on_press(move |_| on_press()) + .children( + text() + .size(20.0) + .weight(FontWeight::Bold) + .selectable(false) + .pointer_events(false) + .children(label), + ) +} + +fn btn_row(buttons: Vec) -> View { + row().gap(6.0).children(buttons) +} + +fn toggle_button(label: &'static str, active: bool, on_press: impl Fn() + 'static) -> View { + let widget_ref = use_widget_ref::(); + let interaction = use_interaction_state(widget_ref.clone()); + let base = if active { + Color::rgba(60, 100, 220, 180) + } else { + Color::rgba(255, 255, 255, 90) + }; + + button() + .widget_ref(widget_ref) + .flex(1.0) + .padding(8.0) + .min_height(17.0) + .align_items(AlignItems::Center) + .background(interactive_background(base, interaction)) + .border_radius(8.0) + .border((0.5, interactive_border(interaction))) + .shadow(interactive_shadow(interaction)) + .on_press(move |_| on_press()) + .children( + text() + .size(14.0) + .weight(FontWeight::Bold) + .selectable(false) + .pointer_events(false) + .children(label), + ) +} + +fn interactive_background(base: Color, interaction: InteractionState) -> Color { + if interaction.pressed { + mix(base, Color::BLACK, 0.22) + } else if interaction.hovered { + mix(base, Color::WHITE, 0.22) + } else { + base + } +} + +fn interactive_border(interaction: InteractionState) -> Color { + if interaction.pressed { + Color::rgba(0, 0, 0, 96) + } else if interaction.hovered { + Color::rgba(0, 0, 0, 72) + } else { + Color::rgba(0, 0, 0, 35) + } +} + +fn interactive_shadow(interaction: InteractionState) -> BoxShadow { + if interaction.pressed { + BoxShadow::new( + Point::new(0.0, 0.0), + 2.0, + 0.0, + Color::rgba(0, 0, 0, 36), + BoxShadowKind::Outer, + ) + } else if interaction.hovered { + BoxShadow::new( + Point::new(0.0, 3.0), + 14.0, + 0.0, + Color::rgba(0, 0, 0, 42), + BoxShadowKind::Outer, + ) + } else { + BoxShadow::new( + Point::new(0.0, 1.0), + 6.0, + 0.0, + Color::rgba(0, 0, 0, 18), + BoxShadowKind::Outer, + ) + } +} + +fn mix(base: Color, overlay: Color, amount: f32) -> Color { + let amount = amount.clamp(0.0, 1.0); + let blend = |from: u8, to: u8| -> u8 { + ((from as f32) + ((to as f32) - (from as f32)) * amount).round() as u8 + }; + + Color::rgba( + blend(base.r, overlay.r), + blend(base.g, overlay.g), + blend(base.b, overlay.b), + base.a, + ) +} + +// --------------------------------------------------------------------------- +// Keypad (basic) +// --------------------------------------------------------------------------- + +#[component] +pub fn Keypad(state: CalcState) -> impl IntoView { + let bg_num = Color::rgba(255, 255, 255, 90); + let bg_op = Color::rgba(210, 230, 255, 140); + let bg_eq = Color::rgba(160, 230, 160, 160); + let bg_fn = Color::rgba(255, 200, 180, 140); + + macro_rules! digit { + ($ch:expr) => {{ + let s = state.clone(); + let t = $ch.to_string(); + let label = t.clone(); + calc_button(label, bg_num, move || s.input(&t)) + }}; + } + macro_rules! op { + ($ch:expr) => {{ + let s = state.clone(); + let t = $ch.to_string(); + let label = t.clone(); + calc_button(label, bg_op, move || s.input(&t)) + }}; + } + + let clear_btn = { + let s = state.clone(); + calc_button("C".to_string(), bg_fn, move || s.clear()) + }; + let paren_btn = { + let s = state.clone(); + calc_button("( )".to_string(), bg_num, move || s.input_paren()) + }; + let pct_btn = { + let s = state.clone(); + calc_button("%".to_string(), bg_num, move || s.input("%")) + }; + let bs_btn = { + let s = state.clone(); + calc_button("⌫".to_string(), bg_fn, move || s.backspace()) + }; + let dot_btn = { + let s = state.clone(); + calc_button(".".to_string(), bg_num, move || s.input(".")) + }; + let eq_btn = { + let s = state.clone(); + calc_button("=".to_string(), bg_eq, move || s.evaluate()) + }; + + view! { + column(gap = 6.0, align_self = AlignItems::Stretch) { + btn_row(vec![clear_btn, paren_btn, pct_btn, bs_btn]) + btn_row(vec![digit!('7'), digit!('8'), digit!('9'), op!('÷')]) + btn_row(vec![digit!('4'), digit!('5'), digit!('6'), op!('×')]) + btn_row(vec![digit!('1'), digit!('2'), digit!('3'), op!('-')]) + btn_row(vec![digit!('0'), dot_btn, eq_btn, op!('+')]) + } + } +} + +// --------------------------------------------------------------------------- +// Scientific panel +// --------------------------------------------------------------------------- + +#[component] +pub fn ScientificPanel(state: CalcState) -> impl IntoView { + let bg = Color::rgba(230, 215, 255, 140); + + macro_rules! sci { + ($label:expr, $insert:expr) => {{ + let s = state.clone(); + let ins: &'static str = $insert; + calc_button($label.to_string(), bg, move || s.input(ins)) + }}; + } + + let sq_btn = { + let s = state.clone(); + calc_button("x²".to_string(), bg, move || { + s.expression.update(|e| { + let wrapped = format!("({e})^2"); + *e = wrapped; + }); + }) + }; + + view! { + column(gap = 6.0, align_self = AlignItems::Stretch) { + btn_row(vec![sci!("sin(", "sin("), sci!("cos(", "cos("), sci!("tan(", "tan("), sci!("asin(", "asin(")]) + btn_row(vec![sci!("log(", "log("), sci!("ln(", "ln("), sci!("√(", "√("), sq_btn]) + btn_row(vec![sci!("xʸ", "^"), sci!("π", "π"), sci!("e", "e"), sci!("fact(", "fact(")]) + } + } +} diff --git a/examples/calculator/src/main.rs b/examples/calculator/src/main.rs new file mode 100644 index 0000000..91746cb --- /dev/null +++ b/examples/calculator/src/main.rs @@ -0,0 +1,161 @@ +mod components; +mod state; + +use ruin_app::prelude::*; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; +use tracing_subscriber::{EnvFilter, fmt}; + +use components::{Display, Keypad, ModeToggle, ScientificPanel}; +use state::{CalcMode, CalcState}; + +#[ruin_runtime::async_main] +async fn main() -> ruin_app::Result<()> { + init_tracing(); + + App::new() + .window( + Window::new() + .title("Calculator") + .app_id("dev.ruin.calculator") + .base_color(Color::rgba(255, 255, 255, 80)) + .resizable(false), + ) + .mount(view! { CalculatorApp() {} }) + .run() + .await +} + +fn init_tracing() { + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| { + EnvFilter::new( + "info,ruin_app::resize=trace,ruin_app::interaction=trace,\ + ruin_ui_platform_wayland::resize=trace,ruin_ui_platform_wayland::perf=debug,\ + ruin_ui_platform_wayland::event_bridge=trace", + ) + }); + + let _ = tracing_subscriber::registry() + .with(fmt::layer().with_target(true).without_time()) + .with(filter) + .try_init(); +} + +#[component] +fn CalculatorApp() -> impl IntoView { + let state = CalcState::new(); + + // ---- Keyboard shortcuts (application-scoped) ---- + + for ch in "0123456789.+-*/%()".chars() { + let s = state.clone(); + let display = match ch { + '*' => "×", + '/' => "÷", + _ => "", + }; + let text = if display.is_empty() { + ch.to_string() + } else { + display.to_string() + }; + use_shortcut(Shortcut::new(Key::Character(ch)), ShortcutScope::Application, { + let text = text.clone(); + move || s.input(&text) + }); + } + + // = key also evaluates + { + let s = state.clone(); + use_shortcut(Shortcut::new(Key::Character('=')), ShortcutScope::Application, move || s.evaluate()); + } + { + let s = state.clone(); + use_shortcut(Shortcut::new(Key::Enter), ShortcutScope::Application, move || s.evaluate()); + } + { + let s = state.clone(); + use_shortcut(Shortcut::new(Key::Backspace), ShortcutScope::Application, move || s.backspace()); + } + { + let s = state.clone(); + use_shortcut(Shortcut::new(Key::Escape), ShortcutScope::Application, move || s.clear()); + } + { + let s = state.clone(); + use_shortcut(Shortcut::new(Key::Delete), ShortcutScope::Application, move || s.clear()); + } + + // ---- Layout ---- + + let mode = state.mode.clone(); + + let text_style = PartialTextStyle { + color: Some(Color::BLACK), + ..Default::default() + }; + + if mode.get() == CalcMode::Scientific { + view! { + column( + gap = 10.0, + padding = 12.0, + align_items = AlignItems::Stretch, + text_style = text_style, + ) { + Display(state = state.clone()) {} + ModeToggle(state = state.clone()) {} + ScientificPanel(state = state.clone()) {} + Keypad(state = state.clone()) {} + } + } + } else { + view! { + column( + gap = 10.0, + padding = 12.0, + align_items = AlignItems::Stretch, + text_style = text_style, + ) { + Display(state = state.clone()) {} + ModeToggle(state = state.clone()) {} + Keypad(state = state.clone()) {} + } + } + } +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::rc::Rc; + + use super::*; + + #[component] + fn StateProbe(slot: Rc>>) -> impl IntoView { + let state = CalcState::new(); + *slot.borrow_mut() = Some(state); + block().children(()) + } + + #[test] + fn successful_evaluation_replaces_expression_and_preserves_history() { + let slot = Rc::new(RefCell::new(None::)); + let _ = ruin_app::__render_mountable_for_test(&view! { + StateProbe(slot = Rc::clone(&slot)) {} + }); + + let state = slot.borrow().clone().expect("state should be captured"); + let _ = state.expression.set("2+2".to_string()); + state.evaluate(); + + assert_eq!(state.expression.get(), "4"); + assert_eq!(state.result.get(), ""); + assert_eq!(state.is_error.get(), false); + assert_eq!(state.history.get().len(), 1); + assert_eq!(state.history.get()[0].expression, "2+2"); + assert_eq!(state.history.get()[0].result, "4"); + } +} diff --git a/examples/calculator/src/state.rs b/examples/calculator/src/state.rs new file mode 100644 index 0000000..febdd6c --- /dev/null +++ b/examples/calculator/src/state.rs @@ -0,0 +1,198 @@ +use ruin_app::prelude::*; + +type EvalResult = std::result::Result; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum CalcMode { + #[default] + Basic, + Scientific, +} + +#[derive(Clone, Debug)] +pub struct HistoryEntry { + pub expression: String, + pub result: String, +} + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +#[derive(Clone)] +pub struct CalcState { + /// The expression currently shown in the input display. + pub expression: Signal, + /// The result of the last evaluation (or error message). Empty until first `=`. + pub result: Signal, + /// Whether the last evaluation produced an error. + pub is_error: Signal, + /// Past evaluations, newest first. + pub history: Signal>, + pub mode: Signal, + /// When true, the next digit/operator input clears the expression first. + pub fresh: Signal, + /// Current scroll offset for the history scroll box. + pub history_scroll: Signal, +} + +impl CalcState { + pub fn new() -> Self { + Self { + expression: use_signal(|| String::new()), + result: use_signal(|| String::new()), + is_error: use_signal(|| false), + history: use_signal(|| Vec::new()), + mode: use_signal(|| CalcMode::Basic), + fresh: use_signal(|| false), + history_scroll: use_signal(|| 10_000.0_f32), + } + } + + /// Append `text` to the expression. If `fresh` is set (just evaluated), + /// clear the expression first so digits don't append to the old result. + pub fn input(&self, text: &str) { + if self.fresh.get() { + // Starting fresh after a result: if it's an operator continue + // from the result, otherwise start a new expression. + let is_operator = matches!(text, "+" | "-" | "×" | "÷" | "^" | "%"); + if !is_operator { + let _ = self.expression.set(String::new()); + } + let _ = self.fresh.set(false); + } + self.expression.update(|e| e.push_str(text)); + } + + /// Delete the last character (or close-paren group) from the expression. + pub fn backspace(&self) { + if self.fresh.get() { + let _ = self.fresh.set(false); + let _ = self.expression.set(String::new()); + return; + } + self.expression.update(|e| { + // Pop a full UTF-8 character + let new_len = e + .char_indices() + .next_back() + .map(|(i, _)| i) + .unwrap_or(0); + e.truncate(new_len); + }); + } + + /// Clear the expression, result, and fresh flag. + pub fn clear(&self) { + let _ = self.expression.set(String::new()); + let _ = self.result.set(String::new()); + let _ = self.is_error.set(false); + let _ = self.fresh.set(false); + } + + /// Evaluate the current expression, update result, push to history. + pub fn evaluate(&self) { + let expr = self.expression.with(|e| e.clone()); + if expr.is_empty() { + return; + } + match evaluate(&expr) { + Ok(value) => { + let result_str = format_result(value); + let _ = self.expression.set(result_str.clone()); + let _ = self.result.set(String::new()); + let _ = self.is_error.set(false); + self.history.update(|h| { + h.insert( + 0, + HistoryEntry { + expression: expr.clone(), + result: result_str, + }, + ); + h.truncate(50); + }); + } + Err(msg) => { + let _ = self.result.set(msg); + let _ = self.is_error.set(true); + } + } + let _ = self.fresh.set(true); + let _ = self.history_scroll.set(10_000.0); + } + + /// Set the expression to a previously computed result (e.g., when clicking a history entry). + pub fn use_history_result(&self, result: String) { + let _ = self.expression.set(result); + let _ = self.result.set(String::new()); + let _ = self.is_error.set(false); + let _ = self.fresh.set(false); + } + + pub fn toggle_mode(&self) { + self.mode.update(|m| { + *m = match *m { + CalcMode::Basic => CalcMode::Scientific, + CalcMode::Scientific => CalcMode::Basic, + }; + }); + } + + /// Insert the right parenthesis character based on current balance. + pub fn input_paren(&self) { + let open = self + .expression + .with(|e| e.chars().filter(|&c| c == '(').count()); + let close = self + .expression + .with(|e| e.chars().filter(|&c| c == ')').count()); + self.input(if open > close { ")" } else { "(" }); + } +} + +// --------------------------------------------------------------------------- +// Evaluation +// --------------------------------------------------------------------------- + +/// Evaluate `raw_expr`, preprocessing display symbols to meval-compatible syntax. +pub fn evaluate(raw_expr: &str) -> EvalResult { + let expr = raw_expr + .replace('×', "*") + .replace('÷', "/") + .replace('π', "pi") + // √( is inserted by the scientific button; meval knows sqrt + .replace("√", "sqrt"); + + let mut ctx = meval::Context::new(); + // Add factorial — only integers 0..=20 to avoid overflow + ctx.func("fact", |x: f64| { + if x < 0.0 || x.fract() != 0.0 || x > 20.0 { + return f64::NAN; + } + (1..=(x as u64)).product::() as f64 + }); + + meval::eval_str_with_context(&expr, ctx).map_err(|e| e.to_string()) +} + +/// Format an f64 result: use integer display when possible, otherwise up to 10 sig figs. +fn format_result(value: f64) -> String { + if value.is_nan() { + return "Error".to_string(); + } + if value.is_infinite() { + return if value > 0.0 { "∞".to_string() } else { "-∞".to_string() }; + } + if value.fract() == 0.0 && value.abs() < 1e15 { + return format!("{}", value as i64); + } + // Up to 10 significant figures, strip trailing zeros + let s = format!("{:.10}", value); + let s = s.trim_end_matches('0').trim_end_matches('.'); + s.to_string() +} diff --git a/lib/reactivity/Cargo.toml b/lib/reactivity/Cargo.toml index 0006d50..3f40f39 100644 --- a/lib/reactivity/Cargo.toml +++ b/lib/reactivity/Cargo.toml @@ -5,7 +5,12 @@ edition = "2024" [dependencies] ruin_runtime = { package = "ruin-runtime", path = "../runtime" } -tracing = { version = "0.1", default-features = false, features = ["std"] } +tracing = { version = "0.1", default-features = false } +hashbrown = "0.17" [dev-dependencies] -tracing-subscriber = { version = "0.3", default-features = false, features = ["env-filter", "fmt", "std"] } +tracing-subscriber = { version = "0.3", default-features = false, features = [ + "env-filter", + "fmt", + "std", +] } diff --git a/lib/reactivity/src/cell.rs b/lib/reactivity/src/cell.rs index 412acb8..f7b8a9e 100644 --- a/lib/reactivity/src/cell.rs +++ b/lib/reactivity/src/cell.rs @@ -1,5 +1,5 @@ -use std::cell::RefCell; -use std::rc::Rc; +use alloc::rc::Rc; +use core::cell::RefCell; use crate::{NodeId, Reactor, current, trace_targets}; @@ -114,7 +114,7 @@ impl Cell { return None; } - let previous = std::mem::replace(&mut *current, value); + let previous = core::mem::replace(&mut *current, value); drop(current); tracing::debug!( target: trace_targets::CELL, diff --git a/lib/reactivity/src/effect.rs b/lib/reactivity/src/effect.rs index 2e2d23a..2e9c6ee 100644 --- a/lib/reactivity/src/effect.rs +++ b/lib/reactivity/src/effect.rs @@ -1,5 +1,6 @@ -use std::cell::{Cell, RefCell}; -use std::rc::{Rc, Weak}; +use alloc::boxed::Box; +use alloc::rc::{Rc, Weak}; +use core::cell::{Cell, RefCell}; use crate::reactor::ObserverHook; use crate::{NodeId, Reactor, current, trace_targets}; @@ -64,7 +65,7 @@ impl EffectHandle { /// 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); + core::mem::forget(self); } /// Disposes the effect immediately. diff --git a/lib/reactivity/src/event.rs b/lib/reactivity/src/event.rs index ff5e941..4490f74 100644 --- a/lib/reactivity/src/event.rs +++ b/lib/reactivity/src/event.rs @@ -1,6 +1,9 @@ -use std::cell::{Cell, RefCell}; -use std::collections::HashMap; -use std::rc::Rc; +use alloc::boxed::Box; +use alloc::rc::Rc; +use alloc::vec::Vec; +use core::cell::{Cell, RefCell}; + +use hashbrown::HashMap; use crate::{NodeId, Reactor, current, trace_targets}; diff --git a/lib/reactivity/src/lib.rs b/lib/reactivity/src/lib.rs index 4343c60..10a54db 100644 --- a/lib/reactivity/src/lib.rs +++ b/lib/reactivity/src/lib.rs @@ -4,6 +4,11 @@ //! single-threaded and designed to live on a runtime-managed thread, while async work feeds it //! from the edges by updating state or emitting events. +#![feature(thread_local)] +#![cfg_attr(not(test), no_std)] + +extern crate alloc; + pub(crate) mod trace_targets { pub const GRAPH: &str = "ruin_reactivity::graph"; pub const CELL: &str = "ruin_reactivity::cell"; @@ -18,6 +23,7 @@ mod effect; mod event; mod id; mod reactor; +mod source; mod thunk; pub use cell::{Cell, cell, cell_in}; @@ -25,4 +31,5 @@ pub use effect::{EffectHandle, effect, effect_in}; pub use event::{Event, Subscription, event, event_in, on, on_in}; pub use id::NodeId; pub use reactor::{ReactCycleError, Reactor, current}; +pub use source::{Source, source, source_in}; pub use thunk::{Memo, Thunk, memo, memo_by, memo_by_in, memo_in, thunk, thunk_in}; diff --git a/lib/reactivity/src/reactor.rs b/lib/reactivity/src/reactor.rs index 3b29941..5933dbd 100644 --- a/lib/reactivity/src/reactor.rs +++ b/lib/reactivity/src/reactor.rs @@ -1,9 +1,12 @@ -use std::cell::{Cell, RefCell}; -use std::collections::{BTreeMap, BTreeSet, VecDeque}; -use std::error::Error; -use std::fmt; -use std::panic::panic_any; -use std::rc::{Rc, Weak}; +use alloc::boxed::Box; +use alloc::collections::VecDeque; +use alloc::rc::{Rc, Weak}; +use alloc::vec::Vec; +use core::cell::{Cell, RefCell}; +use core::error::Error; +use core::fmt; + +use hashbrown::{HashMap, HashSet}; use ruin_runtime::queue_microtask; @@ -11,9 +14,8 @@ use crate::{NodeId, trace_targets}; type Job = Box; -thread_local! { - static CURRENT_REACTOR: RefCell> = const { RefCell::new(Weak::new()) }; -} +#[thread_local] +static CURRENT_REACTOR: RefCell> = const { RefCell::new(Weak::new()) }; /// Returns the current thread's default reactor. /// @@ -28,7 +30,7 @@ pub(crate) trait ObserverHook { fn notify(&self); } -/// Panic payload emitted when the reactor detects a dependency cycle. +/// Error type for cycles detected in the reactive graph. Contains the path of nodes that form the cycle. #[derive(Clone, Debug, Eq, PartialEq)] pub struct ReactCycleError { cycle: Vec, @@ -85,26 +87,24 @@ impl Reactor { /// Returns the current thread's default reactor. pub fn current() -> Self { - CURRENT_REACTOR.with(|slot| { - if let Some(inner) = slot.borrow().upgrade() { - #[cfg(debug_assertions)] - tracing::trace!( - target: trace_targets::GRAPH, - event = "current_reactor_reuse", - "reusing current thread default reactor" - ); - return Self { inner }; - } - - let reactor = Self::new(); - *slot.borrow_mut() = Rc::downgrade(&reactor.inner); - tracing::debug!( + if let Some(inner) = CURRENT_REACTOR.borrow().upgrade() { + #[cfg(debug_assertions)] + tracing::trace!( target: trace_targets::GRAPH, - event = "current_reactor_install", - "installed current thread default reactor" + event = "current_reactor_reuse", + "reusing current thread default reactor" ); - reactor - }) + return Self { inner }; + } + + let reactor = Self::new(); + *CURRENT_REACTOR.borrow_mut() = Rc::downgrade(&reactor.inner); + tracing::debug!( + target: trace_targets::GRAPH, + event = "current_reactor_install", + "installed current thread default reactor" + ); + reactor } /// Runs `f` in the dependency-tracking scope of `observer`. @@ -143,13 +143,9 @@ impl Reactor { f() } - /// Records that the currently executing observer depends on `observable`. - /// - /// # Panics - /// - /// Panics with [`ReactCycleError`] if `observable` is already being computed in the current - /// dependency stack. - pub fn observe(&self, observable: NodeId) { + /// Attempts to record a dependency on `observable` for the currently running observer, returning an + /// error if doing so would create a dependency cycle. + pub fn try_observe(&self, observable: NodeId) -> Result<(), ReactCycleError> { if self .inner .active_computations @@ -170,12 +166,12 @@ impl Reactor { cycle_len = cycle.len(), "reactive cycle detected" ); - panic_any(ReactCycleError::new(cycle)); + return Err(ReactCycleError::new(cycle)); } let current = self.inner.stack.borrow().last().copied(); let Some(observer) = current else { - return; + return Ok(()); }; #[cfg(debug_assertions)] @@ -199,6 +195,18 @@ impl Reactor { .entry(observable) .or_default() .insert(observer); + Ok(()) + } + + /// Records a dependency on `observable` for the currently running observer. + /// + /// # Panics + /// + /// Panics if recording the dependency would create a cycle in the reactive graph. + pub fn observe(&self, observable: NodeId) { + if let Err(e) = self.try_observe(observable) { + panic!("reactive cycle detected: {e}"); + } } /// Notifies dependents of `observable`. @@ -344,11 +352,11 @@ impl fmt::Debug for Reactor { struct ReactorInner { next_node: Cell, - dependencies: RefCell>>, - dependents: RefCell>>, - observers: RefCell>>, + dependencies: RefCell>>, + dependents: RefCell>>, + observers: RefCell>>, stack: RefCell>, - active_computations: RefCell>, + active_computations: RefCell>, pending_jobs: RefCell>, flush_scheduled: Cell, } @@ -357,11 +365,11 @@ impl ReactorInner { fn new() -> Self { Self { next_node: Cell::new(1), - dependencies: RefCell::new(BTreeMap::new()), - dependents: RefCell::new(BTreeMap::new()), - observers: RefCell::new(BTreeMap::new()), + dependencies: RefCell::new(HashMap::new()), + dependents: RefCell::new(HashMap::new()), + observers: RefCell::new(HashMap::new()), stack: RefCell::new(Vec::new()), - active_computations: RefCell::new(BTreeSet::new()), + active_computations: RefCell::new(HashSet::new()), pending_jobs: RefCell::new(VecDeque::new()), flush_scheduled: Cell::new(false), } @@ -421,7 +429,7 @@ mod tests { use ruin_runtime::{queue_task, run}; - use super::{ReactCycleError, Reactor, current}; + use super::{Reactor, current}; #[test] fn current_reactor_is_thread_local_singleton() { @@ -436,7 +444,11 @@ mod tests { let observer = reactor.allocate_node(); let observable = reactor.allocate_node(); - reactor.run_in_context(observer, || reactor.observe(observable)); + reactor.run_in_context(observer, || { + reactor + .try_observe(observable) + .expect("should not detect cycle") + }); assert_eq!( reactor.inner.dependencies.borrow().get(&observer), @@ -449,7 +461,7 @@ mod tests { } #[test] - fn cycle_detection_panics_with_structured_error() { + fn cycle_detection_panics_with_expected() { let reactor = Reactor::new(); let a = reactor.allocate_node(); let b = reactor.allocate_node(); @@ -457,15 +469,26 @@ mod tests { let panic = catch_unwind(AssertUnwindSafe(|| { reactor.run_in_context(a, || { reactor.observe(b); - reactor.run_in_context(b, || reactor.observe(a)); + reactor.run_in_context(b, || { + reactor.observe(a); + }); }); })) .expect_err("cycle should panic"); - let error = panic - .downcast::() - .expect("panic payload should be ReactCycleError"); - assert_eq!(error.cycle(), &[a, b, a]); + let Some(cycle_error) = panic.downcast_ref::() else { + panic!("panic should be a string"); + }; + + assert!( + cycle_error.contains("reactive cycle detected"), + "panic should indicate cycle detected" + ); + + assert!( + cycle_error.contains("1 -> 2 -> 1"), + "panic should include cycle path" + ); } #[test] diff --git a/lib/reactivity/src/source.rs b/lib/reactivity/src/source.rs new file mode 100644 index 0000000..2733baa --- /dev/null +++ b/lib/reactivity/src/source.rs @@ -0,0 +1,70 @@ +use alloc::rc::Rc; + +use crate::{NodeId, Reactor, current, trace_targets}; + +/// Creates a low-level reactive source node in the current reactor. +pub fn source() -> Source { + current().source() +} + +/// Creates a low-level reactive source node associated with `reactor`. +pub fn source_in(reactor: &Reactor) -> Source { + reactor.source() +} + +/// Low-level observable source node. +/// +/// `Source` is useful for advanced data structures that want precise control over when reads +/// observe and writes trigger invalidation without storing their state in a [`crate::Cell`]. +#[derive(Clone)] +pub struct Source { + inner: Rc, +} + +impl Reactor { + /// Creates a low-level source node associated with this reactor. + pub fn source(&self) -> Source { + Source::new(self.clone()) + } +} + +impl Source { + fn new(reactor: Reactor) -> Self { + let id = reactor.allocate_node(); + tracing::debug!( + target: trace_targets::GRAPH, + event = "create_source", + node_id = id.0, + "created low-level reactive source" + ); + Self { + inner: Rc::new(SourceInner { reactor, id }), + } + } + + /// Records a dependency on this source for the currently running observer. + pub fn observe(&self) { + self.inner.reactor.observe(self.inner.id); + } + + /// Triggers this source's dependents. + pub fn trigger(&self) { + self.inner.reactor.trigger(self.inner.id); + } + + /// Returns the source node id. + pub fn id(&self) -> NodeId { + self.inner.id + } +} + +struct SourceInner { + reactor: Reactor, + id: NodeId, +} + +impl Drop for SourceInner { + fn drop(&mut self) { + self.reactor.dispose(self.id); + } +} diff --git a/lib/reactivity/src/thunk.rs b/lib/reactivity/src/thunk.rs index 43f9be5..e18d66f 100644 --- a/lib/reactivity/src/thunk.rs +++ b/lib/reactivity/src/thunk.rs @@ -1,5 +1,6 @@ -use std::cell::{Cell, RefCell}; -use std::rc::Rc; +use alloc::boxed::Box; +use alloc::rc::Rc; +use core::cell::{Cell, RefCell}; use crate::reactor::ObserverHook; use crate::{NodeId, Reactor, current, trace_targets}; @@ -428,9 +429,13 @@ mod tests { .expect_err("mutually recursive thunks should panic"); let error = panic - .downcast::() - .expect("panic payload should be ReactCycleError"); - assert_eq!(error.cycle().len(), 3); + .downcast_ref::() + .expect("panic should be a string"); + + assert!( + error.contains("reactive cycle detected"), + "panic should indicate cycle detected" + ); } #[test] diff --git a/lib/reactivity_parsing/Cargo.toml b/lib/reactivity_parsing/Cargo.toml new file mode 100644 index 0000000..e33d721 --- /dev/null +++ b/lib/reactivity_parsing/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "ruin-reactivity-parsing" +version = "0.1.0" +edition = "2024" + +[lib] +name = "ruin_reactivity_parsing" + +[dependencies] +ruin_reactivity = { path = "../reactivity" } + +[dev-dependencies] +ruin_runtime = { package = "ruin-runtime", path = "../runtime" } diff --git a/lib/reactivity_parsing/examples/json_reactive_parser.rs b/lib/reactivity_parsing/examples/json_reactive_parser.rs new file mode 100644 index 0000000..98baaab --- /dev/null +++ b/lib/reactivity_parsing/examples/json_reactive_parser.rs @@ -0,0 +1,745 @@ +use std::env; +use std::fmt; +use std::rc::Rc; + +use ruin_reactivity::{cell_in, effect_in}; +use ruin_reactivity_parsing::{ + Anchor, AnchorBias, EditDelta, ReactiveRope, RopeEdit, TextPoint, TextRange, +}; +use ruin_runtime::{async_main, fs, stdio}; + +#[derive(Clone, Debug)] +struct ParseSnapshot { + text: String, + root_anchor: Anchor, + root: AstNode, + reused_nodes: usize, + total_nodes: usize, + diagnostics: Vec, +} + +#[derive(Clone, Debug)] +struct AstNode { + id: u64, + kind: JsonKind, + local_range: TextRange, + children: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum JsonKind { + Object, + Array, + Pair, + String, + Number, + True, + False, + Null, +} + +impl fmt::Display for JsonKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let label = match self { + Self::Object => "object", + Self::Array => "array", + Self::Pair => "pair", + Self::String => "string", + Self::Number => "number", + Self::True => "true", + Self::False => "false", + Self::Null => "null", + }; + write!(f, "{label}") + } +} + +#[derive(Clone, Debug)] +struct ParsedNode { + kind: JsonKind, + range: TextRange, + children: Vec, +} + +#[derive(Default)] +struct JsonParser { + next_id: u64, +} + +struct Cursor<'a> { + text: &'a str, + offset: usize, +} + +#[derive(Clone)] +enum Command { + Insert { offset: usize, text: String }, + Replace { start: usize, end: usize, text: String }, + ReplaceAll { text: String }, + Delete { start: usize, end: usize }, + Print, + Help, + Quit, +} + +#[async_main] +async fn main() { + let path = env::args() + .nth(1) + .expect("usage: cargo run -p ruin-reactivity-parsing --example json_reactive_parser -- "); + let initial_text = fs::read_to_string(&path) + .await + .expect("json file should be readable"); + + let reactor = ruin_reactivity::Reactor::new(); + let rope = ReactiveRope::from_text(&reactor, &initial_text); + let parser = Rc::new(std::cell::RefCell::new(JsonParser::default())); + + let initial_snapshot = parser + .borrow_mut() + .parse_initial(&rope) + .expect("initial JSON should parse"); + let parse_state = cell_in(&reactor, initial_snapshot); + let last_delta = cell_in(&reactor, None::); + + let _parser_subscription = { + let rope = rope.clone(); + let parser = Rc::clone(&parser); + let parse_state = parse_state.clone(); + let last_delta = last_delta.clone(); + rope.edit_events().subscribe(move |delta| { + let previous = parse_state.get(); + match parser.borrow_mut().parse_incremental(&rope, &previous, delta) { + Ok(next) => { + last_delta.replace(Some(delta.clone())); + parse_state.replace(next); + } + Err(error) => { + eprintln!("parse error after edit: {error}"); + eprintln!( + "note: current text length is {} bytes; use `replace_all ` to replace the full document without manual offsets", + rope.len_bytes() + ); + } + } + }); + }; + + effect_in(&reactor, { + let rope = rope.clone(); + let parse_state = parse_state.clone(); + let last_delta = last_delta.clone(); + move || { + parse_state.with(|snapshot| print_snapshot(snapshot, &rope, last_delta.with(Clone::clone).as_ref())); + } + }) + .leak(); + + println!("Commands:"); + println!(" insert "); + println!(" replace "); + println!(" replace_all "); + println!(" delete "); + println!(" print"); + println!(" help"); + println!(" quit"); + + let mut input = stdio::stdin().expect("stdin should be available"); + while let Some(line) = input.read_line().await.expect("stdin read should succeed") { + let trimmed = line.trim_end(); + if trimmed.is_empty() { + continue; + } + + match parse_command(trimmed) { + Ok(Command::Insert { offset, text }) => { + let _ = rope.edit(RopeEdit::insert(offset, text)); + } + Ok(Command::Replace { start, end, text }) => { + let _ = rope.edit(RopeEdit::replace(TextRange::new(start, end), text)); + } + Ok(Command::ReplaceAll { text }) => { + let _ = rope.edit(RopeEdit::replace(TextRange::new(0, rope.len_bytes()), text)); + } + Ok(Command::Delete { start, end }) => { + let _ = rope.edit(RopeEdit::delete(TextRange::new(start, end))); + } + Ok(Command::Print) => { + let snapshot = parse_state.get(); + print_snapshot(&snapshot, &rope, None); + } + Ok(Command::Help) => { + println!("Commands:"); + println!(" insert "); + println!(" replace "); + println!(" replace_all "); + println!(" delete "); + println!(" print"); + println!(" help"); + println!(" quit"); + } + Ok(Command::Quit) => break, + Err(error) => eprintln!("{error}"), + } + } +} + +fn parse_command(input: &str) -> Result { + if input == "quit" { + return Ok(Command::Quit); + } + if input == "print" { + return Ok(Command::Print); + } + if input == "help" { + return Ok(Command::Help); + } + if let Some(rest) = input.strip_prefix("insert ") { + let (offset, text) = rest + .split_once(' ') + .ok_or_else(|| "insert expects: insert ".to_string())?; + return Ok(Command::Insert { + offset: offset.parse().map_err(|_| "invalid insert offset".to_string())?, + text: text.to_string(), + }); + } + if let Some(rest) = input.strip_prefix("replace ") { + let mut parts = rest.splitn(3, ' '); + let start = parts + .next() + .ok_or_else(|| "replace expects: replace ".to_string())?; + let end = parts + .next() + .ok_or_else(|| "replace expects: replace ".to_string())?; + let text = parts + .next() + .ok_or_else(|| "replace expects: replace ".to_string())?; + return Ok(Command::Replace { + start: start.parse().map_err(|_| "invalid replace start".to_string())?, + end: end.parse().map_err(|_| "invalid replace end".to_string())?, + text: text.to_string(), + }); + } + if let Some(text) = input.strip_prefix("replace_all ") { + return Ok(Command::ReplaceAll { + text: text.to_string(), + }); + } + if let Some(rest) = input.strip_prefix("delete ") { + let (start, end) = rest + .split_once(' ') + .ok_or_else(|| "delete expects: delete ".to_string())?; + return Ok(Command::Delete { + start: start.parse().map_err(|_| "invalid delete start".to_string())?, + end: end.parse().map_err(|_| "invalid delete end".to_string())?, + }); + } + Err("unknown command; type `help`".to_string()) +} + +impl JsonParser { + fn parse_initial(&mut self, rope: &ReactiveRope) -> Result { + let text = read_rope_text(rope); + let parsed = parse_json(&text)?; + let root = self.materialize_fresh(&parsed, 0); + Ok(ParseSnapshot { + text, + root_anchor: Anchor::new(0, AnchorBias::Left), + total_nodes: count_nodes(&root), + reused_nodes: 0, + diagnostics: Vec::new(), + root, + }) + } + + fn parse_incremental( + &mut self, + rope: &ReactiveRope, + previous: &ParseSnapshot, + delta: &EditDelta, + ) -> Result { + let text = read_rope_text(rope); + let parsed = parse_json(&text)?; + let mut root_anchor = previous.root_anchor; + root_anchor.map_through(delta); + let mut reused = 0usize; + let root = self.reuse_node( + &parsed, + Some(&previous.root), + previous.root_anchor.byte, + root_anchor.byte, + &previous.text, + &text, + delta, + &mut reused, + ); + Ok(ParseSnapshot { + text, + root_anchor, + total_nodes: count_nodes(&root), + reused_nodes: reused, + diagnostics: Vec::new(), + root, + }) + } + + fn materialize_fresh(&mut self, parsed: &ParsedNode, parent_start: usize) -> AstNode { + AstNode { + id: self.alloc_id(), + kind: parsed.kind, + local_range: localize(parsed.range, parent_start), + children: parsed + .children + .iter() + .map(|child| self.materialize_fresh(child, parsed.range.start)) + .collect(), + } + } + + fn reuse_node( + &mut self, + parsed: &ParsedNode, + old: Option<&AstNode>, + old_parent_start: usize, + new_parent_start: usize, + old_text: &str, + new_text: &str, + delta: &EditDelta, + reused: &mut usize, + ) -> AstNode { + let parsed_local = localize(parsed.range, new_parent_start); + if let Some(old) = old + && old.kind == parsed.kind + && map_range(absolute_range(old, old_parent_start), delta) == parsed.range + && old_text + .get(absolute_range(old, old_parent_start).start..absolute_range(old, old_parent_start).end) + == new_text.get(parsed.range.start..parsed.range.end) + { + *reused += count_nodes(old); + return AstNode { + id: old.id, + kind: old.kind, + local_range: parsed_local, + children: old.children.clone(), + }; + } + + let children = parsed + .children + .iter() + .enumerate() + .map(|(index, child)| { + let previous_child = old.and_then(|node| node.children.get(index)); + let next_old_parent_start = old + .map(|node| absolute_range(node, old_parent_start).start) + .unwrap_or(old_parent_start); + self.reuse_node( + child, + previous_child, + next_old_parent_start, + parsed.range.start, + old_text, + new_text, + delta, + reused, + ) + }) + .collect(); + + AstNode { + id: self.alloc_id(), + kind: parsed.kind, + local_range: parsed_local, + children, + } + } + + fn alloc_id(&mut self) -> u64 { + self.next_id = self.next_id.wrapping_add(1); + self.next_id + } +} + +fn read_rope_text(rope: &ReactiveRope) -> String { + let mut cursor = rope.cursor(0); + let mut text = String::new(); + while let Some(chunk) = cursor.next_chunk() { + text.push_str(chunk.text()); + } + text +} + +fn localize(range: TextRange, parent_start: usize) -> TextRange { + TextRange::new( + range.start.saturating_sub(parent_start), + range.end.saturating_sub(parent_start), + ) +} + +fn absolute_range(node: &AstNode, parent_start: usize) -> TextRange { + TextRange::new( + parent_start + node.local_range.start, + parent_start + node.local_range.end, + ) +} + +fn map_range(range: TextRange, delta: &EditDelta) -> TextRange { + if range.end <= delta.range.start { + return range; + } + if range.start >= delta.range.end { + let shift = delta.net_byte_delta(); + return TextRange::new( + range.start.saturating_add_signed(shift), + range.end.saturating_add_signed(shift), + ); + } + TextRange::new(usize::MAX, usize::MAX) +} + +fn parse_json(text: &str) -> Result { + let mut cursor = Cursor { text, offset: 0 }; + cursor.skip_ws(); + let node = cursor.parse_value()?; + cursor.skip_ws(); + if !cursor.is_eof() { + return Err(format!("unexpected trailing data at byte {}", cursor.offset)); + } + Ok(node) +} + +impl<'a> Cursor<'a> { + fn is_eof(&self) -> bool { + self.offset >= self.text.len() + } + + fn skip_ws(&mut self) { + while let Some(ch) = self.peek_char() { + if ch.is_ascii_whitespace() { + self.offset += ch.len_utf8(); + } else { + break; + } + } + } + + fn peek_char(&self) -> Option { + self.text[self.offset..].chars().next() + } + + fn bump_char(&mut self) -> Option { + let ch = self.peek_char()?; + self.offset += ch.len_utf8(); + Some(ch) + } + + fn expect_char(&mut self, expected: char) -> Result<(), String> { + match self.bump_char() { + Some(ch) if ch == expected => Ok(()), + Some(ch) => Err(format!( + "expected `{expected}` at byte {}, found `{ch}`", + self.offset.saturating_sub(ch.len_utf8()) + )), + None => Err(format!("expected `{expected}` at end of file")), + } + } + + fn parse_value(&mut self) -> Result { + self.skip_ws(); + match self.peek_char() { + Some('{') => self.parse_object(), + Some('[') => self.parse_array(), + Some('"') => self.parse_string(), + Some('t') => self.parse_literal("true", JsonKind::True), + Some('f') => self.parse_literal("false", JsonKind::False), + Some('n') => self.parse_literal("null", JsonKind::Null), + Some('-' | '0'..='9') => self.parse_number(), + Some(other) => Err(format!("unexpected `{other}` at byte {}", self.offset)), + None => Err("unexpected end of JSON input".to_string()), + } + } + + fn parse_object(&mut self) -> Result { + let start = self.offset; + self.expect_char('{')?; + self.skip_ws(); + let mut children = Vec::new(); + if self.peek_char() == Some('}') { + self.bump_char(); + return Ok(ParsedNode { + kind: JsonKind::Object, + range: TextRange::new(start, self.offset), + children, + }); + } + + loop { + self.skip_ws(); + let key = self.parse_string()?; + self.skip_ws(); + self.expect_char(':')?; + self.skip_ws(); + let value = self.parse_value()?; + let pair = ParsedNode { + kind: JsonKind::Pair, + range: TextRange::new(key.range.start, value.range.end), + children: vec![key, value], + }; + children.push(pair); + self.skip_ws(); + match self.peek_char() { + Some(',') => { + self.bump_char(); + } + Some('}') => { + self.bump_char(); + break; + } + Some(other) => { + return Err(format!("expected `,` or `}}` at byte {}, found `{other}`", self.offset)); + } + None => return Err("unterminated object".to_string()), + } + } + + Ok(ParsedNode { + kind: JsonKind::Object, + range: TextRange::new(start, self.offset), + children, + }) + } + + fn parse_array(&mut self) -> Result { + let start = self.offset; + self.expect_char('[')?; + self.skip_ws(); + let mut children = Vec::new(); + if self.peek_char() == Some(']') { + self.bump_char(); + return Ok(ParsedNode { + kind: JsonKind::Array, + range: TextRange::new(start, self.offset), + children, + }); + } + + loop { + let value = self.parse_value()?; + children.push(value); + self.skip_ws(); + match self.peek_char() { + Some(',') => { + self.bump_char(); + self.skip_ws(); + } + Some(']') => { + self.bump_char(); + break; + } + Some(other) => { + return Err(format!("expected `,` or `]` at byte {}, found `{other}`", self.offset)); + } + None => return Err("unterminated array".to_string()), + } + } + + Ok(ParsedNode { + kind: JsonKind::Array, + range: TextRange::new(start, self.offset), + children, + }) + } + + fn parse_string(&mut self) -> Result { + let start = self.offset; + self.expect_char('"')?; + loop { + match self.bump_char() { + Some('"') => { + return Ok(ParsedNode { + kind: JsonKind::String, + range: TextRange::new(start, self.offset), + children: Vec::new(), + }); + } + Some('\\') => match self.bump_char() { + Some('"') | Some('\\') | Some('/') | Some('b') | Some('f') | Some('n') + | Some('r') | Some('t') => {} + Some('u') => { + for _ in 0..4 { + match self.bump_char() { + Some(ch) if ch.is_ascii_hexdigit() => {} + _ => return Err(format!("invalid unicode escape at byte {}", self.offset)), + } + } + } + _ => return Err(format!("invalid escape sequence at byte {}", self.offset)), + }, + Some(ch) if ch >= '\u{20}' => {} + Some(_) => return Err(format!("control character in string at byte {}", self.offset)), + None => return Err("unterminated string".to_string()), + } + } + } + + fn parse_number(&mut self) -> Result { + let start = self.offset; + if self.peek_char() == Some('-') { + self.bump_char(); + } + + match self.peek_char() { + Some('0') => { + self.bump_char(); + } + Some('1'..='9') => { + self.bump_char(); + while matches!(self.peek_char(), Some('0'..='9')) { + self.bump_char(); + } + } + _ => return Err(format!("invalid number at byte {}", self.offset)), + } + + if self.peek_char() == Some('.') { + self.bump_char(); + let mut digits = 0usize; + while matches!(self.peek_char(), Some('0'..='9')) { + digits += 1; + self.bump_char(); + } + if digits == 0 { + return Err(format!("expected fraction digits at byte {}", self.offset)); + } + } + + if matches!(self.peek_char(), Some('e' | 'E')) { + self.bump_char(); + if matches!(self.peek_char(), Some('+' | '-')) { + self.bump_char(); + } + let mut digits = 0usize; + while matches!(self.peek_char(), Some('0'..='9')) { + digits += 1; + self.bump_char(); + } + if digits == 0 { + return Err(format!("expected exponent digits at byte {}", self.offset)); + } + } + + Ok(ParsedNode { + kind: JsonKind::Number, + range: TextRange::new(start, self.offset), + children: Vec::new(), + }) + } + + fn parse_literal(&mut self, literal: &str, kind: JsonKind) -> Result { + let start = self.offset; + if !self.text[self.offset..].starts_with(literal) { + return Err(format!("expected `{literal}` at byte {}", self.offset)); + } + self.offset += literal.len(); + Ok(ParsedNode { + kind, + range: TextRange::new(start, self.offset), + children: Vec::new(), + }) + } +} + +fn print_snapshot(snapshot: &ParseSnapshot, rope: &ReactiveRope, delta: Option<&EditDelta>) { + let metrics = rope.snapshot_metrics(); + println!(); + println!("Document revision {}", metrics.revision); + println!( + "Text bytes={}, leaves={}, nodes={}, reused_on_last_edit={}", + metrics.text.bytes, metrics.leaves, snapshot.total_nodes, snapshot.reused_nodes + ); + if let Some(delta) = delta { + println!( + "Last edit: [{}..{}) -> {:?}", + delta.range.start, delta.range.end, delta.inserted_text + ); + let focus = delta.range.start.min(snapshot.text.len().saturating_sub(1)); + let path = path_for_offset(&snapshot.root, snapshot.root_anchor.byte, focus); + println!("Changed path:"); + for (node, range) in path { + let start = rope.point_at(range.start); + let end = rope.point_at(range.end); + println!( + " id={} kind={} span={}", + node.id, + node.kind, + format_span(start, end) + ); + } + } + if !snapshot.diagnostics.is_empty() { + println!("Diagnostics:"); + for diagnostic in &snapshot.diagnostics { + println!(" - {diagnostic}"); + } + } + println!("Root id={} kind={}", snapshot.root.id, snapshot.root.kind); + println!("Parse tree:"); + print_tree(&snapshot.root, snapshot.root_anchor.byte, rope, 0); + println!("Current text:"); + println!("{}", snapshot.text); +} + +fn print_tree(node: &AstNode, parent_start: usize, rope: &ReactiveRope, depth: usize) { + let range = absolute_range(node, parent_start); + let start = rope.point_at(range.start); + let end = rope.point_at(range.end); + let indent = " ".repeat(depth); + println!( + "{indent}- id={} kind={} span={}", + node.id, + node.kind, + format_span(start, end) + ); + for child in &node.children { + print_tree(child, range.start, rope, depth + 1); + } +} + +fn path_for_offset(root: &AstNode, root_start: usize, offset: usize) -> Vec<(&AstNode, TextRange)> { + fn walk<'a>( + node: &'a AstNode, + parent_start: usize, + offset: usize, + out: &mut Vec<(&'a AstNode, TextRange)>, + ) -> bool { + let range = absolute_range(node, parent_start); + if offset < range.start || offset >= range.end { + return false; + } + out.push((node, range)); + for child in &node.children { + if walk(child, range.start, offset, out) { + return true; + } + } + true + } + + let mut path = Vec::new(); + let _ = walk(root, root_start, offset, &mut path); + path +} + +fn format_span(start: TextPoint, end: TextPoint) -> String { + format!( + "{}:{}..{}:{}", + start.line + 1, + start.column + 1, + end.line + 1, + end.column + 1 + ) +} + +fn count_nodes(node: &AstNode) -> usize { + 1 + node.children.iter().map(count_nodes).sum::() +} diff --git a/lib/reactivity_parsing/examples/tokentree_reactive_parser.rs b/lib/reactivity_parsing/examples/tokentree_reactive_parser.rs new file mode 100644 index 0000000..07322c6 --- /dev/null +++ b/lib/reactivity_parsing/examples/tokentree_reactive_parser.rs @@ -0,0 +1,207 @@ +use std::env; +use std::rc::Rc; + +use ruin_reactivity::effect_in; +use ruin_reactivity_parsing::{ + ReactiveRope, ReactiveTokenTree, RopeEdit, TextRange, TokenTreeNode, TokenTreeParse, + TokenTreeUpdate, +}; +use ruin_runtime::{async_main, fs, stdio}; + +enum Command { + Insert { offset: usize, text: String }, + Replace { start: usize, end: usize, text: String }, + ReplaceAll { text: String }, + Delete { start: usize, end: usize }, + Print, + Help, + Quit, +} + +#[async_main] +async fn main() { + let path = env::args() + .nth(1) + .expect("usage: cargo run -p ruin-reactivity-parsing --example tokentree_reactive_parser -- "); + let initial_text = fs::read_to_string(&path) + .await + .expect("source file should be readable"); + + let reactor = ruin_reactivity::Reactor::new(); + let rope = ReactiveRope::from_text(&reactor, &initial_text); + let parser = Rc::new(ReactiveTokenTree::new(&rope)); + + effect_in(&reactor, { + let parser = Rc::clone(&parser); + let rope = rope.clone(); + move || { + parser.with_snapshot(|snapshot| { + let last_update = parser.last_update(); + print_snapshot(snapshot, &rope, last_update.as_ref()); + }); + } + }) + .leak(); + + println!("Commands:"); + println!(" insert "); + println!(" replace "); + println!(" replace_all "); + println!(" delete "); + println!(" print"); + println!(" help"); + println!(" quit"); + + let mut input = stdio::stdin().expect("stdin should be available"); + while let Some(line) = input.read_line().await.expect("stdin read should succeed") { + let trimmed = line.trim_end(); + if trimmed.is_empty() { + continue; + } + + match parse_command(trimmed) { + Ok(Command::Insert { offset, text }) => { + let _ = rope.edit(RopeEdit::insert(offset, text)); + } + Ok(Command::Replace { start, end, text }) => { + let _ = rope.edit(RopeEdit::replace(TextRange::new(start, end), text)); + } + Ok(Command::ReplaceAll { text }) => { + let _ = rope.edit(RopeEdit::replace(TextRange::new(0, rope.len_bytes()), text)); + } + Ok(Command::Delete { start, end }) => { + let _ = rope.edit(RopeEdit::delete(TextRange::new(start, end))); + } + Ok(Command::Print) => { + let snapshot = parser.snapshot(); + let last_update = parser.last_update(); + print_snapshot(&snapshot, &rope, last_update.as_ref()); + } + Ok(Command::Help) => { + println!("Commands:"); + println!(" insert "); + println!(" replace "); + println!(" replace_all "); + println!(" delete "); + println!(" print"); + println!(" help"); + println!(" quit"); + } + Ok(Command::Quit) => break, + Err(error) => eprintln!("{error}"), + } + } +} + +fn parse_command(input: &str) -> Result { + if input == "quit" { + return Ok(Command::Quit); + } + if input == "print" { + return Ok(Command::Print); + } + if input == "help" { + return Ok(Command::Help); + } + if let Some(rest) = input.strip_prefix("insert ") { + let (offset, text) = rest + .split_once(' ') + .ok_or_else(|| "insert expects: insert ".to_string())?; + return Ok(Command::Insert { + offset: offset.parse().map_err(|_| "invalid insert offset".to_string())?, + text: text.to_string(), + }); + } + if let Some(rest) = input.strip_prefix("replace ") { + let mut parts = rest.splitn(3, ' '); + let start = parts + .next() + .ok_or_else(|| "replace expects: replace ".to_string())?; + let end = parts + .next() + .ok_or_else(|| "replace expects: replace ".to_string())?; + let text = parts + .next() + .ok_or_else(|| "replace expects: replace ".to_string())?; + return Ok(Command::Replace { + start: start.parse().map_err(|_| "invalid replace start".to_string())?, + end: end.parse().map_err(|_| "invalid replace end".to_string())?, + text: text.to_string(), + }); + } + if let Some(text) = input.strip_prefix("replace_all ") { + return Ok(Command::ReplaceAll { + text: text.to_string(), + }); + } + if let Some(rest) = input.strip_prefix("delete ") { + let (start, end) = rest + .split_once(' ') + .ok_or_else(|| "delete expects: delete ".to_string())?; + return Ok(Command::Delete { + start: start.parse().map_err(|_| "invalid delete start".to_string())?, + end: end.parse().map_err(|_| "invalid delete end".to_string())?, + }); + } + Err("unknown command; type `help`".to_string()) +} + +fn print_snapshot(snapshot: &TokenTreeParse, rope: &ReactiveRope, update: Option<&TokenTreeUpdate>) { + let metrics = rope.snapshot_metrics(); + println!(); + println!("Document revision {}", metrics.revision); + println!( + "Text bytes={}, leaves={}, nodes={}, reused_on_last_edit={}", + metrics.text.bytes, + metrics.leaves, + snapshot.stats.total_nodes, + snapshot.stats.reused_nodes + ); + if let Some(update) = update { + println!( + "Last edit: [{}..{}) -> {:?}", + update.delta.range.start, + update.delta.range.end, + update.delta.inserted_text + ); + println!( + "Diagnostics changed={}, trivia changed={}", + update.diagnostics_changed, + update.trivia_changed + ); + } + println!("Parse tree:"); + print_tree(snapshot, rope, &snapshot.root, 0); + if !snapshot.diagnostics.is_empty() { + println!("Diagnostics:"); + for diagnostic in &snapshot.diagnostics { + println!( + " {:?} [{}..{}): {}", + diagnostic.kind, diagnostic.range.start, diagnostic.range.end, diagnostic.message + ); + } + } + if !snapshot.trivia.comments().is_empty() { + println!("Comments:"); + for comment in snapshot.trivia.comments() { + println!( + " {:?} [{}..{}): {}", + comment.kind, comment.range.start, comment.range.end, comment.text + ); + } + } +} + +fn print_tree(snapshot: &TokenTreeParse, rope: &ReactiveRope, node: &TokenTreeNode, depth: usize) { + let indent = " ".repeat(depth); + let (start, end) = snapshot + .absolute_span(rope, node.id) + .expect("node span should be available"); + println!( + "{}- id={} kind={} span=({}:{})..({}:{})", + indent, node.id, node.kind, start.line, start.column, end.line, end.column + ); + for child in &node.children { + print_tree(snapshot, rope, child, depth + 1); + } +} diff --git a/lib/reactivity_parsing/src/delta.rs b/lib/reactivity_parsing/src/delta.rs new file mode 100644 index 0000000..5e201c9 --- /dev/null +++ b/lib/reactivity_parsing/src/delta.rs @@ -0,0 +1,152 @@ +use crate::metrics::{TextMetrics, TextPoint}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TextRange { + pub start: usize, + pub end: usize, +} + +impl TextRange { + pub const fn new(start: usize, end: usize) -> Self { + Self { start, end } + } + + pub const fn len(self) -> usize { + self.end - self.start + } + + pub const fn is_empty(self) -> bool { + self.start == self.end + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RopeEdit { + pub range: TextRange, + pub insert: String, +} + +impl RopeEdit { + pub fn insert(offset: usize, text: impl Into) -> Self { + Self { + range: TextRange::new(offset, offset), + insert: text.into(), + } + } + + pub fn delete(range: TextRange) -> Self { + Self { + range, + insert: String::new(), + } + } + + pub fn replace(range: TextRange, text: impl Into) -> Self { + Self { + range, + insert: text.into(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EditDelta { + pub range: TextRange, + pub removed_text: String, + pub inserted_text: String, + pub removed: TextMetrics, + pub inserted: TextMetrics, + pub start: TextPoint, + pub old_end: TextPoint, + pub new_end: TextPoint, +} + +impl EditDelta { + pub fn net_byte_delta(&self) -> isize { + self.inserted.bytes as isize - self.removed.bytes as isize + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AnchorBias { + Left, + Right, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Anchor { + pub byte: usize, + pub bias: AnchorBias, +} + +impl Anchor { + pub const fn new(byte: usize, bias: AnchorBias) -> Self { + Self { byte, bias } + } + + pub fn map_through(&mut self, delta: &EditDelta) { + if self.byte < delta.range.start { + return; + } + + if self.byte > delta.range.end { + self.byte = self.byte.saturating_add_signed(delta.net_byte_delta()); + return; + } + + self.byte = match self.bias { + AnchorBias::Left => delta.range.start, + AnchorBias::Right => delta.range.start + delta.inserted.bytes, + }; + } +} + +#[cfg(test)] +mod tests { + use super::{Anchor, AnchorBias, EditDelta, RopeEdit, TextRange}; + use crate::{TextMetrics, TextPoint}; + + #[test] + fn constructors_build_expected_edits() { + assert_eq!(RopeEdit::insert(2, "x").range, TextRange::new(2, 2)); + assert_eq!(RopeEdit::delete(TextRange::new(2, 4)).insert, ""); + assert_eq!(RopeEdit::replace(TextRange::new(1, 3), "zz").insert, "zz"); + } + + #[test] + fn text_range_helpers_report_size() { + let range = TextRange::new(3, 7); + assert_eq!(range.len(), 4); + assert!(!range.is_empty()); + assert!(TextRange::new(5, 5).is_empty()); + } + + #[test] + fn anchors_map_before_inside_and_after_edits() { + let delta = EditDelta { + range: TextRange::new(4, 7), + removed_text: "abc".into(), + inserted_text: "x".into(), + removed: TextMetrics::from_text("abc"), + inserted: TextMetrics::from_text("x"), + start: TextPoint::origin().advance("1234"), + old_end: TextPoint::origin().advance("1234abc"), + new_end: TextPoint::origin().advance("1234x"), + }; + + let mut before = Anchor::new(2, AnchorBias::Left); + let mut inside_left = Anchor::new(5, AnchorBias::Left); + let mut inside_right = Anchor::new(5, AnchorBias::Right); + let mut after = Anchor::new(10, AnchorBias::Left); + + before.map_through(&delta); + inside_left.map_through(&delta); + inside_right.map_through(&delta); + after.map_through(&delta); + + assert_eq!(before.byte, 2); + assert_eq!(inside_left.byte, 4); + assert_eq!(inside_right.byte, 5); + assert_eq!(after.byte, 8); + } +} diff --git a/lib/reactivity_parsing/src/lib.rs b/lib/reactivity_parsing/src/lib.rs new file mode 100644 index 0000000..1bcfe9a --- /dev/null +++ b/lib/reactivity_parsing/src/lib.rs @@ -0,0 +1,24 @@ +//! Reactive rope text storage and incremental parsing substrate for RUIN. + +mod delta; +mod metrics; +mod parse; +mod rope; +mod tokentree; + +pub use delta::{Anchor, AnchorBias, EditDelta, RopeEdit, TextRange}; +pub use metrics::{TextMetrics, TextPoint}; +pub use parse::{ + IncrementalParser, ParseError, ParseInvalidation, ParseResult, ParserCheckpoint, ParseTree, + SyntaxFragment, +}; +pub use rope::{ + DEFAULT_LEAF_TARGET_BYTES, ReactiveRope, RopeBuilder, RopeChunk, RopeCursor, + RopeSnapshotMetrics, +}; +pub use tokentree::{ + DelimiterKind, NumberBase, NumberLiteral, NumberSuffix, StringDelimiter, StringFragment, + ReactiveTokenTree, TokenTreeDiagnostic, TokenTreeDiagnosticKind, TokenTreeNode, + TokenTreeNodeKind, TokenTreeParse, TokenTreeParser, TokenTreeStats, TokenTreeUpdate, + TriviaComment, TriviaKind, TriviaStore, +}; diff --git a/lib/reactivity_parsing/src/metrics.rs b/lib/reactivity_parsing/src/metrics.rs new file mode 100644 index 0000000..89bc253 --- /dev/null +++ b/lib/reactivity_parsing/src/metrics.rs @@ -0,0 +1,139 @@ +use core::fmt; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct TextMetrics { + pub bytes: usize, + pub chars: usize, + pub utf16: usize, + pub line_breaks: usize, +} + +impl TextMetrics { + pub fn from_text(text: &str) -> Self { + let mut metrics = Self::default(); + for ch in text.chars() { + metrics.bytes += ch.len_utf8(); + metrics.chars += 1; + metrics.utf16 += ch.len_utf16(); + metrics.line_breaks += usize::from(ch == '\n'); + } + metrics + } +} + +impl core::ops::Add for TextMetrics { + type Output = Self; + + fn add(self, rhs: Self) -> Self::Output { + Self { + bytes: self.bytes + rhs.bytes, + chars: self.chars + rhs.chars, + utf16: self.utf16 + rhs.utf16, + line_breaks: self.line_breaks + rhs.line_breaks, + } + } +} + +impl core::ops::AddAssign for TextMetrics { + fn add_assign(&mut self, rhs: Self) { + *self = *self + rhs; + } +} + +impl core::ops::Sub for TextMetrics { + type Output = Self; + + fn sub(self, rhs: Self) -> Self::Output { + Self { + bytes: self.bytes - rhs.bytes, + chars: self.chars - rhs.chars, + utf16: self.utf16 - rhs.utf16, + line_breaks: self.line_breaks - rhs.line_breaks, + } + } +} + +#[derive(Clone, Copy, Default, Eq, PartialEq)] +pub struct TextPoint { + pub byte: usize, + pub char_offset: usize, + pub utf16_offset: usize, + pub line: usize, + pub column: usize, +} + +impl TextPoint { + pub const fn origin() -> Self { + Self { + byte: 0, + char_offset: 0, + utf16_offset: 0, + line: 0, + column: 0, + } + } + + pub fn advance(mut self, text: &str) -> Self { + self.byte += text.len(); + for ch in text.chars() { + self.char_offset += 1; + self.utf16_offset += ch.len_utf16(); + if ch == '\n' { + self.line += 1; + self.column = 0; + } else { + self.column += 1; + } + } + self + } +} + +impl fmt::Debug for TextPoint { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("TextPoint") + .field("byte", &self.byte) + .field("char_offset", &self.char_offset) + .field("utf16_offset", &self.utf16_offset) + .field("line", &self.line) + .field("column", &self.column) + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::{TextMetrics, TextPoint}; + + #[test] + fn metrics_count_utf8_utf16_and_lines() { + let metrics = TextMetrics::from_text("a\né🙂"); + assert_eq!(metrics.bytes, "a\né🙂".len()); + assert_eq!(metrics.chars, 4); + assert_eq!(metrics.utf16, 5); + assert_eq!(metrics.line_breaks, 1); + } + + #[test] + fn metrics_support_arithmetic() { + let left = TextMetrics::from_text("hello\n"); + let right = TextMetrics::from_text("🙂"); + let combined = left + right; + + assert_eq!(combined.bytes, "hello\n🙂".len()); + assert_eq!(combined.chars, 7); + assert_eq!(combined.utf16, 8); + assert_eq!(combined.line_breaks, 1); + assert_eq!(combined - right, left); + } + + #[test] + fn point_advance_tracks_offsets() { + let point = TextPoint::origin().advance("ab\n🙂"); + assert_eq!(point.byte, "ab\n🙂".len()); + assert_eq!(point.char_offset, 4); + assert_eq!(point.utf16_offset, 5); + assert_eq!(point.line, 1); + assert_eq!(point.column, 1); + } +} diff --git a/lib/reactivity_parsing/src/parse.rs b/lib/reactivity_parsing/src/parse.rs new file mode 100644 index 0000000..367d286 --- /dev/null +++ b/lib/reactivity_parsing/src/parse.rs @@ -0,0 +1,162 @@ +use core::fmt; + +use crate::{RopeCursor, TextRange}; + +pub trait ParserCheckpoint: Clone + Eq + fmt::Debug + 'static {} + +impl ParserCheckpoint for T where T: Clone + Eq + fmt::Debug + 'static {} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SyntaxFragment { + pub range: TextRange, + pub start_checkpoint: C, + pub end_checkpoint: C, + pub tree: T, + pub diagnostics: Vec, + pub grammar_revision: u64, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ParseTree { + pub root: T, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ParseInvalidation { + pub resume_at: usize, + pub restart_checkpoint: C, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ParseResult { + pub tree: ParseTree, + pub fragments: Vec>, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ParseError { + pub message: String, +} + +impl ParseError { + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +pub trait IncrementalParser { + type Checkpoint: ParserCheckpoint; + type Tree: Clone + fmt::Debug + 'static; + type Diagnostic: Clone + fmt::Debug + 'static; + + fn grammar_revision(&self) -> u64; + + fn initial_checkpoint(&self) -> Self::Checkpoint; + + fn invalidate( + &self, + delta_range: TextRange, + previous_fragments: &[SyntaxFragment], + ) -> ParseInvalidation; + + fn parse( + &self, + cursor: RopeCursor, + fragments: &[SyntaxFragment], + invalidation: ParseInvalidation, + ) -> Result, ParseError>; +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + use std::rc::Rc; + + use ruin_reactivity::Reactor; + + use super::{ + IncrementalParser, ParseError, ParseInvalidation, ParseResult, ParseTree, SyntaxFragment, + }; + use crate::{ReactiveRope, RopeCursor, TextRange}; + + #[derive(Clone, Debug)] + struct DummyParser { + parse_calls: Rc>, + } + + impl IncrementalParser for DummyParser { + type Checkpoint = u8; + type Tree = String; + type Diagnostic = String; + + fn grammar_revision(&self) -> u64 { + 7 + } + + fn initial_checkpoint(&self) -> Self::Checkpoint { + 0 + } + + fn invalidate( + &self, + delta_range: TextRange, + _previous_fragments: &[SyntaxFragment], + ) -> ParseInvalidation { + ParseInvalidation { + resume_at: delta_range.start.saturating_sub(1), + restart_checkpoint: 1, + } + } + + fn parse( + &self, + mut cursor: RopeCursor, + _fragments: &[SyntaxFragment], + invalidation: ParseInvalidation, + ) -> Result, ParseError> { + self.parse_calls.set(self.parse_calls.get() + 1); + let text = cursor.read_bytes(usize::MAX); + Ok(ParseResult { + tree: ParseTree { root: text.clone() }, + fragments: vec![SyntaxFragment { + range: TextRange::new(invalidation.resume_at, invalidation.resume_at + text.len()), + start_checkpoint: invalidation.restart_checkpoint, + end_checkpoint: 2, + tree: text, + diagnostics: Vec::new(), + grammar_revision: self.grammar_revision(), + }], + }) + } + } + + #[test] + fn parse_error_constructor_sets_message() { + let error = ParseError::new("bad"); + assert_eq!(error.message, "bad"); + } + + #[test] + fn parser_trait_round_trip_produces_fragments() { + let reactor = Reactor::new(); + let rope = ReactiveRope::from_text(&reactor, "{\"a\":1}"); + let parser = DummyParser { + parse_calls: Rc::new(Cell::new(0)), + }; + + let invalidation = parser.invalidate(TextRange::new(3, 4), &[]); + assert_eq!(invalidation.resume_at, 2); + assert_eq!(invalidation.restart_checkpoint, 1); + + let result = parser + .parse(rope.cursor(0), &[], invalidation) + .expect("dummy parse should succeed"); + + assert_eq!(result.tree.root, "{\"a\":1}"); + assert_eq!(result.fragments.len(), 1); + assert_eq!(result.fragments[0].grammar_revision, 7); + assert_eq!(parser.parse_calls.get(), 1); + } +} diff --git a/lib/reactivity_parsing/src/rope.rs b/lib/reactivity_parsing/src/rope.rs new file mode 100644 index 0000000..b59d713 --- /dev/null +++ b/lib/reactivity_parsing/src/rope.rs @@ -0,0 +1,720 @@ +use std::cell::{Cell, RefCell}; +use std::cmp::min; +use std::collections::BTreeSet; +use std::rc::Rc; + +use ruin_reactivity::{Event, Reactor, Source, event_in, source_in}; + +use crate::delta::{EditDelta, RopeEdit, TextRange}; +use crate::metrics::{TextMetrics, TextPoint}; + +pub const DEFAULT_LEAF_TARGET_BYTES: usize = 1024; +const DEFAULT_LEAF_MAX_BYTES: usize = DEFAULT_LEAF_TARGET_BYTES * 2; + +#[derive(Clone)] +pub struct ReactiveRope { + inner: Rc, +} + +pub struct RopeBuilder { + reactor: Reactor, + leaf_target_bytes: usize, + chunks: Vec, +} + +#[derive(Clone)] +pub struct RopeChunk { + text: Rc, + pub start: TextPoint, + pub metrics: TextMetrics, +} + +#[derive(Clone)] +pub struct RopeCursor { + rope: ReactiveRope, + next_offset: usize, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct RopeSnapshotMetrics { + pub leaves: usize, + pub text: TextMetrics, + pub revision: u64, +} + +struct RopeInner { + reactor: Reactor, + structure: Source, + revision: Cell, + leaf_target_bytes: usize, + leaves: RefCell>>, + edit_events: Event, +} + +struct Leaf { + source: Source, + text: RefCell>, + metrics: Cell, + tail_chars: Cell, +} + +impl RopeBuilder { + pub fn new(reactor: &Reactor) -> Self { + Self { + reactor: reactor.clone(), + leaf_target_bytes: DEFAULT_LEAF_TARGET_BYTES, + chunks: Vec::new(), + } + } + + pub fn leaf_target_bytes(mut self, bytes: usize) -> Self { + self.leaf_target_bytes = bytes.max(32); + self + } + + pub fn push(mut self, text: impl Into) -> Self { + self.chunks.push(text.into()); + self + } + + pub fn build(self) -> ReactiveRope { + ReactiveRope::from_chunks(self.reactor, self.leaf_target_bytes, self.chunks) + } +} + +impl ReactiveRope { + pub fn new(reactor: &Reactor) -> Self { + RopeBuilder::new(reactor).build() + } + + pub fn from_text(reactor: &Reactor, text: impl AsRef) -> Self { + RopeBuilder::new(reactor).push(text.as_ref()).build() + } + + fn from_chunks(reactor: Reactor, leaf_target_bytes: usize, chunks: Vec) -> Self { + let structure = source_in(&reactor); + let edit_events = event_in(&reactor); + let mut leaves = Vec::new(); + for chunk in chunks { + Self::append_chunk_into(&reactor, leaf_target_bytes, &mut leaves, &chunk); + } + if leaves.is_empty() { + leaves.push(Rc::new(Leaf::new(&reactor, String::new()))); + } + + Self { + inner: Rc::new(RopeInner { + reactor, + structure, + revision: Cell::new(0), + leaf_target_bytes, + leaves: RefCell::new(leaves), + edit_events, + }), + } + } + + pub fn edit_events(&self) -> Event { + self.inner.edit_events.clone() + } + + pub fn reactor(&self) -> Reactor { + self.inner.reactor.clone() + } + + pub fn snapshot_metrics(&self) -> RopeSnapshotMetrics { + self.inner.structure.observe(); + RopeSnapshotMetrics { + leaves: self.inner.leaves.borrow().len(), + text: self.total_metrics(), + revision: self.inner.revision.get(), + } + } + + pub fn len_bytes(&self) -> usize { + self.total_metrics().bytes + } + + pub fn is_empty(&self) -> bool { + self.len_bytes() == 0 + } + + pub fn to_string(&self) -> String { + self.slice(TextRange::new(0, self.len_bytes())) + } + + pub fn point_at(&self, offset: usize) -> TextPoint { + self.inner.structure.observe(); + let offset = offset.min(self.len_bytes()); + let mut point = TextPoint::origin(); + let leaves = self.inner.leaves.borrow(); + let mut remaining = offset; + for leaf in &*leaves { + let bytes = leaf.metrics.get().bytes; + if remaining > bytes { + remaining -= bytes; + point.byte += bytes; + point.char_offset += leaf.metrics.get().chars; + point.utf16_offset += leaf.metrics.get().utf16; + point.line += leaf.metrics.get().line_breaks; + if leaf.metrics.get().line_breaks == 0 { + point.column += leaf.tail_chars.get(); + } else { + point.column = leaf.tail_chars.get(); + } + continue; + } + let text = leaf.read(); + point = point.advance(&text[..remaining]); + return point; + } + point + } + + pub fn slice(&self, range: TextRange) -> String { + self.inner.structure.observe(); + self.assert_char_boundary(range.start); + self.assert_char_boundary(range.end); + assert!(range.start <= range.end, "invalid text range"); + assert!(range.end <= self.len_bytes(), "range out of bounds"); + + let mut out = String::with_capacity(range.len()); + let mut cursor = 0usize; + let leaves = self.inner.leaves.borrow(); + for leaf in &*leaves { + let metrics = leaf.metrics.get(); + let end = cursor + metrics.bytes; + if end <= range.start { + cursor = end; + continue; + } + if cursor >= range.end { + break; + } + let text = leaf.read(); + let start_in_leaf = range.start.saturating_sub(cursor); + let end_in_leaf = min(metrics.bytes, range.end - cursor); + out.push_str(&text[start_in_leaf..end_in_leaf]); + cursor = end; + } + out + } + + pub fn chunk_at(&self, offset: usize) -> Option { + self.inner.structure.observe(); + if offset >= self.len_bytes() { + return None; + } + let mut start = TextPoint::origin(); + let leaves = self.inner.leaves.borrow(); + let mut cursor = 0usize; + for leaf in &*leaves { + let metrics = leaf.metrics.get(); + let end = cursor + metrics.bytes; + if offset < end { + let text = leaf.read(); + leaf.source.observe(); + return Some(RopeChunk { + text, + start, + metrics, + }); + } + start.byte += metrics.bytes; + start.char_offset += metrics.chars; + start.utf16_offset += metrics.utf16; + start.line += metrics.line_breaks; + if metrics.line_breaks == 0 { + start.column += leaf.tail_chars.get(); + } else { + start.column = leaf.tail_chars.get(); + } + cursor = end; + } + None + } + + pub fn cursor(&self, offset: usize) -> RopeCursor { + RopeCursor { + rope: self.clone(), + next_offset: offset.min(self.len_bytes()), + } + } + + pub fn edit(&self, edit: RopeEdit) -> EditDelta { + let delta = self.apply_edit(edit); + self.inner.edit_events.emit(delta.clone()); + delta + } + + pub fn edit_batch(&self, edits: impl IntoIterator) -> Vec { + let mut deltas = Vec::new(); + for edit in edits { + let delta = self.apply_edit(edit); + self.inner.edit_events.emit(delta.clone()); + deltas.push(delta); + } + deltas + } + + fn apply_edit(&self, edit: RopeEdit) -> EditDelta { + assert!(edit.range.start <= edit.range.end, "invalid edit range"); + assert!(edit.range.end <= self.len_bytes(), "edit range out of bounds"); + self.assert_char_boundary(edit.range.start); + self.assert_char_boundary(edit.range.end); + assert!( + edit.insert.is_char_boundary(edit.insert.len()), + "replacement text must be valid UTF-8" + ); + + let old_start = self.point_at(edit.range.start); + let old_end = self.point_at(edit.range.end); + let removed_text = self.slice(edit.range); + let removed = TextMetrics::from_text(&removed_text); + let inserted = TextMetrics::from_text(&edit.insert); + + let mut leaves = self.inner.leaves.borrow_mut(); + let mut cursor = 0usize; + let mut first_index = None; + let mut last_index = None; + let mut prefix = String::new(); + let mut suffix = String::new(); + + for (index, leaf) in leaves.iter().enumerate() { + let text = leaf.read(); + let end = cursor + text.len(); + if first_index.is_none() && edit.range.start <= end { + first_index = Some(index); + let split = edit.range.start.saturating_sub(cursor); + prefix.push_str(&text[..split]); + } + if first_index.is_some() && edit.range.end <= end { + last_index = Some(index); + let split = edit.range.end.saturating_sub(cursor); + suffix.push_str(&text[split..]); + break; + } + cursor = end; + } + + let first_index = first_index.unwrap_or_else(|| leaves.len().saturating_sub(1)); + let last_index = last_index.unwrap_or(first_index); + + let mut replacement = prefix; + replacement.push_str(&edit.insert); + replacement.push_str(&suffix); + + let structure_changed = last_index > first_index || replacement.len() > DEFAULT_LEAF_MAX_BYTES; + if !structure_changed { + if let Some(leaf) = leaves.get(first_index) { + leaf.set_text(replacement); + leaf.source.trigger(); + } + } else { + let mut replacement_leaves = Vec::new(); + Self::append_chunk_into( + &self.inner.reactor, + self.inner.leaf_target_bytes, + &mut replacement_leaves, + &replacement, + ); + if replacement_leaves.is_empty() { + replacement_leaves.push(Rc::new(Leaf::new(&self.inner.reactor, String::new()))); + } + + let removed_span = leaves.splice(first_index..=last_index, replacement_leaves); + let mut touched = BTreeSet::new(); + for leaf in removed_span { + touched.insert(leaf.source.id()); + } + for leaf in leaves.iter().skip(first_index) { + touched.insert(leaf.source.id()); + } + for leaf in &*leaves { + if touched.contains(&leaf.source.id()) { + leaf.source.trigger(); + } + } + self.inner.structure.trigger(); + } + + self.inner.revision.set(self.inner.revision.get().wrapping_add(1)); + + let new_end = old_start.advance(&edit.insert); + drop(leaves); + + EditDelta { + range: edit.range, + removed_text, + inserted_text: edit.insert, + removed, + inserted, + start: old_start, + old_end, + new_end, + } + } + + fn total_metrics(&self) -> TextMetrics { + self.inner + .leaves + .borrow() + .iter() + .fold(TextMetrics::default(), |sum, leaf| sum + leaf.metrics.get()) + } + + fn append_chunk_into( + reactor: &Reactor, + leaf_target_bytes: usize, + out: &mut Vec>, + text: &str, + ) { + if text.is_empty() { + return; + } + + let max_bytes = (leaf_target_bytes.max(32)) * 2; + let mut start = 0usize; + while start < text.len() { + let mut end = min(text.len(), start + max_bytes); + while end > start && !text.is_char_boundary(end) { + end -= 1; + } + if end == start { + end = text.len(); + } + out.push(Rc::new(Leaf::new(reactor, text[start..end].to_string()))); + start = end; + } + } + + fn assert_char_boundary(&self, offset: usize) { + if offset == self.len_bytes() { + return; + } + let leaves = self.inner.leaves.borrow(); + let mut cursor = 0usize; + for leaf in &*leaves { + let bytes = leaf.metrics.get().bytes; + let end = cursor + bytes; + if offset < end { + let text = leaf.read(); + assert!(text.is_char_boundary(offset - cursor), "offset must be a char boundary"); + return; + } + cursor = end; + } + } +} + +impl RopeChunk { + pub fn text(&self) -> &str { + &self.text + } +} + +impl RopeCursor { + pub fn offset(&self) -> usize { + self.next_offset + } + + pub fn peek_chunk(&self) -> Option { + let chunk = self.rope.chunk_at(self.next_offset)?; + let local = self.next_offset.saturating_sub(chunk.start.byte); + if local == 0 { + return Some(chunk); + } + + let partial = &chunk.text()[local..]; + Some(RopeChunk { + text: Rc::from(partial), + start: self.rope.point_at(self.next_offset), + metrics: TextMetrics::from_text(partial), + }) + } + + pub fn next_chunk(&mut self) -> Option { + let chunk = self.peek_chunk()?; + let end = chunk.start.byte + chunk.metrics.bytes; + self.next_offset = end; + Some(chunk) + } + + pub fn read_bytes(&mut self, len: usize) -> String { + let start = self.next_offset; + let end = min(self.rope.len_bytes(), start + len); + let slice = self.rope.slice(TextRange::new(start, end)); + self.next_offset = end; + slice + } +} + +impl Leaf { + fn new(reactor: &Reactor, text: String) -> Self { + let text: Rc = Rc::from(text); + let metrics = TextMetrics::from_text(&text); + let tail_chars = tail_chars(&text); + Self { + source: source_in(reactor), + text: RefCell::new(text), + metrics: Cell::new(metrics), + tail_chars: Cell::new(tail_chars), + } + } + + fn read(&self) -> Rc { + self.source.observe(); + self.text.borrow().clone() + } + + fn set_text(&self, text: String) { + let text: Rc = Rc::from(text); + self.metrics.set(TextMetrics::from_text(&text)); + self.tail_chars.set(tail_chars(&text)); + *self.text.borrow_mut() = text; + } +} + +fn tail_chars(text: &str) -> usize { + match text.rfind('\n') { + Some(index) => text[index + 1..].chars().count(), + None => text.chars().count(), + } +} + +#[cfg(test)] +mod tests { + use std::cell::Cell as Counter; + use std::cell::RefCell; + use std::panic::{AssertUnwindSafe, catch_unwind}; + use std::rc::Rc; + + use ruin_reactivity::{Reactor, thunk_in}; + + use crate::{Anchor, AnchorBias, ReactiveRope, RopeBuilder, RopeEdit, TextRange}; + + #[test] + fn edits_preserve_text_and_metrics() { + let reactor = Reactor::new(); + let rope = ReactiveRope::from_text(&reactor, "hello\nworld"); + + let delta = rope.edit(RopeEdit::replace(TextRange::new(5, 6), ", ")); + + assert_eq!(rope.to_string(), "hello, world"); + assert_eq!(delta.removed_text, "\n"); + assert_eq!(delta.inserted_text, ", "); + assert_eq!(rope.snapshot_metrics().text.bytes, "hello, world".len()); + assert_eq!(rope.snapshot_metrics().text.line_breaks, 0); + } + + #[test] + fn cursor_walks_tokens_across_leaf_boundaries() { + let reactor = Reactor::new(); + let rope = RopeBuilder::new(&reactor) + .leaf_target_bytes(4) + .push("alpha") + .push("beta") + .build(); + let mut cursor = rope.cursor(3); + + assert_eq!(cursor.read_bytes(4), "habe"); + let rest = cursor.next_chunk().expect("remaining chunk"); + assert_eq!(rest.text(), "ta"); + } + + #[test] + fn anchors_map_through_insertions() { + let reactor = Reactor::new(); + let rope = ReactiveRope::from_text(&reactor, "abcdef"); + let mut left = Anchor::new(3, AnchorBias::Left); + let mut right = Anchor::new(3, AnchorBias::Right); + + let delta = rope.edit(RopeEdit::insert(3, "XYZ")); + left.map_through(&delta); + right.map_through(&delta); + + assert_eq!(left.byte, 3); + assert_eq!(right.byte, 6); + } + + #[test] + fn disjoint_leaf_reads_do_not_recompute_on_local_edit() { + let reactor = Reactor::new(); + let rope = RopeBuilder::new(&reactor) + .leaf_target_bytes(64) + .push("a".repeat(80)) + .push("b".repeat(80)) + .build(); + + let first_runs = Rc::new(Counter::new(0usize)); + let second_runs = Rc::new(Counter::new(0usize)); + + let first = thunk_in(&reactor, { + let rope = rope.clone(); + let first_runs = Rc::clone(&first_runs); + move || { + first_runs.set(first_runs.get() + 1); + rope.slice(TextRange::new(0, 10)) + } + }); + + let second = thunk_in(&reactor, { + let rope = rope.clone(); + let second_runs = Rc::clone(&second_runs); + move || { + second_runs.set(second_runs.get() + 1); + rope.slice(TextRange::new(90, 100)) + } + }); + + assert_eq!(first.get(), "aaaaaaaaaa"); + assert_eq!(second.get(), "bbbbbbbbbb"); + rope.edit(RopeEdit::replace(TextRange::new(2, 4), "zz")); + + assert_eq!(first.get(), "aazzaaaaaa"); + assert_eq!(second.get(), "bbbbbbbbbb"); + assert_eq!(first_runs.get(), 2); + assert_eq!(second_runs.get(), 1); + } + + #[test] + fn point_at_tracks_multibyte_offsets() { + let reactor = Reactor::new(); + let rope = ReactiveRope::from_text(&reactor, "a\né🙂"); + + assert_eq!(rope.point_at(0).byte, 0); + assert_eq!(rope.point_at(2).line, 1); + assert_eq!(rope.point_at(4).char_offset, 3); + assert_eq!(rope.point_at("a\né🙂".len()).utf16_offset, 5); + assert_eq!(rope.point_at("a\né".len()).column, 1); + } + + #[test] + fn point_at_tracks_column_across_leaves() { + let reactor = Reactor::new(); + let rope = RopeBuilder::new(&reactor) + .leaf_target_bytes(4) + .push("ab\n") + .push("cde") + .build(); + + let point = rope.point_at("ab\ncd".len()); + assert_eq!(point.line, 1); + assert_eq!(point.column, 2); + } + + #[test] + fn chunk_at_and_peek_chunk_respect_boundaries() { + let reactor = Reactor::new(); + let rope = RopeBuilder::new(&reactor) + .leaf_target_bytes(4) + .push("abcd") + .push("efgh") + .build(); + + let first = rope.chunk_at(0).expect("chunk at start"); + assert_eq!(first.text(), "abcd"); + + let second = rope.chunk_at(4).expect("chunk at split"); + assert_eq!(second.text(), "efgh"); + assert!(rope.chunk_at(8).is_none()); + + let mut cursor = rope.cursor(2); + let partial = cursor.peek_chunk().expect("partial chunk"); + assert_eq!(partial.text(), "cd"); + let advanced = cursor.next_chunk().expect("advanced chunk"); + assert_eq!(advanced.text(), "cd"); + assert_eq!(cursor.offset(), 4); + } + + #[test] + fn cursor_stops_at_end_of_document() { + let reactor = Reactor::new(); + let rope = RopeBuilder::new(&reactor) + .leaf_target_bytes(4) + .push("abcd") + .push("ef") + .build(); + let mut cursor = rope.cursor(0); + let mut chunks = Vec::new(); + while let Some(chunk) = cursor.next_chunk() { + chunks.push(chunk.text().to_string()); + } + + assert_eq!(chunks, vec!["abcd".to_string(), "ef".to_string()]); + assert!(cursor.next_chunk().is_none()); + } + + #[test] + fn batch_edits_return_deltas_and_increment_revision() { + let reactor = Reactor::new(); + let rope = ReactiveRope::from_text(&reactor, "abcdef"); + let before = rope.snapshot_metrics().revision; + + let deltas = rope.edit_batch([ + RopeEdit::replace(TextRange::new(1, 3), "ZZ"), + RopeEdit::delete(TextRange::new(4, 6)), + ]); + + assert_eq!(deltas.len(), 2); + assert_eq!(rope.to_string(), "aZZd"); + assert_eq!(rope.snapshot_metrics().revision, before + 2); + } + + #[test] + fn delete_to_empty_preserves_valid_empty_rope() { + let reactor = Reactor::new(); + let rope = ReactiveRope::from_text(&reactor, "hello"); + + let delta = rope.edit(RopeEdit::delete(TextRange::new(0, 5))); + + assert_eq!(delta.removed_text, "hello"); + assert!(rope.is_empty()); + assert_eq!(rope.to_string(), ""); + assert_eq!(rope.snapshot_metrics().leaves, 1); + } + + #[test] + fn structure_changing_edit_updates_leaf_count() { + let reactor = Reactor::new(); + let rope = RopeBuilder::new(&reactor) + .leaf_target_bytes(8) + .push("abcdefgh") + .push("ijklmnop") + .build(); + let before = rope.snapshot_metrics(); + + rope.edit(RopeEdit::insert(8, "QRSTUVWXYZ0123456789")); + let after = rope.snapshot_metrics(); + + assert!(after.leaves >= before.leaves); + assert!(after.text.bytes > before.text.bytes); + } + + #[test] + fn invalid_char_boundary_panics() { + let reactor = Reactor::new(); + let rope = ReactiveRope::from_text(&reactor, "é"); + + let panic = catch_unwind(AssertUnwindSafe(|| { + let _ = rope.slice(TextRange::new(1, 2)); + })); + + assert!(panic.is_err()); + } + + #[test] + fn edit_events_emit_immediately() { + let reactor = Reactor::new(); + let rope = ReactiveRope::from_text(&reactor, "abc"); + let seen = Rc::new(RefCell::new(Vec::new())); + let _sub = rope.edit_events().subscribe({ + let seen = Rc::clone(&seen); + move |delta| seen.borrow_mut().push((delta.range, delta.inserted_text.clone())) + }); + + rope.edit(RopeEdit::insert(1, "Z")); + + assert_eq!(&*seen.borrow(), &[(TextRange::new(1, 1), "Z".to_string())]); + } +} diff --git a/lib/reactivity_parsing/src/tokentree.rs b/lib/reactivity_parsing/src/tokentree.rs new file mode 100644 index 0000000..b01ed2f --- /dev/null +++ b/lib/reactivity_parsing/src/tokentree.rs @@ -0,0 +1,2050 @@ +use core::fmt; +use std::cell::RefCell; +use std::rc::Rc; + +use ruin_reactivity::{Cell, Event, Subscription, cell_in, event_in}; + +use crate::{Anchor, AnchorBias, EditDelta, ReactiveRope, TextPoint, TextRange}; + +const SIGILS: &[&str] = &[ + "<<=", ">>=", "..=", "::", "->", "=>", "==", "!=", "<=", ">=", "&&", "||", "+=", "-=", + "*=", "/=", "%=", "&=", "|=", "^=", "<<", ">>", "..", "+", "-", "*", "/", "%", "&", "|", + "^", "!", "~", "=", "<", ">", ":", ";", "?", ".", ",", "@", "#", "$", +]; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DelimiterKind { + Parenthesis, + Bracket, + Brace, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum StringDelimiter { + SingleQuote, + DoubleQuote, + Backtick, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum StringFragment { + Text { range: TextRange, text: String }, + Interpolation { range: TextRange, child_index: usize }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum NumberBase { + Binary, + Octal, + Decimal, + Hexadecimal, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum NumberSuffix { + F32, + F64, + BigFloat, + Decimal128, + BigDecimal, + U8, + U16, + U32, + U64, + U128, + I8, + I16, + I32, + I64, + I128, + BigUint, + BigInt, +} + +impl NumberSuffix { + fn is_integer(self) -> bool { + !matches!( + self, + Self::F32 | Self::F64 | Self::BigFloat | Self::Decimal128 | Self::BigDecimal + ) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct NumberLiteral { + pub raw: String, + pub base: NumberBase, + pub has_radix_point: bool, + pub suffix: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum TokenTreeNodeKind { + Body, + List { + delimiter: DelimiterKind, + terminated: bool, + }, + Symbol { + text: String, + }, + Sigil { + text: String, + }, + String { + delimiter: StringDelimiter, + terminated: bool, + fragments: Vec, + }, + Number(NumberLiteral), + Error { + message: String, + }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TokenTreeNode { + pub id: u64, + pub local_range: TextRange, + pub kind: TokenTreeNodeKind, + pub children: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TriviaKind { + LineComment, + BlockComment, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TriviaComment { + pub kind: TriviaKind, + pub range: TextRange, + pub text: String, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct TriviaStore { + comments: Vec, +} + +impl TriviaStore { + pub fn comments(&self) -> &[TriviaComment] { + &self.comments + } + + pub fn comments_in_range(&self, range: TextRange) -> Vec<&TriviaComment> { + self.comments + .iter() + .filter(|comment| comment.range.start < range.end && comment.range.end > range.start) + .collect() + } + + pub fn comments_before(&self, offset: usize) -> Vec<&TriviaComment> { + self.comments + .iter() + .filter(|comment| comment.range.end <= offset) + .collect() + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TokenTreeDiagnosticKind { + UnterminatedList, + UnmatchedClosingDelimiter, + UnterminatedString, + UnterminatedInterpolation, + InvalidEscape, + InvalidUnicodeEscape, + UnterminatedBlockComment, + InvalidNumber, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TokenTreeDiagnostic { + pub kind: TokenTreeDiagnosticKind, + pub range: TextRange, + pub message: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TokenTreeParse { + text: String, + pub root_anchor: Anchor, + pub root: TokenTreeNode, + pub diagnostics: Vec, + pub trivia: TriviaStore, + pub stats: TokenTreeStats, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TokenTreeStats { + pub reused_nodes: usize, + pub total_nodes: usize, + pub reparsed_range: TextRange, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TokenTreeUpdate { + pub delta: EditDelta, + pub stats: TokenTreeStats, + pub diagnostics_changed: bool, + pub trivia_changed: bool, +} + +pub struct ReactiveTokenTree { + rope: ReactiveRope, + state: Cell, + last_update: Cell>, + updates: Event, + _subscription: Subscription, +} + +impl TokenTreeParse { + pub fn root(&self) -> &TokenTreeNode { + &self.root + } + + pub fn text(&self) -> &str { + &self.text + } + + pub fn diagnostics(&self) -> &[TokenTreeDiagnostic] { + &self.diagnostics + } + + pub fn trivia(&self) -> &TriviaStore { + &self.trivia + } + + pub fn stats(&self) -> &TokenTreeStats { + &self.stats + } + + pub fn absolute_range(&self, node_id: u64) -> Option { + fn walk(node: &TokenTreeNode, parent_start: usize, wanted: u64) -> Option { + let range = absolute_range(node, parent_start); + if node.id == wanted { + return Some(range); + } + for child in &node.children { + if let Some(found) = walk(child, range.start, wanted) { + return Some(found); + } + } + None + } + + walk(&self.root, self.root_anchor.byte, node_id) + } + + pub fn absolute_span( + &self, + rope: &ReactiveRope, + node_id: u64, + ) -> Option<(TextPoint, TextPoint)> { + let range = self.absolute_range(node_id)?; + Some((rope.point_at(range.start), rope.point_at(range.end))) + } +} + +impl ReactiveTokenTree { + pub fn new(rope: &ReactiveRope) -> Self { + let reactor = rope.reactor(); + let mut parser = TokenTreeParser::new(); + let initial = parser.parse(rope); + let state = cell_in(&reactor, initial); + let last_update = cell_in(&reactor, None::); + let updates = event_in(&reactor); + let parser = Rc::new(RefCell::new(parser)); + let rope_clone = rope.clone(); + let state_clone = state.clone(); + let last_update_clone = last_update.clone(); + let updates_clone = updates.clone(); + let subscription = rope.edit_events().subscribe({ + let parser = Rc::clone(&parser); + move |delta| { + let previous = state_clone.get(); + let next = parser.borrow_mut().reparse(&rope_clone, &previous, delta); + let update = TokenTreeUpdate { + delta: delta.clone(), + stats: next.stats.clone(), + diagnostics_changed: previous.diagnostics != next.diagnostics, + trivia_changed: previous.trivia != next.trivia, + }; + last_update_clone.replace(Some(update.clone())); + state_clone.replace(next); + updates_clone.emit(update); + } + }); + + Self { + rope: rope.clone(), + state, + last_update, + updates, + _subscription: subscription, + } + } + + pub fn rope(&self) -> ReactiveRope { + self.rope.clone() + } + + pub fn snapshot(&self) -> TokenTreeParse { + self.state.get() + } + + pub fn with_snapshot(&self, f: impl FnOnce(&TokenTreeParse) -> R) -> R { + self.state.with(f) + } + + pub fn last_update(&self) -> Option { + self.last_update.get() + } + + pub fn with_last_update(&self, f: impl FnOnce(&Option) -> R) -> R { + self.last_update.with(f) + } + + pub fn updates(&self) -> Event { + self.updates.clone() + } +} + +#[derive(Default)] +pub struct TokenTreeParser { + next_id: u64, +} + +impl TokenTreeParser { + pub fn new() -> Self { + Self::default() + } + + pub fn parse(&mut self, rope: &ReactiveRope) -> TokenTreeParse { + let text = read_rope_text(rope); + let raw = parse_raw(rope); + let root = self.materialize_fresh(&raw.root, 0); + let total_nodes = count_nodes(&root); + TokenTreeParse { + text, + root_anchor: Anchor::new(0, AnchorBias::Left), + root, + diagnostics: raw.diagnostics, + trivia: raw.trivia, + stats: TokenTreeStats { + reused_nodes: 0, + total_nodes, + reparsed_range: TextRange::new(0, rope.len_bytes()), + }, + } + } + + pub fn reparse( + &mut self, + rope: &ReactiveRope, + previous: &TokenTreeParse, + delta: &EditDelta, + ) -> TokenTreeParse { + let text = read_rope_text(rope); + let raw = parse_raw(rope); + let mut root_anchor = previous.root_anchor; + root_anchor.map_through(delta); + let mut reused_nodes = 0usize; + let root = self.reuse_node( + &raw.root, + Some(&previous.root), + previous.root_anchor.byte, + root_anchor.byte, + &previous.text, + &text, + delta, + &mut reused_nodes, + ); + let total_nodes = count_nodes(&root); + + TokenTreeParse { + text, + root_anchor, + root, + diagnostics: raw.diagnostics, + trivia: raw.trivia, + stats: TokenTreeStats { + reused_nodes, + total_nodes, + reparsed_range: TextRange::new(0, rope.len_bytes()), + }, + } + } + + fn materialize_fresh(&mut self, parsed: &RawNode, parent_start: usize) -> TokenTreeNode { + TokenTreeNode { + id: self.alloc_id(), + local_range: localize(parsed.range, parent_start), + kind: parsed.kind.clone(), + children: parsed + .children + .iter() + .map(|child| self.materialize_fresh(child, parsed.range.start)) + .collect(), + } + } + + fn reuse_node( + &mut self, + parsed: &RawNode, + old: Option<&TokenTreeNode>, + old_parent_start: usize, + new_parent_start: usize, + old_text: &str, + new_text: &str, + delta: &EditDelta, + reused_nodes: &mut usize, + ) -> TokenTreeNode { + let parsed_local = localize(parsed.range, new_parent_start); + if let Some(old) = old.filter(|old| can_reuse_node(old, old_parent_start, parsed, old_text, new_text, delta)) { + *reused_nodes += count_nodes(old); + return TokenTreeNode { + id: old.id, + local_range: parsed_local, + kind: old.kind.clone(), + children: old.children.clone(), + }; + } + + let children = if let Some(old) = old { + let mut used = vec![false; old.children.len()]; + parsed + .children + .iter() + .enumerate() + .map(|(index, child)| { + let candidate = old + .children + .get(index) + .filter(|candidate| { + !used[index] + && can_descend_into_node(candidate, child) + }) + .map(|candidate| { + used[index] = true; + candidate + }) + .or_else(|| { + old.children.iter().enumerate().find_map(|(candidate_index, candidate)| { + if used[candidate_index] + || !can_descend_into_node(candidate, child) + { + return None; + } + used[candidate_index] = true; + Some(candidate) + }) + }); + + self.reuse_node( + child, + candidate, + absolute_range(old, old_parent_start).start, + parsed.range.start, + old_text, + new_text, + delta, + reused_nodes, + ) + }) + .collect() + } else { + parsed + .children + .iter() + .map(|child| { + self.reuse_node( + child, + None, + old_parent_start, + parsed.range.start, + old_text, + new_text, + delta, + reused_nodes, + ) + }) + .collect() + }; + + TokenTreeNode { + id: self.alloc_id(), + local_range: parsed_local, + kind: parsed.kind.clone(), + children, + } + } + + fn alloc_id(&mut self) -> u64 { + self.next_id = self.next_id.wrapping_add(1); + self.next_id + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct RawNode { + range: TextRange, + kind: TokenTreeNodeKind, + children: Vec, +} + +struct RawParse { + root: RawNode, + diagnostics: Vec, + trivia: TriviaStore, +} + +struct Cursor<'a> { + rope: &'a ReactiveRope, + offset: usize, + diagnostics: Vec, + trivia: TriviaStore, +} + +fn parse_raw(rope: &ReactiveRope) -> RawParse { + let mut cursor = Cursor { + rope, + offset: 0, + diagnostics: Vec::new(), + trivia: TriviaStore::default(), + }; + let children = cursor.parse_sequence(None); + let root = RawNode { + range: TextRange::new(0, rope.len_bytes()), + kind: TokenTreeNodeKind::Body, + children, + }; + RawParse { + root, + diagnostics: cursor.diagnostics, + trivia: cursor.trivia, + } +} + +impl<'a> Cursor<'a> { + fn parse_sequence(&mut self, terminator: Option) -> Vec { + let mut nodes = Vec::new(); + let mut terminated = terminator.is_none(); + loop { + self.skip_ws_and_comments(); + if self.is_eof() { + break; + } + + if let Some(close) = terminator { + if self.peek_char() == Some(close) { + self.bump_char(); + terminated = true; + break; + } + } + + let Some(ch) = self.peek_char() else { + break; + }; + if let Some(delimiter) = opening_delimiter(ch) { + nodes.push(self.parse_list(delimiter)); + continue; + } + if matches!(ch, ')' | ']' | '}') { + let start = self.offset; + self.bump_char(); + self.diagnostics.push(TokenTreeDiagnostic { + kind: TokenTreeDiagnosticKind::UnmatchedClosingDelimiter, + range: TextRange::new(start, self.offset), + message: "unmatched closing delimiter".into(), + }); + nodes.push(RawNode { + range: TextRange::new(start, self.offset), + kind: TokenTreeNodeKind::Error { + message: "unmatched closing delimiter".into(), + }, + children: Vec::new(), + }); + continue; + } + if matches!(ch, '\'' | '"' | '`') { + nodes.push(self.parse_string()); + continue; + } + if ch.is_ascii_digit() { + nodes.push(self.parse_number()); + continue; + } + if let Some(sigil) = self.longest_sigil() { + nodes.push(self.parse_sigil(sigil)); + continue; + } + nodes.push(self.parse_symbol_or_error()); + } + + if let Some(close) = terminator + && !terminated + { + self.diagnostics.push(TokenTreeDiagnostic { + kind: TokenTreeDiagnosticKind::UnterminatedList, + range: TextRange::new(self.offset, self.offset), + message: format!("unterminated list; expected `{close}`"), + }); + } + nodes + } + + fn parse_list(&mut self, delimiter: DelimiterKind) -> RawNode { + let start = self.offset; + let (_, close) = delimiter_chars(delimiter); + self.bump_char(); + let children = self.parse_sequence(Some(close)); + let terminated = + self.slice(self.offset.saturating_sub(close.len_utf8()), self.offset) == close.to_string(); + RawNode { + range: TextRange::new(start, self.offset), + kind: TokenTreeNodeKind::List { + delimiter, + terminated, + }, + children, + } + } + + fn parse_sigil(&mut self, sigil: &str) -> RawNode { + let start = self.offset; + self.offset += sigil.len(); + RawNode { + range: TextRange::new(start, self.offset), + kind: TokenTreeNodeKind::Sigil { + text: sigil.to_string(), + }, + children: Vec::new(), + } + } + + fn parse_symbol_or_error(&mut self) -> RawNode { + let start = self.offset; + let Some(first) = self.peek_char() else { + return RawNode { + range: TextRange::new(start, start), + kind: TokenTreeNodeKind::Error { + message: "unexpected end of input".into(), + }, + children: Vec::new(), + }; + }; + + if !self.is_symbol_start(first) { + self.bump_char(); + return RawNode { + range: TextRange::new(start, self.offset), + kind: TokenTreeNodeKind::Error { + message: format!("unexpected character `{first}`"), + }, + children: Vec::new(), + }; + } + + self.bump_char(); + while let Some(ch) = self.peek_char() { + if !self.is_symbol_continue(ch) { + break; + } + self.bump_char(); + } + RawNode { + range: TextRange::new(start, self.offset), + kind: TokenTreeNodeKind::Symbol { + text: self.slice(start, self.offset), + }, + children: Vec::new(), + } + } + + fn parse_string(&mut self) -> RawNode { + let start = self.offset; + let delimiter_char = self.bump_char().expect("string should start with delimiter"); + let delimiter = match delimiter_char { + '\'' => StringDelimiter::SingleQuote, + '"' => StringDelimiter::DoubleQuote, + '`' => StringDelimiter::Backtick, + _ => unreachable!(), + }; + + let mut fragments = Vec::new(); + let mut children = Vec::new(); + let mut text_start = self.offset; + let mut terminated = false; + while let Some(ch) = self.peek_char() { + if ch == delimiter_char { + if text_start < self.offset { + fragments.push(StringFragment::Text { + range: TextRange::new(text_start, self.offset), + text: self.slice(text_start, self.offset), + }); + } + self.bump_char(); + terminated = true; + break; + } + + if delimiter == StringDelimiter::Backtick && self.starts_with("$(") { + if text_start < self.offset { + fragments.push(StringFragment::Text { + range: TextRange::new(text_start, self.offset), + text: self.slice(text_start, self.offset), + }); + } + let interp_start = self.offset; + self.offset += 2; + let interpolation_children = self.parse_sequence(Some(')')); + let terminated_interp = self + .slice(self.offset.saturating_sub(1), self.offset) + == ")"; + if !terminated_interp { + self.diagnostics.push(TokenTreeDiagnostic { + kind: TokenTreeDiagnosticKind::UnterminatedInterpolation, + range: TextRange::new(interp_start, self.offset), + message: "unterminated string interpolation".into(), + }); + } + let child_index = children.len(); + children.push(RawNode { + range: TextRange::new(interp_start + 1, self.offset), + kind: TokenTreeNodeKind::List { + delimiter: DelimiterKind::Parenthesis, + terminated: terminated_interp, + }, + children: interpolation_children, + }); + fragments.push(StringFragment::Interpolation { + range: TextRange::new(interp_start, self.offset), + child_index, + }); + text_start = self.offset; + continue; + } + + if ch == '\\' { + if let Err((range, kind, message)) = self.consume_escape(delimiter) { + self.diagnostics.push(TokenTreeDiagnostic { + kind, + range, + message: message.clone(), + }); + } + continue; + } + + self.bump_char(); + } + + if !terminated { + if text_start < self.offset { + fragments.push(StringFragment::Text { + range: TextRange::new(text_start, self.offset), + text: self.slice(text_start, self.offset), + }); + } + self.diagnostics.push(TokenTreeDiagnostic { + kind: TokenTreeDiagnosticKind::UnterminatedString, + range: TextRange::new(start, self.offset), + message: "unterminated string".into(), + }); + } + + RawNode { + range: TextRange::new(start, self.offset), + kind: TokenTreeNodeKind::String { + delimiter, + terminated, + fragments, + }, + children, + } + } + + fn parse_number(&mut self) -> RawNode { + let start = self.offset; + let (literal, end, maybe_error) = self.scan_number(start); + self.offset = end; + if let Some((kind, message)) = maybe_error { + self.diagnostics.push(TokenTreeDiagnostic { + kind, + range: TextRange::new(start, end), + message: message.clone(), + }); + return RawNode { + range: TextRange::new(start, end), + kind: TokenTreeNodeKind::Error { message }, + children: Vec::new(), + }; + } + RawNode { + range: TextRange::new(start, end), + kind: TokenTreeNodeKind::Number(literal), + children: Vec::new(), + } + } + + fn skip_ws_and_comments(&mut self) { + loop { + while let Some(ch) = self.peek_char() { + if ch.is_whitespace() { + self.bump_char(); + } else { + break; + } + } + + if self.starts_with("//") { + let start = self.offset; + self.offset += 2; + while let Some(ch) = self.peek_char() { + if ch == '\n' { + break; + } + self.bump_char(); + } + self.trivia.comments.push(TriviaComment { + kind: TriviaKind::LineComment, + range: TextRange::new(start, self.offset), + text: self.slice(start, self.offset), + }); + continue; + } + + if self.starts_with("/*") { + let start = self.offset; + self.offset += 2; + let mut depth = 1usize; + while !self.is_eof() { + if self.starts_with("/*") { + depth += 1; + self.offset += 2; + } else if self.starts_with("*/") { + depth -= 1; + self.offset += 2; + if depth == 0 { + break; + } + } else { + self.bump_char(); + } + } + + if depth != 0 { + self.diagnostics.push(TokenTreeDiagnostic { + kind: TokenTreeDiagnosticKind::UnterminatedBlockComment, + range: TextRange::new(start, self.offset), + message: "unterminated block comment".into(), + }); + } + + self.trivia.comments.push(TriviaComment { + kind: TriviaKind::BlockComment, + range: TextRange::new(start, self.offset), + text: self.slice(start, self.offset), + }); + continue; + } + + break; + } + } + + fn consume_escape( + &mut self, + delimiter: StringDelimiter, + ) -> Result<(), (TextRange, TokenTreeDiagnosticKind, String)> { + let start = self.offset; + self.bump_char(); + match self.bump_char() { + Some('n' | 'r' | 't' | '\\' | '0') => Ok(()), + Some('\'') if delimiter != StringDelimiter::DoubleQuote => Ok(()), + Some('"') if delimiter != StringDelimiter::SingleQuote => Ok(()), + Some('`') if delimiter == StringDelimiter::Backtick => Ok(()), + Some('u') => { + if self.bump_char() != Some('{') { + return Err(( + TextRange::new(start, self.offset), + TokenTreeDiagnosticKind::InvalidUnicodeEscape, + "unicode escapes must be of the form \\u{...}".into(), + )); + } + let digits_start = self.offset; + while let Some(ch) = self.peek_char() { + if ch == '}' { + break; + } + if !ch.is_ascii_hexdigit() { + return Err(( + TextRange::new(start, self.offset + ch.len_utf8()), + TokenTreeDiagnosticKind::InvalidUnicodeEscape, + "unicode escape contains non-hex digit".into(), + )); + } + self.bump_char(); + } + if self.peek_char() != Some('}') || self.offset == digits_start { + return Err(( + TextRange::new(start, self.offset), + TokenTreeDiagnosticKind::InvalidUnicodeEscape, + "unicode escape must contain at least one hex digit and terminate with `}`" + .into(), + )); + } + self.bump_char(); + Ok(()) + } + Some('x') => Err(( + TextRange::new(start, self.offset), + TokenTreeDiagnosticKind::InvalidEscape, + "hex escapes are not supported".into(), + )), + Some(other) => Err(( + TextRange::new(start, self.offset), + TokenTreeDiagnosticKind::InvalidEscape, + format!("invalid escape `\\{other}`"), + )), + None => Err(( + TextRange::new(start, self.offset), + TokenTreeDiagnosticKind::UnterminatedString, + "unterminated escape".into(), + )), + } + } + + fn is_eof(&self) -> bool { + self.offset >= self.rope.len_bytes() + } + + fn peek_char(&self) -> Option { + self.rope + .cursor(self.offset) + .peek_chunk() + .and_then(|chunk| chunk.text().chars().next()) + } + + fn bump_char(&mut self) -> Option { + let ch = self.peek_char()?; + self.offset += ch.len_utf8(); + Some(ch) + } + + fn starts_with(&self, pattern: &str) -> bool { + self.starts_with_at(self.offset, pattern) + } + + fn starts_with_at(&self, offset: usize, pattern: &str) -> bool { + rope_starts_with_at(self.rope, offset, pattern) + } + + fn slice(&self, start: usize, end: usize) -> String { + self.rope.slice(TextRange::new(start, end)) + } + + fn longest_sigil(&self) -> Option<&'static str> { + longest_sigil_at(self.rope, self.offset) + } + + fn is_symbol_start(&self, ch: char) -> bool { + !ch.is_ascii_digit() && self.is_symbol_continue(ch) + } + + fn is_symbol_continue(&self, ch: char) -> bool { + if ch.is_whitespace() || ch.is_control() { + return false; + } + if matches!(ch, '(' | ')' | '[' | ']' | '{' | '}' | '\'' | '"' | '`') { + return false; + } + if self.starts_with("//") || self.starts_with("/*") { + return false; + } + self.longest_sigil().is_none() + } + + fn scan_number( + &self, + start: usize, + ) -> (NumberLiteral, usize, Option<(TokenTreeDiagnosticKind, String)>) { + let mut cursor = start; + let mut base = NumberBase::Decimal; + if self.starts_with_at(start, "0b") { + base = NumberBase::Binary; + cursor += 2; + } else if self.starts_with_at(start, "0o") { + base = NumberBase::Octal; + cursor += 2; + } else if self.starts_with_at(start, "0d") { + base = NumberBase::Decimal; + cursor += 2; + } else if self.starts_with_at(start, "0x") { + base = NumberBase::Hexadecimal; + cursor += 2; + } + + let mut saw_digit = false; + let mut saw_radix = false; + while let Some(ch) = self.char_at(cursor) { + if ch == '_' { + cursor += 1; + continue; + } + if ch == '.' && base == NumberBase::Decimal && !saw_radix { + saw_radix = true; + cursor += 1; + continue; + } + if is_digit_for_base(ch, base) { + saw_digit = true; + cursor += ch.len_utf8(); + continue; + } + break; + } + + let suffix_start = cursor; + while let Some(ch) = self.char_at(cursor) { + if ch.is_ascii_alphanumeric() { + cursor += ch.len_utf8(); + } else { + break; + } + } + + let raw = self.slice(start, cursor); + let suffix_raw = self.slice(suffix_start, cursor); + let suffix = parse_suffix(&suffix_raw); + let literal = NumberLiteral { + raw: raw.clone(), + base, + has_radix_point: saw_radix, + suffix, + }; + + if !saw_digit { + return ( + literal, + cursor, + Some(( + TokenTreeDiagnosticKind::InvalidNumber, + "number literal must contain digits".into(), + )), + ); + } + if suffix_raw.contains('_') { + return ( + literal, + cursor, + Some(( + TokenTreeDiagnosticKind::InvalidNumber, + "numeric suffix cannot contain underscores".into(), + )), + ); + } + if suffix.is_none() && !suffix_raw.is_empty() { + return ( + literal, + cursor, + Some(( + TokenTreeDiagnosticKind::InvalidNumber, + format!("unknown numeric suffix `{suffix_raw}`"), + )), + ); + } + if saw_radix && suffix.is_some_and(NumberSuffix::is_integer) { + return ( + literal, + cursor, + Some(( + TokenTreeDiagnosticKind::InvalidNumber, + "integer-suffixed number cannot contain a radix point".into(), + )), + ); + } + if saw_radix && base != NumberBase::Decimal { + return ( + literal, + cursor, + Some(( + TokenTreeDiagnosticKind::InvalidNumber, + "only decimal numbers may contain a radix point".into(), + )), + ); + } + if !number_body_valid(&raw, base, saw_radix, suffix_start - start) { + return ( + literal, + cursor, + Some(( + TokenTreeDiagnosticKind::InvalidNumber, + "invalid digits for numeric base".into(), + )), + ); + } + + (literal, cursor, None) + } + + fn char_at(&self, offset: usize) -> Option { + self.rope + .cursor(offset) + .peek_chunk() + .and_then(|chunk| chunk.text().chars().next()) + } +} + +fn delimiter_chars(kind: DelimiterKind) -> (char, char) { + match kind { + DelimiterKind::Parenthesis => ('(', ')'), + DelimiterKind::Bracket => ('[', ']'), + DelimiterKind::Brace => ('{', '}'), + } +} + +fn opening_delimiter(ch: char) -> Option { + match ch { + '(' => Some(DelimiterKind::Parenthesis), + '[' => Some(DelimiterKind::Bracket), + '{' => Some(DelimiterKind::Brace), + _ => None, + } +} + +fn longest_sigil_at(rope: &ReactiveRope, offset: usize) -> Option<&'static str> { + SIGILS + .iter() + .copied() + .find(|sigil| rope_starts_with_at(rope, offset, sigil)) +} + +fn rope_starts_with_at(rope: &ReactiveRope, offset: usize, pattern: &str) -> bool { + let mut cursor = offset; + for expected in pattern.chars() { + let Some(found) = rope + .cursor(cursor) + .peek_chunk() + .and_then(|chunk| chunk.text().chars().next()) + else { + return false; + }; + if found != expected { + return false; + } + cursor += found.len_utf8(); + } + true +} + +fn read_rope_text(rope: &ReactiveRope) -> String { + let mut cursor = rope.cursor(0); + let mut text = String::new(); + while let Some(chunk) = cursor.next_chunk() { + text.push_str(chunk.text()); + } + text +} + +fn localize(range: TextRange, parent_start: usize) -> TextRange { + TextRange::new( + range.start.saturating_sub(parent_start), + range.end.saturating_sub(parent_start), + ) +} + +fn absolute_range(node: &TokenTreeNode, parent_start: usize) -> TextRange { + TextRange::new( + parent_start + node.local_range.start, + parent_start + node.local_range.end, + ) +} + +fn map_range(range: TextRange, delta: &EditDelta) -> TextRange { + if range.end <= delta.range.start { + return range; + } + if range.start >= delta.range.end { + let shift = delta.net_byte_delta(); + return TextRange::new( + range.start.saturating_add_signed(shift), + range.end.saturating_add_signed(shift), + ); + } + TextRange::new(usize::MAX, usize::MAX) +} + +fn can_reuse_node( + old: &TokenTreeNode, + old_parent_start: usize, + parsed: &RawNode, + old_text: &str, + new_text: &str, + delta: &EditDelta, +) -> bool { + let old_abs = absolute_range(old, old_parent_start); + old.kind == parsed.kind + && map_range(old_abs, delta) == parsed.range + && old_text.get(old_abs.start..old_abs.end) == new_text.get(parsed.range.start..parsed.range.end) +} + +fn can_descend_into_node(old: &TokenTreeNode, parsed: &RawNode) -> bool { + match (&old.kind, &parsed.kind) { + (TokenTreeNodeKind::Body, TokenTreeNodeKind::Body) => true, + ( + TokenTreeNodeKind::List { delimiter: left, .. }, + TokenTreeNodeKind::List { + delimiter: right, .. + }, + ) => left == right, + ( + TokenTreeNodeKind::String { + delimiter: left, .. + }, + TokenTreeNodeKind::String { + delimiter: right, .. + }, + ) => left == right, + (TokenTreeNodeKind::Symbol { text: left }, TokenTreeNodeKind::Symbol { text: right }) => { + left == right + } + (TokenTreeNodeKind::Sigil { text: left }, TokenTreeNodeKind::Sigil { text: right }) => { + left == right + } + (TokenTreeNodeKind::Number(left), TokenTreeNodeKind::Number(right)) => left == right, + _ => false, + } +} + +fn count_nodes(node: &TokenTreeNode) -> usize { + 1 + node.children.iter().map(count_nodes).sum::() +} + +fn number_body_valid( + raw: &str, + base: NumberBase, + saw_radix: bool, + suffix_start: usize, +) -> bool { + let mut body = &raw[..suffix_start]; + if matches!(base, NumberBase::Binary | NumberBase::Octal | NumberBase::Decimal | NumberBase::Hexadecimal) + && body.starts_with("0") + && body.len() >= 2 + && matches!(body.as_bytes()[1], b'b' | b'o' | b'd' | b'x') + { + body = &body[2..]; + } + if body.is_empty() { + return false; + } + for ch in body.chars() { + if ch == '_' || (saw_radix && ch == '.') { + continue; + } + if !is_digit_for_base(ch, base) { + return false; + } + } + true +} + +fn is_digit_for_base(ch: char, base: NumberBase) -> bool { + match base { + NumberBase::Binary => matches!(ch, '0' | '1'), + NumberBase::Octal => matches!(ch, '0'..='7'), + NumberBase::Decimal => ch.is_ascii_digit(), + NumberBase::Hexadecimal => ch.is_ascii_hexdigit(), + } +} + +fn parse_suffix(text: &str) -> Option { + match text { + "" => None, + "f32" => Some(NumberSuffix::F32), + "f64" => Some(NumberSuffix::F64), + "f" => Some(NumberSuffix::BigFloat), + "d128" => Some(NumberSuffix::Decimal128), + "d" => Some(NumberSuffix::BigDecimal), + "u8" => Some(NumberSuffix::U8), + "u16" => Some(NumberSuffix::U16), + "u32" => Some(NumberSuffix::U32), + "u64" => Some(NumberSuffix::U64), + "u128" => Some(NumberSuffix::U128), + "i8" => Some(NumberSuffix::I8), + "i16" => Some(NumberSuffix::I16), + "i32" => Some(NumberSuffix::I32), + "i64" => Some(NumberSuffix::I64), + "i128" => Some(NumberSuffix::I128), + "u" => Some(NumberSuffix::BigUint), + "i" => Some(NumberSuffix::BigInt), + _ => None, + } +} + +impl fmt::Display for TokenTreeNodeKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Body => write!(f, "body"), + Self::List { delimiter, .. } => write!(f, "{delimiter:?}"), + Self::Symbol { text } => write!(f, "symbol({text})"), + Self::Sigil { text } => write!(f, "sigil({text})"), + Self::String { delimiter, .. } => write!(f, "string({delimiter:?})"), + Self::Number(number) => write!(f, "number({})", number.raw), + Self::Error { message } => write!(f, "error({message})"), + } + } +} + +#[cfg(test)] +mod tests { + use ruin_reactivity::Reactor; + + use super::*; + use crate::RopeEdit; + + fn parse_text(text: &str) -> TokenTreeParse { + let reactor = Reactor::new(); + let rope = ReactiveRope::from_text(&reactor, text); + TokenTreeParser::new().parse(&rope) + } + + fn parse_with_rope(text: &str) -> (ReactiveRope, TokenTreeParse) { + let reactor = Reactor::new(); + let rope = ReactiveRope::from_text(&reactor, text); + let parse = TokenTreeParser::new().parse(&rope); + (rope, parse) + } + + fn reparse_after_edit( + before: &str, + edit: RopeEdit, + ) -> (ReactiveRope, TokenTreeParse, TokenTreeParse, EditDelta) { + let reactor = Reactor::new(); + let rope = ReactiveRope::from_text(&reactor, before); + let mut parser = TokenTreeParser::new(); + let first = parser.parse(&rope); + let delta = rope.edit(edit); + let second = parser.reparse(&rope, &first, &delta); + (rope, first, second, delta) + } + + fn node_text<'a>(parse: &'a TokenTreeParse, node: &TokenTreeNode) -> &'a str { + let range = parse.absolute_range(node.id).expect("node should have absolute range"); + &parse.text()[range.start..range.end] + } + + fn symbol_texts(parse: &TokenTreeParse) -> Vec<&str> { + parse.root + .children + .iter() + .filter_map(|node| match &node.kind { + TokenTreeNodeKind::Symbol { text } => Some(text.as_str()), + _ => None, + }) + .collect() + } + + fn sigil_texts(parse: &TokenTreeParse) -> Vec<&str> { + parse.root + .children + .iter() + .filter_map(|node| match &node.kind { + TokenTreeNodeKind::Sigil { text } => Some(text.as_str()), + _ => None, + }) + .collect() + } + + fn find_list_child<'a>(node: &'a TokenTreeNode, delimiter: DelimiterKind) -> &'a TokenTreeNode { + node.children + .iter() + .find(|child| { + matches!( + child.kind, + TokenTreeNodeKind::List { + delimiter: found, + .. + } if found == delimiter + ) + }) + .expect("expected list child") + } + + fn diagnostic_kinds(parse: &TokenTreeParse) -> Vec { + parse.diagnostics.iter().map(|diagnostic| diagnostic.kind).collect() + } + + #[test] + fn empty_input_yields_empty_body() { + let parse = parse_text(""); + assert!(parse.diagnostics.is_empty()); + assert!(parse.root.children.is_empty()); + assert_eq!(parse.root.kind, TokenTreeNodeKind::Body); + } + + #[test] + fn whitespace_only_input_yields_empty_body() { + let parse = parse_text(" \n\t "); + assert!(parse.diagnostics.is_empty()); + assert!(parse.root.children.is_empty()); + assert!(parse.trivia.comments().is_empty()); + } + + #[test] + fn parses_mixed_top_level_sequence_in_order() { + let parse = parse_text("alpha + 12 \"hi\" [beta]"); + assert_eq!(parse.root.children.len(), 5); + assert!(matches!(parse.root.children[0].kind, TokenTreeNodeKind::Symbol { .. })); + assert!(matches!(parse.root.children[1].kind, TokenTreeNodeKind::Sigil { .. })); + assert!(matches!(parse.root.children[2].kind, TokenTreeNodeKind::Number(..))); + assert!(matches!(parse.root.children[3].kind, TokenTreeNodeKind::String { .. })); + assert!(matches!(parse.root.children[4].kind, TokenTreeNodeKind::List { .. })); + } + + #[test] + fn parses_nested_lists() { + let parse = parse_text("{ foo(bar, [baz]) }"); + assert!(parse.diagnostics.is_empty()); + assert!(matches!( + parse.root.children[0].kind, + TokenTreeNodeKind::List { + delimiter: DelimiterKind::Brace, + .. + } + )); + let outer = &parse.root.children[0]; + assert!(matches!(outer.children[0].kind, TokenTreeNodeKind::Symbol { .. })); + let inner_paren = find_list_child(outer, DelimiterKind::Parenthesis); + assert!(matches!(inner_paren.children[0].kind, TokenTreeNodeKind::Symbol { .. })); + assert!(matches!(inner_paren.children[1].kind, TokenTreeNodeKind::Sigil { .. })); + let inner_bracket = find_list_child(inner_paren, DelimiterKind::Bracket); + assert!(matches!(inner_bracket.children[0].kind, TokenTreeNodeKind::Symbol { .. })); + } + + #[test] + fn parses_empty_lists_for_all_delimiters() { + let parse = parse_text("() [] {}"); + assert_eq!(parse.root.children.len(), 3); + assert!(matches!( + parse.root.children[0].kind, + TokenTreeNodeKind::List { + delimiter: DelimiterKind::Parenthesis, + terminated: true + } + )); + assert!(matches!( + parse.root.children[1].kind, + TokenTreeNodeKind::List { + delimiter: DelimiterKind::Bracket, + terminated: true + } + )); + assert!(matches!( + parse.root.children[2].kind, + TokenTreeNodeKind::List { + delimiter: DelimiterKind::Brace, + terminated: true + } + )); + } + + #[test] + fn unterminated_lists_report_diagnostics_per_delimiter() { + let parse = parse_text("( [ {"); + assert_eq!( + diagnostic_kinds(&parse), + vec![ + TokenTreeDiagnosticKind::UnterminatedList, + TokenTreeDiagnosticKind::UnterminatedList, + TokenTreeDiagnosticKind::UnterminatedList, + ] + ); + assert!(matches!( + parse.root.children[0].kind, + TokenTreeNodeKind::List { + delimiter: DelimiterKind::Parenthesis, + terminated: false + } + )); + let bracket = find_list_child(&parse.root.children[0], DelimiterKind::Bracket); + assert!(matches!( + bracket.kind, + TokenTreeNodeKind::List { + delimiter: DelimiterKind::Bracket, + terminated: false + } + )); + } + + #[test] + fn unmatched_closing_delimiters_produce_error_nodes_and_continue() { + let parse = parse_text(") foo ]"); + assert_eq!( + diagnostic_kinds(&parse), + vec![ + TokenTreeDiagnosticKind::UnmatchedClosingDelimiter, + TokenTreeDiagnosticKind::UnmatchedClosingDelimiter, + ] + ); + assert!(matches!(parse.root.children[0].kind, TokenTreeNodeKind::Error { .. })); + assert!(matches!(parse.root.children[1].kind, TokenTreeNodeKind::Symbol { .. })); + assert!(matches!(parse.root.children[2].kind, TokenTreeNodeKind::Error { .. })); + } + + #[test] + fn mismatched_closer_inside_list_becomes_error_child() { + let parse = parse_text("(foo]"); + assert_eq!( + diagnostic_kinds(&parse), + vec![ + TokenTreeDiagnosticKind::UnmatchedClosingDelimiter, + TokenTreeDiagnosticKind::UnterminatedList, + ] + ); + let list = &parse.root.children[0]; + assert!(matches!( + list.kind, + TokenTreeNodeKind::List { + delimiter: DelimiterKind::Parenthesis, + terminated: false + } + )); + assert!(matches!(list.children[0].kind, TokenTreeNodeKind::Symbol { .. })); + assert!(matches!(list.children[1].kind, TokenTreeNodeKind::Error { .. })); + } + + #[test] + fn extra_closer_after_valid_list_is_reported_at_top_level() { + let parse = parse_text("(foo))"); + assert_eq!( + diagnostic_kinds(&parse), + vec![TokenTreeDiagnosticKind::UnmatchedClosingDelimiter] + ); + assert!(matches!(parse.root.children[0].kind, TokenTreeNodeKind::List { .. })); + assert!(matches!(parse.root.children[1].kind, TokenTreeNodeKind::Error { .. })); + } + + #[test] + fn symbols_accept_unicode_printable_text() { + let parse = parse_text("alpha βeta café"); + assert_eq!(symbol_texts(&parse), vec!["alpha", "βeta", "café"]); + } + + #[test] + fn symbols_can_include_printable_non_sigil_punctuation() { + let parse = parse_text("foo§bar baz_ß"); + assert_eq!(symbol_texts(&parse), vec!["foo§bar", "baz_ß"]); + } + + #[test] + fn symbols_split_around_sigils_and_delimiters() { + let parse = parse_text("left+right(foo)"); + assert_eq!(symbol_texts(&parse), vec!["left", "right"]); + assert_eq!(sigil_texts(&parse), vec!["+"]); + assert!(matches!(parse.root.children[3].kind, TokenTreeNodeKind::List { .. })); + } + + #[test] + fn numeric_prefix_does_not_start_symbol() { + let parse = parse_text("1abc"); + assert_eq!( + diagnostic_kinds(&parse), + vec![TokenTreeDiagnosticKind::InvalidNumber] + ); + assert!(matches!(parse.root.children[0].kind, TokenTreeNodeKind::Error { .. })); + } + + #[test] + fn every_defined_sigil_parses_as_a_sigil_node() { + for sigil in SIGILS { + let parse = parse_text(sigil); + assert_eq!(parse.root.children.len(), 1, "sigil `{sigil}` should be a single token"); + assert!( + matches!( + &parse.root.children[0].kind, + TokenTreeNodeKind::Sigil { text } if text == sigil + ), + "expected `{sigil}` to parse as sigil" + ); + } + } + + #[test] + fn longest_match_sigils_win() { + let parse = parse_text("a >>= b => c ..= d :: e"); + assert_eq!(sigil_texts(&parse), vec![">>=", "=>", "..=", "::"]); + } + + #[test] + fn comments_take_precedence_over_slash_sigils() { + let parse = parse_text("a// comment\nb/* block */c"); + assert!(sigil_texts(&parse).is_empty()); + assert_eq!(symbol_texts(&parse), vec!["a", "b", "c"]); + assert_eq!(parse.trivia.comments().len(), 2); + } + + #[test] + fn preserves_comments_as_trivia() { + let parse = parse_text("foo // hi\n/* block */ bar"); + assert_eq!(parse.trivia.comments().len(), 2); + assert!(matches!(parse.root.children[0].kind, TokenTreeNodeKind::Symbol { .. })); + assert!(matches!(parse.root.children[1].kind, TokenTreeNodeKind::Symbol { .. })); + } + + #[test] + fn nested_block_comments_are_preserved_as_single_trivia_item() { + let parse = parse_text("foo /* outer /* inner */ done */ bar"); + assert!(parse.diagnostics.is_empty()); + assert_eq!(parse.trivia.comments().len(), 1); + assert_eq!(parse.trivia.comments()[0].kind, TriviaKind::BlockComment); + assert_eq!(parse.trivia.comments()[0].text, "/* outer /* inner */ done */"); + } + + #[test] + fn unterminated_block_comment_reports_diagnostic_and_trivia() { + let parse = parse_text("foo /* never closes"); + assert_eq!( + diagnostic_kinds(&parse), + vec![TokenTreeDiagnosticKind::UnterminatedBlockComment] + ); + assert_eq!(parse.trivia.comments().len(), 1); + assert_eq!(parse.trivia.comments()[0].kind, TriviaKind::BlockComment); + } + + #[test] + fn trivia_queries_filter_by_range_and_offset() { + let parse = parse_text("foo // hi\nbar /* block */ baz"); + let line = &parse.trivia.comments()[0]; + let block = &parse.trivia.comments()[1]; + let overlapping = parse.trivia.comments_in_range(TextRange::new(line.range.start, block.range.end)); + assert_eq!(overlapping.len(), 2); + let before_block = parse.trivia.comments_before(block.range.start); + assert_eq!(before_block.len(), 1); + assert_eq!(before_block[0].text, "// hi"); + } + + #[test] + fn comments_before_respects_exact_end_boundary() { + let parse = parse_text("foo // one\nbar"); + let comment = &parse.trivia.comments()[0]; + let before_comment_end = parse.trivia.comments_before(comment.range.end); + assert_eq!(before_comment_end.len(), 1); + let before_comment_start = parse.trivia.comments_before(comment.range.start); + assert!(before_comment_start.is_empty()); + } + + #[test] + fn parses_empty_single_and_double_quoted_strings() { + let parse = parse_text("'' \"\""); + assert_eq!(parse.root.children.len(), 2); + assert!(matches!( + parse.root.children[0].kind, + TokenTreeNodeKind::String { + delimiter: StringDelimiter::SingleQuote, + terminated: true, + .. + } + )); + assert!(matches!( + parse.root.children[1].kind, + TokenTreeNodeKind::String { + delimiter: StringDelimiter::DoubleQuote, + terminated: true, + .. + } + )); + } + + #[test] + fn quoted_strings_collect_text_fragments() { + let parse = parse_text("\"hello\" 'world'"); + match &parse.root.children[0].kind { + TokenTreeNodeKind::String { fragments, .. } => { + assert_eq!( + fragments, + &vec![StringFragment::Text { + range: TextRange::new(1, 6), + text: "hello".into(), + }] + ); + } + other => panic!("expected string, got {other:?}"), + } + match &parse.root.children[1].kind { + TokenTreeNodeKind::String { fragments, .. } => { + assert_eq!( + fragments, + &vec![StringFragment::Text { + range: TextRange::new(9, 14), + text: "world".into(), + }] + ); + } + other => panic!("expected string, got {other:?}"), + } + } + + #[test] + fn strings_support_valid_common_escapes() { + let parse = parse_text("\"line\\nquote\\\"tab\\t\" '\\'' `tick\\``"); + assert!(parse.diagnostics.is_empty()); + assert_eq!(parse.root.children.len(), 3); + } + + #[test] + fn invalid_escape_sequences_report_diagnostics() { + let parse = parse_text("\"bad\\q\" \"bad\\x41\""); + assert_eq!( + diagnostic_kinds(&parse), + vec![ + TokenTreeDiagnosticKind::InvalidEscape, + TokenTreeDiagnosticKind::InvalidEscape, + ] + ); + } + + #[test] + fn unicode_escape_sequences_validate_shape_and_digits() { + let parse = parse_text("\"ok\\u{1f642}\" \"bad\\u{}\" \"bad\\u{zz}\" \"bad\\u1234\""); + assert_eq!( + diagnostic_kinds(&parse), + vec![ + TokenTreeDiagnosticKind::InvalidUnicodeEscape, + TokenTreeDiagnosticKind::InvalidUnicodeEscape, + TokenTreeDiagnosticKind::InvalidUnicodeEscape, + ] + ); + assert!(matches!( + parse.root.children[0].kind, + TokenTreeNodeKind::String { terminated: true, .. } + )); + } + + #[test] + fn unterminated_strings_report_diagnostic() { + let parse = parse_text("\"unterminated"); + assert_eq!( + diagnostic_kinds(&parse), + vec![TokenTreeDiagnosticKind::UnterminatedString] + ); + assert!(matches!( + parse.root.children[0].kind, + TokenTreeNodeKind::String { + terminated: false, + .. + } + )); + } + + #[test] + fn backtick_strings_support_multiple_interpolations() { + let parse = parse_text("`a $(foo) b $(bar)`"); + let string = &parse.root.children[0]; + match &string.kind { + TokenTreeNodeKind::String { + delimiter: StringDelimiter::Backtick, + terminated: true, + fragments, + } => { + assert_eq!(fragments.len(), 4); + assert_eq!(string.children.len(), 2); + assert!(matches!(fragments[0], StringFragment::Text { .. })); + assert!(matches!(fragments[1], StringFragment::Interpolation { child_index: 0, .. })); + assert!(matches!(fragments[2], StringFragment::Text { .. })); + assert!(matches!(fragments[3], StringFragment::Interpolation { child_index: 1, .. })); + assert!(matches!( + string.children[0].kind, + TokenTreeNodeKind::List { + delimiter: DelimiterKind::Parenthesis, + terminated: true + } + )); + assert!(matches!( + string.children[1].kind, + TokenTreeNodeKind::List { + delimiter: DelimiterKind::Parenthesis, + terminated: true + } + )); + } + other => panic!("expected backtick string, got {other:?}"), + } + } + + #[test] + fn strings_support_interpolation() { + let parse = parse_text("`Hello $(world)`"); + let string = &parse.root.children[0]; + match &string.kind { + TokenTreeNodeKind::String { fragments, .. } => { + assert_eq!(fragments.len(), 2); + assert_eq!(string.children.len(), 1); + assert!(matches!(string.children[0].kind, TokenTreeNodeKind::List { .. })); + } + other => panic!("expected string, got {other:?}"), + } + } + + #[test] + fn unterminated_interpolation_reports_both_relevant_diagnostics() { + let parse = parse_text("`Hello $(world`"); + let kinds = diagnostic_kinds(&parse); + assert_eq!( + kinds, + vec![ + TokenTreeDiagnosticKind::UnterminatedString, + TokenTreeDiagnosticKind::UnterminatedList, + TokenTreeDiagnosticKind::UnterminatedInterpolation, + TokenTreeDiagnosticKind::UnterminatedString, + ] + ); + let string = &parse.root.children[0]; + assert!(matches!( + string.kind, + TokenTreeNodeKind::String { + delimiter: StringDelimiter::Backtick, + terminated: false, + .. + } + )); + } + + #[test] + fn parses_numbers_with_supported_bases_and_suffixes() { + let cases = [ + ("0", NumberBase::Decimal, false, None), + ("12.3f64", NumberBase::Decimal, true, Some(NumberSuffix::F64)), + ("0d1_2_3", NumberBase::Decimal, false, None), + ("0b1010u8", NumberBase::Binary, false, Some(NumberSuffix::U8)), + ("0o77i16", NumberBase::Octal, false, Some(NumberSuffix::I16)), + ("0xffu", NumberBase::Hexadecimal, false, Some(NumberSuffix::BigUint)), + ("99d", NumberBase::Decimal, false, Some(NumberSuffix::BigDecimal)), + ("42d128", NumberBase::Decimal, false, Some(NumberSuffix::Decimal128)), + ("5f", NumberBase::Decimal, false, Some(NumberSuffix::BigFloat)), + ]; + + for (text, base, has_radix_point, suffix) in cases { + let parse = parse_text(text); + assert!(parse.diagnostics.is_empty(), "unexpected diagnostics for `{text}`"); + match &parse.root.children[0].kind { + TokenTreeNodeKind::Number(number) => { + assert_eq!(number.raw, text); + assert_eq!(number.base, base); + assert_eq!(number.has_radix_point, has_radix_point); + assert_eq!(number.suffix, suffix); + } + other => panic!("expected number for `{text}`, got {other:?}"), + } + } + } + + #[test] + fn numbers_accept_current_underscore_placements() { + let parse = parse_text("1_2_3 0x_ff 0d12_.3f64 1._5"); + assert!(parse.diagnostics.is_empty()); + assert_eq!(parse.root.children.len(), 4); + assert!(parse.root.children.iter().all(|node| matches!(node.kind, TokenTreeNodeKind::Number(..)))); + } + + #[test] + fn numbers_reject_integer_suffixes_on_radix_numbers() { + let parse = parse_text("1.0u8"); + assert_eq!( + diagnostic_kinds(&parse), + vec![TokenTreeDiagnosticKind::InvalidNumber] + ); + assert!(matches!(parse.root.children[0].kind, TokenTreeNodeKind::Error { .. })); + } + + #[test] + fn numbers_require_digits_after_base_prefix() { + let parse = parse_text("0x 0b2 0o"); + assert_eq!( + diagnostic_kinds(&parse), + vec![ + TokenTreeDiagnosticKind::InvalidNumber, + TokenTreeDiagnosticKind::InvalidNumber, + TokenTreeDiagnosticKind::InvalidNumber, + ] + ); + assert!(parse.root.children.iter().all(|node| matches!(node.kind, TokenTreeNodeKind::Error { .. }))); + } + + #[test] + fn numbers_reject_unknown_or_underscored_suffixes() { + let parse = parse_text("12foo 9u_8"); + assert_eq!(diagnostic_kinds(&parse), vec![TokenTreeDiagnosticKind::InvalidNumber]); + assert!(matches!(parse.root.children[0].kind, TokenTreeNodeKind::Error { .. })); + assert!(matches!(parse.root.children[1].kind, TokenTreeNodeKind::Number(..))); + assert!(matches!(parse.root.children[2].kind, TokenTreeNodeKind::Symbol { .. })); + } + + #[test] + fn numbers_stop_before_non_numeric_sigils() { + let parse = parse_text("0x1..2"); + assert_eq!(parse.root.children.len(), 3); + assert!(matches!(parse.root.children[0].kind, TokenTreeNodeKind::Number(..))); + assert!(matches!( + parse.root.children[1].kind, + TokenTreeNodeKind::Sigil { ref text } if text == ".." + )); + assert!(matches!(parse.root.children[2].kind, TokenTreeNodeKind::Number(..))); + } + + #[test] + fn diagnostics_capture_ranges_and_messages() { + let parse = parse_text("\"bad\\x41\""); + let diagnostic = &parse.diagnostics[0]; + assert_eq!(diagnostic.kind, TokenTreeDiagnosticKind::InvalidEscape); + assert!(diagnostic.range.start < diagnostic.range.end); + assert!(diagnostic.message.contains("hex escapes are not supported")); + } + + #[test] + fn absolute_range_and_span_track_nested_nodes() { + let (rope, parse) = parse_with_rope("{\n foo\n [bar]\n}"); + let outer = &parse.root.children[0]; + let bracket = find_list_child(outer, DelimiterKind::Bracket); + let range = parse.absolute_range(bracket.id).expect("bracket should have range"); + assert_eq!(&parse.text()[range.start..range.end], "[bar]"); + let (start, end) = parse.absolute_span(&rope, bracket.id).expect("span should exist"); + assert_eq!(start.line, 2); + assert_eq!(start.column, 2); + assert_eq!(end.line, 2); + assert_eq!(end.column, 7); + } + + #[test] + fn node_text_uses_derived_absolute_ranges() { + let parse = parse_text("{ alpha beta }"); + let list = &parse.root.children[0]; + let alpha = &list.children[0]; + let beta = &list.children[1]; + assert_eq!(node_text(&parse, alpha), "alpha"); + assert_eq!(node_text(&parse, beta), "beta"); + } + + #[test] + fn reparsing_reuses_shifted_subtrees() { + let (_rope, first, second, _delta) = reparse_after_edit("{ a b }", RopeEdit::insert(2, "long ")); + let original_b = first.root.children[0] + .children + .iter() + .find(|node| matches!(&node.kind, TokenTreeNodeKind::Symbol { text } if text == "b")) + .expect("original tree should contain symbol b") + .id; + let reparsed_b = second.root.children[0] + .children + .iter() + .find(|node| matches!(&node.kind, TokenTreeNodeKind::Symbol { text } if text == "b")) + .expect("reparsed tree should contain symbol b") + .id; + assert_eq!(original_b, reparsed_b); + } + + #[test] + fn reparsing_preserves_shifted_node_identity_but_updates_span() { + let (rope, first, second, _delta) = + reparse_after_edit("{\n a\n b\n}", RopeEdit::insert(2, "long ")); + let original_b = first.root.children[0] + .children + .iter() + .find(|node| matches!(&node.kind, TokenTreeNodeKind::Symbol { text } if text == "b")) + .expect("original tree should contain symbol b"); + let reparsed_b = second.root.children[0] + .children + .iter() + .find(|node| matches!(&node.kind, TokenTreeNodeKind::Symbol { text } if text == "b")) + .expect("reparsed tree should contain symbol b"); + assert_eq!(original_b.id, reparsed_b.id); + let first_span = first.absolute_span(&rope, original_b.id).expect("first span should exist"); + let second_span = second.absolute_span(&rope, reparsed_b.id).expect("second span should exist"); + assert_ne!(first_span, second_span); + assert_eq!(second_span.0.line, 2); + } + + #[test] + fn reparsing_reuses_unaffected_siblings_when_one_symbol_changes() { + let (_rope, first, second, _delta) = + reparse_after_edit("{ left right }", RopeEdit::replace(TextRange::new(2, 6), "long")); + let first_list = &first.root.children[0]; + let second_list = &second.root.children[0]; + let old_right = first_list.children[1].id; + let new_right = second_list.children[1].id; + assert_eq!(old_right, new_right); + assert_ne!(first.root.id, second.root.id); + assert_ne!(first_list.id, second_list.id); + assert_ne!(first_list.children[0].id, second_list.children[0].id); + } + + #[test] + fn reparsing_does_not_reuse_node_when_kind_changes() { + let (_rope, first, second, _delta) = + reparse_after_edit("foo", RopeEdit::replace(TextRange::new(0, 3), "123")); + assert!(matches!(first.root.children[0].kind, TokenTreeNodeKind::Symbol { .. })); + assert!(matches!(second.root.children[0].kind, TokenTreeNodeKind::Number(..))); + assert_ne!(first.root.children[0].id, second.root.children[0].id); + } + + #[test] + fn reparsing_preserves_nodes_across_comment_and_whitespace_shifts() { + let (_rope, first, second, _delta) = + reparse_after_edit("{ alpha beta }", RopeEdit::insert(2, "/* hi */\n")); + let first_beta = first.root.children[0].children[1].id; + let second_beta = second.root.children[0].children[1].id; + assert_eq!(first_beta, second_beta); + assert_eq!(second.trivia.comments().len(), 1); + } + + #[test] + fn reactive_parser_updates_snapshot_and_reuses_subtrees() { + let reactor = Reactor::new(); + let rope = ReactiveRope::from_text(&reactor, "{ a b }"); + let parser = ReactiveTokenTree::new(&rope); + + let first = parser.snapshot(); + let original_b = first.root.children[0] + .children + .iter() + .find(|node| matches!(&node.kind, TokenTreeNodeKind::Symbol { text } if text == "b")) + .expect("original tree should contain symbol b") + .id; + + let _ = rope.edit(RopeEdit::insert(2, "long ")); + + let second = parser.snapshot(); + let reparsed_b = second.root.children[0] + .children + .iter() + .find(|node| matches!(&node.kind, TokenTreeNodeKind::Symbol { text } if text == "b")) + .expect("reparsed tree should contain symbol b") + .id; + assert_eq!(original_b, reparsed_b); + let update = parser.last_update().expect("edit should produce update"); + assert!(update.stats.reused_nodes > 0); + } + + #[test] + fn reactive_parser_emits_update_events() { + let reactor = Reactor::new(); + let rope = ReactiveRope::from_text(&reactor, "foo"); + let parser = ReactiveTokenTree::new(&rope); + let seen = Rc::new(RefCell::new(Vec::new())); + let _subscription = parser.updates().subscribe({ + let seen = Rc::clone(&seen); + move |update| seen.borrow_mut().push(update.clone()) + }); + + let _ = rope.edit(RopeEdit::replace(TextRange::new(0, 3), "123")); + + let seen = seen.borrow(); + assert_eq!(seen.len(), 1); + assert!(seen[0].diagnostics_changed || seen[0].stats.total_nodes > 0); + } + +} diff --git a/lib/ruin_app/src/app.rs b/lib/ruin_app/src/app.rs index 613f5a6..1251811 100644 --- a/lib/ruin_app/src/app.rs +++ b/lib/ruin_app/src/app.rs @@ -14,7 +14,9 @@ use ruin_ui::{ use ruin_ui_platform_wayland::start_wayland_ui; use crate::Result; -use crate::context::{RenderState, render_with_context}; +use crate::context::{ + InteractionSnapshot, RenderState, render_with_context, render_with_context_and_interaction, +}; use crate::input::{ EventBindings, InputState, ShortcutBinding, TextSelection, TextSelectionDrag, TextSelectionState, apply_text_selection_overlay, focused_element_for_pointer, @@ -58,6 +60,11 @@ impl Window { self.spec = self.spec.base_color(color); self } + + pub fn resizable(mut self, resizable: bool) -> Self { + self.spec.resizable = resizable; + self + } } impl Default for Window { @@ -160,7 +167,10 @@ impl MountedApp { 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 last_fixed_size = Rc::new(RefCell::new(None::)); let explicit_min_size = app_window.spec.min_inner_size; + let explicit_max_size = app_window.spec.max_inner_size; + let window_resizable = app_window.spec.resizable; let mut input_state = InputState::new(); let mut pointer_router = PointerRouter::new(); @@ -174,18 +184,31 @@ impl MountedApp { let shortcuts = Rc::clone(&shortcuts); let current_title = Rc::clone(¤t_title); let last_min_size = Rc::clone(&last_min_size); + let last_fixed_size = Rc::clone(&last_fixed_size); let root = Rc::clone(&root); let render_state = Rc::clone(&render_state); let text_selection = Rc::clone(&input_state.text_selection); + let hovered_elements = input_state.hovered_elements.clone(); + let pressed_elements = input_state.pressed_elements.clone(); + let interaction_version = input_state.interaction_version.clone(); move || { let viewport = viewport.get(); let version = scene_version.get().wrapping_add(1); scene_version.set(version); let _ = text_selection.version.get(); + let _ = interaction_version.get(); let t_effect = Instant::now(); + let interaction_snapshot = Rc::new(InteractionSnapshot { + hovered: hovered_elements.get(), + pressed: pressed_elements.get(), + }); - let render_output = render_with_context(Rc::clone(&render_state), || root.render()); + let render_output = render_with_context_and_interaction( + Rc::clone(&render_state), + interaction_snapshot, + || root.render(), + ); if render_output.side_effects.window_title != *current_title.borrow() { if let Some(title) = &render_output.side_effects.window_title { @@ -211,7 +234,6 @@ impl MountedApp { ); 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 @@ -220,10 +242,31 @@ impl MountedApp { .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); + if window_resizable { + 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); + } + } else { + let desired_fixed = UiSize::new( + desired_min + .width + .min(explicit_max_size.map_or(f32::INFINITY, |s| s.width)), + desired_min + .height + .min(explicit_max_size.map_or(f32::INFINITY, |s| s.height)), + ); + let current_fixed = *last_fixed_size.borrow(); + if current_fixed != Some(desired_fixed) { + let _ = window.update( + WindowUpdate::new() + .requested_inner_size(Some(desired_fixed)) + .min_inner_size(Some(desired_fixed)) + .max_inner_size(Some(desired_fixed)), + ); + *last_fixed_size.borrow_mut() = Some(desired_fixed); + } } let effect_us = t_effect.elapsed().as_micros(); @@ -355,10 +398,15 @@ impl MountedApp { .last() .map(|target| target.cursor) .unwrap_or(ruin_ui::CursorIcon::Default); + let hovered_ids = Self::interaction_ids(pointer_router.hovered_targets()); + let pressed_ids = Self::interaction_ids(pointer_router.pressed_targets()); if next_cursor != input_state.current_cursor { input_state.current_cursor = next_cursor; window.set_cursor_icon(next_cursor)?; } + if Self::update_interaction_state(input_state, hovered_ids, pressed_ids) { + input_state.interaction_version.update(|value| *value += 1); + } Ok(()) } @@ -458,6 +506,32 @@ impl MountedApp { Ok(()) } + + fn interaction_ids(targets: &[ruin_ui::HitTarget]) -> Vec { + targets.iter().filter_map(|target| target.element_id).collect() + } + + fn update_interaction_state( + input_state: &mut InputState, + hovered_ids: Vec, + pressed_ids: Vec, + ) -> bool { + let previous_hovered = input_state.hovered_elements.get(); + let previous_pressed = input_state.pressed_elements.get(); + let hovered_changed = input_state.hovered_elements.set(hovered_ids.clone()).is_some(); + let pressed_changed = input_state.pressed_elements.set(pressed_ids.clone()).is_some(); + if hovered_changed || pressed_changed { + tracing::trace!( + target: "ruin_app::interaction", + hovered_before = ?previous_hovered, + hovered_after = ?hovered_ids, + pressed_before = ?previous_pressed, + pressed_after = ?pressed_ids, + "interaction state changed" + ); + } + hovered_changed || pressed_changed + } } #[doc(hidden)] diff --git a/lib/ruin_app/src/context.rs b/lib/ruin_app/src/context.rs index 619d6e7..ef884e7 100644 --- a/lib/ruin_app/src/context.rs +++ b/lib/ruin_app/src/context.rs @@ -29,6 +29,12 @@ pub(crate) struct RenderState { pub(crate) next_element_id: StdCell, } +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct InteractionSnapshot { + pub(crate) hovered: Vec, + pub(crate) pressed: Vec, +} + #[derive(Clone, Default)] pub(crate) struct RenderSideEffects { pub(crate) window_title: Option, @@ -42,6 +48,7 @@ pub(crate) struct RenderContext { pub(crate) element_index: Rc>, pub(crate) side_effects: Rc>, pub(crate) context_entries: Rc>, + pub(crate) interaction: Rc, } impl RenderContext { @@ -52,6 +59,7 @@ impl RenderContext { element_index: Rc::clone(&self.element_index), side_effects: Rc::clone(&self.side_effects), context_entries, + interaction: Rc::clone(&self.interaction), } } } @@ -68,6 +76,14 @@ thread_local! { pub(crate) fn render_with_context( state: Rc, render: impl FnOnce() -> View, +) -> RenderOutput { + render_with_context_and_interaction(state, Rc::new(InteractionSnapshot::default()), render) +} + +pub(crate) fn render_with_context_and_interaction( + state: Rc, + interaction: Rc, + render: impl FnOnce() -> View, ) -> RenderOutput { let context = RenderContext { state, @@ -75,6 +91,7 @@ pub(crate) fn render_with_context( element_index: Rc::new(StdCell::new(0)), side_effects: Rc::new(RefCell::new(RenderSideEffects::default())), context_entries: Rc::new(Vec::new()), + interaction, }; let view = with_render_context(context.clone(), render); diff --git a/lib/ruin_app/src/hooks.rs b/lib/ruin_app/src/hooks.rs index e87c533..f719e9d 100644 --- a/lib/ruin_app/src/hooks.rs +++ b/lib/ruin_app/src/hooks.rs @@ -155,6 +155,11 @@ pub fn use_window_title(compute: impl FnOnce() -> String) { pub enum Key { Character(char), Enter, + Backspace, + Escape, + Delete, + ArrowLeft, + ArrowRight, ArrowUp, ArrowDown, Home, @@ -169,6 +174,11 @@ impl Key { .next() .is_some_and(|actual| actual.eq_ignore_ascii_case(expected)), (Self::Enter, KeyboardKey::Enter) => true, + (Self::Backspace, KeyboardKey::Backspace) => true, + (Self::Escape, KeyboardKey::Escape) => true, + (Self::Delete, KeyboardKey::Delete) => true, + (Self::ArrowLeft, KeyboardKey::ArrowLeft) => true, + (Self::ArrowRight, KeyboardKey::ArrowRight) => true, (Self::ArrowUp, KeyboardKey::ArrowUp) => true, (Self::ArrowDown, KeyboardKey::ArrowDown) => true, (Self::Home, KeyboardKey::Home) => true, @@ -209,12 +219,27 @@ impl Shortcut { } 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 + if event.kind != KeyboardEventKind::Pressed { + return false; + } + if !self.key.matches(event) { + return false; + } + if event.modifiers.control != self.control { + return false; + } + if event.modifiers.alt != self.alt { + return false; + } + if event.modifiers.super_key != self.super_key { + return false; + } + // For character shortcuts the produced character already encodes the shift + // state, so don't additionally require that Shift is (un)held. + if !matches!(self.key, Key::Character(_)) && event.modifiers.shift != self.shift { + return false; + } + true } } @@ -223,6 +248,12 @@ pub struct FocusScope { pub(crate) element_id: Signal>, } +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct InteractionState { + pub hovered: bool, + pub pressed: bool, +} + #[derive(Clone)] pub enum ShortcutScope { Application, @@ -296,6 +327,18 @@ pub fn use_widget_ref() -> WidgetRef { }) } +pub fn use_interaction_state(widget_ref: WidgetRef) -> InteractionState { + with_render_context_state(|context| { + let Some(element_id) = widget_ref.element_id() else { + return InteractionState::default(); + }; + InteractionState { + hovered: context.interaction.hovered.contains(&element_id), + pressed: context.interaction.pressed.contains(&element_id), + } + }) +} + pub fn use_shortcut(shortcut: Shortcut, scope: ShortcutScope, action: impl Fn() + 'static) { use_shortcut_with_context(shortcut, scope, move |_| action()); } @@ -371,4 +414,3 @@ where |slot: &mut ResourceSlot| slot.resource.clone(), ) } - diff --git a/lib/ruin_app/src/input.rs b/lib/ruin_app/src/input.rs index 5e04d09..0d6eff7 100644 --- a/lib/ruin_app/src/input.rs +++ b/lib/ruin_app/src/input.rs @@ -124,6 +124,9 @@ pub(crate) struct InputState { pub(crate) current_cursor: CursorIcon, pub(crate) focused_element: Option, pub(crate) text_selection: Rc, + pub(crate) hovered_elements: Signal>, + pub(crate) pressed_elements: Signal>, + pub(crate) interaction_version: Signal, } impl InputState { @@ -132,6 +135,9 @@ impl InputState { current_cursor: CursorIcon::Default, focused_element: None, text_selection: Rc::new(TextSelectionState::new()), + hovered_elements: Signal::new(Vec::new()), + pressed_elements: Signal::new(Vec::new()), + interaction_version: Signal::new(0), } } } diff --git a/lib/ruin_app/src/lib.rs b/lib/ruin_app/src/lib.rs index 86febdd..6f22d2b 100644 --- a/lib/ruin_app/src/lib.rs +++ b/lib/ruin_app/src/lib.rs @@ -23,9 +23,10 @@ pub mod view; 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, + BlockWidget, FocusScope, InteractionState, Key, Memo, Resource, ResourceState, + ScrollBoxWidget, Shortcut, ShortcutScope, Signal, WidgetRef, provide, use_context, + use_effect, use_interaction_state, use_memo, use_resource, use_shortcut, + use_shortcut_with_context, use_signal, use_widget_ref, use_window_title, }; pub use primitives::{ ButtonBuilder, ContainerBuilder, ContainerProps, FontWeight, ScrollBoxBuilder, TextBuilder, @@ -59,19 +60,20 @@ pub type Result = std::result::Result>; pub mod prelude { pub use crate::{ App, BlockWidget, ButtonBuilder, ChildViews, Children, Component, ContainerBuilder, - ContextKey, FocusScope, FontWeight, IntoBorder, IntoEdges, IntoView, Key, Memo, Mountable, - Pending, Ready, Resource, ResourceState, Result, ScrollBoxBuilder, ScrollBoxWidget, - Shortcut, ShortcutScope, Signal, TextBuilder, TextChildren, TextRole, TextValue, View, - WidgetRef, Window, block, button, colors, column, component, context_provider, provide, - row, scroll_box, surfaces, text, use_context, use_effect, use_memo, use_resource, - use_shortcut, use_shortcut_with_context, use_signal, use_widget_ref, use_window_title, - view, + ContextKey, FocusScope, FontWeight, InteractionState, IntoBorder, IntoEdges, IntoView, + Key, Memo, Mountable, Pending, Ready, Resource, ResourceState, Result, ScrollBoxBuilder, + ScrollBoxWidget, Shortcut, ShortcutScope, Signal, TextBuilder, TextChildren, TextRole, + TextValue, View, WidgetRef, Window, block, button, colors, column, component, + context_provider, provide, row, scroll_box, surfaces, text, use_context, use_effect, + use_interaction_state, use_memo, use_resource, use_shortcut, use_shortcut_with_context, + use_signal, use_widget_ref, use_window_title, view, }; pub use ruin_ui::{ AlignItems, Border, Color, CursorIcon, Edges, Element, ElementId, InteractionTree, PointerButton, PointerEventKind, RoutedPointerEvent, RoutedPointerEventKind, ScrollbarStyle, TextFontFamily, TextStyle, TextWrap, UiSize, }; + pub use crate::PartialTextStyle; } #[cfg(test)] @@ -84,7 +86,9 @@ mod tests { use ruin_ui::{KeyboardModifiers, Point, WindowSpec}; use super::*; - use crate::context::{RenderState, render_with_context}; + use crate::context::{ + InteractionSnapshot, RenderState, render_with_context, render_with_context_and_interaction, + }; use crate::input::{ InputState, KeyHandler, focused_element_for_pointer, scroll_handler_for_event, }; @@ -177,6 +181,53 @@ mod tests { assert!(debug.contains("body child"), "{debug}"); } + #[test] + fn interaction_state_tracks_hover_and_press_for_widget_refs() { + let render_state = Rc::new(RenderState::default()); + let hovered = Rc::new(RefCell::new(None::)); + let pressed = Rc::new(RefCell::new(None::)); + let widget_ref_slot = Rc::new(RefCell::new(None::>)); + + let _ = render_with_context(Rc::clone(&render_state), { + let hovered = Rc::clone(&hovered); + let pressed = Rc::clone(&pressed); + let widget_ref_slot = Rc::clone(&widget_ref_slot); + move || { + let widget_ref = use_widget_ref::(); + *hovered.borrow_mut() = Some(use_interaction_state(widget_ref.clone()).hovered); + *pressed.borrow_mut() = Some(use_interaction_state(widget_ref.clone()).pressed); + *widget_ref_slot.borrow_mut() = Some(widget_ref.clone()); + block().widget_ref(widget_ref).children(()) + } + }); + + let widget_ref = widget_ref_slot.borrow().clone().expect("widget ref should be set"); + let element_id = widget_ref + .element_id() + .expect("widget ref should have an element id after render"); + let _ = render_with_context_and_interaction( + render_state, + Rc::new(InteractionSnapshot { + hovered: vec![element_id], + pressed: vec![element_id], + }), + { + let hovered = Rc::clone(&hovered); + let pressed = Rc::clone(&pressed); + let widget_ref_slot = Rc::clone(&widget_ref_slot); + move || { + let widget_ref = widget_ref_slot.borrow().clone().expect("widget ref should persist"); + *hovered.borrow_mut() = Some(use_interaction_state(widget_ref.clone()).hovered); + *pressed.borrow_mut() = Some(use_interaction_state(widget_ref.clone()).pressed); + block().widget_ref(widget_ref).children(()) + } + }, + ); + + assert_eq!(*hovered.borrow(), Some(true)); + assert_eq!(*pressed.borrow(), Some(true)); + } + #[test] fn key_dispatch_prefers_the_nearest_focused_ancestor_handler() { use ruin_ui::{ElementId, KeyboardEvent, KeyboardEventKind, KeyboardKey}; diff --git a/lib/ruin_app/src/primitives/button.rs b/lib/ruin_app/src/primitives/button.rs index 3d669a3..3ec44ad 100644 --- a/lib/ruin_app/src/primitives/button.rs +++ b/lib/ruin_app/src/primitives/button.rs @@ -31,6 +31,11 @@ pub fn button() -> ButtonBuilder { impl ButtonBuilder { impl_props_methods!(); + pub fn shadow(mut self, shadow: impl crate::converters::IntoShadow) -> Self { + self.props = self.props.shadow(shadow); + self + } + pub fn on_press(mut self, handler: impl Fn(&RoutedPointerEvent) + 'static) -> Self { self.on_press = Some(Rc::new(handler)); self @@ -44,6 +49,9 @@ impl ButtonBuilder { pub fn children(mut self, children: impl Children) -> View { let id = allocate_element_id(); self.props.element = self.props.element.id(id); + if let Some(widget_ref) = &self.props.widget_ref { + let _ = widget_ref.set(Some(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); diff --git a/lib/ruin_app/src/primitives/container.rs b/lib/ruin_app/src/primitives/container.rs index 148f71f..fb6a2bb 100644 --- a/lib/ruin_app/src/primitives/container.rs +++ b/lib/ruin_app/src/primitives/container.rs @@ -128,16 +128,6 @@ pub fn block() -> ContainerBuilder { impl ContainerBuilder { impl_props_methods!(); - pub fn align_items(mut self, align: AlignItems) -> Self { - self.props = self.props.align_items(align); - self - } - - pub fn align_self(mut self, align: AlignItems) -> Self { - self.props = self.props.align_self(align); - self - } - pub fn shadow(mut self, shadow: impl IntoShadow) -> Self { self.props = self.props.shadow(shadow); self diff --git a/lib/ruin_app/src/primitives/mod.rs b/lib/ruin_app/src/primitives/mod.rs index acfa8aa..406570a 100644 --- a/lib/ruin_app/src/primitives/mod.rs +++ b/lib/ruin_app/src/primitives/mod.rs @@ -45,6 +45,14 @@ macro_rules! impl_props_methods { self.props = self.props.flex(flex); self } + pub fn align_items(mut self, align: ruin_ui::AlignItems) -> Self { + self.props = self.props.align_items(align); + self + } + pub fn align_self(mut self, align: ruin_ui::AlignItems) -> Self { + self.props = self.props.align_self(align); + self + } pub fn widget_ref(mut self, widget_ref: $crate::hooks::WidgetRef) -> Self { self.props = self.props.widget_ref(widget_ref); self diff --git a/lib/runtime/README.md b/lib/runtime/README.md index e69de29..ad9266d 100644 --- a/lib/runtime/README.md +++ b/lib/runtime/README.md @@ -0,0 +1,4 @@ +# RUIN - Runtime + +The RUIN runtime is an event-loop-per-thread, non-workstealing async executor implemented on top of Linux io_uring +with \ No newline at end of file diff --git a/lib/runtime/src/fs.rs b/lib/runtime/src/fs.rs index 8b8514d..d4c070b 100644 --- a/lib/runtime/src/fs.rs +++ b/lib/runtime/src/fs.rs @@ -8,11 +8,12 @@ //! The public surface intentionally mirrors `std::fs` where that shape makes sense, while using //! async methods for operations that may block the caller. +use alloc::sync::Arc; + use std::ffi::OsStr; use std::io; use std::os::fd::{AsRawFd, OwnedFd}; use std::path::{Path, PathBuf}; -use std::sync::Arc; use crate::op::fs::{ FileType as RawFileType, FsOp, MetadataTarget, OpenOptions as OpOpenOptions, diff --git a/lib/runtime/src/lib.rs b/lib/runtime/src/lib.rs index ca2aad5..0f96abd 100644 --- a/lib/runtime/src/lib.rs +++ b/lib/runtime/src/lib.rs @@ -44,6 +44,7 @@ pub mod net; pub mod op; #[doc(hidden)] pub mod platform; +pub mod stdio; #[doc(hidden)] pub mod sys; pub mod time; diff --git a/lib/runtime/src/net.rs b/lib/runtime/src/net.rs index 7902f53..51eb936 100644 --- a/lib/runtime/src/net.rs +++ b/lib/runtime/src/net.rs @@ -3,14 +3,15 @@ //! The public surface follows the general shape of `std::net`, but uses async methods for socket //! operations that would otherwise block the caller. -use std::future::Future; +use core::future::Future; +use core::pin::Pin; +use core::task::{Context, Poll}; +use core::time::Duration; + use std::io; use std::net::{Shutdown, SocketAddr, ToSocketAddrs}; use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}; -use std::pin::Pin; use std::sync::{Arc, Mutex}; -use std::task::{Context, Poll}; -use std::time::Duration; use hyper::rt::{Read as HyperRead, ReadBufCursor, Write as HyperWrite}; diff --git a/lib/runtime/src/platform/linux_x86_64/driver.rs b/lib/runtime/src/platform/linux_x86_64/driver.rs index 344ae9a..a4e79db 100644 --- a/lib/runtime/src/platform/linux_x86_64/driver.rs +++ b/lib/runtime/src/platform/linux_x86_64/driver.rs @@ -1,4 +1,6 @@ -//! Public runtime driver primitives. +//! RUIN Runtime Driver for Linux x86_64. +//! +//! use std::cell::Cell; use std::cell::RefCell; @@ -88,12 +90,21 @@ pub struct ReadyEvents { /// Low-level Linux runtime driver backed by `io_uring`. pub struct Driver { + /// The `io_uring` instance driving this runtime thread. ring: IoUring, + /// Shared notifier that other threads can use to wake this runtime thread. notifier: Arc, + /// Next sequence number for generated completion tokens. next_token: Cell, + /// The token of the currently active timer, if any timer is armed. active_timer_token: Cell>, + /// Accumulated count of pending wake notifications that have not yet been triggered. pending_wakes: Cell, + /// Accumulated count of pending timer expirations that have not yet been triggered. pending_timers: Cell, + /// Map of active completion tokens to associated handlers. When a CQE is received with a token in this map, the + /// corresponding handler will be invoked with the CQE and removed from the map. This is the core mechanism by which + /// async operations are tracked and dispatched to their continuations. completions: RefCell>, } diff --git a/lib/runtime/src/stdio.rs b/lib/runtime/src/stdio.rs new file mode 100644 index 0000000..26004f4 --- /dev/null +++ b/lib/runtime/src/stdio.rs @@ -0,0 +1,191 @@ +//! Async standard-input helpers. + +use std::cell::Cell; +use std::io; +use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}; +use std::thread; + +use crate::op::completion::completion_for_current_thread; +use crate::platform::linux_x86_64::runtime::with_current_driver; +use crate::platform::linux_x86_64::uring::{IORING_OP_READ, IoUringCqe, IoUringSqe}; + +thread_local! { + static STDIN_URING_SUPPORTED: Cell> = const { Cell::new(None) }; +} + +const FILE_CURSOR: u64 = u64::MAX; +const READ_CHUNK_BYTES: usize = 1024; + +/// Async line-oriented stdin reader. +/// +/// The reader is `io_uring`-first. When the active stdin fd rejects `IORING_OP_READ`, the module +/// falls back to a helper-thread blocking read on the same duplicated fd. +pub struct Stdin { + fd: OwnedFd, + buffer: Vec, +} + +/// Opens an async stdin reader. +pub fn stdin() -> io::Result { + let raw = cvt(unsafe { libc::fcntl(libc::STDIN_FILENO, libc::F_DUPFD_CLOEXEC, 0) })?; + Ok(Stdin { + fd: unsafe { OwnedFd::from_raw_fd(raw) }, + buffer: Vec::new(), + }) +} + +impl Stdin { + /// Reads a single UTF-8 line, including the trailing newline when present. + /// + /// Returns `Ok(None)` on EOF. + pub async fn read_line(&mut self) -> io::Result> { + loop { + if let Some(index) = self.buffer.iter().position(|byte| *byte == b'\n') { + let line = self.buffer.drain(..=index).collect::>(); + return decode_line(line).map(Some); + } + + let chunk = self.read_chunk().await?; + if chunk.is_empty() { + if self.buffer.is_empty() { + return Ok(None); + } + let line = std::mem::take(&mut self.buffer); + return decode_line(line).map(Some); + } + + self.buffer.extend_from_slice(&chunk); + } + } + + async fn read_chunk(&self) -> io::Result> { + let fd = self.fd.as_raw_fd(); + let support = STDIN_URING_SUPPORTED.with(Cell::get); + if support != Some(false) { + match submit_uring_read(fd, READ_CHUNK_BYTES).await { + Ok(bytes) => { + STDIN_URING_SUPPORTED.with(|state| state.set(Some(true))); + return Ok(bytes); + } + Err(error) if should_fallback_to_offload(&error) => { + STDIN_URING_SUPPORTED.with(|state| state.set(Some(false))); + } + Err(error) => return Err(error), + } + } + + offload(move || { + let mut buffer = vec![0; READ_CHUNK_BYTES]; + let read = blocking_read(fd, &mut buffer)?; + if read == 0 { + buffer.clear(); + } else { + buffer.truncate(read); + } + Ok(buffer) + }) + .await + } +} + +async fn offload( + task: impl FnOnce() -> io::Result + Send + 'static, +) -> io::Result { + let (future, handle) = completion_for_current_thread::>(); + thread::Builder::new() + .name("ruin-runtime-stdio-offload".into()) + .spawn(move || handle.complete(task())) + .map_err(io::Error::other)?; + future.await +} + +async fn submit_uring_read(fd: RawFd, len: usize) -> io::Result> { + let mut buffer = vec![0; len]; + let ptr = buffer.as_mut_ptr(); + let capacity = buffer.len(); + submit_uring( + move |sqe| { + sqe.opcode = IORING_OP_READ; + sqe.fd = fd; + sqe.addr = ptr as u64; + sqe.len = capacity as u32; + sqe.off = FILE_CURSOR; + }, + move |cqe| { + let read = cqe_to_result(cqe)? as usize; + buffer.truncate(read); + Ok(buffer) + }, + ) + .await +} + +async fn submit_uring( + fill: impl FnOnce(&mut IoUringSqe), + map: M, +) -> io::Result +where + M: FnOnce(IoUringCqe) -> io::Result + Send + 'static, +{ + let (future, handle) = completion_for_current_thread::>(); + let callback_handle = handle.clone(); + let token = with_current_driver(|driver| { + driver.submit_operation(fill, move |cqe| { + callback_handle.complete(map(cqe)); + }) + })?; + + handle.set_cancel(move || { + let _ = with_current_driver(|driver| driver.cancel_operation(token)); + }); + + future.await +} + +fn blocking_read(fd: RawFd, buffer: &mut [u8]) -> io::Result { + loop { + let read = unsafe { + libc::read( + fd, + buffer.as_mut_ptr().cast::(), + buffer.len(), + ) + }; + if read >= 0 { + return Ok(read as usize); + } + + let error = io::Error::last_os_error(); + if error.kind() == io::ErrorKind::Interrupted { + continue; + } + return Err(error); + } +} + +fn decode_line(bytes: Vec) -> io::Result { + String::from_utf8(bytes).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) +} + +fn cqe_to_result(cqe: IoUringCqe) -> io::Result { + if cqe.res < 0 { + Err(io::Error::from_raw_os_error(-cqe.res)) + } else { + Ok(cqe.res) + } +} + +fn should_fallback_to_offload(error: &io::Error) -> bool { + matches!( + error.raw_os_error(), + Some(libc::EINVAL | libc::ENOSYS | libc::EOPNOTSUPP) + ) +} + +fn cvt(value: libc::c_int) -> io::Result { + if value == -1 { + Err(io::Error::last_os_error()) + } else { + Ok(value) + } +} diff --git a/lib/runtime/src/time.rs b/lib/runtime/src/time.rs index 9e8d519..dcaacc9 100644 --- a/lib/runtime/src/time.rs +++ b/lib/runtime/src/time.rs @@ -3,14 +3,14 @@ //! These helpers integrate with the runtime's timer queue and are designed to be used from //! futures scheduled with [`crate::queue_future`] or one of the runtime entry macros. -use std::cell::{Cell, RefCell}; -use std::fmt; -use std::future::{Future, poll_fn}; -use std::pin::Pin; -use std::rc::Rc; -use std::task::Waker; -use std::task::{Context, Poll}; -use std::time::Duration; +use alloc::rc::Rc; +use core::cell::{Cell, RefCell}; +use core::fmt; +use core::future::{Future, poll_fn}; +use core::pin::Pin; +use core::task::Waker; +use core::task::{Context, Poll}; +use core::time::Duration; use crate::{clear_timeout, set_timeout}; diff --git a/lib/ui/src/interaction.rs b/lib/ui/src/interaction.rs index 0370daa..eedfb70 100644 --- a/lib/ui/src/interaction.rs +++ b/lib/ui/src/interaction.rs @@ -54,7 +54,7 @@ pub struct RoutedPointerEvent { #[derive(Default)] pub struct PointerRouter { hovered: Vec, - pressed: Option, + pressed: Option>, } impl PointerRouter { @@ -71,7 +71,11 @@ impl PointerRouter { } pub fn pressed_target(&self) -> Option<&HitTarget> { - self.pressed.as_ref() + self.pressed.as_ref().and_then(|targets| targets.last()) + } + + pub fn pressed_targets(&self) -> &[HitTarget] { + self.pressed.as_deref().unwrap_or(&[]) } pub fn route( @@ -114,7 +118,12 @@ impl PointerRouter { match event.kind { PointerEventKind::Move => { - if let Some(target) = self.pressed.clone().or(hit_target.clone()) { + if let Some(target) = self + .pressed + .as_ref() + .and_then(|targets| targets.last().cloned()) + .or(hit_target.clone()) + { routed.push(RoutedPointerEvent { kind: RoutedPointerEventKind::Move, target, @@ -125,7 +134,7 @@ impl PointerRouter { } PointerEventKind::Down { button } => { if let Some(target) = hit_target { - self.pressed = Some(target.clone()); + self.pressed = Some(self.hovered.clone()); routed.push(RoutedPointerEvent { kind: RoutedPointerEventKind::Down { button }, target, @@ -135,7 +144,12 @@ impl PointerRouter { } } PointerEventKind::Up { button } => { - if let Some(target) = self.pressed.take().or(hit_target) { + if let Some(target) = self + .pressed + .take() + .and_then(|targets| targets.last().cloned()) + .or(hit_target) + { routed.push(RoutedPointerEvent { kind: RoutedPointerEventKind::Up { button }, target, diff --git a/lib/ui_platform_wayland/src/lib.rs b/lib/ui_platform_wayland/src/lib.rs index 30ce8b8..b3928fc 100644 --- a/lib/ui_platform_wayland/src/lib.rs +++ b/lib/ui_platform_wayland/src/lib.rs @@ -148,6 +148,7 @@ enum InternalBackendEvent { struct State { running: bool, + fixed_size_request: Option<(u32, u32)>, _connection: Connection, _compositor: wl_compositor::WlCompositor, _surface: wl_surface::WlSurface, @@ -232,6 +233,18 @@ impl State { } } +fn fixed_size_request_for_spec(spec: &WindowSpec) -> Option<(u32, u32)> { + if spec.resizable { + return None; + } + let size = spec + .requested_inner_size + .or(spec.min_inner_size) + .or(spec.max_inner_size) + .unwrap_or_else(|| UiSize::new(800.0, 500.0)); + Some((size.width.max(1.0).round() as u32, size.height.max(1.0).round() as u32)) +} + fn wayland_cursor_shape(icon: CursorIcon) -> wp_cursor_shape_device_v1::Shape { match icon { CursorIcon::Default => wp_cursor_shape_device_v1::Shape::Default, @@ -364,6 +377,7 @@ impl WaylandWindow { surface_target, state: State { running: true, + fixed_size_request: fixed_size_request_for_spec(&spec), _connection: connection, _compositor: compositor, _surface: surface, @@ -697,6 +711,7 @@ impl WaylandWindow { } pub fn apply_spec(&mut self, spec: &WindowSpec) -> Result<(), Box> { + self.state.fixed_size_request = fixed_size_request_for_spec(spec); self.state._toplevel.set_title(spec.title.clone()); if let Some(app_id) = spec.app_id.as_ref() { self.state._toplevel.set_app_id(app_id.clone()); @@ -732,6 +747,14 @@ impl WaylandWindow { } if let Some((width, height)) = self.state.pending_size.take() { + trace!( + target: "ruin_ui_platform_wayland::resize", + width, + height, + previous_width = self.state.current_size.0, + previous_height = self.state.current_size.1, + "prepare_frame observed pending size change" + ); self.state.current_size = (width, height); self.state.needs_redraw = false; return Some(FrameRequest { @@ -742,6 +765,12 @@ impl WaylandWindow { } if self.state.needs_redraw { + trace!( + target: "ruin_ui_platform_wayland::resize", + width = self.state.current_size.0, + height = self.state.current_size.1, + "prepare_frame servicing redraw without size change" + ); self.state.needs_redraw = false; return Some(FrameRequest { width: self.state.current_size.0, @@ -2101,6 +2130,14 @@ impl Dispatch for State { if let xdg_surface::Event::Configure { serial } = event { xdg_surface.ack_configure(serial); state.configured = true; + trace!( + target: "ruin_ui_platform_wayland::resize", + serial, + current_width = state.current_size.0, + current_height = state.current_size.1, + has_pending_size = state.pending_size.is_some(), + "received xdg_surface configure" + ); state.request_redraw(); } } @@ -2153,7 +2190,37 @@ impl Dispatch for State { height, "received Wayland toplevel configure" ); - state.pending_size = Some((width, height)); + let next_size = (width, height); + if let Some(expected_size) = state.fixed_size_request { + if next_size != expected_size { + trace!( + target: "ruin_ui_platform_wayland::resize", + width, + height, + expected_width = expected_size.0, + expected_height = expected_size.1, + "ignoring stale toplevel configure for fixed-size window" + ); + state.request_redraw(); + return; + } + } + let size_changed = + next_size != state.current_size && state.pending_size != Some(next_size); + trace!( + target: "ruin_ui_platform_wayland::resize", + width, + height, + current_width = state.current_size.0, + current_height = state.current_size.1, + pending_width = state.pending_size.map(|size| size.0), + pending_height = state.pending_size.map(|size| size.1), + size_changed, + "processed Wayland toplevel configure" + ); + if size_changed { + state.pending_size = Some(next_size); + } state.request_redraw(); } _ => {}