Refactor ruin_app, add some README files, minor usability in reactivity

This commit is contained in:
2026-03-25 22:32:11 -04:00
parent 5f67c06083
commit 341d07d82f
28 changed files with 2782 additions and 2118 deletions

152
lib/reactivity/README.md Normal file
View File

@@ -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<T>`: 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<T>`: 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<T>`: 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::<String>();
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).

View File

@@ -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);
}
});
}

View File

@@ -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);
});
}

View File

@@ -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::<String>();
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();
});
}

View File

@@ -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<EffectInner>,
}
@@ -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();

View File

@@ -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<T: 'static>(reactor: &Reactor) -> Event<T> {
}
/// 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<T: Clone + 'static>(event: &Event<T>, 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<T: Clone + 'static>(
reactor: &Reactor,
event: &Event<T>,
@@ -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<T: Clone + 'static>(
&self,
event: &Event<T>,
@@ -103,7 +106,7 @@ impl<T: 'static> Event<T> {
reactor,
id,
next_subscriber: Cell::new(1),
subscribers: RefCell::new(BTreeMap::new()),
subscribers: RefCell::new(Default::default()),
}),
}
}
@@ -204,7 +207,7 @@ struct EventInner<T> {
reactor: Reactor,
id: NodeId,
next_subscriber: Cell<usize>,
subscribers: RefCell<BTreeMap<usize, Rc<SubscriberFn<T>>>>,
subscribers: RefCell<HashMap<usize, Rc<SubscriberFn<T>>>>,
}
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 {

View File

@@ -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<i32>) -> 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<i32>) -> 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<i32>) -> 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<i32>) -> impl IntoView {
}
},
) {
"+1"
text(selectable = false, pointer_events = false) { "+1" }
}
}
}

466
lib/ruin_app/src/app.rs Normal file
View File

@@ -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<String>) -> Self {
self.spec.title = title.into();
self
}
pub fn app_id(mut self, app_id: impl Into<String>) -> 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<M: Mountable>(self, root: M) -> MountedApp<M> {
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<T: Component> 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<M: Mountable> {
pub(crate) window: Window,
pub(crate) root: Rc<M>,
pub(crate) render_state: Rc<RenderState>,
}
impl<M: Mountable> MountedApp<M> {
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::<InteractionTree>));
let bindings = Rc::new(RefCell::new(EventBindings::default()));
let shortcuts = Rc::new(RefCell::new(Vec::<ShortcutBinding>::new()));
let current_title = Rc::new(RefCell::new(None::<String>));
let last_min_size = Rc::new(RefCell::new(None::<UiSize>));
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(&current_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<Option<InteractionTree>>,
bindings: &RefCell<EventBindings>,
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<Option<InteractionTree>>,
bindings: &RefCell<EventBindings>,
shortcuts: &RefCell<Vec<ShortcutBinding>>,
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<M: Mountable>(mountable: &M) -> View {
render_with_context(Rc::new(RenderState::default()), || mountable.render()).view
}

View File

@@ -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)
}

187
lib/ruin_app/src/context.rs Normal file
View File

@@ -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<dyn Any>,
}
impl ContextEntry {
pub(crate) fn new<C: ContextKey>(value: C::Value) -> Self {
Self {
key: TypeId::of::<C>(),
value: Rc::new(value),
}
}
}
#[derive(Default)]
pub(crate) struct RenderState {
pub(crate) hooks: RefCell<Vec<Box<dyn Any>>>,
pub(crate) element_ids: RefCell<Vec<ElementId>>,
pub(crate) next_element_id: StdCell<u64>,
}
#[derive(Clone, Default)]
pub(crate) struct RenderSideEffects {
pub(crate) window_title: Option<String>,
pub(crate) shortcuts: Vec<crate::input::ShortcutBinding>,
}
#[derive(Clone)]
pub(crate) struct RenderContext {
pub(crate) state: Rc<RenderState>,
pub(crate) hook_index: Rc<StdCell<usize>>,
pub(crate) element_index: Rc<StdCell<usize>>,
pub(crate) side_effects: Rc<RefCell<RenderSideEffects>>,
pub(crate) context_entries: Rc<Vec<ContextEntry>>,
}
impl RenderContext {
pub(crate) fn with_context_entries(&self, context_entries: Rc<Vec<ContextEntry>>) -> 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<Option<RenderContext>> = const { RefCell::new(None) };
}
pub(crate) fn render_with_context(
state: Rc<RenderState>,
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<Option<RenderContext>>,
previous: Option<RenderContext>,
}
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<R>(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<T: 'static, R>(
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::<T>()
.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<T> {
pub(crate) compute: Rc<RefCell<Box<dyn Fn() -> T>>>,
pub(crate) handle: crate::hooks::Memo<T>,
}
pub(crate) struct ResourceSlot<T, E> {
pub(crate) resource: crate::hooks::Resource<T, E>,
pub(crate) _effect: ruin_reactivity::EffectHandle,
}
impl<T: 'static> MemoSlot<T> {
pub(crate) fn new(compute: Rc<RefCell<Box<dyn Fn() -> 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<RefCell<Box<dyn Fn() -> T>>>) {
self.compute = Rc::clone(&compute);
self.handle.compute = Rc::new({
let compute = Rc::clone(&compute);
move || {
let compute = compute.borrow();
(compute.as_ref())()
}
});
}
}

View File

@@ -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
}
}

374
lib/ruin_app/src/hooks.rs Normal file
View File

@@ -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<T> {
pub(crate) cell: ruin_reactivity::Cell<T>,
}
pub struct Signal<T> {
pub(crate) inner: Rc<SignalInner<T>>,
}
impl<T> Clone for Signal<T> {
fn clone(&self) -> Self {
Self {
inner: Rc::clone(&self.inner),
}
}
}
impl<T: 'static> Signal<T> {
pub(crate) fn new(initial: T) -> Self {
Self {
inner: Rc::new(SignalInner {
cell: ruin_reactivity::cell(initial),
}),
}
}
pub fn with<R>(&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<R>(&self, f: impl FnOnce(&mut T) -> R) -> R {
self.inner.cell.update(f)
}
}
impl<T: Clone + 'static> Signal<T> {
pub fn get(&self) -> T {
self.inner.cell.get()
}
}
impl<T: PartialEq + 'static> Signal<T> {
pub fn set(&self, value: T) -> Option<T> {
self.inner.cell.set(value)
}
}
pub struct Memo<T> {
pub(crate) compute: Rc<dyn Fn() -> T>,
}
impl<T> Clone for Memo<T> {
fn clone(&self) -> Self {
Self {
compute: Rc::clone(&self.compute),
}
}
}
impl<T> Memo<T> {
pub fn with<R>(&self, f: impl FnOnce(&T) -> R) -> R {
let value = (self.compute)();
f(&value)
}
}
impl<T: Clone> Memo<T> {
pub fn get(&self) -> T {
(self.compute)()
}
}
pub fn use_signal<T: 'static>(initial: impl FnOnce() -> T) -> Signal<T> {
with_hook_slot(|| Signal::new(initial()), |signal| signal.clone())
}
pub fn use_context<C: ContextKey>() -> C::Value {
with_render_context_state(|context| {
context
.context_entries
.iter()
.rev()
.find_map(|entry| {
(entry.key == TypeId::of::<C>())
.then(|| entry.value.downcast_ref::<C::Value>())
.flatten()
.cloned()
})
.unwrap_or_else(|| {
panic!(
"missing context provider for {} while rendering",
type_name::<C>()
)
})
})
}
pub fn provide<C: ContextKey>(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::<C>(value));
with_render_context(
context.with_context_entries(Rc::new(context_entries)),
render,
)
})
}
pub fn use_memo<T: 'static>(compute: impl Fn() -> T + 'static) -> Memo<T> {
use std::cell::RefCell;
let compute: Rc<RefCell<Box<dyn Fn() -> T>>> =
Rc::new(RefCell::new(Box::new(compute) as Box<dyn Fn() -> T>));
with_hook_slot(
{
let compute = Rc::clone(&compute);
move || MemoSlot::new(compute)
},
|slot: &mut MemoSlot<T>| {
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<Option<ElementId>>,
}
#[derive(Clone)]
pub enum ShortcutScope {
Application,
FocusedWithin(FocusScope),
}
impl ShortcutScope {
pub(crate) fn matches(
&self,
focused_element: Option<ElementId>,
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<T> {
pub(crate) element_id: Signal<Option<ElementId>>,
pub(crate) _marker: PhantomData<fn() -> T>,
}
impl<T> Clone for WidgetRef<T> {
fn clone(&self) -> Self {
Self {
element_id: self.element_id.clone(),
_marker: PhantomData,
}
}
}
impl<T> WidgetRef<T> {
pub(crate) fn new() -> Self {
Self {
element_id: Signal::new(None),
_marker: PhantomData,
}
}
pub fn element_id(&self) -> Option<ElementId> {
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<T: 'static>() -> WidgetRef<T> {
with_hook_slot(WidgetRef::new, |widget_ref: &mut WidgetRef<T>| {
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<T, E> {
pub(crate) state: Signal<ResourceState<T, E>>,
}
impl<T: Clone + 'static, E: Clone + 'static> Resource<T, E> {
pub fn read(&self) -> ResourceState<T, E> {
self.state.get()
}
}
#[derive(Clone)]
pub enum ResourceState<T, E> {
Pending,
Ready(std::result::Result<T, E>),
}
pub fn use_resource<T, E, Fut, F>(factory: F) -> Resource<T, E>
where
T: Clone + 'static,
E: Clone + 'static,
Fut: Future<Output = std::result::Result<T, E>> + '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<T, E>| slot.resource.clone(),
)
}

344
lib/ruin_app/src/input.rs Normal file
View File

@@ -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<dyn Fn(&RoutedPointerEvent) + 'static>;
pub(crate) type ScrollHandler = Rc<dyn Fn(&RoutedPointerEvent, &InteractionTree) + 'static>;
pub(crate) type KeyHandler = Rc<dyn Fn(&KeyboardEvent, &InteractionTree) -> bool + 'static>;
#[derive(Clone, Default)]
pub(crate) struct EventBindings {
pub(crate) on_press: HashMap<ElementId, PressHandler>,
pub(crate) on_scroll: HashMap<ElementId, ScrollHandler>,
pub(crate) on_key: HashMap<ElementId, KeyHandler>,
}
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<ElementId>,
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<Option<TextSelection>>,
pub(crate) drag: RefCell<Option<TextSelectionDrag>>,
pub(crate) version: Signal<u64>,
}
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<ElementId>,
pub(crate) text_selection: Rc<TextSelectionState>,
}
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<dyn Fn(&InteractionTree)>,
}
impl ShortcutBinding {
pub(crate) fn matches(
&self,
event: &KeyboardEvent,
focused_element: Option<ElementId>,
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<TextSelection>,
) {
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<TextSelection>,
) -> 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<ElementId, ScrollHandler>,
direct_target: Option<ElementId>,
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<ElementId> {
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<ElementId, KeyHandler>,
focused_element: Option<ElementId>,
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<Vec<ElementId>> {
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<ElementId>,
) -> 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
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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<PressHandler>,
}
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,
}
}
}

View File

@@ -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<Signal<Option<ElementId>>>,
pub(crate) partial_text_style: Option<PartialTextStyle>,
}
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<T>(mut self, widget_ref: WidgetRef<T>) -> 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)
}
}

View File

@@ -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<T>(mut self, widget_ref: $crate::hooks::WidgetRef<T>) -> 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};

View File

@@ -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<Signal<f32>>,
pub(crate) drag: Signal<Option<ScrollbarDrag>>,
}
pub fn scroll_box() -> ScrollBoxBuilder {
ScrollBoxBuilder {
props: ContainerProps::new(Element::scroll_box(0.0)),
offset_y: None,
drag: with_hook_slot(|| Signal::new(None::<ScrollbarDrag>), |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<f32>) -> 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
}
}

View File

@@ -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<TextRole>,
pub(crate) size: Option<f32>,
pub(crate) weight: Option<FontWeight>,
pub(crate) color: Option<Color>,
pub(crate) font_family: Option<TextFontFamily>,
pub(crate) wrap: Option<TextWrap>,
pub(crate) selectable: Option<bool>,
pub(crate) pointer_events: Option<bool>,
}
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()),
)
}
}

View File

@@ -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)
}

View File

@@ -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<f32>,
pub color: Option<Color>,
pub weight: Option<FontWeight>,
pub font_family: Option<TextFontFamily>,
pub wrap: Option<TextWrap>,
pub line_height: Option<f32>,
}
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<Vec<PartialTextStyle>> = 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))
})
}

239
lib/ruin_app/src/view.rs Normal file
View File

@@ -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<View>) -> 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<Element> 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<T: Component> IntoView for T {
fn into_view(self) -> View {
self.render()
}
}
pub trait Children {
fn into_views(self) -> Vec<View>;
}
#[derive(Clone, Default)]
pub struct ChildViews(pub(crate) Vec<View>);
impl ChildViews {
pub fn from_children(children: impl Children) -> Self {
Self(children.into_views())
}
pub fn into_vec(self) -> Vec<View> {
self.0
}
}
impl Children for () {
fn into_views(self) -> Vec<View> {
Vec::new()
}
}
impl<T: IntoView> Children for T {
fn into_views(self) -> Vec<View> {
vec![self.into_view()]
}
}
impl<T: IntoView> Children for Vec<T> {
fn into_views(self) -> Vec<View> {
self.into_iter().map(IntoView::into_view).collect()
}
}
impl Children for ChildViews {
fn into_views(self) -> Vec<View> {
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<F: FnOnce() -> Vec<View>>(pub F);
impl<F: FnOnce() -> Vec<View>> Children for LazyChildren<F> {
fn into_views(self) -> Vec<View> {
(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<View> {
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<T: std::fmt::Display + 'static> TextChildren for crate::hooks::Signal<T> {
fn into_text(self) -> String {
self.with(|value| value.to_string())
}
}
impl<T: std::fmt::Display + 'static> TextChildren for crate::hooks::Memo<T> {
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);

View File

@@ -686,21 +686,47 @@ 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<T: IntoView> 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 {

0
lib/runtime/README.md Normal file
View File

View File

@@ -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";
}

View File

@@ -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<T> {
/// 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,
}

View File

@@ -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;

View File

@@ -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<ElementId>,
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);