Refactor ruin_app, add some README files, minor usability in reactivity
This commit is contained in:
152
lib/reactivity/README.md
Normal file
152
lib/reactivity/README.md
Normal 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).
|
||||
31
lib/reactivity/examples/readme_md_01.rs
Normal file
31
lib/reactivity/examples/readme_md_01.rs
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
49
lib/reactivity/examples/readme_md_02.rs
Normal file
49
lib/reactivity/examples/readme_md_02.rs
Normal 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);
|
||||
});
|
||||
}
|
||||
29
lib/reactivity/examples/readme_md_03.rs
Normal file
29
lib/reactivity/examples/readme_md_03.rs
Normal 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();
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
466
lib/ruin_app/src/app.rs
Normal 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(¤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<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
|
||||
}
|
||||
13
lib/ruin_app/src/colors.rs
Normal file
13
lib/ruin_app/src/colors.rs
Normal 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
187
lib/ruin_app/src/context.rs
Normal 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())()
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
43
lib/ruin_app/src/converters.rs
Normal file
43
lib/ruin_app/src/converters.rs
Normal 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
374
lib/ruin_app/src/hooks.rs
Normal 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
344
lib/ruin_app/src/input.rs
Normal 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
61
lib/ruin_app/src/primitives/button.rs
Normal file
61
lib/ruin_app/src/primitives/button.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
147
lib/ruin_app/src/primitives/container.rs
Normal file
147
lib/ruin_app/src/primitives/container.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
63
lib/ruin_app/src/primitives/mod.rs
Normal file
63
lib/ruin_app/src/primitives/mod.rs
Normal 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};
|
||||
160
lib/ruin_app/src/primitives/scroll_box.rs
Normal file
160
lib/ruin_app/src/primitives/scroll_box.rs
Normal 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
|
||||
}
|
||||
}
|
||||
129
lib/ruin_app/src/primitives/text.rs
Normal file
129
lib/ruin_app/src/primitives/text.rs
Normal 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()),
|
||||
)
|
||||
}
|
||||
}
|
||||
17
lib/ruin_app/src/surfaces.rs
Normal file
17
lib/ruin_app/src/surfaces.rs
Normal 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)
|
||||
}
|
||||
61
lib/ruin_app/src/text_style.rs
Normal file
61
lib/ruin_app/src/text_style.rs
Normal 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
239
lib/ruin_app/src/view.rs
Normal 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);
|
||||
@@ -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<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 {
|
||||
let path = &node.path;
|
||||
let value = match node.props.as_slice() {
|
||||
|
||||
0
lib/runtime/README.md
Normal file
0
lib/runtime/README.md
Normal 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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user