Compare commits

..

10 Commits

66 changed files with 9558 additions and 2326 deletions

85
Cargo.lock generated
View File

@@ -150,7 +150,7 @@ dependencies = [
"anyhow", "anyhow",
"arrayvec", "arrayvec",
"log", "log",
"nom", "nom 8.0.0",
"num-rational", "num-rational",
"v_frame", "v_frame",
] ]
@@ -253,6 +253,17 @@ version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "calculator"
version = "0.1.0"
dependencies = [
"meval",
"ruin-runtime",
"ruin_app",
"ruin_ui",
"tracing-subscriber",
]
[[package]] [[package]]
name = "cast" name = "cast"
version = "0.3.0" version = "0.3.0"
@@ -606,6 +617,12 @@ dependencies = [
"miniz_oxide", "miniz_oxide",
] ]
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]] [[package]]
name = "foldhash" name = "foldhash"
version = "0.1.5" version = "0.1.5"
@@ -834,6 +851,17 @@ dependencies = [
"foldhash 0.2.0", "foldhash 0.2.0",
] ]
[[package]]
name = "hashbrown"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash 0.2.0",
]
[[package]] [[package]]
name = "hermit-abi" name = "hermit-abi"
version = "0.5.2" version = "0.5.2"
@@ -1165,6 +1193,16 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "meval"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f79496a5651c8d57cd033c5add8ca7ee4e3d5f7587a4777484640d9cb60392d9"
dependencies = [
"fnv",
"nom 1.2.4",
]
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.9" version = "0.8.9"
@@ -1226,6 +1264,12 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nom"
version = "1.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5b8c256fd9471521bcb84c3cdba98921497f1a331cbc15b8030fc63b82050ce"
[[package]] [[package]]
name = "nom" name = "nom"
version = "8.0.0" version = "8.0.0"
@@ -1241,6 +1285,15 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys",
]
[[package]] [[package]]
name = "num-bigint" name = "num-bigint"
version = "0.4.6" version = "0.4.6"
@@ -1838,6 +1891,14 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "ruin-reactivity-parsing"
version = "0.1.0"
dependencies = [
"ruin-runtime",
"ruin_reactivity",
]
[[package]] [[package]]
name = "ruin-runtime" name = "ruin-runtime"
version = "0.1.0" version = "0.1.0"
@@ -1877,6 +1938,7 @@ dependencies = [
name = "ruin_reactivity" name = "ruin_reactivity"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"hashbrown 0.17.0",
"ruin-runtime", "ruin-runtime",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
@@ -2320,6 +2382,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [ dependencies = [
"once_cell", "once_cell",
"valuable",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"log",
"once_cell",
"tracing-core",
] ]
[[package]] [[package]]
@@ -2329,12 +2403,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
dependencies = [ dependencies = [
"matchers", "matchers",
"nu-ansi-term",
"once_cell", "once_cell",
"regex-automata", "regex-automata",
"sharded-slab", "sharded-slab",
"smallvec",
"thread_local", "thread_local",
"tracing", "tracing",
"tracing-core", "tracing-core",
"tracing-log",
] ]
[[package]] [[package]]
@@ -2399,6 +2476,12 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]] [[package]]
name = "version_check" name = "version_check"
version = "0.9.5" version = "0.9.5"

18
LICENSE.txt Normal file
View File

@@ -0,0 +1,18 @@
Copyright 2026 Will Temple
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the “Software”), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

14
README.md Normal file
View File

@@ -0,0 +1,14 @@
# RUIN - Rust Native Application Framework
A layered architecture for fast, native, cross-platform applications.
RUIN is a modern asynchronous runtime for interactive Rust applications on personal computers. It has a simple
threading and scheduling model, fast asynchronous I/O (io_uring on Linux, kqueue on macOS, and IOCP on windows),
and several libraries to enable building modern, reactive applications.
## Runtime
RUIN's core runtime is provided in [`ruin-runtime`](./lib/runtime/).
The runtime is non-workstealing with no threadpool. When a RUIN application starts, it runs a simple event loop on the
main thread.

View File

@@ -0,0 +1,15 @@
[package]
name = "calculator"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "calculator"
path = "src/main.rs"
[dependencies]
ruin_app = { path = "../../lib/ruin_app" }
ruin_ui = { path = "../../lib/ui" }
ruin-runtime = { path = "../../lib/runtime" }
meval = "0.2"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

View File

@@ -0,0 +1,379 @@
use ruin_app::prelude::*;
use ruin_ui::{BoxShadow, BoxShadowKind, Point, TextFontFamily};
use crate::state::{CalcMode, CalcState};
// ---------------------------------------------------------------------------
// Display panel
// ---------------------------------------------------------------------------
#[component]
pub fn Display(state: CalcState) -> impl IntoView {
let expression = state.expression.clone();
let is_error = state.is_error.clone();
let history = state.history.clone();
let result_color = use_memo({
let is_error = is_error.clone();
move || {
if is_error.get() {
Color::rgba(200, 30, 30, 245)
} else {
Color::rgba(20, 140, 20, 245)
}
}
});
let result_text = use_memo({
let result = state.result.clone();
move || {
let r = result.get();
if r.is_empty() {
" ".to_string()
} else {
format!("= {r}")
}
}
});
view! {
block(
padding = 12.0,
gap = 8.0,
// min_height (content) sized to cover scroll_box + expression + result lines
// so the window auto-sizes correctly despite text min-size being 0.
// Content: scroll_box(100) + expr_text(31) + result_text(20) + 2×gap(16) = 167
min_height = 167.0,
align_self = AlignItems::Stretch,
align_items = AlignItems::End,
background = Color::rgba(255, 255, 255, 200),
border_radius = 12.0,
border = (1.0, Color::rgba(0, 0, 0, 60)),
shadow = BoxShadow::new(Point::new(2.0, 2.0), 8.0, 2.0, Color::rgba(0, 0, 0, 40), BoxShadowKind::Outer),
) {
block(flex = 1.0, min_height = 0.0) {}
// History (oldest at top, newest at bottom, scrollable)
scroll_box(
height = 100.0,
offset_y = state.history_scroll.clone(),
align_self = AlignItems::Stretch,
) {
column(padding = 4.0, gap = 2.0, align_items = AlignItems::End) {
history.with(|entries| {
entries
.iter()
.rev()
.map(|e| {
let expr = e.expression.clone();
let res = e.result.clone();
let s = state.clone();
let res_for_press = res.clone();
view! {
button(
align_self = AlignItems::End,
padding = 4.0,
background = Color::rgba(0, 0, 0, 0),
border_radius = 4.0,
on_press = move |_| s.use_history_result(res_for_press.clone()),
) {
row(gap = 6.0, align_self = AlignItems::End) {
text(size = 12.0, color = Color::rgba(0, 0, 0, 200), font_family = TextFontFamily::Monospace, wrap = TextWrap::None, weight = FontWeight::Normal) {
expr.clone()
}
text(size = 12.0, color = Color::rgba(20, 130, 20, 230), font_family = TextFontFamily::Monospace, wrap = TextWrap::None, weight = FontWeight::Normal) {
format!("= {res}")
}
}
}
}
})
.collect::<Vec<_>>()
})
}
}
// Current expression
text(
size = 26.0,
font_family = TextFontFamily::Monospace,
color = Color::rgba(0, 0, 0, 245),
wrap = TextWrap::None,
weight = FontWeight::Normal,
pointer_events = false,
) {
expression.get()
}
// Result / error line
if is_error.get() {
view! {
text(
size = 16.0,
font_family = TextFontFamily::Monospace,
color = result_color.get(),
wrap = TextWrap::None,
weight = FontWeight::Normal,
pointer_events = false,
) {
result_text.get()
}
}
} else {
view! {
block(min_height = 0.0) {}
}
}
}
}
}
// ---------------------------------------------------------------------------
// Mode toggle
// ---------------------------------------------------------------------------
#[component]
pub fn ModeToggle(state: CalcState) -> impl IntoView {
let mode = state.mode.clone();
view! {
row(gap = 6.0, align_self = AlignItems::Stretch) {
toggle_button("Basic", mode.get() == CalcMode::Basic, {
let state = state.clone();
move || state.toggle_mode()
})
toggle_button("Scientific", mode.get() == CalcMode::Scientific, {
let state = state.clone();
move || state.toggle_mode()
})
}
}
}
// ---------------------------------------------------------------------------
// Button helpers
// ---------------------------------------------------------------------------
fn calc_button(label: String, bg: Color, on_press: impl Fn() + 'static) -> View {
let widget_ref = use_widget_ref::<BlockWidget>();
let interaction = use_interaction_state(widget_ref.clone());
button()
.widget_ref(widget_ref)
.flex(1.0)
.min_width(54.0)
// min_height = content height for 20px bold text (20 * 1.2 = 24).
// This matches the actual rendered line height so window sizing is accurate
// and the text is visually centered by the symmetric 8px top/bottom padding.
.min_height(24.0)
.padding(Edges::symmetric(8.0, 12.0))
.align_items(AlignItems::Center)
.background(interactive_background(bg, interaction))
.border_radius(8.0)
.border((0.5, interactive_border(interaction)))
.shadow(interactive_shadow(interaction))
.on_press(move |_| on_press())
.children(
text()
.size(20.0)
.weight(FontWeight::Bold)
.selectable(false)
.pointer_events(false)
.children(label),
)
}
fn btn_row(buttons: Vec<View>) -> View {
row().gap(6.0).children(buttons)
}
fn toggle_button(label: &'static str, active: bool, on_press: impl Fn() + 'static) -> View {
let widget_ref = use_widget_ref::<BlockWidget>();
let interaction = use_interaction_state(widget_ref.clone());
let base = if active {
Color::rgba(60, 100, 220, 180)
} else {
Color::rgba(255, 255, 255, 90)
};
button()
.widget_ref(widget_ref)
.flex(1.0)
.padding(8.0)
.min_height(17.0)
.align_items(AlignItems::Center)
.background(interactive_background(base, interaction))
.border_radius(8.0)
.border((0.5, interactive_border(interaction)))
.shadow(interactive_shadow(interaction))
.on_press(move |_| on_press())
.children(
text()
.size(14.0)
.weight(FontWeight::Bold)
.selectable(false)
.pointer_events(false)
.children(label),
)
}
fn interactive_background(base: Color, interaction: InteractionState) -> Color {
if interaction.pressed {
mix(base, Color::BLACK, 0.22)
} else if interaction.hovered {
mix(base, Color::WHITE, 0.22)
} else {
base
}
}
fn interactive_border(interaction: InteractionState) -> Color {
if interaction.pressed {
Color::rgba(0, 0, 0, 96)
} else if interaction.hovered {
Color::rgba(0, 0, 0, 72)
} else {
Color::rgba(0, 0, 0, 35)
}
}
fn interactive_shadow(interaction: InteractionState) -> BoxShadow {
if interaction.pressed {
BoxShadow::new(
Point::new(0.0, 0.0),
2.0,
0.0,
Color::rgba(0, 0, 0, 36),
BoxShadowKind::Outer,
)
} else if interaction.hovered {
BoxShadow::new(
Point::new(0.0, 3.0),
14.0,
0.0,
Color::rgba(0, 0, 0, 42),
BoxShadowKind::Outer,
)
} else {
BoxShadow::new(
Point::new(0.0, 1.0),
6.0,
0.0,
Color::rgba(0, 0, 0, 18),
BoxShadowKind::Outer,
)
}
}
fn mix(base: Color, overlay: Color, amount: f32) -> Color {
let amount = amount.clamp(0.0, 1.0);
let blend = |from: u8, to: u8| -> u8 {
((from as f32) + ((to as f32) - (from as f32)) * amount).round() as u8
};
Color::rgba(
blend(base.r, overlay.r),
blend(base.g, overlay.g),
blend(base.b, overlay.b),
base.a,
)
}
// ---------------------------------------------------------------------------
// Keypad (basic)
// ---------------------------------------------------------------------------
#[component]
pub fn Keypad(state: CalcState) -> impl IntoView {
let bg_num = Color::rgba(255, 255, 255, 90);
let bg_op = Color::rgba(210, 230, 255, 140);
let bg_eq = Color::rgba(160, 230, 160, 160);
let bg_fn = Color::rgba(255, 200, 180, 140);
macro_rules! digit {
($ch:expr) => {{
let s = state.clone();
let t = $ch.to_string();
let label = t.clone();
calc_button(label, bg_num, move || s.input(&t))
}};
}
macro_rules! op {
($ch:expr) => {{
let s = state.clone();
let t = $ch.to_string();
let label = t.clone();
calc_button(label, bg_op, move || s.input(&t))
}};
}
let clear_btn = {
let s = state.clone();
calc_button("C".to_string(), bg_fn, move || s.clear())
};
let paren_btn = {
let s = state.clone();
calc_button("( )".to_string(), bg_num, move || s.input_paren())
};
let pct_btn = {
let s = state.clone();
calc_button("%".to_string(), bg_num, move || s.input("%"))
};
let bs_btn = {
let s = state.clone();
calc_button("".to_string(), bg_fn, move || s.backspace())
};
let dot_btn = {
let s = state.clone();
calc_button(".".to_string(), bg_num, move || s.input("."))
};
let eq_btn = {
let s = state.clone();
calc_button("=".to_string(), bg_eq, move || s.evaluate())
};
view! {
column(gap = 6.0, align_self = AlignItems::Stretch) {
btn_row(vec![clear_btn, paren_btn, pct_btn, bs_btn])
btn_row(vec![digit!('7'), digit!('8'), digit!('9'), op!('÷')])
btn_row(vec![digit!('4'), digit!('5'), digit!('6'), op!('×')])
btn_row(vec![digit!('1'), digit!('2'), digit!('3'), op!('-')])
btn_row(vec![digit!('0'), dot_btn, eq_btn, op!('+')])
}
}
}
// ---------------------------------------------------------------------------
// Scientific panel
// ---------------------------------------------------------------------------
#[component]
pub fn ScientificPanel(state: CalcState) -> impl IntoView {
let bg = Color::rgba(230, 215, 255, 140);
macro_rules! sci {
($label:expr, $insert:expr) => {{
let s = state.clone();
let ins: &'static str = $insert;
calc_button($label.to_string(), bg, move || s.input(ins))
}};
}
let sq_btn = {
let s = state.clone();
calc_button("".to_string(), bg, move || {
s.expression.update(|e| {
let wrapped = format!("({e})^2");
*e = wrapped;
});
})
};
view! {
column(gap = 6.0, align_self = AlignItems::Stretch) {
btn_row(vec![sci!("sin(", "sin("), sci!("cos(", "cos("), sci!("tan(", "tan("), sci!("asin(", "asin(")])
btn_row(vec![sci!("log(", "log("), sci!("ln(", "ln("), sci!("√(", "√("), sq_btn])
btn_row(vec![sci!("", "^"), sci!("π", "π"), sci!("e", "e"), sci!("fact(", "fact(")])
}
}
}

View File

@@ -0,0 +1,185 @@
mod components;
mod state;
use ruin_app::prelude::*;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::{fmt, EnvFilter};
use components::{Display, Keypad, ModeToggle, ScientificPanel};
use state::{CalcMode, CalcState};
#[ruin_runtime::async_main]
async fn main() -> ruin_app::Result<()> {
init_tracing();
App::new()
.window(
Window::new()
.title("Calculator")
.app_id("dev.ruin.calculator")
.base_color(Color::rgba(255, 255, 255, 80))
.resizable(false),
)
.mount(view! { CalculatorApp() {} })
.run()
.await
}
fn init_tracing() {
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| {
EnvFilter::new(
"info,ruin_app::resize=trace,ruin_app::interaction=trace,\
ruin_ui_platform_wayland::resize=trace,ruin_ui_platform_wayland::perf=debug,\
ruin_ui_platform_wayland::event_bridge=trace",
)
});
let _ = tracing_subscriber::registry()
.with(fmt::layer().with_target(true).without_time())
.with(filter)
.try_init();
}
#[component]
fn CalculatorApp() -> impl IntoView {
let state = CalcState::new();
// ---- Keyboard shortcuts (application-scoped) ----
for ch in "0123456789.+-*/%()".chars() {
let s = state.clone();
let display = match ch {
'*' => "×",
'/' => "÷",
_ => "",
};
let text = if display.is_empty() {
ch.to_string()
} else {
display.to_string()
};
use_shortcut(
Shortcut::new(Key::Character(ch)),
ShortcutScope::Application,
{
let text = text.clone();
move || s.input(&text)
},
);
}
// = key also evaluates
{
let s = state.clone();
use_shortcut(
Shortcut::new(Key::Character('=')),
ShortcutScope::Application,
move || s.evaluate(),
);
}
{
let s = state.clone();
use_shortcut(
Shortcut::new(Key::Enter),
ShortcutScope::Application,
move || s.evaluate(),
);
}
{
let s = state.clone();
use_shortcut(
Shortcut::new(Key::Backspace),
ShortcutScope::Application,
move || s.backspace(),
);
}
{
let s = state.clone();
use_shortcut(
Shortcut::new(Key::Escape),
ShortcutScope::Application,
move || s.clear(),
);
}
{
let s = state.clone();
use_shortcut(
Shortcut::new(Key::Delete),
ShortcutScope::Application,
move || s.clear(),
);
}
// ---- Layout ----
let mode = state.mode.clone();
let text_style = PartialTextStyle {
color: Some(Color::BLACK),
..Default::default()
};
if mode.get() == CalcMode::Scientific {
view! {
column(
gap = 10.0,
padding = 12.0,
align_items = AlignItems::Stretch,
text_style = text_style,
) {
Display(state = state.clone()) {}
ModeToggle(state = state.clone()) {}
ScientificPanel(state = state.clone()) {}
Keypad(state = state.clone()) {}
}
}
} else {
view! {
column(
gap = 10.0,
padding = 12.0,
align_items = AlignItems::Stretch,
text_style = text_style,
) {
Display(state = state.clone()) {}
ModeToggle(state = state.clone()) {}
Keypad(state = state.clone()) {}
}
}
}
}
#[cfg(test)]
mod tests {
use std::cell::RefCell;
use std::rc::Rc;
use super::*;
#[component]
fn StateProbe(slot: Rc<RefCell<Option<CalcState>>>) -> impl IntoView {
let state = CalcState::new();
*slot.borrow_mut() = Some(state);
block().children(())
}
#[test]
fn successful_evaluation_replaces_expression_and_preserves_history() {
let slot = Rc::new(RefCell::new(None::<CalcState>));
let _ = ruin_app::__render_mountable_for_test(&view! {
StateProbe(slot = Rc::clone(&slot)) {}
});
let state = slot.borrow().clone().expect("state should be captured");
let _ = state.expression.set("2+2".to_string());
state.evaluate();
assert_eq!(state.expression.get(), "4");
assert_eq!(state.result.get(), "");
assert_eq!(state.is_error.get(), false);
assert_eq!(state.history.get().len(), 1);
assert_eq!(state.history.get()[0].expression, "2+2");
assert_eq!(state.history.get()[0].result, "4");
}
}

View File

@@ -0,0 +1,198 @@
use ruin_app::prelude::*;
type EvalResult = std::result::Result<f64, String>;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum CalcMode {
#[default]
Basic,
Scientific,
}
#[derive(Clone, Debug)]
pub struct HistoryEntry {
pub expression: String,
pub result: String,
}
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
#[derive(Clone)]
pub struct CalcState {
/// The expression currently shown in the input display.
pub expression: Signal<String>,
/// The result of the last evaluation (or error message). Empty until first `=`.
pub result: Signal<String>,
/// Whether the last evaluation produced an error.
pub is_error: Signal<bool>,
/// Past evaluations, newest first.
pub history: Signal<Vec<HistoryEntry>>,
pub mode: Signal<CalcMode>,
/// When true, the next digit/operator input clears the expression first.
pub fresh: Signal<bool>,
/// Current scroll offset for the history scroll box.
pub history_scroll: Signal<f32>,
}
impl CalcState {
pub fn new() -> Self {
Self {
expression: use_signal(|| String::new()),
result: use_signal(|| String::new()),
is_error: use_signal(|| false),
history: use_signal(|| Vec::new()),
mode: use_signal(|| CalcMode::Basic),
fresh: use_signal(|| false),
history_scroll: use_signal(|| 10_000.0_f32),
}
}
/// Append `text` to the expression. If `fresh` is set (just evaluated),
/// clear the expression first so digits don't append to the old result.
pub fn input(&self, text: &str) {
if self.fresh.get() {
// Starting fresh after a result: if it's an operator continue
// from the result, otherwise start a new expression.
let is_operator = matches!(text, "+" | "-" | "×" | "÷" | "^" | "%");
if !is_operator {
let _ = self.expression.set(String::new());
}
let _ = self.fresh.set(false);
}
self.expression.update(|e| e.push_str(text));
}
/// Delete the last character (or close-paren group) from the expression.
pub fn backspace(&self) {
if self.fresh.get() {
let _ = self.fresh.set(false);
let _ = self.expression.set(String::new());
return;
}
self.expression.update(|e| {
// Pop a full UTF-8 character
let new_len = e.char_indices().next_back().map(|(i, _)| i).unwrap_or(0);
e.truncate(new_len);
});
}
/// Clear the expression, result, and fresh flag.
pub fn clear(&self) {
let _ = self.expression.set(String::new());
let _ = self.result.set(String::new());
let _ = self.is_error.set(false);
let _ = self.fresh.set(false);
}
/// Evaluate the current expression, update result, push to history.
pub fn evaluate(&self) {
let expr = self.expression.with(|e| e.clone());
if expr.is_empty() {
return;
}
match evaluate(&expr) {
Ok(value) => {
let result_str = format_result(value);
let _ = self.expression.set(result_str.clone());
let _ = self.result.set(String::new());
let _ = self.is_error.set(false);
self.history.update(|h| {
h.insert(
0,
HistoryEntry {
expression: expr.clone(),
result: result_str,
},
);
h.truncate(50);
});
}
Err(msg) => {
let _ = self.result.set(msg);
let _ = self.is_error.set(true);
}
}
let _ = self.fresh.set(true);
let _ = self.history_scroll.set(10_000.0);
}
/// Set the expression to a previously computed result (e.g., when clicking a history entry).
pub fn use_history_result(&self, result: String) {
let _ = self.expression.set(result);
let _ = self.result.set(String::new());
let _ = self.is_error.set(false);
let _ = self.fresh.set(false);
}
pub fn toggle_mode(&self) {
self.mode.update(|m| {
*m = match *m {
CalcMode::Basic => CalcMode::Scientific,
CalcMode::Scientific => CalcMode::Basic,
};
});
}
/// Insert the right parenthesis character based on current balance.
pub fn input_paren(&self) {
let open = self
.expression
.with(|e| e.chars().filter(|&c| c == '(').count());
let close = self
.expression
.with(|e| e.chars().filter(|&c| c == ')').count());
self.input(if open > close { ")" } else { "(" });
}
}
// ---------------------------------------------------------------------------
// Evaluation
// ---------------------------------------------------------------------------
/// Evaluate `raw_expr`, preprocessing display symbols to meval-compatible syntax.
pub fn evaluate(raw_expr: &str) -> EvalResult {
let expr = raw_expr
.replace('×', "*")
.replace('÷', "/")
.replace('π', "pi")
// √( is inserted by the scientific button; meval knows sqrt
.replace("", "sqrt");
let mut ctx = meval::Context::new();
// Add factorial — only integers 0..=20 to avoid overflow
ctx.func("fact", |x: f64| {
if x < 0.0 || x.fract() != 0.0 || x > 20.0 {
return f64::NAN;
}
(1..=(x as u64)).product::<u64>() as f64
});
meval::eval_str_with_context(&expr, ctx).map_err(|e| e.to_string())
}
/// Format an f64 result: use integer display when possible, otherwise up to 10 sig figs.
fn format_result(value: f64) -> String {
if value.is_nan() {
return "Error".to_string();
}
if value.is_infinite() {
return if value > 0.0 {
"".to_string()
} else {
"-∞".to_string()
};
}
if value.fract() == 0.0 && value.abs() < 1e15 {
return format!("{}", value as i64);
}
// Up to 10 significant figures, strip trailing zeros
let s = format!("{:.10}", value);
let s = s.trim_end_matches('0').trim_end_matches('.');
s.to_string()
}

View File

@@ -101,6 +101,7 @@ async fn run_demo() -> Result<(), Box<dyn Error>> {
window.surface_target(), window.surface_target(),
initial_size.width as u32, initial_size.width as u32,
initial_size.height as u32, initial_size.height as u32,
ruin_ui::Color::rgb(0x08, 0x0C, 0x14),
) )
.expect("wgpu renderer should initialize"); .expect("wgpu renderer should initialize");
let state = Rc::new(RefCell::new(WorkerState { let state = Rc::new(RefCell::new(WorkerState {

View File

@@ -795,6 +795,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
let LayoutSnapshot { let LayoutSnapshot {
scene, scene,
interaction_tree: next_interaction_tree, interaction_tree: next_interaction_tree,
..
} = build_snapshot( } = build_snapshot(
viewport, viewport,
version, version,
@@ -1077,6 +1078,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
let LayoutSnapshot { let LayoutSnapshot {
scene, scene,
interaction_tree: next_interaction_tree, interaction_tree: next_interaction_tree,
..
} = build_snapshot( } = build_snapshot(
viewport, viewport,
version, version,

View File

@@ -36,6 +36,7 @@ fn main() -> Result<(), Box<dyn Error>> {
window.surface_target(), window.surface_target(),
scene.logical_size.width as u32, scene.logical_size.width as u32,
scene.logical_size.height as u32, scene.logical_size.height as u32,
ruin_ui::Color::rgb(0x08, 0x0C, 0x14),
)?; )?;
println!("Opening RUIN Wayland / wgpu demo window..."); println!("Opening RUIN Wayland / wgpu demo window...");

View File

@@ -5,7 +5,12 @@ edition = "2024"
[dependencies] [dependencies]
ruin_runtime = { package = "ruin-runtime", path = "../runtime" } ruin_runtime = { package = "ruin-runtime", path = "../runtime" }
tracing = { version = "0.1", default-features = false, features = ["std"] } tracing = { version = "0.1", default-features = false }
hashbrown = "0.17"
[dev-dependencies] [dev-dependencies]
tracing-subscriber = { version = "0.3", default-features = false, features = ["env-filter", "fmt", "std"] } tracing-subscriber = { version = "0.3", default-features = false, features = [
"env-filter",
"fmt",
"std",
] }

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

@@ -0,0 +1,152 @@
# RUIN Reactivity - Fine-Grained Reactor
Automatic thread-stack-based reactivity for RUIN-apps.
RUIN Reactivity provides an implementation of fine-grained reactivity primitives
for dependency tracking and incremental computation. Those primitives are:
- `Cell<T>`: a tracked-state value cell, primitively observable (sometimes
called a "signal" in other reactor implementations).
- `effect`: a primitive observer that runs once, observes its dependencies, and
runs again whenever its dependencies change.
- `Thunk<T>`: a tracked-state recomputable value defined by a closure.
Invalidated if any of its dependencies change. A `Thunk` is both an observer
and an observable.
- `Event<T>`: a push-style source of events of type `T`. Supports subscription
and cancellation of interest in events.
RUIN reactivity requires the RUIN runtime to function, and cannot be used on
threads not managed by the RUIN runtime.
RUIN reactivity does not function across thread boundaries. It tracks
dependencies between entities on the same thread only.
## Examples
### Observe a cell using an effect
```rs
use std::time::Duration;
use ruin_reactivity::{cell, effect};
use ruin_runtime::{main, set_timeout};
#[main]
fn main() {
// Creates an observable value. Calling `.get` from within an observer will create a dependency, and calling `.set`
// will trigger updates to any dependent observers.
let v = cell(5);
// Creates an observer that prints the value of `v` whenever it changes.
// Calling `.leak()` on the effect handle allows it to run for the lifetime of the program without automatically
// disposing when dropped.
effect({
let v = v.clone();
move || {
println!("v is: {}", v.get());
}
})
.leak();
// Queue a future to wait 5 seconds and then update `v`. This will trigger
// the effect to run again and print the new value.
set_timeout(Duration::from_secs(5), {
let v = v.clone();
move || {
v.set(v.get() + 20);
}
});
}
```
### Observe a thunk using an effect
```rs
use std::time::Duration;
use ruin_reactivity::{cell, effect, thunk};
use ruin_runtime::{clear_interval, main, set_interval, set_timeout};
#[main]
fn main() {
// Two primitive observable values.
let x = cell(5);
let y = cell(10);
// A derived observable value that depends on `x` and `y`. The closure will only run when `x` or `y` change, and the
// result will be cached until then.
let z = thunk({
let x = x.clone();
let y = y.clone();
move || {
println!("calculating z...");
x.get() + y.get()
}
});
// The effect observes `z`, so it will run whenever `z` changes. Because `z` depends on `x` and `y`, the effect will
// run whenever `x` or `y` change.
effect({
let z = z.clone();
move || {
println!("z is: {}", z.get());
}
})
.leak();
// Update `x` and `y` every second. This will trigger the effect to run and print the new value of `z`.
let interval = set_interval(Duration::from_secs(1), {
let x = x.clone();
let y = y.clone();
move || {
x.update(|value| *value += 1);
y.update(|value| *value += 2);
}
});
// After 10 seconds, clear the interval to stop updating `x` and `y`. Once the interval is cleared, the queue will
// empty and the program will exit since there are no more pending tasks.
set_timeout(Duration::from_secs(10), move || {
println!("clearing interval...");
clear_interval(&interval);
});
}
```
### Use an event to handle intra-thread messaging
```rs
use std::{cell::Cell, rc::Rc, time::Duration};
use ruin_reactivity::event;
use ruin_runtime::{main, queue_future, set_interval, time::sleep};
#[main]
fn main() {
let my_event = event::<String>();
my_event.subscribe(|message| {
println!("got event with message: {message}");
});
// Emit an event every 250ms with an incrementing count.
let interval = set_interval(Duration::from_millis(250), {
let counter = Rc::new(Cell::new(0));
move || {
let count = counter.get();
my_event.emit(format!("the count is {}", count));
counter.set(count + 1);
}
});
// After 5 seconds, clear the interval to stop emitting events.
queue_future(async move {
sleep(Duration::from_secs(5)).await;
interval.clear();
});
}
```
## License
Licensed under the [MIT License](../../LICENSE.txt).

View File

@@ -0,0 +1,31 @@
use std::time::Duration;
use ruin_reactivity::{cell, effect};
use ruin_runtime::{main, set_timeout};
#[main]
fn main() {
// Creates an observable value. Calling `.get` from within an observer will create a dependency, and calling `.set`
// will trigger updates to any dependent observers.
let v = cell(5);
// Creates an observer that prints the value of `v` whenever it changes.
// Calling `.leak()` on the effect handle allows it to run for the lifetime of the program without automatically
// disposing when dropped.
effect({
let v = v.clone();
move || {
println!("v is: {}", v.get());
}
})
.leak();
// Queue a future to wait 5 seconds and then update `v`. This will trigger
// the effect to run again and print the new value.
set_timeout(Duration::from_secs(5), {
let v = v.clone();
move || {
v.set(v.get() + 20);
}
});
}

View File

@@ -0,0 +1,49 @@
use std::time::Duration;
use ruin_reactivity::{cell, effect, thunk};
use ruin_runtime::{clear_interval, main, set_interval, set_timeout};
#[main]
fn main() {
// Two primitive observable values.
let x = cell(5);
let y = cell(10);
// A derived observable value that depends on `x` and `y`. The closure will only run when `x` or `y` change, and the
// result will be cached until then.
let z = thunk({
let x = x.clone();
let y = y.clone();
move || {
println!("calculating z...");
x.get() + y.get()
}
});
// The effect observes `z`, so it will run whenever `z` changes. Because `z` depends on `x` and `y`, the effect will
// run whenever `x` or `y` change.
effect({
let z = z.clone();
move || {
println!("z is: {}", z.get());
}
})
.leak();
// Update `x` and `y` every second. This will trigger the effect to run and print the new value of `z`.
let interval = set_interval(Duration::from_secs(1), {
let x = x.clone();
let y = y.clone();
move || {
x.update(|value| *value += 1);
y.update(|value| *value += 2);
}
});
// After 10 seconds, clear the interval to stop updating `x` and `y`. Once the interval is cleared, the queue will
// empty and the program will exit since there are no more pending tasks.
set_timeout(Duration::from_secs(10), move || {
println!("clearing interval...");
clear_interval(&interval);
});
}

View File

@@ -0,0 +1,29 @@
use std::{cell::Cell, rc::Rc, time::Duration};
use ruin_reactivity::event;
use ruin_runtime::{main, queue_future, set_interval, time::sleep};
#[main]
fn main() {
let my_event = event::<String>();
my_event.subscribe(|message| {
println!("got event with message: {message}");
});
// Emit an event every 250ms with an incrementing count.
let interval = set_interval(Duration::from_millis(250), {
let counter = Rc::new(Cell::new(0));
move || {
let count = counter.get();
my_event.emit(format!("the count is {}", count));
counter.set(count + 1);
}
});
// After 5 seconds, clear the interval to stop emitting events.
queue_future(async move {
sleep(Duration::from_secs(5)).await;
interval.clear();
});
}

View File

@@ -1,5 +1,5 @@
use std::cell::RefCell; use alloc::rc::Rc;
use std::rc::Rc; use core::cell::RefCell;
use crate::{NodeId, Reactor, current, trace_targets}; use crate::{NodeId, Reactor, current, trace_targets};
@@ -114,7 +114,7 @@ impl<T: PartialEq + 'static> Cell<T> {
return None; return None;
} }
let previous = std::mem::replace(&mut *current, value); let previous = core::mem::replace(&mut *current, value);
drop(current); drop(current);
tracing::debug!( tracing::debug!(
target: trace_targets::CELL, target: trace_targets::CELL,

View File

@@ -1,10 +1,14 @@
use std::cell::{Cell, RefCell}; use alloc::boxed::Box;
use std::rc::{Rc, Weak}; use alloc::rc::{Rc, Weak};
use core::cell::{Cell, RefCell};
use crate::reactor::ObserverHook; use crate::reactor::ObserverHook;
use crate::{NodeId, Reactor, current, trace_targets}; use crate::{NodeId, Reactor, current, trace_targets};
/// Creates an effect in the current thread's default reactor. /// 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 { pub fn effect(f: impl Fn() + 'static) -> EffectHandle {
current().effect(f) current().effect(f)
} }
@@ -16,6 +20,7 @@ pub fn effect_in(reactor: &Reactor, f: impl Fn() + 'static) -> EffectHandle {
/// Disposable handle for a reactive effect. /// Disposable handle for a reactive effect.
#[derive(Clone)] #[derive(Clone)]
#[must_use = "effects are automatically disposed when dropped, so you must use the handle or explicitly leak it"]
pub struct EffectHandle { pub struct EffectHandle {
inner: Rc<EffectInner>, inner: Rc<EffectInner>,
} }
@@ -55,6 +60,14 @@ impl EffectHandle {
Self { inner } 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) {
core::mem::forget(self);
}
/// Disposes the effect immediately. /// Disposes the effect immediately.
pub fn dispose(&self) { pub fn dispose(&self) {
self.inner.dispose(); self.inner.dispose();

View File

@@ -1,6 +1,9 @@
use std::cell::{Cell, RefCell}; use alloc::boxed::Box;
use std::collections::BTreeMap; use alloc::rc::Rc;
use std::rc::Rc; use alloc::vec::Vec;
use core::cell::{Cell, RefCell};
use hashbrown::HashMap;
use crate::{NodeId, Reactor, current, trace_targets}; use crate::{NodeId, Reactor, current, trace_targets};
@@ -17,11 +20,13 @@ pub fn event_in<T: 'static>(reactor: &Reactor) -> Event<T> {
} }
/// Creates a reactive draining subscription for `event` in the current reactor. /// 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 { pub fn on<T: Clone + 'static>(event: &Event<T>, handler: impl Fn(&T) + 'static) -> Subscription {
current().on(event, handler) current().on(event, handler)
} }
/// Creates a reactive draining subscription for `event` associated with `reactor`. /// 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>( pub fn on_in<T: Clone + 'static>(
reactor: &Reactor, reactor: &Reactor,
event: &Event<T>, event: &Event<T>,
@@ -49,6 +54,7 @@ impl Reactor {
} }
/// Creates a reactive draining subscription for `event`. /// 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>( pub fn on<T: Clone + 'static>(
&self, &self,
event: &Event<T>, event: &Event<T>,
@@ -103,7 +109,7 @@ impl<T: 'static> Event<T> {
reactor, reactor,
id, id,
next_subscriber: Cell::new(1), next_subscriber: Cell::new(1),
subscribers: RefCell::new(BTreeMap::new()), subscribers: RefCell::new(Default::default()),
}), }),
} }
} }
@@ -204,7 +210,7 @@ struct EventInner<T> {
reactor: Reactor, reactor: Reactor,
id: NodeId, id: NodeId,
next_subscriber: Cell<usize>, next_subscriber: Cell<usize>,
subscribers: RefCell<BTreeMap<usize, Rc<SubscriberFn<T>>>>, subscribers: RefCell<HashMap<usize, Rc<SubscriberFn<T>>>>,
} }
struct SubscriptionInner { struct SubscriptionInner {
@@ -227,11 +233,12 @@ impl SubscriptionInner {
} }
} }
impl Drop for SubscriptionInner { // TODO: it's actually kind of hard to use events if subcscriptions have to be manually managed.
fn drop(&mut self) { // impl Drop for SubscriptionInner {
self.unsubscribe(); // fn drop(&mut self) {
} // self.unsubscribe();
} // }
// }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {

View File

@@ -4,6 +4,11 @@
//! single-threaded and designed to live on a runtime-managed thread, while async work feeds it //! single-threaded and designed to live on a runtime-managed thread, while async work feeds it
//! from the edges by updating state or emitting events. //! from the edges by updating state or emitting events.
#![feature(thread_local)]
#![cfg_attr(not(test), no_std)]
extern crate alloc;
pub(crate) mod trace_targets { pub(crate) mod trace_targets {
pub const GRAPH: &str = "ruin_reactivity::graph"; pub const GRAPH: &str = "ruin_reactivity::graph";
pub const CELL: &str = "ruin_reactivity::cell"; pub const CELL: &str = "ruin_reactivity::cell";
@@ -18,6 +23,7 @@ mod effect;
mod event; mod event;
mod id; mod id;
mod reactor; mod reactor;
mod source;
mod thunk; mod thunk;
pub use cell::{Cell, cell, cell_in}; pub use cell::{Cell, cell, cell_in};
@@ -25,4 +31,5 @@ pub use effect::{EffectHandle, effect, effect_in};
pub use event::{Event, Subscription, event, event_in, on, on_in}; pub use event::{Event, Subscription, event, event_in, on, on_in};
pub use id::NodeId; pub use id::NodeId;
pub use reactor::{ReactCycleError, Reactor, current}; pub use reactor::{ReactCycleError, Reactor, current};
pub use source::{Source, source, source_in};
pub use thunk::{Memo, Thunk, memo, memo_by, memo_by_in, memo_in, thunk, thunk_in}; pub use thunk::{Memo, Thunk, memo, memo_by, memo_by_in, memo_in, thunk, thunk_in};

View File

@@ -1,9 +1,12 @@
use std::cell::{Cell, RefCell}; use alloc::boxed::Box;
use std::collections::{BTreeMap, BTreeSet, VecDeque}; use alloc::collections::VecDeque;
use std::error::Error; use alloc::rc::{Rc, Weak};
use std::fmt; use alloc::vec::Vec;
use std::panic::panic_any; use core::cell::{Cell, RefCell};
use std::rc::{Rc, Weak}; use core::error::Error;
use core::fmt;
use hashbrown::{HashMap, HashSet};
use ruin_runtime::queue_microtask; use ruin_runtime::queue_microtask;
@@ -11,9 +14,8 @@ use crate::{NodeId, trace_targets};
type Job = Box<dyn FnOnce() + 'static>; type Job = Box<dyn FnOnce() + 'static>;
thread_local! { #[thread_local]
static CURRENT_REACTOR: RefCell<Weak<ReactorInner>> = const { RefCell::new(Weak::new()) }; static CURRENT_REACTOR: RefCell<Weak<ReactorInner>> = const { RefCell::new(Weak::new()) };
}
/// Returns the current thread's default reactor. /// Returns the current thread's default reactor.
/// ///
@@ -28,7 +30,7 @@ pub(crate) trait ObserverHook {
fn notify(&self); fn notify(&self);
} }
/// Panic payload emitted when the reactor detects a dependency cycle. /// Error type for cycles detected in the reactive graph. Contains the path of nodes that form the cycle.
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub struct ReactCycleError { pub struct ReactCycleError {
cycle: Vec<NodeId>, cycle: Vec<NodeId>,
@@ -85,8 +87,7 @@ impl Reactor {
/// Returns the current thread's default reactor. /// Returns the current thread's default reactor.
pub fn current() -> Self { pub fn current() -> Self {
CURRENT_REACTOR.with(|slot| { if let Some(inner) = CURRENT_REACTOR.borrow().upgrade() {
if let Some(inner) = slot.borrow().upgrade() {
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
tracing::trace!( tracing::trace!(
target: trace_targets::GRAPH, target: trace_targets::GRAPH,
@@ -97,14 +98,13 @@ impl Reactor {
} }
let reactor = Self::new(); let reactor = Self::new();
*slot.borrow_mut() = Rc::downgrade(&reactor.inner); *CURRENT_REACTOR.borrow_mut() = Rc::downgrade(&reactor.inner);
tracing::debug!( tracing::debug!(
target: trace_targets::GRAPH, target: trace_targets::GRAPH,
event = "current_reactor_install", event = "current_reactor_install",
"installed current thread default reactor" "installed current thread default reactor"
); );
reactor reactor
})
} }
/// Runs `f` in the dependency-tracking scope of `observer`. /// Runs `f` in the dependency-tracking scope of `observer`.
@@ -143,13 +143,9 @@ impl Reactor {
f() f()
} }
/// Records that the currently executing observer depends on `observable`. /// Attempts to record a dependency on `observable` for the currently running observer, returning an
/// /// error if doing so would create a dependency cycle.
/// # Panics pub fn try_observe(&self, observable: NodeId) -> Result<(), ReactCycleError> {
///
/// Panics with [`ReactCycleError`] if `observable` is already being computed in the current
/// dependency stack.
pub fn observe(&self, observable: NodeId) {
if self if self
.inner .inner
.active_computations .active_computations
@@ -170,12 +166,12 @@ impl Reactor {
cycle_len = cycle.len(), cycle_len = cycle.len(),
"reactive cycle detected" "reactive cycle detected"
); );
panic_any(ReactCycleError::new(cycle)); return Err(ReactCycleError::new(cycle));
} }
let current = self.inner.stack.borrow().last().copied(); let current = self.inner.stack.borrow().last().copied();
let Some(observer) = current else { let Some(observer) = current else {
return; return Ok(());
}; };
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
@@ -199,6 +195,18 @@ impl Reactor {
.entry(observable) .entry(observable)
.or_default() .or_default()
.insert(observer); .insert(observer);
Ok(())
}
/// Records a dependency on `observable` for the currently running observer.
///
/// # Panics
///
/// Panics if recording the dependency would create a cycle in the reactive graph.
pub fn observe(&self, observable: NodeId) {
if let Err(e) = self.try_observe(observable) {
panic!("reactive cycle detected: {e}");
}
} }
/// Notifies dependents of `observable`. /// Notifies dependents of `observable`.
@@ -352,11 +360,11 @@ impl fmt::Debug for Reactor {
struct ReactorInner { struct ReactorInner {
next_node: Cell<u64>, next_node: Cell<u64>,
dependencies: RefCell<BTreeMap<NodeId, BTreeSet<NodeId>>>, dependencies: RefCell<HashMap<NodeId, HashSet<NodeId>>>,
dependents: RefCell<BTreeMap<NodeId, BTreeSet<NodeId>>>, dependents: RefCell<HashMap<NodeId, HashSet<NodeId>>>,
observers: RefCell<BTreeMap<NodeId, Weak<dyn ObserverHook>>>, observers: RefCell<HashMap<NodeId, Weak<dyn ObserverHook>>>,
stack: RefCell<Vec<NodeId>>, stack: RefCell<Vec<NodeId>>,
active_computations: RefCell<BTreeSet<NodeId>>, active_computations: RefCell<HashSet<NodeId>>,
pending_jobs: RefCell<VecDeque<Job>>, pending_jobs: RefCell<VecDeque<Job>>,
flush_scheduled: Cell<bool>, flush_scheduled: Cell<bool>,
} }
@@ -365,11 +373,11 @@ impl ReactorInner {
fn new() -> Self { fn new() -> Self {
Self { Self {
next_node: Cell::new(1), next_node: Cell::new(1),
dependencies: RefCell::new(BTreeMap::new()), dependencies: RefCell::new(HashMap::new()),
dependents: RefCell::new(BTreeMap::new()), dependents: RefCell::new(HashMap::new()),
observers: RefCell::new(BTreeMap::new()), observers: RefCell::new(HashMap::new()),
stack: RefCell::new(Vec::new()), stack: RefCell::new(Vec::new()),
active_computations: RefCell::new(BTreeSet::new()), active_computations: RefCell::new(HashSet::new()),
pending_jobs: RefCell::new(VecDeque::new()), pending_jobs: RefCell::new(VecDeque::new()),
flush_scheduled: Cell::new(false), flush_scheduled: Cell::new(false),
} }
@@ -429,7 +437,7 @@ mod tests {
use ruin_runtime::{queue_task, run}; use ruin_runtime::{queue_task, run};
use super::{ReactCycleError, Reactor, current}; use super::{Reactor, current};
#[test] #[test]
fn current_reactor_is_thread_local_singleton() { fn current_reactor_is_thread_local_singleton() {
@@ -444,7 +452,11 @@ mod tests {
let observer = reactor.allocate_node(); let observer = reactor.allocate_node();
let observable = reactor.allocate_node(); let observable = reactor.allocate_node();
reactor.run_in_context(observer, || reactor.observe(observable)); reactor.run_in_context(observer, || {
reactor
.try_observe(observable)
.expect("should not detect cycle")
});
assert_eq!( assert_eq!(
reactor.inner.dependencies.borrow().get(&observer), reactor.inner.dependencies.borrow().get(&observer),
@@ -457,7 +469,7 @@ mod tests {
} }
#[test] #[test]
fn cycle_detection_panics_with_structured_error() { fn cycle_detection_panics_with_expected() {
let reactor = Reactor::new(); let reactor = Reactor::new();
let a = reactor.allocate_node(); let a = reactor.allocate_node();
let b = reactor.allocate_node(); let b = reactor.allocate_node();
@@ -465,15 +477,26 @@ mod tests {
let panic = catch_unwind(AssertUnwindSafe(|| { let panic = catch_unwind(AssertUnwindSafe(|| {
reactor.run_in_context(a, || { reactor.run_in_context(a, || {
reactor.observe(b); reactor.observe(b);
reactor.run_in_context(b, || reactor.observe(a)); reactor.run_in_context(b, || {
reactor.observe(a);
});
}); });
})) }))
.expect_err("cycle should panic"); .expect_err("cycle should panic");
let error = panic let Some(cycle_error) = panic.downcast_ref::<String>() else {
.downcast::<ReactCycleError>() panic!("panic should be a string");
.expect("panic payload should be ReactCycleError"); };
assert_eq!(error.cycle(), &[a, b, a]);
assert!(
cycle_error.contains("reactive cycle detected"),
"panic should indicate cycle detected"
);
assert!(
cycle_error.contains("1 -> 2 -> 1"),
"panic should include cycle path"
);
} }
#[test] #[test]

View File

@@ -0,0 +1,70 @@
use alloc::rc::Rc;
use crate::{NodeId, Reactor, current, trace_targets};
/// Creates a low-level reactive source node in the current reactor.
pub fn source() -> Source {
current().source()
}
/// Creates a low-level reactive source node associated with `reactor`.
pub fn source_in(reactor: &Reactor) -> Source {
reactor.source()
}
/// Low-level observable source node.
///
/// `Source` is useful for advanced data structures that want precise control over when reads
/// observe and writes trigger invalidation without storing their state in a [`crate::Cell`].
#[derive(Clone)]
pub struct Source {
inner: Rc<SourceInner>,
}
impl Reactor {
/// Creates a low-level source node associated with this reactor.
pub fn source(&self) -> Source {
Source::new(self.clone())
}
}
impl Source {
fn new(reactor: Reactor) -> Self {
let id = reactor.allocate_node();
tracing::debug!(
target: trace_targets::GRAPH,
event = "create_source",
node_id = id.0,
"created low-level reactive source"
);
Self {
inner: Rc::new(SourceInner { reactor, id }),
}
}
/// Records a dependency on this source for the currently running observer.
pub fn observe(&self) {
self.inner.reactor.observe(self.inner.id);
}
/// Triggers this source's dependents.
pub fn trigger(&self) {
self.inner.reactor.trigger(self.inner.id);
}
/// Returns the source node id.
pub fn id(&self) -> NodeId {
self.inner.id
}
}
struct SourceInner {
reactor: Reactor,
id: NodeId,
}
impl Drop for SourceInner {
fn drop(&mut self) {
self.reactor.dispose(self.id);
}
}

View File

@@ -1,5 +1,6 @@
use std::cell::{Cell, RefCell}; use alloc::boxed::Box;
use std::rc::Rc; use alloc::rc::Rc;
use core::cell::{Cell, RefCell};
use crate::reactor::ObserverHook; use crate::reactor::ObserverHook;
use crate::{NodeId, Reactor, current, trace_targets}; use crate::{NodeId, Reactor, current, trace_targets};
@@ -428,9 +429,13 @@ mod tests {
.expect_err("mutually recursive thunks should panic"); .expect_err("mutually recursive thunks should panic");
let error = panic let error = panic
.downcast::<crate::ReactCycleError>() .downcast_ref::<String>()
.expect("panic payload should be ReactCycleError"); .expect("panic should be a string");
assert_eq!(error.cycle().len(), 3);
assert!(
error.contains("reactive cycle detected"),
"panic should indicate cycle detected"
);
} }
#[test] #[test]

View File

@@ -0,0 +1,13 @@
[package]
name = "ruin-reactivity-parsing"
version = "0.1.0"
edition = "2024"
[lib]
name = "ruin_reactivity_parsing"
[dependencies]
ruin_reactivity = { path = "../reactivity" }
[dev-dependencies]
ruin_runtime = { package = "ruin-runtime", path = "../runtime" }

View File

@@ -0,0 +1,785 @@
use std::env;
use std::fmt;
use std::rc::Rc;
use ruin_reactivity::{cell_in, effect_in};
use ruin_reactivity_parsing::{
Anchor, AnchorBias, EditDelta, ReactiveRope, RopeEdit, TextPoint, TextRange,
};
use ruin_runtime::{async_main, fs, stdio};
#[derive(Clone, Debug)]
struct ParseSnapshot {
text: String,
root_anchor: Anchor,
root: AstNode,
reused_nodes: usize,
total_nodes: usize,
diagnostics: Vec<String>,
}
#[derive(Clone, Debug)]
struct AstNode {
id: u64,
kind: JsonKind,
local_range: TextRange,
children: Vec<AstNode>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum JsonKind {
Object,
Array,
Pair,
String,
Number,
True,
False,
Null,
}
impl fmt::Display for JsonKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let label = match self {
Self::Object => "object",
Self::Array => "array",
Self::Pair => "pair",
Self::String => "string",
Self::Number => "number",
Self::True => "true",
Self::False => "false",
Self::Null => "null",
};
write!(f, "{label}")
}
}
#[derive(Clone, Debug)]
struct ParsedNode {
kind: JsonKind,
range: TextRange,
children: Vec<ParsedNode>,
}
#[derive(Default)]
struct JsonParser {
next_id: u64,
}
struct Cursor<'a> {
text: &'a str,
offset: usize,
}
#[derive(Clone)]
enum Command {
Insert {
offset: usize,
text: String,
},
Replace {
start: usize,
end: usize,
text: String,
},
ReplaceAll {
text: String,
},
Delete {
start: usize,
end: usize,
},
Print,
Help,
Quit,
}
#[async_main]
async fn main() {
let path = env::args()
.nth(1)
.expect("usage: cargo run -p ruin-reactivity-parsing --example json_reactive_parser -- <path/to/file.json>");
let initial_text = fs::read_to_string(&path)
.await
.expect("json file should be readable");
let reactor = ruin_reactivity::Reactor::new();
let rope = ReactiveRope::from_text(&reactor, &initial_text);
let parser = Rc::new(std::cell::RefCell::new(JsonParser::default()));
let initial_snapshot = parser
.borrow_mut()
.parse_initial(&rope)
.expect("initial JSON should parse");
let parse_state = cell_in(&reactor, initial_snapshot);
let last_delta = cell_in(&reactor, None::<EditDelta>);
let _parser_subscription = {
let rope = rope.clone();
let parser = Rc::clone(&parser);
let parse_state = parse_state.clone();
let last_delta = last_delta.clone();
rope.edit_events().subscribe(move |delta| {
let previous = parse_state.get();
match parser.borrow_mut().parse_incremental(&rope, &previous, delta) {
Ok(next) => {
last_delta.replace(Some(delta.clone()));
parse_state.replace(next);
}
Err(error) => {
eprintln!("parse error after edit: {error}");
eprintln!(
"note: current text length is {} bytes; use `replace_all <text>` to replace the full document without manual offsets",
rope.len_bytes()
);
}
}
});
};
effect_in(&reactor, {
let rope = rope.clone();
let parse_state = parse_state.clone();
let last_delta = last_delta.clone();
move || {
parse_state.with(|snapshot| {
print_snapshot(snapshot, &rope, last_delta.with(Clone::clone).as_ref())
});
}
})
.leak();
println!("Commands:");
println!(" insert <byte_offset> <text>");
println!(" replace <start> <end> <text>");
println!(" replace_all <text>");
println!(" delete <start> <end>");
println!(" print");
println!(" help");
println!(" quit");
let mut input = stdio::stdin().expect("stdin should be available");
while let Some(line) = input.read_line().await.expect("stdin read should succeed") {
let trimmed = line.trim_end();
if trimmed.is_empty() {
continue;
}
match parse_command(trimmed) {
Ok(Command::Insert { offset, text }) => {
let _ = rope.edit(RopeEdit::insert(offset, text));
}
Ok(Command::Replace { start, end, text }) => {
let _ = rope.edit(RopeEdit::replace(TextRange::new(start, end), text));
}
Ok(Command::ReplaceAll { text }) => {
let _ = rope.edit(RopeEdit::replace(TextRange::new(0, rope.len_bytes()), text));
}
Ok(Command::Delete { start, end }) => {
let _ = rope.edit(RopeEdit::delete(TextRange::new(start, end)));
}
Ok(Command::Print) => {
let snapshot = parse_state.get();
print_snapshot(&snapshot, &rope, None);
}
Ok(Command::Help) => {
println!("Commands:");
println!(" insert <byte_offset> <text>");
println!(" replace <start> <end> <text>");
println!(" replace_all <text>");
println!(" delete <start> <end>");
println!(" print");
println!(" help");
println!(" quit");
}
Ok(Command::Quit) => break,
Err(error) => eprintln!("{error}"),
}
}
}
fn parse_command(input: &str) -> Result<Command, String> {
if input == "quit" {
return Ok(Command::Quit);
}
if input == "print" {
return Ok(Command::Print);
}
if input == "help" {
return Ok(Command::Help);
}
if let Some(rest) = input.strip_prefix("insert ") {
let (offset, text) = rest
.split_once(' ')
.ok_or_else(|| "insert expects: insert <offset> <text>".to_string())?;
return Ok(Command::Insert {
offset: offset
.parse()
.map_err(|_| "invalid insert offset".to_string())?,
text: text.to_string(),
});
}
if let Some(rest) = input.strip_prefix("replace ") {
let mut parts = rest.splitn(3, ' ');
let start = parts
.next()
.ok_or_else(|| "replace expects: replace <start> <end> <text>".to_string())?;
let end = parts
.next()
.ok_or_else(|| "replace expects: replace <start> <end> <text>".to_string())?;
let text = parts
.next()
.ok_or_else(|| "replace expects: replace <start> <end> <text>".to_string())?;
return Ok(Command::Replace {
start: start
.parse()
.map_err(|_| "invalid replace start".to_string())?,
end: end.parse().map_err(|_| "invalid replace end".to_string())?,
text: text.to_string(),
});
}
if let Some(text) = input.strip_prefix("replace_all ") {
return Ok(Command::ReplaceAll {
text: text.to_string(),
});
}
if let Some(rest) = input.strip_prefix("delete ") {
let (start, end) = rest
.split_once(' ')
.ok_or_else(|| "delete expects: delete <start> <end>".to_string())?;
return Ok(Command::Delete {
start: start
.parse()
.map_err(|_| "invalid delete start".to_string())?,
end: end.parse().map_err(|_| "invalid delete end".to_string())?,
});
}
Err("unknown command; type `help`".to_string())
}
impl JsonParser {
fn parse_initial(&mut self, rope: &ReactiveRope) -> Result<ParseSnapshot, String> {
let text = read_rope_text(rope);
let parsed = parse_json(&text)?;
let root = self.materialize_fresh(&parsed, 0);
Ok(ParseSnapshot {
text,
root_anchor: Anchor::new(0, AnchorBias::Left),
total_nodes: count_nodes(&root),
reused_nodes: 0,
diagnostics: Vec::new(),
root,
})
}
fn parse_incremental(
&mut self,
rope: &ReactiveRope,
previous: &ParseSnapshot,
delta: &EditDelta,
) -> Result<ParseSnapshot, String> {
let text = read_rope_text(rope);
let parsed = parse_json(&text)?;
let mut root_anchor = previous.root_anchor;
root_anchor.map_through(delta);
let mut reused = 0usize;
let root = self.reuse_node(
&parsed,
Some(&previous.root),
previous.root_anchor.byte,
root_anchor.byte,
&previous.text,
&text,
delta,
&mut reused,
);
Ok(ParseSnapshot {
text,
root_anchor,
total_nodes: count_nodes(&root),
reused_nodes: reused,
diagnostics: Vec::new(),
root,
})
}
fn materialize_fresh(&mut self, parsed: &ParsedNode, parent_start: usize) -> AstNode {
AstNode {
id: self.alloc_id(),
kind: parsed.kind,
local_range: localize(parsed.range, parent_start),
children: parsed
.children
.iter()
.map(|child| self.materialize_fresh(child, parsed.range.start))
.collect(),
}
}
fn reuse_node(
&mut self,
parsed: &ParsedNode,
old: Option<&AstNode>,
old_parent_start: usize,
new_parent_start: usize,
old_text: &str,
new_text: &str,
delta: &EditDelta,
reused: &mut usize,
) -> AstNode {
let parsed_local = localize(parsed.range, new_parent_start);
if let Some(old) = old
&& old.kind == parsed.kind
&& map_range(absolute_range(old, old_parent_start), delta) == parsed.range
&& old_text.get(
absolute_range(old, old_parent_start).start
..absolute_range(old, old_parent_start).end,
) == new_text.get(parsed.range.start..parsed.range.end)
{
*reused += count_nodes(old);
return AstNode {
id: old.id,
kind: old.kind,
local_range: parsed_local,
children: old.children.clone(),
};
}
let children = parsed
.children
.iter()
.enumerate()
.map(|(index, child)| {
let previous_child = old.and_then(|node| node.children.get(index));
let next_old_parent_start = old
.map(|node| absolute_range(node, old_parent_start).start)
.unwrap_or(old_parent_start);
self.reuse_node(
child,
previous_child,
next_old_parent_start,
parsed.range.start,
old_text,
new_text,
delta,
reused,
)
})
.collect();
AstNode {
id: self.alloc_id(),
kind: parsed.kind,
local_range: parsed_local,
children,
}
}
fn alloc_id(&mut self) -> u64 {
self.next_id = self.next_id.wrapping_add(1);
self.next_id
}
}
fn read_rope_text(rope: &ReactiveRope) -> String {
let mut cursor = rope.cursor(0);
let mut text = String::new();
while let Some(chunk) = cursor.next_chunk() {
text.push_str(chunk.text());
}
text
}
fn localize(range: TextRange, parent_start: usize) -> TextRange {
TextRange::new(
range.start.saturating_sub(parent_start),
range.end.saturating_sub(parent_start),
)
}
fn absolute_range(node: &AstNode, parent_start: usize) -> TextRange {
TextRange::new(
parent_start + node.local_range.start,
parent_start + node.local_range.end,
)
}
fn map_range(range: TextRange, delta: &EditDelta) -> TextRange {
if range.end <= delta.range.start {
return range;
}
if range.start >= delta.range.end {
let shift = delta.net_byte_delta();
return TextRange::new(
range.start.saturating_add_signed(shift),
range.end.saturating_add_signed(shift),
);
}
TextRange::new(usize::MAX, usize::MAX)
}
fn parse_json(text: &str) -> Result<ParsedNode, String> {
let mut cursor = Cursor { text, offset: 0 };
cursor.skip_ws();
let node = cursor.parse_value()?;
cursor.skip_ws();
if !cursor.is_eof() {
return Err(format!(
"unexpected trailing data at byte {}",
cursor.offset
));
}
Ok(node)
}
impl<'a> Cursor<'a> {
fn is_eof(&self) -> bool {
self.offset >= self.text.len()
}
fn skip_ws(&mut self) {
while let Some(ch) = self.peek_char() {
if ch.is_ascii_whitespace() {
self.offset += ch.len_utf8();
} else {
break;
}
}
}
fn peek_char(&self) -> Option<char> {
self.text[self.offset..].chars().next()
}
fn bump_char(&mut self) -> Option<char> {
let ch = self.peek_char()?;
self.offset += ch.len_utf8();
Some(ch)
}
fn expect_char(&mut self, expected: char) -> Result<(), String> {
match self.bump_char() {
Some(ch) if ch == expected => Ok(()),
Some(ch) => Err(format!(
"expected `{expected}` at byte {}, found `{ch}`",
self.offset.saturating_sub(ch.len_utf8())
)),
None => Err(format!("expected `{expected}` at end of file")),
}
}
fn parse_value(&mut self) -> Result<ParsedNode, String> {
self.skip_ws();
match self.peek_char() {
Some('{') => self.parse_object(),
Some('[') => self.parse_array(),
Some('"') => self.parse_string(),
Some('t') => self.parse_literal("true", JsonKind::True),
Some('f') => self.parse_literal("false", JsonKind::False),
Some('n') => self.parse_literal("null", JsonKind::Null),
Some('-' | '0'..='9') => self.parse_number(),
Some(other) => Err(format!("unexpected `{other}` at byte {}", self.offset)),
None => Err("unexpected end of JSON input".to_string()),
}
}
fn parse_object(&mut self) -> Result<ParsedNode, String> {
let start = self.offset;
self.expect_char('{')?;
self.skip_ws();
let mut children = Vec::new();
if self.peek_char() == Some('}') {
self.bump_char();
return Ok(ParsedNode {
kind: JsonKind::Object,
range: TextRange::new(start, self.offset),
children,
});
}
loop {
self.skip_ws();
let key = self.parse_string()?;
self.skip_ws();
self.expect_char(':')?;
self.skip_ws();
let value = self.parse_value()?;
let pair = ParsedNode {
kind: JsonKind::Pair,
range: TextRange::new(key.range.start, value.range.end),
children: vec![key, value],
};
children.push(pair);
self.skip_ws();
match self.peek_char() {
Some(',') => {
self.bump_char();
}
Some('}') => {
self.bump_char();
break;
}
Some(other) => {
return Err(format!(
"expected `,` or `}}` at byte {}, found `{other}`",
self.offset
));
}
None => return Err("unterminated object".to_string()),
}
}
Ok(ParsedNode {
kind: JsonKind::Object,
range: TextRange::new(start, self.offset),
children,
})
}
fn parse_array(&mut self) -> Result<ParsedNode, String> {
let start = self.offset;
self.expect_char('[')?;
self.skip_ws();
let mut children = Vec::new();
if self.peek_char() == Some(']') {
self.bump_char();
return Ok(ParsedNode {
kind: JsonKind::Array,
range: TextRange::new(start, self.offset),
children,
});
}
loop {
let value = self.parse_value()?;
children.push(value);
self.skip_ws();
match self.peek_char() {
Some(',') => {
self.bump_char();
self.skip_ws();
}
Some(']') => {
self.bump_char();
break;
}
Some(other) => {
return Err(format!(
"expected `,` or `]` at byte {}, found `{other}`",
self.offset
));
}
None => return Err("unterminated array".to_string()),
}
}
Ok(ParsedNode {
kind: JsonKind::Array,
range: TextRange::new(start, self.offset),
children,
})
}
fn parse_string(&mut self) -> Result<ParsedNode, String> {
let start = self.offset;
self.expect_char('"')?;
loop {
match self.bump_char() {
Some('"') => {
return Ok(ParsedNode {
kind: JsonKind::String,
range: TextRange::new(start, self.offset),
children: Vec::new(),
});
}
Some('\\') => match self.bump_char() {
Some('"') | Some('\\') | Some('/') | Some('b') | Some('f') | Some('n')
| Some('r') | Some('t') => {}
Some('u') => {
for _ in 0..4 {
match self.bump_char() {
Some(ch) if ch.is_ascii_hexdigit() => {}
_ => {
return Err(format!(
"invalid unicode escape at byte {}",
self.offset
));
}
}
}
}
_ => return Err(format!("invalid escape sequence at byte {}", self.offset)),
},
Some(ch) if ch >= '\u{20}' => {}
Some(_) => {
return Err(format!(
"control character in string at byte {}",
self.offset
));
}
None => return Err("unterminated string".to_string()),
}
}
}
fn parse_number(&mut self) -> Result<ParsedNode, String> {
let start = self.offset;
if self.peek_char() == Some('-') {
self.bump_char();
}
match self.peek_char() {
Some('0') => {
self.bump_char();
}
Some('1'..='9') => {
self.bump_char();
while matches!(self.peek_char(), Some('0'..='9')) {
self.bump_char();
}
}
_ => return Err(format!("invalid number at byte {}", self.offset)),
}
if self.peek_char() == Some('.') {
self.bump_char();
let mut digits = 0usize;
while matches!(self.peek_char(), Some('0'..='9')) {
digits += 1;
self.bump_char();
}
if digits == 0 {
return Err(format!("expected fraction digits at byte {}", self.offset));
}
}
if matches!(self.peek_char(), Some('e' | 'E')) {
self.bump_char();
if matches!(self.peek_char(), Some('+' | '-')) {
self.bump_char();
}
let mut digits = 0usize;
while matches!(self.peek_char(), Some('0'..='9')) {
digits += 1;
self.bump_char();
}
if digits == 0 {
return Err(format!("expected exponent digits at byte {}", self.offset));
}
}
Ok(ParsedNode {
kind: JsonKind::Number,
range: TextRange::new(start, self.offset),
children: Vec::new(),
})
}
fn parse_literal(&mut self, literal: &str, kind: JsonKind) -> Result<ParsedNode, String> {
let start = self.offset;
if !self.text[self.offset..].starts_with(literal) {
return Err(format!("expected `{literal}` at byte {}", self.offset));
}
self.offset += literal.len();
Ok(ParsedNode {
kind,
range: TextRange::new(start, self.offset),
children: Vec::new(),
})
}
}
fn print_snapshot(snapshot: &ParseSnapshot, rope: &ReactiveRope, delta: Option<&EditDelta>) {
let metrics = rope.snapshot_metrics();
println!();
println!("Document revision {}", metrics.revision);
println!(
"Text bytes={}, leaves={}, nodes={}, reused_on_last_edit={}",
metrics.text.bytes, metrics.leaves, snapshot.total_nodes, snapshot.reused_nodes
);
if let Some(delta) = delta {
println!(
"Last edit: [{}..{}) -> {:?}",
delta.range.start, delta.range.end, delta.inserted_text
);
let focus = delta.range.start.min(snapshot.text.len().saturating_sub(1));
let path = path_for_offset(&snapshot.root, snapshot.root_anchor.byte, focus);
println!("Changed path:");
for (node, range) in path {
let start = rope.point_at(range.start);
let end = rope.point_at(range.end);
println!(
" id={} kind={} span={}",
node.id,
node.kind,
format_span(start, end)
);
}
}
if !snapshot.diagnostics.is_empty() {
println!("Diagnostics:");
for diagnostic in &snapshot.diagnostics {
println!(" - {diagnostic}");
}
}
println!("Root id={} kind={}", snapshot.root.id, snapshot.root.kind);
println!("Parse tree:");
print_tree(&snapshot.root, snapshot.root_anchor.byte, rope, 0);
println!("Current text:");
println!("{}", snapshot.text);
}
fn print_tree(node: &AstNode, parent_start: usize, rope: &ReactiveRope, depth: usize) {
let range = absolute_range(node, parent_start);
let start = rope.point_at(range.start);
let end = rope.point_at(range.end);
let indent = " ".repeat(depth);
println!(
"{indent}- id={} kind={} span={}",
node.id,
node.kind,
format_span(start, end)
);
for child in &node.children {
print_tree(child, range.start, rope, depth + 1);
}
}
fn path_for_offset(root: &AstNode, root_start: usize, offset: usize) -> Vec<(&AstNode, TextRange)> {
fn walk<'a>(
node: &'a AstNode,
parent_start: usize,
offset: usize,
out: &mut Vec<(&'a AstNode, TextRange)>,
) -> bool {
let range = absolute_range(node, parent_start);
if offset < range.start || offset >= range.end {
return false;
}
out.push((node, range));
for child in &node.children {
if walk(child, range.start, offset, out) {
return true;
}
}
true
}
let mut path = Vec::new();
let _ = walk(root, root_start, offset, &mut path);
path
}
fn format_span(start: TextPoint, end: TextPoint) -> String {
format!(
"{}:{}..{}:{}",
start.line + 1,
start.column + 1,
end.line + 1,
end.column + 1
)
}
fn count_nodes(node: &AstNode) -> usize {
1 + node.children.iter().map(count_nodes).sum::<usize>()
}

View File

@@ -0,0 +1,223 @@
use std::env;
use std::rc::Rc;
use ruin_reactivity::effect_in;
use ruin_reactivity_parsing::{
ReactiveRope, ReactiveTokenTree, RopeEdit, TextRange, TokenTreeNode, TokenTreeParse,
TokenTreeUpdate,
};
use ruin_runtime::{async_main, fs, stdio};
enum Command {
Insert {
offset: usize,
text: String,
},
Replace {
start: usize,
end: usize,
text: String,
},
ReplaceAll {
text: String,
},
Delete {
start: usize,
end: usize,
},
Print,
Help,
Quit,
}
#[async_main]
async fn main() {
let path = env::args()
.nth(1)
.expect("usage: cargo run -p ruin-reactivity-parsing --example tokentree_reactive_parser -- <path/to/file>");
let initial_text = fs::read_to_string(&path)
.await
.expect("source file should be readable");
let reactor = ruin_reactivity::Reactor::new();
let rope = ReactiveRope::from_text(&reactor, &initial_text);
let parser = Rc::new(ReactiveTokenTree::new(&rope));
effect_in(&reactor, {
let parser = Rc::clone(&parser);
let rope = rope.clone();
move || {
parser.with_snapshot(|snapshot| {
let last_update = parser.last_update();
print_snapshot(snapshot, &rope, last_update.as_ref());
});
}
})
.leak();
println!("Commands:");
println!(" insert <byte_offset> <text>");
println!(" replace <start> <end> <text>");
println!(" replace_all <text>");
println!(" delete <start> <end>");
println!(" print");
println!(" help");
println!(" quit");
let mut input = stdio::stdin().expect("stdin should be available");
while let Some(line) = input.read_line().await.expect("stdin read should succeed") {
let trimmed = line.trim_end();
if trimmed.is_empty() {
continue;
}
match parse_command(trimmed) {
Ok(Command::Insert { offset, text }) => {
let _ = rope.edit(RopeEdit::insert(offset, text));
}
Ok(Command::Replace { start, end, text }) => {
let _ = rope.edit(RopeEdit::replace(TextRange::new(start, end), text));
}
Ok(Command::ReplaceAll { text }) => {
let _ = rope.edit(RopeEdit::replace(TextRange::new(0, rope.len_bytes()), text));
}
Ok(Command::Delete { start, end }) => {
let _ = rope.edit(RopeEdit::delete(TextRange::new(start, end)));
}
Ok(Command::Print) => {
let snapshot = parser.snapshot();
let last_update = parser.last_update();
print_snapshot(&snapshot, &rope, last_update.as_ref());
}
Ok(Command::Help) => {
println!("Commands:");
println!(" insert <byte_offset> <text>");
println!(" replace <start> <end> <text>");
println!(" replace_all <text>");
println!(" delete <start> <end>");
println!(" print");
println!(" help");
println!(" quit");
}
Ok(Command::Quit) => break,
Err(error) => eprintln!("{error}"),
}
}
}
fn parse_command(input: &str) -> Result<Command, String> {
if input == "quit" {
return Ok(Command::Quit);
}
if input == "print" {
return Ok(Command::Print);
}
if input == "help" {
return Ok(Command::Help);
}
if let Some(rest) = input.strip_prefix("insert ") {
let (offset, text) = rest
.split_once(' ')
.ok_or_else(|| "insert expects: insert <offset> <text>".to_string())?;
return Ok(Command::Insert {
offset: offset
.parse()
.map_err(|_| "invalid insert offset".to_string())?,
text: text.to_string(),
});
}
if let Some(rest) = input.strip_prefix("replace ") {
let mut parts = rest.splitn(3, ' ');
let start = parts
.next()
.ok_or_else(|| "replace expects: replace <start> <end> <text>".to_string())?;
let end = parts
.next()
.ok_or_else(|| "replace expects: replace <start> <end> <text>".to_string())?;
let text = parts
.next()
.ok_or_else(|| "replace expects: replace <start> <end> <text>".to_string())?;
return Ok(Command::Replace {
start: start
.parse()
.map_err(|_| "invalid replace start".to_string())?,
end: end.parse().map_err(|_| "invalid replace end".to_string())?,
text: text.to_string(),
});
}
if let Some(text) = input.strip_prefix("replace_all ") {
return Ok(Command::ReplaceAll {
text: text.to_string(),
});
}
if let Some(rest) = input.strip_prefix("delete ") {
let (start, end) = rest
.split_once(' ')
.ok_or_else(|| "delete expects: delete <start> <end>".to_string())?;
return Ok(Command::Delete {
start: start
.parse()
.map_err(|_| "invalid delete start".to_string())?,
end: end.parse().map_err(|_| "invalid delete end".to_string())?,
});
}
Err("unknown command; type `help`".to_string())
}
fn print_snapshot(
snapshot: &TokenTreeParse,
rope: &ReactiveRope,
update: Option<&TokenTreeUpdate>,
) {
let metrics = rope.snapshot_metrics();
println!();
println!("Document revision {}", metrics.revision);
println!(
"Text bytes={}, leaves={}, nodes={}, reused_on_last_edit={}",
metrics.text.bytes, metrics.leaves, snapshot.stats.total_nodes, snapshot.stats.reused_nodes
);
if let Some(update) = update {
println!(
"Last edit: [{}..{}) -> {:?}",
update.delta.range.start, update.delta.range.end, update.delta.inserted_text
);
println!(
"Diagnostics changed={}, trivia changed={}",
update.diagnostics_changed, update.trivia_changed
);
}
println!("Parse tree:");
print_tree(snapshot, rope, &snapshot.root, 0);
if !snapshot.diagnostics.is_empty() {
println!("Diagnostics:");
for diagnostic in &snapshot.diagnostics {
println!(
" {:?} [{}..{}): {}",
diagnostic.kind, diagnostic.range.start, diagnostic.range.end, diagnostic.message
);
}
}
if !snapshot.trivia.comments().is_empty() {
println!("Comments:");
for comment in snapshot.trivia.comments() {
println!(
" {:?} [{}..{}): {}",
comment.kind, comment.range.start, comment.range.end, comment.text
);
}
}
}
fn print_tree(snapshot: &TokenTreeParse, rope: &ReactiveRope, node: &TokenTreeNode, depth: usize) {
let indent = " ".repeat(depth);
let (start, end) = snapshot
.absolute_span(rope, node.id)
.expect("node span should be available");
println!(
"{}- id={} kind={} span=({}:{})..({}:{})",
indent, node.id, node.kind, start.line, start.column, end.line, end.column
);
for child in &node.children {
print_tree(snapshot, rope, child, depth + 1);
}
}

View File

@@ -0,0 +1,152 @@
use crate::metrics::{TextMetrics, TextPoint};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TextRange {
pub start: usize,
pub end: usize,
}
impl TextRange {
pub const fn new(start: usize, end: usize) -> Self {
Self { start, end }
}
pub const fn len(self) -> usize {
self.end - self.start
}
pub const fn is_empty(self) -> bool {
self.start == self.end
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RopeEdit {
pub range: TextRange,
pub insert: String,
}
impl RopeEdit {
pub fn insert(offset: usize, text: impl Into<String>) -> Self {
Self {
range: TextRange::new(offset, offset),
insert: text.into(),
}
}
pub fn delete(range: TextRange) -> Self {
Self {
range,
insert: String::new(),
}
}
pub fn replace(range: TextRange, text: impl Into<String>) -> Self {
Self {
range,
insert: text.into(),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct EditDelta {
pub range: TextRange,
pub removed_text: String,
pub inserted_text: String,
pub removed: TextMetrics,
pub inserted: TextMetrics,
pub start: TextPoint,
pub old_end: TextPoint,
pub new_end: TextPoint,
}
impl EditDelta {
pub fn net_byte_delta(&self) -> isize {
self.inserted.bytes as isize - self.removed.bytes as isize
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum AnchorBias {
Left,
Right,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Anchor {
pub byte: usize,
pub bias: AnchorBias,
}
impl Anchor {
pub const fn new(byte: usize, bias: AnchorBias) -> Self {
Self { byte, bias }
}
pub fn map_through(&mut self, delta: &EditDelta) {
if self.byte < delta.range.start {
return;
}
if self.byte > delta.range.end {
self.byte = self.byte.saturating_add_signed(delta.net_byte_delta());
return;
}
self.byte = match self.bias {
AnchorBias::Left => delta.range.start,
AnchorBias::Right => delta.range.start + delta.inserted.bytes,
};
}
}
#[cfg(test)]
mod tests {
use super::{Anchor, AnchorBias, EditDelta, RopeEdit, TextRange};
use crate::{TextMetrics, TextPoint};
#[test]
fn constructors_build_expected_edits() {
assert_eq!(RopeEdit::insert(2, "x").range, TextRange::new(2, 2));
assert_eq!(RopeEdit::delete(TextRange::new(2, 4)).insert, "");
assert_eq!(RopeEdit::replace(TextRange::new(1, 3), "zz").insert, "zz");
}
#[test]
fn text_range_helpers_report_size() {
let range = TextRange::new(3, 7);
assert_eq!(range.len(), 4);
assert!(!range.is_empty());
assert!(TextRange::new(5, 5).is_empty());
}
#[test]
fn anchors_map_before_inside_and_after_edits() {
let delta = EditDelta {
range: TextRange::new(4, 7),
removed_text: "abc".into(),
inserted_text: "x".into(),
removed: TextMetrics::from_text("abc"),
inserted: TextMetrics::from_text("x"),
start: TextPoint::origin().advance("1234"),
old_end: TextPoint::origin().advance("1234abc"),
new_end: TextPoint::origin().advance("1234x"),
};
let mut before = Anchor::new(2, AnchorBias::Left);
let mut inside_left = Anchor::new(5, AnchorBias::Left);
let mut inside_right = Anchor::new(5, AnchorBias::Right);
let mut after = Anchor::new(10, AnchorBias::Left);
before.map_through(&delta);
inside_left.map_through(&delta);
inside_right.map_through(&delta);
after.map_through(&delta);
assert_eq!(before.byte, 2);
assert_eq!(inside_left.byte, 4);
assert_eq!(inside_right.byte, 5);
assert_eq!(after.byte, 8);
}
}

View File

@@ -0,0 +1,24 @@
//! Reactive rope text storage and incremental parsing substrate for RUIN.
mod delta;
mod metrics;
mod parse;
mod rope;
mod tokentree;
pub use delta::{Anchor, AnchorBias, EditDelta, RopeEdit, TextRange};
pub use metrics::{TextMetrics, TextPoint};
pub use parse::{
IncrementalParser, ParseError, ParseInvalidation, ParseResult, ParseTree, ParserCheckpoint,
SyntaxFragment,
};
pub use rope::{
DEFAULT_LEAF_TARGET_BYTES, ReactiveRope, RopeBuilder, RopeChunk, RopeCursor,
RopeSnapshotMetrics,
};
pub use tokentree::{
DelimiterKind, NumberBase, NumberLiteral, NumberSuffix, ReactiveTokenTree, StringDelimiter,
StringFragment, TokenTreeDiagnostic, TokenTreeDiagnosticKind, TokenTreeNode, TokenTreeNodeKind,
TokenTreeParse, TokenTreeParser, TokenTreeStats, TokenTreeUpdate, TriviaComment, TriviaKind,
TriviaStore,
};

View File

@@ -0,0 +1,139 @@
use core::fmt;
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct TextMetrics {
pub bytes: usize,
pub chars: usize,
pub utf16: usize,
pub line_breaks: usize,
}
impl TextMetrics {
pub fn from_text(text: &str) -> Self {
let mut metrics = Self::default();
for ch in text.chars() {
metrics.bytes += ch.len_utf8();
metrics.chars += 1;
metrics.utf16 += ch.len_utf16();
metrics.line_breaks += usize::from(ch == '\n');
}
metrics
}
}
impl core::ops::Add for TextMetrics {
type Output = Self;
fn add(self, rhs: Self) -> Self::Output {
Self {
bytes: self.bytes + rhs.bytes,
chars: self.chars + rhs.chars,
utf16: self.utf16 + rhs.utf16,
line_breaks: self.line_breaks + rhs.line_breaks,
}
}
}
impl core::ops::AddAssign for TextMetrics {
fn add_assign(&mut self, rhs: Self) {
*self = *self + rhs;
}
}
impl core::ops::Sub for TextMetrics {
type Output = Self;
fn sub(self, rhs: Self) -> Self::Output {
Self {
bytes: self.bytes - rhs.bytes,
chars: self.chars - rhs.chars,
utf16: self.utf16 - rhs.utf16,
line_breaks: self.line_breaks - rhs.line_breaks,
}
}
}
#[derive(Clone, Copy, Default, Eq, PartialEq)]
pub struct TextPoint {
pub byte: usize,
pub char_offset: usize,
pub utf16_offset: usize,
pub line: usize,
pub column: usize,
}
impl TextPoint {
pub const fn origin() -> Self {
Self {
byte: 0,
char_offset: 0,
utf16_offset: 0,
line: 0,
column: 0,
}
}
pub fn advance(mut self, text: &str) -> Self {
self.byte += text.len();
for ch in text.chars() {
self.char_offset += 1;
self.utf16_offset += ch.len_utf16();
if ch == '\n' {
self.line += 1;
self.column = 0;
} else {
self.column += 1;
}
}
self
}
}
impl fmt::Debug for TextPoint {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TextPoint")
.field("byte", &self.byte)
.field("char_offset", &self.char_offset)
.field("utf16_offset", &self.utf16_offset)
.field("line", &self.line)
.field("column", &self.column)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::{TextMetrics, TextPoint};
#[test]
fn metrics_count_utf8_utf16_and_lines() {
let metrics = TextMetrics::from_text("a\né🙂");
assert_eq!(metrics.bytes, "a\né🙂".len());
assert_eq!(metrics.chars, 4);
assert_eq!(metrics.utf16, 5);
assert_eq!(metrics.line_breaks, 1);
}
#[test]
fn metrics_support_arithmetic() {
let left = TextMetrics::from_text("hello\n");
let right = TextMetrics::from_text("🙂");
let combined = left + right;
assert_eq!(combined.bytes, "hello\n🙂".len());
assert_eq!(combined.chars, 7);
assert_eq!(combined.utf16, 8);
assert_eq!(combined.line_breaks, 1);
assert_eq!(combined - right, left);
}
#[test]
fn point_advance_tracks_offsets() {
let point = TextPoint::origin().advance("ab\n🙂");
assert_eq!(point.byte, "ab\n🙂".len());
assert_eq!(point.char_offset, 4);
assert_eq!(point.utf16_offset, 5);
assert_eq!(point.line, 1);
assert_eq!(point.column, 1);
}
}

View File

@@ -0,0 +1,170 @@
use core::fmt;
use crate::{RopeCursor, TextRange};
pub trait ParserCheckpoint: Clone + Eq + fmt::Debug + 'static {}
impl<T> ParserCheckpoint for T where T: Clone + Eq + fmt::Debug + 'static {}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SyntaxFragment<C, T, D> {
pub range: TextRange,
pub start_checkpoint: C,
pub end_checkpoint: C,
pub tree: T,
pub diagnostics: Vec<D>,
pub grammar_revision: u64,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ParseTree<T> {
pub root: T,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ParseInvalidation<C> {
pub resume_at: usize,
pub restart_checkpoint: C,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ParseResult<C, T, D> {
pub tree: ParseTree<T>,
pub fragments: Vec<SyntaxFragment<C, T, D>>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ParseError {
pub message: String,
}
impl ParseError {
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
}
pub trait IncrementalParser {
type Checkpoint: ParserCheckpoint;
type Tree: Clone + fmt::Debug + 'static;
type Diagnostic: Clone + fmt::Debug + 'static;
fn grammar_revision(&self) -> u64;
fn initial_checkpoint(&self) -> Self::Checkpoint;
fn invalidate(
&self,
delta_range: TextRange,
previous_fragments: &[SyntaxFragment<Self::Checkpoint, Self::Tree, Self::Diagnostic>],
) -> ParseInvalidation<Self::Checkpoint>;
fn parse(
&self,
cursor: RopeCursor,
fragments: &[SyntaxFragment<Self::Checkpoint, Self::Tree, Self::Diagnostic>],
invalidation: ParseInvalidation<Self::Checkpoint>,
) -> Result<ParseResult<Self::Checkpoint, Self::Tree, Self::Diagnostic>, ParseError>;
}
#[cfg(test)]
mod tests {
use std::cell::Cell;
use std::rc::Rc;
use ruin_reactivity::Reactor;
use super::{
IncrementalParser, ParseError, ParseInvalidation, ParseResult, ParseTree, SyntaxFragment,
};
use crate::{ReactiveRope, RopeCursor, TextRange};
#[derive(Clone, Debug)]
struct DummyParser {
parse_calls: Rc<Cell<usize>>,
}
impl IncrementalParser for DummyParser {
type Checkpoint = u8;
type Tree = String;
type Diagnostic = String;
fn grammar_revision(&self) -> u64 {
7
}
fn initial_checkpoint(&self) -> Self::Checkpoint {
0
}
fn invalidate(
&self,
delta_range: TextRange,
_previous_fragments: &[SyntaxFragment<
Self::Checkpoint,
Self::Tree,
Self::Diagnostic,
>],
) -> ParseInvalidation<Self::Checkpoint> {
ParseInvalidation {
resume_at: delta_range.start.saturating_sub(1),
restart_checkpoint: 1,
}
}
fn parse(
&self,
mut cursor: RopeCursor,
_fragments: &[SyntaxFragment<Self::Checkpoint, Self::Tree, Self::Diagnostic>],
invalidation: ParseInvalidation<Self::Checkpoint>,
) -> Result<ParseResult<Self::Checkpoint, Self::Tree, Self::Diagnostic>, ParseError>
{
self.parse_calls.set(self.parse_calls.get() + 1);
let text = cursor.read_bytes(usize::MAX);
Ok(ParseResult {
tree: ParseTree { root: text.clone() },
fragments: vec![SyntaxFragment {
range: TextRange::new(
invalidation.resume_at,
invalidation.resume_at + text.len(),
),
start_checkpoint: invalidation.restart_checkpoint,
end_checkpoint: 2,
tree: text,
diagnostics: Vec::new(),
grammar_revision: self.grammar_revision(),
}],
})
}
}
#[test]
fn parse_error_constructor_sets_message() {
let error = ParseError::new("bad");
assert_eq!(error.message, "bad");
}
#[test]
fn parser_trait_round_trip_produces_fragments() {
let reactor = Reactor::new();
let rope = ReactiveRope::from_text(&reactor, "{\"a\":1}");
let parser = DummyParser {
parse_calls: Rc::new(Cell::new(0)),
};
let invalidation = parser.invalidate(TextRange::new(3, 4), &[]);
assert_eq!(invalidation.resume_at, 2);
assert_eq!(invalidation.restart_checkpoint, 1);
let result = parser
.parse(rope.cursor(0), &[], invalidation)
.expect("dummy parse should succeed");
assert_eq!(result.tree.root, "{\"a\":1}");
assert_eq!(result.fragments.len(), 1);
assert_eq!(result.fragments[0].grammar_revision, 7);
assert_eq!(parser.parse_calls.get(), 1);
}
}

View File

@@ -0,0 +1,732 @@
use std::cell::{Cell, RefCell};
use std::cmp::min;
use std::collections::BTreeSet;
use std::rc::Rc;
use ruin_reactivity::{Event, Reactor, Source, event_in, source_in};
use crate::delta::{EditDelta, RopeEdit, TextRange};
use crate::metrics::{TextMetrics, TextPoint};
pub const DEFAULT_LEAF_TARGET_BYTES: usize = 1024;
const DEFAULT_LEAF_MAX_BYTES: usize = DEFAULT_LEAF_TARGET_BYTES * 2;
#[derive(Clone)]
pub struct ReactiveRope {
inner: Rc<RopeInner>,
}
pub struct RopeBuilder {
reactor: Reactor,
leaf_target_bytes: usize,
chunks: Vec<String>,
}
#[derive(Clone)]
pub struct RopeChunk {
text: Rc<str>,
pub start: TextPoint,
pub metrics: TextMetrics,
}
#[derive(Clone)]
pub struct RopeCursor {
rope: ReactiveRope,
next_offset: usize,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct RopeSnapshotMetrics {
pub leaves: usize,
pub text: TextMetrics,
pub revision: u64,
}
struct RopeInner {
reactor: Reactor,
structure: Source,
revision: Cell<u64>,
leaf_target_bytes: usize,
leaves: RefCell<Vec<Rc<Leaf>>>,
edit_events: Event<EditDelta>,
}
struct Leaf {
source: Source,
text: RefCell<Rc<str>>,
metrics: Cell<TextMetrics>,
tail_chars: Cell<usize>,
}
impl RopeBuilder {
pub fn new(reactor: &Reactor) -> Self {
Self {
reactor: reactor.clone(),
leaf_target_bytes: DEFAULT_LEAF_TARGET_BYTES,
chunks: Vec::new(),
}
}
pub fn leaf_target_bytes(mut self, bytes: usize) -> Self {
self.leaf_target_bytes = bytes.max(32);
self
}
pub fn push(mut self, text: impl Into<String>) -> Self {
self.chunks.push(text.into());
self
}
pub fn build(self) -> ReactiveRope {
ReactiveRope::from_chunks(self.reactor, self.leaf_target_bytes, self.chunks)
}
}
impl ReactiveRope {
pub fn new(reactor: &Reactor) -> Self {
RopeBuilder::new(reactor).build()
}
pub fn from_text(reactor: &Reactor, text: impl AsRef<str>) -> Self {
RopeBuilder::new(reactor).push(text.as_ref()).build()
}
fn from_chunks(reactor: Reactor, leaf_target_bytes: usize, chunks: Vec<String>) -> Self {
let structure = source_in(&reactor);
let edit_events = event_in(&reactor);
let mut leaves = Vec::new();
for chunk in chunks {
Self::append_chunk_into(&reactor, leaf_target_bytes, &mut leaves, &chunk);
}
if leaves.is_empty() {
leaves.push(Rc::new(Leaf::new(&reactor, String::new())));
}
Self {
inner: Rc::new(RopeInner {
reactor,
structure,
revision: Cell::new(0),
leaf_target_bytes,
leaves: RefCell::new(leaves),
edit_events,
}),
}
}
pub fn edit_events(&self) -> Event<EditDelta> {
self.inner.edit_events.clone()
}
pub fn reactor(&self) -> Reactor {
self.inner.reactor.clone()
}
pub fn snapshot_metrics(&self) -> RopeSnapshotMetrics {
self.inner.structure.observe();
RopeSnapshotMetrics {
leaves: self.inner.leaves.borrow().len(),
text: self.total_metrics(),
revision: self.inner.revision.get(),
}
}
pub fn len_bytes(&self) -> usize {
self.total_metrics().bytes
}
pub fn is_empty(&self) -> bool {
self.len_bytes() == 0
}
pub fn to_string(&self) -> String {
self.slice(TextRange::new(0, self.len_bytes()))
}
pub fn point_at(&self, offset: usize) -> TextPoint {
self.inner.structure.observe();
let offset = offset.min(self.len_bytes());
let mut point = TextPoint::origin();
let leaves = self.inner.leaves.borrow();
let mut remaining = offset;
for leaf in &*leaves {
let bytes = leaf.metrics.get().bytes;
if remaining > bytes {
remaining -= bytes;
point.byte += bytes;
point.char_offset += leaf.metrics.get().chars;
point.utf16_offset += leaf.metrics.get().utf16;
point.line += leaf.metrics.get().line_breaks;
if leaf.metrics.get().line_breaks == 0 {
point.column += leaf.tail_chars.get();
} else {
point.column = leaf.tail_chars.get();
}
continue;
}
let text = leaf.read();
point = point.advance(&text[..remaining]);
return point;
}
point
}
pub fn slice(&self, range: TextRange) -> String {
self.inner.structure.observe();
self.assert_char_boundary(range.start);
self.assert_char_boundary(range.end);
assert!(range.start <= range.end, "invalid text range");
assert!(range.end <= self.len_bytes(), "range out of bounds");
let mut out = String::with_capacity(range.len());
let mut cursor = 0usize;
let leaves = self.inner.leaves.borrow();
for leaf in &*leaves {
let metrics = leaf.metrics.get();
let end = cursor + metrics.bytes;
if end <= range.start {
cursor = end;
continue;
}
if cursor >= range.end {
break;
}
let text = leaf.read();
let start_in_leaf = range.start.saturating_sub(cursor);
let end_in_leaf = min(metrics.bytes, range.end - cursor);
out.push_str(&text[start_in_leaf..end_in_leaf]);
cursor = end;
}
out
}
pub fn chunk_at(&self, offset: usize) -> Option<RopeChunk> {
self.inner.structure.observe();
if offset >= self.len_bytes() {
return None;
}
let mut start = TextPoint::origin();
let leaves = self.inner.leaves.borrow();
let mut cursor = 0usize;
for leaf in &*leaves {
let metrics = leaf.metrics.get();
let end = cursor + metrics.bytes;
if offset < end {
let text = leaf.read();
leaf.source.observe();
return Some(RopeChunk {
text,
start,
metrics,
});
}
start.byte += metrics.bytes;
start.char_offset += metrics.chars;
start.utf16_offset += metrics.utf16;
start.line += metrics.line_breaks;
if metrics.line_breaks == 0 {
start.column += leaf.tail_chars.get();
} else {
start.column = leaf.tail_chars.get();
}
cursor = end;
}
None
}
pub fn cursor(&self, offset: usize) -> RopeCursor {
RopeCursor {
rope: self.clone(),
next_offset: offset.min(self.len_bytes()),
}
}
pub fn edit(&self, edit: RopeEdit) -> EditDelta {
let delta = self.apply_edit(edit);
self.inner.edit_events.emit(delta.clone());
delta
}
pub fn edit_batch(&self, edits: impl IntoIterator<Item = RopeEdit>) -> Vec<EditDelta> {
let mut deltas = Vec::new();
for edit in edits {
let delta = self.apply_edit(edit);
self.inner.edit_events.emit(delta.clone());
deltas.push(delta);
}
deltas
}
fn apply_edit(&self, edit: RopeEdit) -> EditDelta {
assert!(edit.range.start <= edit.range.end, "invalid edit range");
assert!(
edit.range.end <= self.len_bytes(),
"edit range out of bounds"
);
self.assert_char_boundary(edit.range.start);
self.assert_char_boundary(edit.range.end);
assert!(
edit.insert.is_char_boundary(edit.insert.len()),
"replacement text must be valid UTF-8"
);
let old_start = self.point_at(edit.range.start);
let old_end = self.point_at(edit.range.end);
let removed_text = self.slice(edit.range);
let removed = TextMetrics::from_text(&removed_text);
let inserted = TextMetrics::from_text(&edit.insert);
let mut leaves = self.inner.leaves.borrow_mut();
let mut cursor = 0usize;
let mut first_index = None;
let mut last_index = None;
let mut prefix = String::new();
let mut suffix = String::new();
for (index, leaf) in leaves.iter().enumerate() {
let text = leaf.read();
let end = cursor + text.len();
if first_index.is_none() && edit.range.start <= end {
first_index = Some(index);
let split = edit.range.start.saturating_sub(cursor);
prefix.push_str(&text[..split]);
}
if first_index.is_some() && edit.range.end <= end {
last_index = Some(index);
let split = edit.range.end.saturating_sub(cursor);
suffix.push_str(&text[split..]);
break;
}
cursor = end;
}
let first_index = first_index.unwrap_or_else(|| leaves.len().saturating_sub(1));
let last_index = last_index.unwrap_or(first_index);
let mut replacement = prefix;
replacement.push_str(&edit.insert);
replacement.push_str(&suffix);
let structure_changed =
last_index > first_index || replacement.len() > DEFAULT_LEAF_MAX_BYTES;
if !structure_changed {
if let Some(leaf) = leaves.get(first_index) {
leaf.set_text(replacement);
leaf.source.trigger();
}
} else {
let mut replacement_leaves = Vec::new();
Self::append_chunk_into(
&self.inner.reactor,
self.inner.leaf_target_bytes,
&mut replacement_leaves,
&replacement,
);
if replacement_leaves.is_empty() {
replacement_leaves.push(Rc::new(Leaf::new(&self.inner.reactor, String::new())));
}
let removed_span = leaves.splice(first_index..=last_index, replacement_leaves);
let mut touched = BTreeSet::new();
for leaf in removed_span {
touched.insert(leaf.source.id());
}
for leaf in leaves.iter().skip(first_index) {
touched.insert(leaf.source.id());
}
for leaf in &*leaves {
if touched.contains(&leaf.source.id()) {
leaf.source.trigger();
}
}
self.inner.structure.trigger();
}
self.inner
.revision
.set(self.inner.revision.get().wrapping_add(1));
let new_end = old_start.advance(&edit.insert);
drop(leaves);
EditDelta {
range: edit.range,
removed_text,
inserted_text: edit.insert,
removed,
inserted,
start: old_start,
old_end,
new_end,
}
}
fn total_metrics(&self) -> TextMetrics {
self.inner
.leaves
.borrow()
.iter()
.fold(TextMetrics::default(), |sum, leaf| sum + leaf.metrics.get())
}
fn append_chunk_into(
reactor: &Reactor,
leaf_target_bytes: usize,
out: &mut Vec<Rc<Leaf>>,
text: &str,
) {
if text.is_empty() {
return;
}
let max_bytes = (leaf_target_bytes.max(32)) * 2;
let mut start = 0usize;
while start < text.len() {
let mut end = min(text.len(), start + max_bytes);
while end > start && !text.is_char_boundary(end) {
end -= 1;
}
if end == start {
end = text.len();
}
out.push(Rc::new(Leaf::new(reactor, text[start..end].to_string())));
start = end;
}
}
fn assert_char_boundary(&self, offset: usize) {
if offset == self.len_bytes() {
return;
}
let leaves = self.inner.leaves.borrow();
let mut cursor = 0usize;
for leaf in &*leaves {
let bytes = leaf.metrics.get().bytes;
let end = cursor + bytes;
if offset < end {
let text = leaf.read();
assert!(
text.is_char_boundary(offset - cursor),
"offset must be a char boundary"
);
return;
}
cursor = end;
}
}
}
impl RopeChunk {
pub fn text(&self) -> &str {
&self.text
}
}
impl RopeCursor {
pub fn offset(&self) -> usize {
self.next_offset
}
pub fn peek_chunk(&self) -> Option<RopeChunk> {
let chunk = self.rope.chunk_at(self.next_offset)?;
let local = self.next_offset.saturating_sub(chunk.start.byte);
if local == 0 {
return Some(chunk);
}
let partial = &chunk.text()[local..];
Some(RopeChunk {
text: Rc::from(partial),
start: self.rope.point_at(self.next_offset),
metrics: TextMetrics::from_text(partial),
})
}
pub fn next_chunk(&mut self) -> Option<RopeChunk> {
let chunk = self.peek_chunk()?;
let end = chunk.start.byte + chunk.metrics.bytes;
self.next_offset = end;
Some(chunk)
}
pub fn read_bytes(&mut self, len: usize) -> String {
let start = self.next_offset;
let end = min(self.rope.len_bytes(), start + len);
let slice = self.rope.slice(TextRange::new(start, end));
self.next_offset = end;
slice
}
}
impl Leaf {
fn new(reactor: &Reactor, text: String) -> Self {
let text: Rc<str> = Rc::from(text);
let metrics = TextMetrics::from_text(&text);
let tail_chars = tail_chars(&text);
Self {
source: source_in(reactor),
text: RefCell::new(text),
metrics: Cell::new(metrics),
tail_chars: Cell::new(tail_chars),
}
}
fn read(&self) -> Rc<str> {
self.source.observe();
self.text.borrow().clone()
}
fn set_text(&self, text: String) {
let text: Rc<str> = Rc::from(text);
self.metrics.set(TextMetrics::from_text(&text));
self.tail_chars.set(tail_chars(&text));
*self.text.borrow_mut() = text;
}
}
fn tail_chars(text: &str) -> usize {
match text.rfind('\n') {
Some(index) => text[index + 1..].chars().count(),
None => text.chars().count(),
}
}
#[cfg(test)]
mod tests {
use std::cell::Cell as Counter;
use std::cell::RefCell;
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::rc::Rc;
use ruin_reactivity::{Reactor, thunk_in};
use crate::{Anchor, AnchorBias, ReactiveRope, RopeBuilder, RopeEdit, TextRange};
#[test]
fn edits_preserve_text_and_metrics() {
let reactor = Reactor::new();
let rope = ReactiveRope::from_text(&reactor, "hello\nworld");
let delta = rope.edit(RopeEdit::replace(TextRange::new(5, 6), ", "));
assert_eq!(rope.to_string(), "hello, world");
assert_eq!(delta.removed_text, "\n");
assert_eq!(delta.inserted_text, ", ");
assert_eq!(rope.snapshot_metrics().text.bytes, "hello, world".len());
assert_eq!(rope.snapshot_metrics().text.line_breaks, 0);
}
#[test]
fn cursor_walks_tokens_across_leaf_boundaries() {
let reactor = Reactor::new();
let rope = RopeBuilder::new(&reactor)
.leaf_target_bytes(4)
.push("alpha")
.push("beta")
.build();
let mut cursor = rope.cursor(3);
assert_eq!(cursor.read_bytes(4), "habe");
let rest = cursor.next_chunk().expect("remaining chunk");
assert_eq!(rest.text(), "ta");
}
#[test]
fn anchors_map_through_insertions() {
let reactor = Reactor::new();
let rope = ReactiveRope::from_text(&reactor, "abcdef");
let mut left = Anchor::new(3, AnchorBias::Left);
let mut right = Anchor::new(3, AnchorBias::Right);
let delta = rope.edit(RopeEdit::insert(3, "XYZ"));
left.map_through(&delta);
right.map_through(&delta);
assert_eq!(left.byte, 3);
assert_eq!(right.byte, 6);
}
#[test]
fn disjoint_leaf_reads_do_not_recompute_on_local_edit() {
let reactor = Reactor::new();
let rope = RopeBuilder::new(&reactor)
.leaf_target_bytes(64)
.push("a".repeat(80))
.push("b".repeat(80))
.build();
let first_runs = Rc::new(Counter::new(0usize));
let second_runs = Rc::new(Counter::new(0usize));
let first = thunk_in(&reactor, {
let rope = rope.clone();
let first_runs = Rc::clone(&first_runs);
move || {
first_runs.set(first_runs.get() + 1);
rope.slice(TextRange::new(0, 10))
}
});
let second = thunk_in(&reactor, {
let rope = rope.clone();
let second_runs = Rc::clone(&second_runs);
move || {
second_runs.set(second_runs.get() + 1);
rope.slice(TextRange::new(90, 100))
}
});
assert_eq!(first.get(), "aaaaaaaaaa");
assert_eq!(second.get(), "bbbbbbbbbb");
rope.edit(RopeEdit::replace(TextRange::new(2, 4), "zz"));
assert_eq!(first.get(), "aazzaaaaaa");
assert_eq!(second.get(), "bbbbbbbbbb");
assert_eq!(first_runs.get(), 2);
assert_eq!(second_runs.get(), 1);
}
#[test]
fn point_at_tracks_multibyte_offsets() {
let reactor = Reactor::new();
let rope = ReactiveRope::from_text(&reactor, "a\né🙂");
assert_eq!(rope.point_at(0).byte, 0);
assert_eq!(rope.point_at(2).line, 1);
assert_eq!(rope.point_at(4).char_offset, 3);
assert_eq!(rope.point_at("a\né🙂".len()).utf16_offset, 5);
assert_eq!(rope.point_at("a\né".len()).column, 1);
}
#[test]
fn point_at_tracks_column_across_leaves() {
let reactor = Reactor::new();
let rope = RopeBuilder::new(&reactor)
.leaf_target_bytes(4)
.push("ab\n")
.push("cde")
.build();
let point = rope.point_at("ab\ncd".len());
assert_eq!(point.line, 1);
assert_eq!(point.column, 2);
}
#[test]
fn chunk_at_and_peek_chunk_respect_boundaries() {
let reactor = Reactor::new();
let rope = RopeBuilder::new(&reactor)
.leaf_target_bytes(4)
.push("abcd")
.push("efgh")
.build();
let first = rope.chunk_at(0).expect("chunk at start");
assert_eq!(first.text(), "abcd");
let second = rope.chunk_at(4).expect("chunk at split");
assert_eq!(second.text(), "efgh");
assert!(rope.chunk_at(8).is_none());
let mut cursor = rope.cursor(2);
let partial = cursor.peek_chunk().expect("partial chunk");
assert_eq!(partial.text(), "cd");
let advanced = cursor.next_chunk().expect("advanced chunk");
assert_eq!(advanced.text(), "cd");
assert_eq!(cursor.offset(), 4);
}
#[test]
fn cursor_stops_at_end_of_document() {
let reactor = Reactor::new();
let rope = RopeBuilder::new(&reactor)
.leaf_target_bytes(4)
.push("abcd")
.push("ef")
.build();
let mut cursor = rope.cursor(0);
let mut chunks = Vec::new();
while let Some(chunk) = cursor.next_chunk() {
chunks.push(chunk.text().to_string());
}
assert_eq!(chunks, vec!["abcd".to_string(), "ef".to_string()]);
assert!(cursor.next_chunk().is_none());
}
#[test]
fn batch_edits_return_deltas_and_increment_revision() {
let reactor = Reactor::new();
let rope = ReactiveRope::from_text(&reactor, "abcdef");
let before = rope.snapshot_metrics().revision;
let deltas = rope.edit_batch([
RopeEdit::replace(TextRange::new(1, 3), "ZZ"),
RopeEdit::delete(TextRange::new(4, 6)),
]);
assert_eq!(deltas.len(), 2);
assert_eq!(rope.to_string(), "aZZd");
assert_eq!(rope.snapshot_metrics().revision, before + 2);
}
#[test]
fn delete_to_empty_preserves_valid_empty_rope() {
let reactor = Reactor::new();
let rope = ReactiveRope::from_text(&reactor, "hello");
let delta = rope.edit(RopeEdit::delete(TextRange::new(0, 5)));
assert_eq!(delta.removed_text, "hello");
assert!(rope.is_empty());
assert_eq!(rope.to_string(), "");
assert_eq!(rope.snapshot_metrics().leaves, 1);
}
#[test]
fn structure_changing_edit_updates_leaf_count() {
let reactor = Reactor::new();
let rope = RopeBuilder::new(&reactor)
.leaf_target_bytes(8)
.push("abcdefgh")
.push("ijklmnop")
.build();
let before = rope.snapshot_metrics();
rope.edit(RopeEdit::insert(8, "QRSTUVWXYZ0123456789"));
let after = rope.snapshot_metrics();
assert!(after.leaves >= before.leaves);
assert!(after.text.bytes > before.text.bytes);
}
#[test]
fn invalid_char_boundary_panics() {
let reactor = Reactor::new();
let rope = ReactiveRope::from_text(&reactor, "é");
let panic = catch_unwind(AssertUnwindSafe(|| {
let _ = rope.slice(TextRange::new(1, 2));
}));
assert!(panic.is_err());
}
#[test]
fn edit_events_emit_immediately() {
let reactor = Reactor::new();
let rope = ReactiveRope::from_text(&reactor, "abc");
let seen = Rc::new(RefCell::new(Vec::new()));
let _sub = rope.edit_events().subscribe({
let seen = Rc::clone(&seen);
move |delta| {
seen.borrow_mut()
.push((delta.range, delta.inserted_text.clone()))
}
});
rope.edit(RopeEdit::insert(1, "Z"));
assert_eq!(&*seen.borrow(), &[(TextRange::new(1, 1), "Z".to_string())]);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
use ruin_app::prelude::*; use ruin_app::{PartialTextStyle, prelude::*};
use ruin_ui::{BoxShadow, BoxShadowKind, Point};
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
#[ruin_runtime::async_main] #[ruin_runtime::async_main]
@@ -18,6 +19,8 @@ async fn main() -> ruin_app::Result<()> {
Window::new() Window::new()
.title(title) .title(title)
.app_id("dev.ruin.counter") .app_id("dev.ruin.counter")
.base_color(Color::rgba(255, 255, 255, 80))
.min_size(320.0, 240.0)
.size(960.0, 640.0), .size(960.0, 640.0),
) )
.mount(view! { .mount(view! {
@@ -40,9 +43,15 @@ fn CounterApp(title: &'static str) -> impl IntoView {
move || format!("{title} ({})", count.get()) move || format!("{title} ({})", count.get())
}); });
let text_style = PartialTextStyle {
color: Some(Color::BLACK),
weight: Some(FontWeight::Bold),
..Default::default()
};
view! { view! {
column(gap = 16.0, padding = 24.0) { column(gap = 16.0, padding = 24.0, align_items = AlignItems::Center, text_style = text_style) {
text(role = TextRole::Heading(1), size = 32.0, weight = FontWeight::Semibold) { text(role = TextRole::Heading(1), size = 32.0, weight = FontWeight::Semibold, color = Color::rgba(0, 0, 0, 255)) {
title title
} }
@@ -51,12 +60,14 @@ fn CounterApp(title: &'static str) -> impl IntoView {
block( block(
padding = 16.0, padding = 16.0,
gap = 8.0, gap = 8.0,
background = surfaces::raised(), align_self = AlignItems::Stretch,
background = Color::rgba(255, 255, 255, 200),
border_radius = 12.0, border_radius = 12.0,
border = Border::new(2.0, Color::rgb(0, 0, 0)) border = Border::new(2.0, Color::rgba(0, 0, 0, 120)),
shadow = BoxShadow::new(Point::new(2.0, 2.0), 8.0, 2.0, Color::rgba(0, 0, 0, 60), BoxShadowKind::Outer),
) { ) {
text(size = 18.0) { "count = "; count.clone() } text(size = 18.0, color = Color::rgb(0, 0, 0)) { "count = "; count.clone() }
text(color = colors::muted()) { "double = "; doubled.clone() } text(color = Color::rgba(0, 0, 0, 200)) { "double = "; doubled.clone() }
} }
} }
} }
@@ -65,8 +76,9 @@ fn CounterApp(title: &'static str) -> impl IntoView {
#[component] #[component]
fn CounterActions(count: Signal<i32>) -> impl IntoView { fn CounterActions(count: Signal<i32>) -> impl IntoView {
view! { view! {
row(gap = 8.0) { row(gap = 16.0) {
button( button(
background = Color::rgba(255, 255, 255, 90),
on_press = { on_press = {
let count = count.clone(); let count = count.clone();
move |_| { move |_| {
@@ -74,10 +86,11 @@ fn CounterActions(count: Signal<i32>) -> impl IntoView {
} }
}, },
) { ) {
"-1" text(selectable = false, pointer_events = false) { "-1" }
} }
button( button(
background = Color::rgba(255, 255, 255, 90),
on_press = { on_press = {
let count = count.clone(); let count = count.clone();
move |_| { move |_| {
@@ -85,10 +98,11 @@ fn CounterActions(count: Signal<i32>) -> impl IntoView {
} }
}, },
) { ) {
"Reset" text(selectable = false, pointer_events = false) { "Reset" }
} }
button( button(
background = Color::rgba(255, 255, 255, 90),
on_press = { on_press = {
let count = count.clone(); let count = count.clone();
move |_| { move |_| {
@@ -96,7 +110,7 @@ fn CounterActions(count: Signal<i32>) -> impl IntoView {
} }
}, },
) { ) {
"+1" text(selectable = false, pointer_events = false) { "+1" }
} }
} }
} }

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

@@ -0,0 +1,621 @@
use std::cell::Cell as StdCell;
use std::cell::RefCell;
use std::iter;
use std::rc::Rc;
use std::time::Instant;
use ruin_reactivity::{current as current_reactor, effect};
#[cfg(target_os = "macos")]
use ruin_runtime::{queue_future, spawn_worker};
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,
};
#[cfg(target_os = "macos")]
use ruin_ui_platform_macos::{run_macos_app_event_loop, start_macos_ui};
#[cfg(target_os = "linux")]
use ruin_ui_platform_wayland::start_wayland_ui;
use crate::Result;
use crate::context::{
InteractionSnapshot, RenderState, render_with_context, render_with_context_and_interaction,
};
use crate::input::{
EventBindings, InputState, ShortcutBinding, TextSelection, TextSelectionDrag,
TextSelectionState, apply_text_selection_overlay, focused_element_for_pointer,
sync_primary_selection,
};
use crate::view::View;
#[cfg(target_os = "linux")]
fn start_platform_ui() -> ruin_ui::UiRuntime {
start_wayland_ui()
}
#[cfg(target_os = "macos")]
fn start_platform_ui() -> ruin_ui::UiRuntime {
start_macos_ui()
}
#[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
}
pub fn resizable(mut self, resizable: bool) -> Self {
self.spec.resizable = resizable;
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,
}
}
}
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: M,
}
impl<M: Mountable> MountedApp<M> {
async fn run_with_ui_runtime(
app_window: Window,
root: M,
mut ui: ruin_ui::UiRuntime,
) -> Result<()> {
let root = Rc::new(root);
let render_state = Rc::new(RenderState::default());
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 configure_serial = ruin_reactivity::cell(0_u64);
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 last_fixed_size = Rc::new(RefCell::new(None::<UiSize>));
let explicit_min_size = app_window.spec.min_inner_size;
let explicit_max_size = app_window.spec.max_inner_size;
let window_resizable = app_window.spec.resizable;
let mut input_state = InputState::new();
let mut pointer_router = PointerRouter::new();
let _scene_effect = effect({
let window = window.clone();
let viewport = viewport.clone();
let configure_serial = configure_serial.clone();
let text_system = Rc::clone(&text_system);
let layout_cache = Rc::clone(&layout_cache);
let interaction_tree = Rc::clone(&interaction_tree);
let bindings = Rc::clone(&bindings);
let shortcuts = Rc::clone(&shortcuts);
let current_title = Rc::clone(&current_title);
let last_min_size = Rc::clone(&last_min_size);
let last_fixed_size = Rc::clone(&last_fixed_size);
let root = Rc::clone(&root);
let render_state = Rc::clone(&render_state);
let text_selection = Rc::clone(&input_state.text_selection);
let hovered_elements = input_state.hovered_elements.clone();
let pressed_elements = input_state.pressed_elements.clone();
let interaction_version = input_state.interaction_version.clone();
move || {
let viewport = viewport.get();
let _ = configure_serial.get();
let version = scene_version.get().wrapping_add(1);
scene_version.set(version);
let _ = text_selection.version.get();
let _ = interaction_version.get();
let t_effect = Instant::now();
let interaction_snapshot = Rc::new(InteractionSnapshot {
hovered: hovered_elements.get(),
pressed: pressed_elements.get(),
});
let render_output = render_with_context_and_interaction(
Rc::clone(&render_state),
interaction_snapshot,
|| root.render(),
);
if render_output.side_effects.window_title != *current_title.borrow() {
if let Some(title) = &render_output.side_effects.window_title {
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();
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)),
);
if window_resizable {
let current_min = *last_min_size.borrow();
if current_min != Some(desired_min) {
let _ =
window.update(WindowUpdate::new().min_inner_size(Some(desired_min)));
*last_min_size.borrow_mut() = Some(desired_min);
}
} else {
let desired_fixed = UiSize::new(
desired_min
.width
.min(explicit_max_size.map_or(f32::INFINITY, |s| s.width)),
desired_min
.height
.min(explicit_max_size.map_or(f32::INFINITY, |s| s.height)),
);
let current_fixed = *last_fixed_size.borrow();
if current_fixed != Some(desired_fixed) {
let _ = window.update(
WindowUpdate::new()
.requested_inner_size(Some(desired_fixed))
.min_inner_size(Some(desired_fixed))
.max_inner_size(Some(desired_fixed)),
);
*last_fixed_size.borrow_mut() = Some(desired_fixed);
}
}
let effect_us = t_effect.elapsed().as_micros();
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;
};
let mut should_flush_reactive_work = false;
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);
configure_serial.update(|value| *value = value.wrapping_add(1));
should_flush_reactive_work = true;
}
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,
)?;
should_flush_reactive_work = true;
}
PlatformEvent::Keyboard { window_id, event } if window_id == window.id() => {
Self::handle_keyboard_event(
&interaction_tree,
&bindings,
&shortcuts,
&input_state,
event,
)?;
should_flush_reactive_work = true;
}
PlatformEvent::CloseRequested { window_id } if window_id == window.id() => {
tracing::debug!(
target: "ruin_app::lifecycle",
?window_id,
"app received CloseRequested, issuing open(false)"
);
let _ = window.update(WindowUpdate::new().open(false));
}
PlatformEvent::Closed { window_id } if window_id == window.id() => {
tracing::debug!(
target: "ruin_app::lifecycle",
?window_id,
"app received Closed, shutting down ui runtime"
);
ui.shutdown()?;
return Ok(());
}
_ => {}
}
}
if should_flush_reactive_work {
current_reactor().flush_now();
}
}
ui.shutdown()?;
Ok(())
}
}
impl<M: Mountable> MountedApp<M> {
#[cfg(not(target_os = "macos"))]
pub async fn run(self) -> Result<()> {
let ui = start_platform_ui();
Self::run_with_ui_runtime(self.window, self.root, ui).await
}
#[cfg(target_os = "macos")]
pub async fn run(self) -> Result<()>
where
M: Send + 'static,
{
let ui = start_platform_ui();
let (done_tx, done_rx) = std::sync::mpsc::channel::<std::result::Result<(), String>>();
let window = self.window;
let root = self.root;
let _worker = spawn_worker(
move || {
queue_future(async move {
let result = Self::run_with_ui_runtime(window, root, ui)
.await
.map_err(|err| err.to_string());
let _ = done_tx.send(result);
});
},
|| {},
);
run_macos_app_event_loop();
match done_rx.recv() {
Ok(Ok(())) => Ok(()),
Ok(Err(err)) => Err(err.into()),
Err(_) => Err("ui worker terminated before completion".into()),
}
}
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);
let hovered_ids = Self::interaction_ids(pointer_router.hovered_targets());
let pressed_ids = Self::interaction_ids(pointer_router.pressed_targets());
if next_cursor != input_state.current_cursor {
input_state.current_cursor = next_cursor;
window.set_cursor_icon(next_cursor)?;
}
if Self::update_interaction_state(input_state, hovered_ids, pressed_ids) {
input_state.interaction_version.update(|value| *value += 1);
}
Ok(())
}
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(())
}
fn interaction_ids(targets: &[ruin_ui::HitTarget]) -> Vec<ruin_ui::ElementId> {
targets
.iter()
.filter_map(|target| target.element_id)
.collect()
}
fn update_interaction_state(
input_state: &mut InputState,
hovered_ids: Vec<ruin_ui::ElementId>,
pressed_ids: Vec<ruin_ui::ElementId>,
) -> bool {
let previous_hovered = input_state.hovered_elements.get();
let previous_pressed = input_state.pressed_elements.get();
let hovered_changed = input_state
.hovered_elements
.set(hovered_ids.clone())
.is_some();
let pressed_changed = input_state
.pressed_elements
.set(pressed_ids.clone())
.is_some();
if hovered_changed || pressed_changed {
tracing::trace!(
target: "ruin_app::interaction",
hovered_before = ?previous_hovered,
hovered_after = ?hovered_ids,
pressed_before = ?previous_pressed,
pressed_after = ?pressed_ids,
"interaction state changed"
);
}
hovered_changed || pressed_changed
}
}
#[doc(hidden)]
pub fn __render_mountable_for_test<M: Mountable>(mountable: &M) -> View {
render_with_context(Rc::new(RenderState::default()), || mountable.render()).view
}

View File

@@ -0,0 +1,13 @@
use ruin_ui::Color;
pub const fn text() -> Color {
Color::rgb(0xFF, 0xFF, 0xFF)
}
pub const fn muted() -> Color {
Color::rgb(0xB6, 0xC2, 0xD9)
}
pub const fn danger() -> Color {
Color::rgb(0xFF, 0x7B, 0x72)
}

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

@@ -0,0 +1,201 @@
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, Debug, Default, PartialEq, Eq)]
pub(crate) struct InteractionSnapshot {
pub(crate) hovered: Vec<ElementId>,
pub(crate) pressed: Vec<ElementId>,
}
#[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>>,
pub(crate) interaction: Rc<InteractionSnapshot>,
}
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,
interaction: Rc::clone(&self.interaction),
}
}
}
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 {
render_with_context_and_interaction(state, Rc::new(InteractionSnapshot::default()), render)
}
pub(crate) fn render_with_context_and_interaction(
state: Rc<RenderState>,
interaction: Rc<InteractionSnapshot>,
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()),
interaction,
};
let view = with_render_context(context.clone(), render);
let side_effects = context.side_effects.borrow().clone();
RenderOutput { view, side_effects }
}
pub(crate) fn with_render_context(context: RenderContext, render: impl FnOnce() -> View) -> View {
CURRENT_RENDER_CONTEXT.with(|slot| {
let previous = slot.replace(Some(context.clone()));
struct Guard<'a> {
slot: &'a RefCell<Option<RenderContext>>,
previous: Option<RenderContext>,
}
impl Drop for Guard<'_> {
fn drop(&mut self) {
let _ = self.slot.replace(self.previous.take());
}
}
let _guard = Guard { slot, previous };
render()
})
}
pub(crate) fn with_render_context_state<R>(f: impl FnOnce(&RenderContext) -> R) -> R {
CURRENT_RENDER_CONTEXT.with(|slot| {
let context = slot
.borrow()
.clone()
.expect("ruin_app hooks can only run while rendering a mounted component");
f(&context)
})
}
pub(crate) fn with_hook_slot<T: 'static, R>(
init: impl FnOnce() -> T,
f: impl FnOnce(&mut T) -> R,
) -> R {
with_render_context_state(|context| {
let index = context.hook_index.get();
context.hook_index.set(index + 1);
let mut hooks = context.state.hooks.borrow_mut();
if hooks.len() == index {
hooks.push(Box::new(init()));
}
let slot = hooks[index]
.downcast_mut::<T>()
.expect("ruin_app hook call order changed between renders");
f(slot)
})
}
pub(crate) fn allocate_element_id() -> ElementId {
with_render_context_state(|context| {
let index = context.element_index.get();
context.element_index.set(index + 1);
let mut element_ids = context.state.element_ids.borrow_mut();
if element_ids.len() == index {
let next = context.state.next_element_id.get().wrapping_add(1);
context.state.next_element_id.set(next);
element_ids.push(ElementId::new(next));
}
element_ids[index]
})
}
pub(crate) struct MemoSlot<T> {
pub(crate) compute: Rc<RefCell<Box<dyn Fn() -> T>>>,
pub(crate) handle: crate::hooks::Memo<T>,
}
pub(crate) struct ResourceSlot<T, E> {
pub(crate) resource: crate::hooks::Resource<T, E>,
pub(crate) _effect: ruin_reactivity::EffectHandle,
}
impl<T: 'static> MemoSlot<T> {
pub(crate) fn new(compute: Rc<RefCell<Box<dyn Fn() -> T>>>) -> Self {
let handle = crate::hooks::Memo {
compute: Rc::new({
let compute = Rc::clone(&compute);
move || {
let compute = compute.borrow();
(compute.as_ref())()
}
}),
};
Self { compute, handle }
}
pub(crate) fn replace_compute(&mut self, compute: Rc<RefCell<Box<dyn Fn() -> T>>>) {
self.compute = Rc::clone(&compute);
self.handle.compute = Rc::new({
let compute = Rc::clone(&compute);
move || {
let compute = compute.borrow();
(compute.as_ref())()
}
});
}
}

View File

@@ -0,0 +1,43 @@
use ruin_ui::{Border, BoxShadow, Color, Edges};
pub trait IntoEdges {
fn into_edges(self) -> Edges;
}
impl IntoEdges for Edges {
fn into_edges(self) -> Edges {
self
}
}
impl IntoEdges for f32 {
fn into_edges(self) -> Edges {
Edges::all(self)
}
}
pub trait IntoBorder {
fn into_border(self) -> Border;
}
impl IntoBorder for Border {
fn into_border(self) -> Border {
self
}
}
impl IntoBorder for (f32, Color) {
fn into_border(self) -> Border {
Border::new(self.0, self.1)
}
}
pub trait IntoShadow {
fn into_shadow(self) -> BoxShadow;
}
impl IntoShadow for BoxShadow {
fn into_shadow(self) -> BoxShadow {
self
}
}

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

@@ -0,0 +1,419 @@
use std::any::TypeId;
use std::any::type_name;
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,
Backspace,
Escape,
Delete,
ArrowLeft,
ArrowRight,
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::Backspace, KeyboardKey::Backspace) => true,
(Self::Escape, KeyboardKey::Escape) => true,
(Self::Delete, KeyboardKey::Delete) => true,
(Self::ArrowLeft, KeyboardKey::ArrowLeft) => true,
(Self::ArrowRight, KeyboardKey::ArrowRight) => true,
(Self::ArrowUp, KeyboardKey::ArrowUp) => true,
(Self::ArrowDown, KeyboardKey::ArrowDown) => true,
(Self::Home, KeyboardKey::Home) => true,
(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 {
if event.kind != KeyboardEventKind::Pressed {
return false;
}
if !self.key.matches(event) {
return false;
}
if event.modifiers.control != self.control {
return false;
}
if event.modifiers.alt != self.alt {
return false;
}
if event.modifiers.super_key != self.super_key {
return false;
}
// For character shortcuts the produced character already encodes the shift
// state, so don't additionally require that Shift is (un)held.
if !matches!(self.key, Key::Character(_)) && event.modifiers.shift != self.shift {
return false;
}
true
}
}
#[derive(Clone)]
pub struct FocusScope {
pub(crate) element_id: Signal<Option<ElementId>>,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct InteractionState {
pub hovered: bool,
pub pressed: bool,
}
#[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_interaction_state<T: 'static>(widget_ref: WidgetRef<T>) -> InteractionState {
with_render_context_state(|context| {
let Some(element_id) = widget_ref.element_id() else {
return InteractionState::default();
};
InteractionState {
hovered: context.interaction.hovered.contains(&element_id),
pressed: context.interaction.pressed.contains(&element_id),
}
})
}
pub fn use_shortcut(shortcut: Shortcut, scope: ShortcutScope, action: impl Fn() + 'static) {
use_shortcut_with_context(shortcut, scope, move |_| action());
}
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(),
)
}

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

@@ -0,0 +1,357 @@
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>,
pub(crate) hovered_elements: Signal<Vec<ElementId>>,
pub(crate) pressed_elements: Signal<Vec<ElementId>>,
pub(crate) interaction_version: Signal<u64>,
}
impl InputState {
pub(crate) fn new() -> Self {
Self {
current_cursor: CursorIcon::Default,
focused_element: None,
text_selection: Rc::new(TextSelectionState::new()),
hovered_elements: Signal::new(Vec::new()),
pressed_elements: Signal::new(Vec::new()),
interaction_version: Signal::new(0),
}
}
}
#[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<()> {
#[cfg(not(target_os = "linux"))]
{
let _ = (window, interaction_tree, selection);
return Ok(());
}
#[cfg(target_os = "linux")]
{
let Some(selection) = selection else {
window.set_primary_selection_text(String::new())?;
return Ok(());
};
let Some(text) = interaction_tree.text_for_element(selection.element_id) else {
return Ok(());
};
let copied = text
.selected_text(selection.anchor, selection.focus)
.unwrap_or_default()
.to_owned();
window.set_primary_selection_text(copied)?;
Ok(())
}
}
pub(crate) fn scroll_handler_for_event<'a>(
handlers: &'a HashMap<ElementId, ScrollHandler>,
direct_target: Option<ElementId>,
hovered_targets: &[HitTarget],
) -> Option<&'a ScrollHandler> {
if let Some(element_id) = direct_target
&& let Some(handler) = handlers.get(&element_id)
{
return Some(handler);
}
hovered_targets
.iter()
.rev()
.filter_map(|target| target.element_id)
.find_map(|element_id| handlers.get(&element_id))
}
pub(crate) fn focused_element_for_pointer(
interaction_tree: &InteractionTree,
event: &PointerEvent,
) -> Option<ElementId> {
let hit_path = interaction_tree.hit_path(event.position);
hit_path
.iter()
.rev()
.find_map(|target| target.focusable.then_some(target.element_id).flatten())
.or_else(|| hit_path.iter().rev().find_map(|target| target.element_id))
}
pub(crate) fn element_contains_element(
interaction_tree: &InteractionTree,
ancestor: ElementId,
descendant: ElementId,
) -> bool {
fn contains_descendant(node: &ruin_ui::LayoutNode, descendant: ElementId) -> bool {
if node.element_id == Some(descendant) {
return true;
}
node.children
.iter()
.any(|child| contains_descendant(child, descendant))
}
fn ancestor_contains(
node: &ruin_ui::LayoutNode,
ancestor: ElementId,
descendant: ElementId,
) -> bool {
if node.element_id == Some(ancestor) {
return contains_descendant(node, descendant);
}
node.children
.iter()
.any(|child| ancestor_contains(child, ancestor, descendant))
}
ancestor_contains(&interaction_tree.root, ancestor, descendant)
}
pub(crate) fn key_handler_for_focus<'a>(
handlers: &'a HashMap<ElementId, KeyHandler>,
focused_element: Option<ElementId>,
interaction_tree: &InteractionTree,
) -> Option<&'a KeyHandler> {
let focused_element = focused_element?;
focused_ancestor_chain(&interaction_tree.root, focused_element)?
.into_iter()
.find_map(|element_id| handlers.get(&element_id))
}
pub(crate) fn focused_ancestor_chain(
node: &ruin_ui::LayoutNode,
focused_element: ElementId,
) -> Option<Vec<ElementId>> {
let mut chain = Vec::new();
if build_focused_ancestor_chain(node, focused_element, &mut chain) {
Some(chain)
} else {
None
}
}
pub(crate) fn build_focused_ancestor_chain(
node: &ruin_ui::LayoutNode,
focused_element: ElementId,
chain: &mut Vec<ElementId>,
) -> bool {
if node.element_id == Some(focused_element) {
if let Some(element_id) = node.element_id {
chain.push(element_id);
}
return true;
}
for child in &node.children {
if build_focused_ancestor_chain(child, focused_element, chain) {
if let Some(element_id) = node.element_id {
chain.push(element_id);
}
return true;
}
}
false
}
// View helper methods for attaching handlers — used by primitives
impl View {
pub(crate) fn with_press_handler(
mut self,
element_id: ElementId,
handler: PressHandler,
) -> Self {
self.bindings.on_press.insert(element_id, handler);
self
}
pub(crate) fn with_scroll_handler(
mut self,
element_id: ElementId,
handler: ScrollHandler,
) -> Self {
self.bindings.on_scroll.insert(element_id, handler);
self
}
pub(crate) fn with_key_handler(mut self, element_id: ElementId, handler: KeyHandler) -> Self {
self.bindings.on_key.insert(element_id, handler);
self
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,69 @@
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::surfaces;
use crate::text_style::{pop_text_style, push_text_style};
use crate::view::{Children, View};
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 shadow(mut self, shadow: impl crate::converters::IntoShadow) -> Self {
self.props = self.props.shadow(shadow);
self
}
pub fn on_press(mut self, handler: impl Fn(&RoutedPointerEvent) + 'static) -> Self {
self.on_press = Some(Rc::new(handler));
self
}
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);
if let Some(widget_ref) = &self.props.widget_ref {
let _ = widget_ref.set(Some(id));
}
let had_style = self.props.partial_text_style.is_some();
if let Some(style) = self.props.partial_text_style.take() {
push_text_style(style);
}
let children_views = children.into_views();
if had_style {
pop_text_style();
}
let view = View::from_container(self.props.element, children_views);
match self.on_press {
Some(handler) => view.with_press_handler(id, handler),
None => view,
}
}
}

View File

@@ -0,0 +1,157 @@
use ruin_ui::{AlignItems, 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 align_items(mut self, align: AlignItems) -> Self {
self.element = self.element.align_items(align);
self
}
pub fn align_self(mut self, align: AlignItems) -> Self {
self.element = self.element.align_self(align);
self
}
pub fn shadow(mut self, shadow: impl IntoShadow) -> Self {
self.element = self.element.shadow(shadow.into_shadow());
self
}
pub fn widget_ref<T>(mut self, widget_ref: WidgetRef<T>) -> Self {
self.widget_ref = Some(widget_ref.element_id.clone());
self
}
}
pub struct ContainerBuilder {
pub(crate) props: ContainerProps,
}
pub fn column() -> ContainerBuilder {
ContainerBuilder {
props: ContainerProps::new(Element::column()),
}
}
pub fn row() -> ContainerBuilder {
ContainerBuilder {
props: ContainerProps::new(Element::row()),
}
}
pub fn block() -> ContainerBuilder {
ContainerBuilder {
props: ContainerProps::new(Element::column()),
}
}
impl ContainerBuilder {
impl_props_methods!();
pub fn shadow(mut self, shadow: impl IntoShadow) -> Self {
self.props = self.props.shadow(shadow);
self
}
pub fn text_style(mut self, style: PartialTextStyle) -> Self {
self.props = self.props.text_style(style);
self
}
pub fn children(mut self, children: impl Children) -> View {
if let Some(widget_ref) = &self.props.widget_ref {
let element_id = allocate_element_id();
self.props.element = self.props.element.id(element_id);
let _ = widget_ref.set(Some(element_id));
}
let had_style = self.props.partial_text_style.is_some();
if let Some(style) = self.props.partial_text_style.take() {
push_text_style(style);
}
let children_views = children.into_views();
if had_style {
pop_text_style();
}
View::from_container(self.props.element, children_views)
}
}

View File

@@ -0,0 +1,71 @@
/// 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 align_items(mut self, align: ruin_ui::AlignItems) -> Self {
self.props = self.props.align_items(align);
self
}
pub fn align_self(mut self, align: ruin_ui::AlignItems) -> Self {
self.props = self.props.align_self(align);
self
}
pub fn widget_ref<T>(mut self, widget_ref: $crate::hooks::WidgetRef<T>) -> Self {
self.props = self.props.widget_ref(widget_ref);
self
}
};
}
pub mod button;
pub mod container;
pub mod scroll_box;
pub mod text;
pub use button::{ButtonBuilder, button};
pub use container::{ContainerBuilder, ContainerProps, block, column, row};
pub use scroll_box::{ScrollBoxBuilder, scroll_box};
pub use text::{FontWeight, TextBuilder, TextRole, text};

View File

@@ -0,0 +1,160 @@
use std::rc::Rc;
use ruin_ui::{Element, KeyboardEventKind, PointerButton, RoutedPointerEventKind, ScrollbarStyle};
use crate::context::{allocate_element_id, with_hook_slot};
use crate::hooks::Signal;
use crate::input::{ScrollbarDrag, clamp_scroll_offset};
use crate::primitives::ContainerProps;
use crate::text_style::{pop_text_style, push_text_style};
use crate::view::{Children, View};
pub struct ScrollBoxBuilder {
pub(crate) props: ContainerProps,
pub(crate) offset_y: Option<Signal<f32>>,
pub(crate) drag: Signal<Option<ScrollbarDrag>>,
}
pub fn scroll_box() -> ScrollBoxBuilder {
ScrollBoxBuilder {
props: ContainerProps::new(Element::scroll_box(0.0)),
offset_y: None,
drag: with_hook_slot(|| Signal::new(None::<ScrollbarDrag>), |drag| drag.clone()),
}
}
impl ScrollBoxBuilder {
impl_props_methods!();
pub fn text_style(mut self, style: crate::text_style::PartialTextStyle) -> Self {
self.props = self.props.text_style(style);
self
}
pub fn scrollbar_style(mut self, style: ScrollbarStyle) -> Self {
self.props.element = self.props.element.scrollbar_style(style);
self
}
pub fn offset_y(mut self, offset_y: Signal<f32>) -> Self {
self.props.element = self.props.element.scroll_offset(offset_y.get());
self.offset_y = Some(offset_y);
self
}
pub fn children(mut self, children: impl Children) -> View {
let element_id = allocate_element_id();
self.props.element = self.props.element.id(element_id);
if let Some(widget_ref) = &self.props.widget_ref {
let _ = widget_ref.set(Some(element_id));
}
let had_style = self.props.partial_text_style.is_some();
if let Some(style) = self.props.partial_text_style.take() {
push_text_style(style);
}
let children_views = children.into_views();
if had_style {
pop_text_style();
}
let mut view = View::from_container(self.props.element, children_views);
if let Some(offset_y) = self.offset_y {
let drag = self.drag;
let offset_y_for_pointer = offset_y.clone();
let offset_y_for_keys = offset_y.clone();
view = view.with_scroll_handler(
element_id,
Rc::new(move |event, interaction_tree| {
let Some(metrics) = interaction_tree.scroll_metrics_for_element(element_id)
else {
return;
};
match event.kind {
RoutedPointerEventKind::Scroll { delta } => {
offset_y_for_pointer.update(|value| {
*value =
clamp_scroll_offset(*value + delta.y, metrics.max_offset_y);
});
}
RoutedPointerEventKind::Down {
button: PointerButton::Primary,
} => {
let Some(thumb_rect) = metrics.scrollbar_thumb else {
return;
};
if !thumb_rect.contains(event.position) {
return;
}
let _ = drag.set(Some(ScrollbarDrag {
start_pointer_y: event.position.y,
start_offset_y: offset_y_for_pointer.get(),
}));
}
RoutedPointerEventKind::Move => {
let Some(drag_state) = drag.get() else {
return;
};
let Some(track_rect) = metrics.scrollbar_track else {
return;
};
let Some(thumb_rect) = metrics.scrollbar_thumb else {
return;
};
let thumb_travel =
(track_rect.size.height - thumb_rect.size.height).max(0.0);
if thumb_travel <= 0.0 || metrics.max_offset_y <= 0.0 {
return;
}
let pointer_delta = event.position.y - drag_state.start_pointer_y;
let next_offset = drag_state.start_offset_y
+ pointer_delta * (metrics.max_offset_y / thumb_travel);
offset_y_for_pointer.update(|value| {
*value = clamp_scroll_offset(next_offset, metrics.max_offset_y);
});
}
RoutedPointerEventKind::Up {
button: PointerButton::Primary,
} => {
let _ = drag.set(None);
}
_ => {}
}
}),
);
view = view.with_key_handler(
element_id,
Rc::new(move |event, interaction_tree| {
if event.kind != KeyboardEventKind::Pressed
|| event.modifiers.control
|| event.modifiers.alt
|| event.modifiers.super_key
{
return false;
}
let Some(metrics) = interaction_tree.scroll_metrics_for_element(element_id)
else {
return false;
};
let line_step = (metrics.viewport_rect.size.height * 0.12).clamp(28.0, 52.0);
let next_offset = match event.key {
ruin_ui::KeyboardKey::ArrowUp => offset_y_for_keys.get() - line_step,
ruin_ui::KeyboardKey::ArrowDown => offset_y_for_keys.get() + line_step,
ruin_ui::KeyboardKey::Home => 0.0,
ruin_ui::KeyboardKey::End => metrics.max_offset_y,
_ => return false,
};
let next_offset = clamp_scroll_offset(next_offset, metrics.max_offset_y);
let changed = (offset_y_for_keys.get() - next_offset).abs() > f32::EPSILON;
if changed {
let _ = offset_y_for_keys.set(next_offset);
}
changed
}),
);
}
view
}
}

View File

@@ -0,0 +1,129 @@
use ruin_ui::{Color, Element, TextFontFamily, TextSpan, TextSpanWeight, TextStyle, TextWrap};
use crate::colors;
use crate::context::allocate_element_id;
use crate::text_style::current_text_style;
use crate::view::{TextChildren, View};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TextRole {
Body,
Heading(u8),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum FontWeight {
Normal,
Medium,
Semibold,
Bold,
}
impl FontWeight {
pub(crate) const fn to_text_span_weight(self) -> TextSpanWeight {
match self {
Self::Normal => TextSpanWeight::Normal,
Self::Medium => TextSpanWeight::Medium,
Self::Semibold => TextSpanWeight::Semibold,
Self::Bold => TextSpanWeight::Bold,
}
}
}
#[derive(Default)]
pub struct TextBuilder {
pub(crate) role: Option<TextRole>,
pub(crate) size: Option<f32>,
pub(crate) weight: Option<FontWeight>,
pub(crate) color: Option<Color>,
pub(crate) font_family: Option<TextFontFamily>,
pub(crate) wrap: Option<TextWrap>,
pub(crate) selectable: Option<bool>,
pub(crate) pointer_events: Option<bool>,
}
pub fn text() -> TextBuilder {
TextBuilder::default()
}
impl TextBuilder {
pub fn role(mut self, role: TextRole) -> Self {
self.role = Some(role);
self
}
pub fn size(mut self, size: f32) -> Self {
self.size = Some(size);
self
}
pub fn weight(mut self, weight: FontWeight) -> Self {
self.weight = Some(weight);
self
}
pub fn color(mut self, color: Color) -> Self {
self.color = Some(color);
self
}
pub fn font_family(mut self, family: TextFontFamily) -> Self {
self.font_family = Some(family);
self
}
pub fn wrap(mut self, wrap: TextWrap) -> Self {
self.wrap = Some(wrap);
self
}
pub fn selectable(mut self, selectable: bool) -> Self {
self.selectable = Some(selectable);
self
}
pub fn pointer_events(mut self, pointer_events: bool) -> Self {
self.pointer_events = Some(pointer_events);
self
}
pub fn children(self, children: impl TextChildren) -> View {
let inherited = current_text_style();
let role = self.role.unwrap_or(TextRole::Body);
let size = self.size.or(inherited.size).unwrap_or(match role {
TextRole::Body => 18.0,
TextRole::Heading(1) => 32.0,
TextRole::Heading(2) => 28.0,
TextRole::Heading(_) => 24.0,
});
let color = self.color.or(inherited.color).unwrap_or(colors::text());
let weight = self.weight.or(inherited.weight).unwrap_or(match role {
TextRole::Body => FontWeight::Normal,
TextRole::Heading(_) => FontWeight::Semibold,
});
let wrap = self.wrap.or(inherited.wrap).unwrap_or(TextWrap::None);
let line_height = inherited.line_height.unwrap_or(size * 1.2);
let mut style = TextStyle::new(size, color)
.with_line_height(line_height)
.with_wrap(wrap);
let font_family = self.font_family.or(inherited.font_family);
if let Some(font_family) = font_family {
style = style.with_font_family(font_family);
}
let span = TextSpan::new(children.into_text())
.color(color)
.weight(weight.to_text_span_weight());
View::from_element(
Element::spans(
[span],
style.with_selectable(self.selectable.unwrap_or(true)),
)
.pointer_events(self.pointer_events.unwrap_or(true))
.id(allocate_element_id()),
)
}
}

View File

@@ -0,0 +1,17 @@
use ruin_ui::Color;
pub const fn canvas() -> Color {
Color::rgb(0x0F, 0x16, 0x25)
}
pub const fn raised() -> Color {
Color::rgb(0x1B, 0x26, 0x3D)
}
pub const fn interactive() -> Color {
Color::rgb(0x2B, 0x3A, 0x67)
}
pub const fn interactive_muted() -> Color {
Color::rgb(0x3D, 0x4B, 0x72)
}

View File

@@ -0,0 +1,63 @@
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)
})
})
}

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

@@ -0,0 +1,238 @@
use ruin_ui::Element;
use crate::Component;
use crate::input::EventBindings;
#[derive(Clone, Default)]
pub struct View {
pub(crate) element: Element,
pub(crate) bindings: EventBindings,
}
impl View {
pub fn from_element(element: Element) -> Self {
Self {
element,
bindings: EventBindings::default(),
}
}
pub fn element(&self) -> &Element {
&self.element
}
pub fn from_container(element: Element, children: Vec<View>) -> Self {
let mut composed = Self::from_element(element);
let mut element = composed.element;
for child in children {
element = element.child(child.element);
composed.bindings.extend(child.bindings);
}
composed.element = element;
composed
}
}
impl From<Element> for View {
fn from(element: Element) -> Self {
Self::from_element(element)
}
}
pub trait IntoView {
fn into_view(self) -> View;
}
impl IntoView for View {
fn into_view(self) -> View {
self
}
}
impl IntoView for Element {
fn into_view(self) -> View {
View::from_element(self)
}
}
impl<T: Component> IntoView for T {
fn into_view(self) -> View {
self.render()
}
}
pub trait Children {
fn into_views(self) -> Vec<View>;
}
#[derive(Clone, Default)]
pub struct ChildViews(pub(crate) Vec<View>);
impl ChildViews {
pub fn from_children(children: impl Children) -> Self {
Self(children.into_views())
}
pub fn into_vec(self) -> Vec<View> {
self.0
}
}
impl Children for () {
fn into_views(self) -> Vec<View> {
Vec::new()
}
}
impl<T: IntoView> Children for T {
fn into_views(self) -> Vec<View> {
vec![self.into_view()]
}
}
impl<T: IntoView> Children for Vec<T> {
fn into_views(self) -> Vec<View> {
self.into_iter().map(IntoView::into_view).collect()
}
}
impl Children for ChildViews {
fn into_views(self) -> Vec<View> {
self.0
}
}
/// Wraps a lazy closure so it can be used as container children.
///
/// The closure is called exactly once, inside the builder's `.children()` method,
/// after any `PartialTextStyle` context has been pushed. This enables text style
/// inheritance from ancestor containers.
///
/// The `view!` macro automatically generates `LazyChildren(|| vec![...])` for
/// primitive container nodes so that style context propagates correctly.
pub struct LazyChildren<F: FnOnce() -> Vec<View>>(pub F);
impl<F: FnOnce() -> Vec<View>> Children for LazyChildren<F> {
fn into_views(self) -> Vec<View> {
(self.0)()
}
}
macro_rules! impl_children_tuple {
($($name:ident),+ $(,)?) => {
#[allow(non_camel_case_types)]
impl<$($name: IntoView),+> Children for ($($name,)+) {
fn into_views(self) -> Vec<View> {
let ($($name,)+) = self;
vec![$($name.into_view(),)+]
}
}
};
}
impl_children_tuple!(a, b);
impl_children_tuple!(a, b, c);
impl_children_tuple!(a, b, c, d);
impl_children_tuple!(a, b, c, d, e);
impl_children_tuple!(a, b, c, d, e, f);
impl_children_tuple!(a, b, c, d, e, f, g);
impl_children_tuple!(a, b, c, d, e, f, g, h);
pub trait TextChildren {
fn into_text(self) -> String;
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct TextValue(pub(crate) String);
impl TextValue {
pub fn from_text(children: impl TextChildren) -> Self {
Self(children.into_text())
}
pub fn into_string(self) -> String {
self.0
}
}
impl TextChildren for TextValue {
fn into_text(self) -> String {
self.0
}
}
impl TextChildren for &'static str {
fn into_text(self) -> String {
self.to_string()
}
}
impl TextChildren for String {
fn into_text(self) -> String {
self
}
}
impl TextChildren for i32 {
fn into_text(self) -> String {
self.to_string()
}
}
impl TextChildren for i64 {
fn into_text(self) -> String {
self.to_string()
}
}
impl TextChildren for usize {
fn into_text(self) -> String {
self.to_string()
}
}
impl TextChildren for u64 {
fn into_text(self) -> String {
self.to_string()
}
}
impl TextChildren for f32 {
fn into_text(self) -> String {
self.to_string()
}
}
impl<T: std::fmt::Display + 'static> TextChildren for crate::hooks::Signal<T> {
fn into_text(self) -> String {
self.with(|value| value.to_string())
}
}
impl<T: std::fmt::Display + 'static> TextChildren for crate::hooks::Memo<T> {
fn into_text(self) -> String {
self.with(|value| value.to_string())
}
}
macro_rules! impl_text_children_tuple {
($($name:ident),+ $(,)?) => {
#[allow(non_camel_case_types)]
impl<$($name: TextChildren),+> TextChildren for ($($name,)+) {
fn into_text(self) -> String {
let ($($name,)+) = self;
let mut text = String::new();
$(text.push_str(&$name.into_text());)+
text
}
}
};
}
impl_text_children_tuple!(a, b);
impl_text_children_tuple!(a, b, c);
impl_text_children_tuple!(a, b, c, d);
impl_text_children_tuple!(a, b, c, d, e);
impl_text_children_tuple!(a, b, c, d, e, f);

View File

@@ -681,22 +681,48 @@ fn expand_node(node: &Node) -> proc_macro2::TokenStream {
let value = &property.value; let value = &property.value;
quote! { .#name(#value) } quote! { .#name(#value) }
}); });
let children = expand_children(&node.children);
if is_component_path(path) { if is_component_path(path) {
// Component nodes: eager children so #[component] builder impls keep working.
let children = expand_children(&node.children);
quote! { quote! {
#path::builder() #path::builder()
#(#prop_calls)* #(#prop_calls)*
.children(#children) .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! { quote! {
::ruin_app::#path() ::ruin_app::#path()
#(#prop_calls)* #(#prop_calls)*
.children(#children) .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 { fn expand_provider_node(node: &Node) -> proc_macro2::TokenStream {
let path = &node.path; let path = &node.path;

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

@@ -0,0 +1,4 @@
# RUIN - Runtime
The RUIN runtime is an event-loop-per-thread, non-workstealing async executor implemented on top of Linux io_uring
with

View File

@@ -8,11 +8,12 @@
//! The public surface intentionally mirrors `std::fs` where that shape makes sense, while using //! The public surface intentionally mirrors `std::fs` where that shape makes sense, while using
//! async methods for operations that may block the caller. //! async methods for operations that may block the caller.
use alloc::sync::Arc;
use std::ffi::OsStr; use std::ffi::OsStr;
use std::io; use std::io;
use std::os::fd::{AsRawFd, OwnedFd}; use std::os::fd::{AsRawFd, OwnedFd};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::op::fs::{ use crate::op::fs::{
FileType as RawFileType, FsOp, MetadataTarget, OpenOptions as OpOpenOptions, FileType as RawFileType, FsOp, MetadataTarget, OpenOptions as OpOpenOptions,

View File

@@ -34,7 +34,10 @@ pub(crate) mod trace_targets {
pub const DRIVER: &str = "ruin_runtime::driver"; pub const DRIVER: &str = "ruin_runtime::driver";
pub const RUNTIME: &str = "ruin_runtime::runtime"; pub const RUNTIME: &str = "ruin_runtime::runtime";
pub const SCHEDULER: &str = "ruin_runtime::scheduler"; pub const SCHEDULER: &str = "ruin_runtime::scheduler";
#[cfg(debug_assertions)]
pub const TIMER: &str = "ruin_runtime::timer"; pub const TIMER: &str = "ruin_runtime::timer";
#[cfg(debug_assertions)]
pub const ASYNC: &str = "ruin_runtime::async"; pub const ASYNC: &str = "ruin_runtime::async";
} }
@@ -46,6 +49,7 @@ pub mod net;
pub mod op; pub mod op;
#[doc(hidden)] #[doc(hidden)]
pub mod platform; pub mod platform;
pub mod stdio;
#[doc(hidden)] #[doc(hidden)]
pub mod sys; pub mod sys;
pub mod time; pub mod time;

View File

@@ -3,14 +3,15 @@
//! The public surface follows the general shape of `std::net`, but uses async methods for socket //! The public surface follows the general shape of `std::net`, but uses async methods for socket
//! operations that would otherwise block the caller. //! operations that would otherwise block the caller.
use std::future::Future; use core::future::Future;
use core::pin::Pin;
use core::task::{Context, Poll};
use core::time::Duration;
use std::io; use std::io;
use std::net::{Shutdown, SocketAddr, ToSocketAddrs}; use std::net::{Shutdown, SocketAddr, ToSocketAddrs};
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}; use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
use std::pin::Pin;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::task::{Context, Poll};
use std::time::Duration;
use hyper::rt::{Read as HyperRead, ReadBufCursor, Write as HyperWrite}; use hyper::rt::{Read as HyperRead, ReadBufCursor, Write as HyperWrite};

View File

@@ -1,4 +1,6 @@
//! Public runtime driver primitives. //! RUIN Runtime Driver for Linux x86_64.
//!
//!
use std::cell::Cell; use std::cell::Cell;
use std::cell::RefCell; use std::cell::RefCell;
@@ -88,12 +90,21 @@ pub struct ReadyEvents {
/// Low-level Linux runtime driver backed by `io_uring`. /// Low-level Linux runtime driver backed by `io_uring`.
pub struct Driver { pub struct Driver {
/// The `io_uring` instance driving this runtime thread.
ring: IoUring, ring: IoUring,
/// Shared notifier that other threads can use to wake this runtime thread.
notifier: Arc<NotifierInner>, notifier: Arc<NotifierInner>,
/// Next sequence number for generated completion tokens.
next_token: Cell<u64>, next_token: Cell<u64>,
/// The token of the currently active timer, if any timer is armed.
active_timer_token: Cell<Option<u64>>, active_timer_token: Cell<Option<u64>>,
/// Accumulated count of pending wake notifications that have not yet been triggered.
pending_wakes: Cell<u64>, pending_wakes: Cell<u64>,
/// Accumulated count of pending timer expirations that have not yet been triggered.
pending_timers: Cell<u64>, pending_timers: Cell<u64>,
/// Map of active completion tokens to associated handlers. When a CQE is received with a token in this map, the
/// corresponding handler will be invoked with the CQE and removed from the map. This is the core mechanism by which
/// async operations are tracked and dispatched to their continuations.
completions: RefCell<HashMap<u64, CompletionHandler>>, completions: RefCell<HashMap<u64, CompletionHandler>>,
} }

View File

@@ -50,6 +50,12 @@ pub struct TimeoutHandle {
_local: Rc<()>, _local: Rc<()>,
} }
impl TimeoutHandle {
pub fn clear(&self) {
clear_timeout(self);
}
}
#[derive(Clone)] #[derive(Clone)]
/// Handle returned by [`set_interval`]. /// Handle returned by [`set_interval`].
pub struct IntervalHandle { pub struct IntervalHandle {
@@ -58,6 +64,12 @@ pub struct IntervalHandle {
_local: Rc<()>, _local: Rc<()>,
} }
impl IntervalHandle {
pub fn clear(&self) {
clear_interval(self);
}
}
/// Handle returned by [`queue_future`]. /// Handle returned by [`queue_future`].
/// ///
/// Awaiting a join handle yields the output of the queued future. /// Awaiting a join handle yields the output of the queued future.
@@ -68,9 +80,10 @@ pub struct JoinHandle<T> {
/// Future returned by [`yield_now`]. /// Future returned by [`yield_now`].
/// ///
/// Awaiting this future will immediately yield control back to the runtime scheduler, allowing other queued microtasks /// 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 /// 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 { pub struct YieldNow {
yielded: bool, yielded: bool,
} }
@@ -213,17 +226,19 @@ where
); );
if delay.is_zero() { if delay.is_zero() {
unimplemented!("Zero-delay intervals are not yet implemented.")
// TODO: vibeshit got this completely wrong
// Zero-delay intervals never touch the timer heap or arm an io_uring // Zero-delay intervals never touch the timer heap or arm an io_uring
// timer (a past-deadline kernel timer would fire on every event loop // timer (a past-deadline kernel timer would fire on every event loop
// iteration, spinning the runtime at 100 % CPU). Instead the callback // iteration, spinning the runtime at 100 % CPU). Instead the callback
// is stored in `immediate_intervals` and self-schedules as a macrotask // is stored in `immediate_intervals` and self-schedules as a macrotask
// each turn, mirroring JS `setInterval(f, 0)` semantics. // each turn, mirroring JS `setInterval(f, 0)` semantics.
let callback = Rc::new(RefCell::new(Box::new(callback) as Box<dyn FnMut()>)); // let callback = Rc::new(RefCell::new(Box::new(callback) as Box<dyn FnMut()>));
current_thread() // current_thread()
.immediate_intervals // .immediate_intervals
.borrow_mut() // .borrow_mut()
.insert(id, Rc::clone(&callback)); // .insert(id, Rc::clone(&callback));
schedule_immediate_interval(owner, id); // schedule_immediate_interval(owner, id);
} else { } else {
let deadline = deadline_from_now(delay); let deadline = deadline_from_now(delay);
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
@@ -660,6 +675,8 @@ impl Future for YieldNow {
} }
} }
type ImmediateIntervals = RefCell<HashMap<usize, Rc<RefCell<Box<dyn FnMut()>>>>>;
struct ThreadState { struct ThreadState {
driver: Driver, driver: Driver,
shared: Arc<ThreadShared>, shared: Arc<ThreadShared>,
@@ -669,7 +686,7 @@ struct ThreadState {
timers: RefCell<TimerHeap>, timers: RefCell<TimerHeap>,
/// Zero-delay intervals bypasses the timer heap entirely. Each entry /// Zero-delay intervals bypasses the timer heap entirely. Each entry
/// re-enqueues itself as a macrotask on every turn. /// re-enqueues itself as a macrotask on every turn.
immediate_intervals: RefCell<HashMap<usize, IntervalCallback>>, immediate_intervals: ImmediateIntervals,
next_timer_id: Cell<usize>, next_timer_id: Cell<usize>,
children: RefCell<Vec<ChildWorker>>, children: RefCell<Vec<ChildWorker>>,
} }
@@ -1251,31 +1268,32 @@ fn clear_timer(owner: *const ThreadState, id: usize) {
} }
} }
/// Enqueues one macrotask turn for a zero-delay interval. // TODO: vibeshit got this completely wrong
/// // /// Enqueues one macrotask turn for a zero-delay interval.
/// The macrotask checks whether the interval is still live (not cleared), fires // ///
/// the callback, and re-enqueues itself for the next turn. // /// The macrotask checks whether the interval is still live (not cleared), fires
fn schedule_immediate_interval(owner: *const ThreadState, id: usize) { // /// the callback, and re-enqueues itself for the next turn.
push_local_macrotask(Box::new(move || { // fn schedule_immediate_interval(owner: *const ThreadState, id: usize) {
let callback = current_thread() // push_local_macrotask(Box::new(move || {
.immediate_intervals // let callback = current_thread()
.borrow() // .immediate_intervals
.get(&id) // .borrow()
.map(Rc::clone); // .get(&id)
let Some(callback) = callback else { // .map(Rc::clone);
return; // interval was cleared before this turn ran // let Some(callback) = callback else {
}; // return; // interval was cleared before this turn ran
(callback.borrow_mut())(); // };
// Re-enqueue for the next turn if still live. // (callback.borrow_mut())();
if current_thread() // // Re-enqueue for the next turn if still live.
.immediate_intervals // if current_thread()
.borrow() // .immediate_intervals
.contains_key(&id) // .borrow()
{ // .contains_key(&id)
schedule_immediate_interval(owner, id); // {
} // schedule_immediate_interval(owner, id);
})); // }
} // }));
// }
fn dispatch_expired_timers() { fn dispatch_expired_timers() {
let now = deadline_from_now(Duration::ZERO); let now = deadline_from_now(Duration::ZERO);

View File

@@ -58,6 +58,12 @@ pub struct IntervalHandle {
_local: Rc<()>, _local: Rc<()>,
} }
impl IntervalHandle {
pub fn clear(&self) {
clear_interval(self);
}
}
/// Handle returned by [`queue_future`]. /// Handle returned by [`queue_future`].
/// ///
/// Awaiting a join handle yields the output of the queued future. /// Awaiting a join handle yields the output of the queued future.

198
lib/runtime/src/stdio.rs Normal file
View File

@@ -0,0 +1,198 @@
//! Async standard-input helpers.
use std::io;
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
use std::thread;
use crate::op::completion::completion_for_current_thread;
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
use crate::platform::linux_x86_64::runtime::with_current_driver;
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
use crate::platform::linux_x86_64::uring::{IORING_OP_READ, IoUringCqe, IoUringSqe};
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
use std::cell::Cell;
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
thread_local! {
static STDIN_URING_SUPPORTED: Cell<Option<bool>> = const { Cell::new(None) };
}
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
const FILE_CURSOR: u64 = u64::MAX;
const READ_CHUNK_BYTES: usize = 1024;
/// Async line-oriented stdin reader.
///
/// The reader is `io_uring`-first. When the active stdin fd rejects `IORING_OP_READ`, the module
/// falls back to a helper-thread blocking read on the same duplicated fd.
pub struct Stdin {
fd: OwnedFd,
buffer: Vec<u8>,
}
/// Opens an async stdin reader.
pub fn stdin() -> io::Result<Stdin> {
let raw = cvt(unsafe { libc::fcntl(libc::STDIN_FILENO, libc::F_DUPFD_CLOEXEC, 0) })?;
Ok(Stdin {
fd: unsafe { OwnedFd::from_raw_fd(raw) },
buffer: Vec::new(),
})
}
impl Stdin {
/// Reads a single UTF-8 line, including the trailing newline when present.
///
/// Returns `Ok(None)` on EOF.
pub async fn read_line(&mut self) -> io::Result<Option<String>> {
loop {
if let Some(index) = self.buffer.iter().position(|byte| *byte == b'\n') {
let line = self.buffer.drain(..=index).collect::<Vec<_>>();
return decode_line(line).map(Some);
}
let chunk = self.read_chunk().await?;
if chunk.is_empty() {
if self.buffer.is_empty() {
return Ok(None);
}
let line = std::mem::take(&mut self.buffer);
return decode_line(line).map(Some);
}
self.buffer.extend_from_slice(&chunk);
}
}
async fn read_chunk(&self) -> io::Result<Vec<u8>> {
let fd = self.fd.as_raw_fd();
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
{
let support = STDIN_URING_SUPPORTED.with(Cell::get);
if support != Some(false) {
match submit_uring_read(fd, READ_CHUNK_BYTES).await {
Ok(bytes) => {
STDIN_URING_SUPPORTED.with(|state| state.set(Some(true)));
return Ok(bytes);
}
Err(error) if should_fallback_to_offload(&error) => {
STDIN_URING_SUPPORTED.with(|state| state.set(Some(false)));
}
Err(error) => return Err(error),
}
}
}
offload(move || {
let mut buffer = vec![0; READ_CHUNK_BYTES];
let read = blocking_read(fd, &mut buffer)?;
if read == 0 {
buffer.clear();
} else {
buffer.truncate(read);
}
Ok(buffer)
})
.await
}
}
async fn offload<T: Send + 'static>(
task: impl FnOnce() -> io::Result<T> + Send + 'static,
) -> io::Result<T> {
let (future, handle) = completion_for_current_thread::<io::Result<T>>();
thread::Builder::new()
.name("ruin-runtime-stdio-offload".into())
.spawn(move || handle.complete(task()))
.map_err(io::Error::other)?;
future.await
}
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
async fn submit_uring_read(fd: RawFd, len: usize) -> io::Result<Vec<u8>> {
let mut buffer = vec![0; len];
let ptr = buffer.as_mut_ptr();
let capacity = buffer.len();
submit_uring(
move |sqe| {
sqe.opcode = IORING_OP_READ;
sqe.fd = fd;
sqe.addr = ptr as u64;
sqe.len = capacity as u32;
sqe.off = FILE_CURSOR;
},
move |cqe| {
let read = cqe_to_result(cqe)? as usize;
buffer.truncate(read);
Ok(buffer)
},
)
.await
}
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
async fn submit_uring<T: Send + 'static, M>(
fill: impl FnOnce(&mut IoUringSqe),
map: M,
) -> io::Result<T>
where
M: FnOnce(IoUringCqe) -> io::Result<T> + Send + 'static,
{
let (future, handle) = completion_for_current_thread::<io::Result<T>>();
let callback_handle = handle.clone();
let token = with_current_driver(|driver| {
driver.submit_operation(fill, move |cqe| {
callback_handle.complete(map(cqe));
})
})?;
handle.set_cancel(move || {
let _ = with_current_driver(|driver| driver.cancel_operation(token));
});
future.await
}
fn blocking_read(fd: RawFd, buffer: &mut [u8]) -> io::Result<usize> {
loop {
let read =
unsafe { libc::read(fd, buffer.as_mut_ptr().cast::<libc::c_void>(), buffer.len()) };
if read >= 0 {
return Ok(read as usize);
}
let error = io::Error::last_os_error();
if error.kind() == io::ErrorKind::Interrupted {
continue;
}
return Err(error);
}
}
fn decode_line(bytes: Vec<u8>) -> io::Result<String> {
String::from_utf8(bytes).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
}
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
fn cqe_to_result(cqe: IoUringCqe) -> io::Result<i32> {
if cqe.res < 0 {
Err(io::Error::from_raw_os_error(-cqe.res))
} else {
Ok(cqe.res)
}
}
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
fn should_fallback_to_offload(error: &io::Error) -> bool {
matches!(
error.raw_os_error(),
Some(libc::EINVAL | libc::ENOSYS | libc::EOPNOTSUPP)
)
}
fn cvt(value: libc::c_int) -> io::Result<libc::c_int> {
if value == -1 {
Err(io::Error::last_os_error())
} else {
Ok(value)
}
}

View File

@@ -3,14 +3,14 @@
//! These helpers integrate with the runtime's timer queue and are designed to be used from //! These helpers integrate with the runtime's timer queue and are designed to be used from
//! futures scheduled with [`crate::queue_future`] or one of the runtime entry macros. //! futures scheduled with [`crate::queue_future`] or one of the runtime entry macros.
use std::cell::{Cell, RefCell}; use alloc::rc::Rc;
use std::fmt; use core::cell::{Cell, RefCell};
use std::future::{Future, poll_fn}; use core::fmt;
use std::pin::Pin; use core::future::{Future, poll_fn};
use std::rc::Rc; use core::pin::Pin;
use std::task::Waker; use core::task::Waker;
use std::task::{Context, Poll}; use core::task::{Context, Poll};
use std::time::Duration; use core::time::Duration;
use crate::{clear_timeout, set_timeout}; use crate::{clear_timeout, set_timeout};

View File

@@ -54,7 +54,7 @@ pub struct RoutedPointerEvent {
#[derive(Default)] #[derive(Default)]
pub struct PointerRouter { pub struct PointerRouter {
hovered: Vec<HitTarget>, hovered: Vec<HitTarget>,
pressed: Option<HitTarget>, pressed: Option<Vec<HitTarget>>,
} }
impl PointerRouter { impl PointerRouter {
@@ -71,7 +71,11 @@ impl PointerRouter {
} }
pub fn pressed_target(&self) -> Option<&HitTarget> { pub fn pressed_target(&self) -> Option<&HitTarget> {
self.pressed.as_ref() self.pressed.as_ref().and_then(|targets| targets.last())
}
pub fn pressed_targets(&self) -> &[HitTarget] {
self.pressed.as_deref().unwrap_or(&[])
} }
pub fn route( pub fn route(
@@ -114,7 +118,12 @@ impl PointerRouter {
match event.kind { match event.kind {
PointerEventKind::Move => { PointerEventKind::Move => {
if let Some(target) = self.pressed.clone().or(hit_target.clone()) { if let Some(target) = self
.pressed
.as_ref()
.and_then(|targets| targets.last().cloned())
.or(hit_target.clone())
{
routed.push(RoutedPointerEvent { routed.push(RoutedPointerEvent {
kind: RoutedPointerEventKind::Move, kind: RoutedPointerEventKind::Move,
target, target,
@@ -125,7 +134,7 @@ impl PointerRouter {
} }
PointerEventKind::Down { button } => { PointerEventKind::Down { button } => {
if let Some(target) = hit_target { if let Some(target) = hit_target {
self.pressed = Some(target.clone()); self.pressed = Some(self.hovered.clone());
routed.push(RoutedPointerEvent { routed.push(RoutedPointerEvent {
kind: RoutedPointerEventKind::Down { button }, kind: RoutedPointerEventKind::Down { button },
target, target,
@@ -135,7 +144,12 @@ impl PointerRouter {
} }
} }
PointerEventKind::Up { button } => { PointerEventKind::Up { button } => {
if let Some(target) = self.pressed.take().or(hit_target) { if let Some(target) = self
.pressed
.take()
.and_then(|targets| targets.last().cloned())
.or(hit_target)
{
routed.push(RoutedPointerEvent { routed.push(RoutedPointerEvent {
kind: RoutedPointerEventKind::Up { button }, kind: RoutedPointerEventKind::Up { button },
target, target,

View File

@@ -7,7 +7,9 @@ use crate::scene::{
UiSize, UiSize,
}; };
use crate::text::TextSystem; use crate::text::TextSystem;
use crate::tree::{CursorIcon, Edges, Element, ElementId, FlexDirection, ImageNode, Style}; use crate::tree::{
AlignItems, CursorIcon, Edges, Element, ElementId, FlexDirection, ImageNode, Style,
};
pub fn layout_scene(version: u64, logical_size: UiSize, root: &Element) -> SceneSnapshot { pub fn layout_scene(version: u64, logical_size: UiSize, root: &Element) -> SceneSnapshot {
let mut text_system = TextSystem::new(); let mut text_system = TextSystem::new();
@@ -27,6 +29,11 @@ pub fn layout_scene_with_text_system(
pub struct LayoutSnapshot { pub struct LayoutSnapshot {
pub scene: SceneSnapshot, pub scene: SceneSnapshot,
pub interaction_tree: InteractionTree, pub interaction_tree: InteractionTree,
/// Minimum size the root element needs in logical units. Accounts for
/// `min_width`/`min_height` constraints and the natural minimum of fixed-
/// size content. Scroll boxes contribute only their own explicit/min size
/// on the scroll axis, not their content.
pub root_min_size: UiSize,
} }
#[derive(Clone, Debug, Eq, Hash, PartialEq)] #[derive(Clone, Debug, Eq, Hash, PartialEq)]
@@ -242,11 +249,13 @@ pub fn layout_snapshot_with_cache(
"layout snapshot perf" "layout snapshot perf"
); );
} }
let root_min_size = element_min_size(root);
LayoutSnapshot { LayoutSnapshot {
scene, scene,
interaction_tree: InteractionTree { interaction_tree: InteractionTree {
root: interaction_root, root: interaction_root,
}, },
root_min_size,
} }
} }
@@ -264,6 +273,7 @@ fn layout_element(
clip_rect: Option<Rect>, clip_rect: Option<Rect>,
) -> LayoutNode { ) -> LayoutNode {
perf_stats.nodes += 1; perf_stats.nodes += 1;
let rect = snap_rect(rect);
// Viewport culling: skip fully off-screen elements inside a scroll box. // Viewport culling: skip fully off-screen elements inside a scroll box.
if let Some(clip) = clip_rect if let Some(clip) = clip_rect
@@ -774,7 +784,11 @@ fn point_in_rounded_rect(point: crate::scene::Point, rect: Rect, radius: f32) ->
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
struct MeasuredChild { struct MeasuredChild {
/// Offered cross-axis size (full available_cross for stretch; explicit value otherwise).
cross: f32, cross: f32,
/// Natural cross-axis size: set when parent uses non-stretch alignment, child has no
/// explicit cross size, and the child is not flex. `None` means use `cross`.
natural_cross: Option<f32>,
main: f32, main: f32,
is_flex: bool, is_flex: bool,
} }
@@ -804,15 +818,29 @@ fn layout_container_children(
let mut fixed_total = 0.0; let mut fixed_total = 0.0;
let mut flex_total = 0.0; let mut flex_total = 0.0;
for child in &element.children { for child in &element.children {
let child_insets = content_insets(&child.style);
let main_inset = main_axis_padding(child_insets, element.style.direction);
let cross_inset = cross_axis_padding(child_insets, element.style.direction);
// min-size constraints (content-box) converted to outer.
let min_cross_outer = child_min_cross_size(child, element.style.direction)
.map(|c| (c + cross_inset).max(0.0))
.unwrap_or(0.0);
let min_main_outer = child_min_main_size(child, element.style.direction)
.map(|m| (m + main_inset).max(0.0))
.unwrap_or(0.0);
// style.width/height are content-box; add insets to get the outer (allocated) size.
// min-cross acts as floor; allow exceeding available_cross only when min demands it.
let cross = child_cross_size(child, element.style.direction) let cross = child_cross_size(child, element.style.direction)
.unwrap_or(available_cross) .map(|c| (c + cross_inset).max(0.0).max(min_cross_outer))
.clamp(0.0, available_cross); .unwrap_or(available_cross.max(min_cross_outer))
let explicit_main = .min(available_cross.max(min_cross_outer));
child_main_size(child, element.style.direction).map(|main| main.max(0.0)); let explicit_main = child_main_size(child, element.style.direction)
.map(|main| (main + main_inset).max(0.0).max(min_main_outer));
let is_flex = explicit_main.is_none() && child.style.flex_grow > 0.0; let is_flex = explicit_main.is_none() && child.style.flex_grow > 0.0;
let measured_main = explicit_main.unwrap_or_else(|| { let measured_main = explicit_main.unwrap_or_else(|| {
if is_flex { if is_flex {
0.0 // Flex items get their share of remaining space later; record min floor now.
min_main_outer
} else { } else {
perf_stats.intrinsic_calls += 1; perf_stats.intrinsic_calls += 1;
if child.text_node().is_some() { if child.text_node().is_some() {
@@ -833,9 +861,33 @@ fn layout_container_children(
if let Some(intrinsic_started) = intrinsic_started { if let Some(intrinsic_started) = intrinsic_started {
perf_stats.intrinsic_ms += intrinsic_started.elapsed().as_secs_f64() * 1_000.0; perf_stats.intrinsic_ms += intrinsic_started.elapsed().as_secs_f64() * 1_000.0;
} }
intrinsic // Apply min-main floor to intrinsic result.
intrinsic.max(min_main_outer)
} }
}); });
// For non-stretch alignment, measure the child's natural cross size so it is not
// forcibly stretched to fill the container's cross axis. Only done for non-flex
// children with no explicit cross-axis style, since flex main size is unknown here.
// align_self on the child overrides the parent's align_items for this child.
let effective_align = child.style.align_self.unwrap_or(element.style.align_items);
let natural_cross = if effective_align != AlignItems::Stretch
&& !is_flex
&& child_cross_size(child, element.style.direction).is_none()
{
let offered = match element.style.direction {
FlexDirection::Row => UiSize::new(measured_main, available_cross),
FlexDirection::Column => UiSize::new(available_cross, measured_main),
};
let natural = intrinsic_size(child, offered, text_system, perf_stats, layout_cache);
Some(
cross_axis_size(natural, element.style.direction)
.max(min_cross_outer)
.min(available_cross.max(min_cross_outer)),
)
} else {
None
};
if is_flex { if is_flex {
flex_total += child_flex_weight(child); flex_total += child_flex_weight(child);
} else { } else {
@@ -843,6 +895,7 @@ fn layout_container_children(
} }
measured_children.push(MeasuredChild { measured_children.push(MeasuredChild {
cross, cross,
natural_cross,
main: measured_main, main: measured_main,
is_flex, is_flex,
}); });
@@ -853,20 +906,39 @@ fn layout_container_children(
let mut children = Vec::with_capacity(element.children.len()); let mut children = Vec::with_capacity(element.children.len());
for (index, (child, measured)) in element.children.iter().zip(measured_children).enumerate() { for (index, (child, measured)) in element.children.iter().zip(measured_children).enumerate() {
let child_main = if measured.is_flex { let child_main = if measured.is_flex {
if flex_total <= 0.0 { let allocated = if flex_total <= 0.0 {
0.0 0.0
} else { } else {
remaining_main * (child_flex_weight(child) / flex_total) remaining_main * (child_flex_weight(child) / flex_total)
} };
// Apply min-main floor to flex allocation.
let child_insets = content_insets(&child.style);
let main_inset = main_axis_padding(child_insets, element.style.direction);
let min_main_outer = child_min_main_size(child, element.style.direction)
.map(|m| (m + main_inset).max(0.0))
.unwrap_or(0.0);
allocated.max(min_main_outer)
} else { } else {
measured.main measured.main
}; };
// For non-stretch alignment, use the child's natural cross size; otherwise stretch.
// align_self on the child overrides the parent's align_items for this child.
let effective_align = child.style.align_self.unwrap_or(element.style.align_items);
let actual_cross = measured.natural_cross.unwrap_or(measured.cross);
let cross_origin = {
let base = cross_axis_origin(content, element.style.direction);
match effective_align {
AlignItems::Stretch | AlignItems::Start => base,
AlignItems::Center => base + (available_cross - actual_cross) * 0.5,
AlignItems::End => base + available_cross - actual_cross,
}
};
let child_rect = child_rect( let child_rect = child_rect(
content, cross_origin,
element.style.direction, element.style.direction,
cursor, cursor,
child_main.max(0.0), child_main.max(0.0),
measured.cross, actual_cross,
); );
children.push(layout_element( children.push(layout_element(
child, child,
@@ -1021,15 +1093,25 @@ fn layout_interaction_skeleton_children(
let mut fixed_total = 0.0; let mut fixed_total = 0.0;
let mut flex_total = 0.0; let mut flex_total = 0.0;
for child in &element.children { for child in &element.children {
let child_insets = content_insets(&child.style);
let main_inset = main_axis_padding(child_insets, element.style.direction);
let cross_inset = cross_axis_padding(child_insets, element.style.direction);
let min_cross_outer = child_min_cross_size(child, element.style.direction)
.map(|c| (c + cross_inset).max(0.0))
.unwrap_or(0.0);
let min_main_outer = child_min_main_size(child, element.style.direction)
.map(|m| (m + main_inset).max(0.0))
.unwrap_or(0.0);
let cross = child_cross_size(child, element.style.direction) let cross = child_cross_size(child, element.style.direction)
.unwrap_or(available_cross) .map(|c| (c + cross_inset).max(0.0).max(min_cross_outer))
.clamp(0.0, available_cross); .unwrap_or(available_cross.max(min_cross_outer))
let explicit_main = .min(available_cross.max(min_cross_outer));
child_main_size(child, element.style.direction).map(|main| main.max(0.0)); let explicit_main = child_main_size(child, element.style.direction)
.map(|main| (main + main_inset).max(0.0).max(min_main_outer));
let is_flex = explicit_main.is_none() && child.style.flex_grow > 0.0; let is_flex = explicit_main.is_none() && child.style.flex_grow > 0.0;
let measured_main = explicit_main.unwrap_or_else(|| { let measured_main = explicit_main.unwrap_or_else(|| {
if is_flex { if is_flex {
0.0 min_main_outer
} else { } else {
intrinsic_main_size( intrinsic_main_size(
child, child,
@@ -1040,8 +1122,27 @@ fn layout_interaction_skeleton_children(
perf_stats, perf_stats,
layout_cache, layout_cache,
) )
.max(min_main_outer)
} }
}); });
let effective_align = child.style.align_self.unwrap_or(element.style.align_items);
let natural_cross = if effective_align != AlignItems::Stretch
&& !is_flex
&& child_cross_size(child, element.style.direction).is_none()
{
let offered = match element.style.direction {
FlexDirection::Row => UiSize::new(measured_main, available_cross),
FlexDirection::Column => UiSize::new(available_cross, measured_main),
};
let natural = intrinsic_size(child, offered, text_system, perf_stats, layout_cache);
Some(
cross_axis_size(natural, element.style.direction)
.max(min_cross_outer)
.min(available_cross.max(min_cross_outer)),
)
} else {
None
};
if is_flex { if is_flex {
flex_total += child_flex_weight(child); flex_total += child_flex_weight(child);
} else { } else {
@@ -1049,6 +1150,7 @@ fn layout_interaction_skeleton_children(
} }
measured_children.push(MeasuredChild { measured_children.push(MeasuredChild {
cross, cross,
natural_cross,
main: measured_main, main: measured_main,
is_flex, is_flex,
}); });
@@ -1059,20 +1161,36 @@ fn layout_interaction_skeleton_children(
let mut children = Vec::with_capacity(element.children.len()); let mut children = Vec::with_capacity(element.children.len());
for (index, (child, measured)) in element.children.iter().zip(measured_children).enumerate() { for (index, (child, measured)) in element.children.iter().zip(measured_children).enumerate() {
let child_main = if measured.is_flex { let child_main = if measured.is_flex {
if flex_total <= 0.0 { let allocated = if flex_total <= 0.0 {
0.0 0.0
} else { } else {
remaining_main * (child_flex_weight(child) / flex_total) remaining_main * (child_flex_weight(child) / flex_total)
} };
let child_insets = content_insets(&child.style);
let main_inset = main_axis_padding(child_insets, element.style.direction);
let min_main_outer = child_min_main_size(child, element.style.direction)
.map(|m| (m + main_inset).max(0.0))
.unwrap_or(0.0);
allocated.max(min_main_outer)
} else { } else {
measured.main measured.main
}; };
let effective_align = child.style.align_self.unwrap_or(element.style.align_items);
let actual_cross = measured.natural_cross.unwrap_or(measured.cross);
let cross_origin = {
let base = cross_axis_origin(content, element.style.direction);
match effective_align {
AlignItems::Stretch | AlignItems::Start => base,
AlignItems::Center => base + (available_cross - actual_cross) * 0.5,
AlignItems::End => base + available_cross - actual_cross,
}
};
let child_rect = child_rect( let child_rect = child_rect(
content, cross_origin,
element.style.direction, element.style.direction,
cursor, cursor,
child_main.max(0.0), child_main.max(0.0),
measured.cross, actual_cross,
); );
children.push(layout_interaction_skeleton( children.push(layout_interaction_skeleton(
child, child,
@@ -1097,13 +1215,22 @@ fn intrinsic_main_size(
layout_cache: &mut LayoutCache, layout_cache: &mut LayoutCache,
) -> f32 { ) -> f32 {
if let Some(text) = child.text_node() { if let Some(text) = child.text_node() {
// cross_size and available_main are outer sizes; subtract insets to get content dimensions
// for text measurement so wrapping is constrained to the actual content area.
let child_insets = content_insets(&child.style);
let constraints = match direction { let constraints = match direction {
FlexDirection::Row => (Some(available_main.max(0.0)), Some(cross_size.max(0.0))), FlexDirection::Row => (
FlexDirection::Column => (Some(cross_size.max(0.0)), None), Some((available_main - main_axis_padding(child_insets, direction)).max(0.0)),
Some((cross_size - cross_axis_padding(child_insets, direction)).max(0.0)),
),
FlexDirection::Column => (
Some((cross_size - cross_axis_padding(child_insets, direction)).max(0.0)),
None,
),
}; };
let content = let content =
text_system.measure_spans(&text.spans, &text.style, constraints.0, constraints.1); text_system.measure_spans(&text.spans, &text.style, constraints.0, constraints.1);
let padding = main_axis_padding(content_insets(&child.style), direction); let padding = main_axis_padding(child_insets, direction);
return main_axis_size(content, direction) + padding; return main_axis_size(content, direction) + padding;
} }
@@ -1140,19 +1267,29 @@ fn intrinsic_container_content_size(
let mut height = gap_total; let mut height = gap_total;
for child in &element.children { for child in &element.children {
let skip_main = child.style.flex_grow > 0.0 && child.style.height.is_none(); let skip_main = child.style.flex_grow > 0.0 && child.style.height.is_none();
let child_insets = content_insets(&child.style);
// Offer outer size: content-box style.w/h + insets, or parent's content width.
let offered_w = child
.style
.width
.map(|w| w + horizontal_insets(child_insets))
.unwrap_or(content_size.width);
let offered_h = child
.style
.height
.map(|h| h + vertical_insets(child_insets))
.unwrap_or(content_size.height);
let child_size = intrinsic_size( let child_size = intrinsic_size(
child, child,
UiSize::new( UiSize::new(offered_w, offered_h),
child.style.width.unwrap_or(content_size.width),
child.style.height.unwrap_or(content_size.height),
),
text_system, text_system,
perf_stats, perf_stats,
layout_cache, layout_cache,
); );
width = width.max(child.style.width.unwrap_or(child_size.width)); // child_size is always outer; accumulate directly.
width = width.max(child_size.width);
if !skip_main { if !skip_main {
height += child.style.height.unwrap_or(child_size.height); height += child_size.height;
} }
} }
UiSize::new(width, height) UiSize::new(width, height)
@@ -1170,17 +1307,26 @@ fn intrinsic_container_content_size(
child_main_sizes.push(None); child_main_sizes.push(None);
continue; continue;
} }
let child_insets = content_insets(&child.style);
let offered_w = child
.style
.width
.map(|w| w + horizontal_insets(child_insets))
.unwrap_or(content_size.width);
let offered_h = child
.style
.height
.map(|h| h + vertical_insets(child_insets))
.unwrap_or(content_size.height);
let child_size = intrinsic_size( let child_size = intrinsic_size(
child, child,
UiSize::new( UiSize::new(offered_w, offered_h),
child.style.width.unwrap_or(content_size.width),
child.style.height.unwrap_or(content_size.height),
),
text_system, text_system,
perf_stats, perf_stats,
layout_cache, layout_cache,
); );
let child_main = child.style.width.unwrap_or(child_size.width); // child_size.width is outer; use directly as the fixed main contribution.
let child_main = child_size.width;
fixed_main += child_main; fixed_main += child_main;
child_main_sizes.push(Some(child_main)); child_main_sizes.push(Some(child_main));
} }
@@ -1194,12 +1340,15 @@ fn intrinsic_container_content_size(
remaining_main * (child_flex_weight(child) / flex_total) remaining_main * (child_flex_weight(child) / flex_total)
} }
}); });
let child_insets = content_insets(&child.style);
let offered_h = child
.style
.height
.map(|h| h + vertical_insets(child_insets))
.unwrap_or(content_size.height);
let child_size = intrinsic_size( let child_size = intrinsic_size(
child, child,
UiSize::new( UiSize::new(child_main, offered_h),
child_main,
child.style.height.unwrap_or(content_size.height),
),
text_system, text_system,
perf_stats, perf_stats,
layout_cache, layout_cache,
@@ -1207,7 +1356,7 @@ fn intrinsic_container_content_size(
if !skip_main { if !skip_main {
width += child_main; width += child_main;
} }
height = height.max(child.style.height.unwrap_or(child_size.height)); height = height.max(child_size.height);
} }
UiSize::new(width, height) UiSize::new(width, height)
} }
@@ -1255,30 +1404,51 @@ fn intrinsic_size_inner(
perf_stats: &mut LayoutPerfStats, perf_stats: &mut LayoutPerfStats,
layout_cache: &mut LayoutCache, layout_cache: &mut LayoutCache,
) -> UiSize { ) -> UiSize {
// style.width/height are content-box (content dimensions).
// available_size is the outer size the parent is offering.
// All returned sizes are outer (content + insets).
let min_outer_w = element
.style
.min_width
.map(|mw| mw + horizontal_insets(insets))
.unwrap_or(0.0);
let min_outer_h = element
.style
.min_height
.map(|mh| mh + vertical_insets(insets))
.unwrap_or(0.0);
if let Some(text) = element.text_node() { if let Some(text) = element.text_node() {
// Measure at content dimensions: use explicit if set, else available minus insets.
let content_w = element
.style
.width
.unwrap_or_else(|| (available_size.width - horizontal_insets(insets)).max(0.0));
let content_h = element
.style
.height
.unwrap_or_else(|| (available_size.height - vertical_insets(insets)).max(0.0));
let measured = text_system.measure_spans( let measured = text_system.measure_spans(
&text.spans, &text.spans,
&text.style, &text.style,
Some(available_size.width.max(0.0)), Some(content_w.max(0.0)),
Some(available_size.height.max(0.0)), Some(content_h.max(0.0)),
); );
// Return outer: explicit (content) + insets, or measured + insets for auto.
return UiSize::new( return UiSize::new(
element (element.style.width.unwrap_or(measured.width) + horizontal_insets(insets))
.style .max(min_outer_w),
.width (element.style.height.unwrap_or(measured.height) + vertical_insets(insets))
.unwrap_or(measured.width + horizontal_insets(insets)), .max(min_outer_h),
element
.style
.height
.unwrap_or(measured.height + vertical_insets(insets)),
); );
} }
if let Some(image) = element.image_node() { if let Some(image) = element.image_node() {
let resolved = resolve_image_element_size(element, image.resource.size()); let resolved = resolve_image_element_size(element, image.resource.size());
return UiSize::new( return UiSize::new(
resolved.width + horizontal_insets(insets), (resolved.width + horizontal_insets(insets)).max(min_outer_w),
resolved.height + vertical_insets(insets), (resolved.height + vertical_insets(insets)).max(min_outer_h),
); );
} }
@@ -1287,10 +1457,13 @@ fn intrinsic_size_inner(
let scroll_gutter = element let scroll_gutter = element
.scroll_box_node() .scroll_box_node()
.map_or(0.0, |scroll_box| scroll_box.scrollbar.gutter_width.max(0.0)); .map_or(0.0, |scroll_box| scroll_box.scrollbar.gutter_width.max(0.0));
let content_width = // Content dimensions: explicit = already content; auto = available_outer - insets.
explicit_width.unwrap_or(available_size.width).max(0.0) - horizontal_insets(insets); let content_width = explicit_width
let content_height = .map(|w| w.max(0.0))
explicit_height.unwrap_or(available_size.height).max(0.0) - vertical_insets(insets); .unwrap_or_else(|| (available_size.width - horizontal_insets(insets)).max(0.0));
let content_height = explicit_height
.map(|h| h.max(0.0))
.unwrap_or_else(|| (available_size.height - vertical_insets(insets)).max(0.0));
let content_size = UiSize::new( let content_size = UiSize::new(
(content_width - scroll_gutter).max(0.0), (content_width - scroll_gutter).max(0.0),
content_height.max(0.0), content_height.max(0.0),
@@ -1298,8 +1471,14 @@ fn intrinsic_size_inner(
if element.children.is_empty() { if element.children.is_empty() {
return UiSize::new( return UiSize::new(
explicit_width.unwrap_or(horizontal_insets(insets) + scroll_gutter), (explicit_width
explicit_height.unwrap_or(vertical_insets(insets)), .map(|w| w + horizontal_insets(insets) + scroll_gutter)
.unwrap_or(horizontal_insets(insets) + scroll_gutter))
.max(min_outer_w),
(explicit_height
.map(|h| h + vertical_insets(insets))
.unwrap_or(vertical_insets(insets)))
.max(min_outer_h),
); );
} }
@@ -1312,9 +1491,159 @@ fn intrinsic_size_inner(
); );
UiSize::new( UiSize::new(
explicit_width (explicit_width
.unwrap_or(intrinsic_content.width + horizontal_insets(insets) + scroll_gutter), .map(|w| w + horizontal_insets(insets) + scroll_gutter)
explicit_height.unwrap_or(intrinsic_content.height + vertical_insets(insets)), .unwrap_or(intrinsic_content.width + horizontal_insets(insets) + scroll_gutter))
.max(min_outer_w),
(explicit_height
.map(|h| h + vertical_insets(insets))
.unwrap_or(intrinsic_content.height + vertical_insets(insets)))
.max(min_outer_h),
)
}
/// Computes the minimum logical size that the element's content requires.
///
/// Unlike [`intrinsic_size`], this function is designed for window minimum-size
/// propagation. The key difference is scroll boxes: a vertically-scrolling box
/// does not propagate its content height to the window minimum — only its own
/// explicit `height` or `min_height` contributes. Flex children without an
/// explicit size or `min_*` constraint contribute 0 on the main axis (they
/// can shrink). Text nodes with no explicit size contribute 0 (they wrap).
///
/// The result is in the same coordinate space as [`intrinsic_size`] (outer /
/// including padding and border insets).
pub fn element_min_size(element: &Element) -> UiSize {
let insets = content_insets(&element.style);
let min_outer_w = element
.style
.min_width
.map(|mw| (mw + horizontal_insets(insets)).max(0.0))
.unwrap_or(0.0);
let min_outer_h = element
.style
.min_height
.map(|mh| (mh + vertical_insets(insets)).max(0.0))
.unwrap_or(0.0);
// Text: can wrap to any width; only explicit size or min_* constrain the window.
if element.text_node().is_some() {
let w = element
.style
.width
.map(|w| w + horizontal_insets(insets))
.unwrap_or(0.0)
.max(min_outer_w);
let h = element
.style
.height
.map(|h| h + vertical_insets(insets))
.unwrap_or(0.0)
.max(min_outer_h);
return UiSize::new(w, h);
}
// Image: natural size counts as a minimum.
if let Some(image) = element.image_node() {
let resolved = resolve_image_element_size(element, image.resource.size());
return UiSize::new(
(resolved.width + horizontal_insets(insets)).max(min_outer_w),
(resolved.height + vertical_insets(insets)).max(min_outer_h),
);
}
let explicit_w = element.style.width;
let explicit_h = element.style.height;
// Scroll box: content scrolls on the vertical axis; use only explicit/min height.
// Width still accumulates child widths (no horizontal scroll yet).
if element.scroll_box_node().is_some() {
let scroll_gutter = element
.scroll_box_node()
.map_or(0.0, |sb| sb.scrollbar.gutter_width.max(0.0));
let w = explicit_w
.map(|w| w + horizontal_insets(insets) + scroll_gutter)
.unwrap_or(horizontal_insets(insets) + scroll_gutter)
.max(min_outer_w);
let h = explicit_h
.map(|h| h + vertical_insets(insets))
.unwrap_or(vertical_insets(insets))
.max(min_outer_h);
return UiSize::new(w, h);
}
// Regular container: sum/max children's minimums.
if element.children.is_empty() {
return UiSize::new(
explicit_w
.map(|w| w + horizontal_insets(insets))
.unwrap_or(horizontal_insets(insets))
.max(min_outer_w),
explicit_h
.map(|h| h + vertical_insets(insets))
.unwrap_or(vertical_insets(insets))
.max(min_outer_h),
);
}
let gap_total = element.style.gap * element.children.len().saturating_sub(1) as f32;
let children_min = match element.style.direction {
FlexDirection::Column => {
let mut width: f32 = 0.0;
let mut height = gap_total;
for child in &element.children {
let child_min = element_min_size(child);
// Flex children with no explicit height contribute their min_height only;
// if that is also unset they contribute 0 (they can shrink freely).
let skip_main = child.style.flex_grow > 0.0 && child.style.height.is_none();
width = width.max(child_min.width);
if !skip_main {
height += child_min.height;
} else {
// Still count the min_height floor even for flex children.
let child_insets = content_insets(&child.style);
let min_h = child
.style
.min_height
.map(|mh| mh + vertical_insets(child_insets))
.unwrap_or(0.0);
height += min_h;
}
}
UiSize::new(width, height)
}
FlexDirection::Row => {
let mut width = gap_total;
let mut height: f32 = 0.0;
for child in &element.children {
let child_min = element_min_size(child);
let skip_main = child.style.flex_grow > 0.0 && child.style.width.is_none();
if !skip_main {
width += child_min.width;
} else {
let child_insets = content_insets(&child.style);
let min_w = child
.style
.min_width
.map(|mw| mw + horizontal_insets(child_insets))
.unwrap_or(0.0);
width += min_w;
}
height = height.max(child_min.height);
}
UiSize::new(width, height)
}
};
UiSize::new(
explicit_w
.map(|w| w + horizontal_insets(insets))
.unwrap_or(children_min.width + horizontal_insets(insets))
.max(min_outer_w),
explicit_h
.map(|h| h + vertical_insets(insets))
.unwrap_or(children_min.height + vertical_insets(insets))
.max(min_outer_h),
) )
} }
@@ -1758,6 +2087,20 @@ fn child_cross_size(child: &Element, direction: FlexDirection) -> Option<f32> {
} }
} }
fn child_min_main_size(child: &Element, direction: FlexDirection) -> Option<f32> {
match direction {
FlexDirection::Row => child.style.min_width,
FlexDirection::Column => child.style.min_height,
}
}
fn child_min_cross_size(child: &Element, direction: FlexDirection) -> Option<f32> {
match direction {
FlexDirection::Row => child.style.min_height,
FlexDirection::Column => child.style.min_width,
}
}
fn child_flex_weight(child: &Element) -> f32 { fn child_flex_weight(child: &Element) -> f32 {
if child.style.flex_grow > 0.0 { if child.style.flex_grow > 0.0 {
child.style.flex_grow child.style.flex_grow
@@ -1773,6 +2116,26 @@ fn main_axis_padding(edges: Edges, direction: FlexDirection) -> f32 {
} }
} }
fn cross_axis_padding(edges: Edges, direction: FlexDirection) -> f32 {
match direction {
FlexDirection::Row => edges.top + edges.bottom,
FlexDirection::Column => edges.left + edges.right,
}
}
/// Snap a rect to the integer pixel grid.
///
/// Origin and far edge are each rounded independently so that adjacent
/// elements (where one's far edge equals the other's near edge) always
/// butt flush against each other with no gap or overlap.
fn snap_rect(r: Rect) -> Rect {
let x0 = r.origin.x.round();
let y0 = r.origin.y.round();
let x1 = (r.origin.x + r.size.width).round();
let y1 = (r.origin.y + r.size.height).round();
Rect::new(x0, y0, (x1 - x0).max(0.0), (y1 - y0).max(0.0))
}
fn main_axis_size(size: UiSize, direction: FlexDirection) -> f32 { fn main_axis_size(size: UiSize, direction: FlexDirection) -> f32 {
match direction { match direction {
FlexDirection::Row => size.width, FlexDirection::Row => size.width,
@@ -1794,16 +2157,23 @@ fn main_axis_origin(rect: Rect, direction: FlexDirection) -> f32 {
} }
} }
fn cross_axis_origin(content: Rect, direction: FlexDirection) -> f32 {
match direction {
FlexDirection::Row => content.origin.y,
FlexDirection::Column => content.origin.x,
}
}
fn child_rect( fn child_rect(
content: Rect, cross_origin: f32,
direction: FlexDirection, direction: FlexDirection,
main_origin: f32, main_origin: f32,
main_size: f32, main_size: f32,
cross_size: f32, cross_size: f32,
) -> Rect { ) -> Rect {
match direction { match direction {
FlexDirection::Row => Rect::new(main_origin, content.origin.y, main_size, cross_size), FlexDirection::Row => Rect::new(main_origin, cross_origin, main_size, cross_size),
FlexDirection::Column => Rect::new(content.origin.x, main_origin, cross_size, main_size), FlexDirection::Column => Rect::new(cross_origin, main_origin, cross_size, main_size),
} }
} }

View File

@@ -6,6 +6,7 @@
//! sibling crates. //! sibling crates.
pub(crate) mod trace_targets { pub(crate) mod trace_targets {
#![allow(dead_code)]
pub const PLATFORM: &str = "ruin_ui::platform"; pub const PLATFORM: &str = "ruin_ui::platform";
pub const SCENE: &str = "ruin_ui::scene"; pub const SCENE: &str = "ruin_ui::scene";
#[allow(dead_code)] #[allow(dead_code)]
@@ -31,7 +32,8 @@ pub use interaction::{
pub use keyboard::{KeyboardEvent, KeyboardEventKind, KeyboardKey, KeyboardModifiers}; pub use keyboard::{KeyboardEvent, KeyboardEventKind, KeyboardKey, KeyboardModifiers};
pub use layout::{ pub use layout::{
HitTarget, InteractionTree, LayoutCache, LayoutNode, LayoutPath, LayoutSnapshot, ScrollMetrics, HitTarget, InteractionTree, LayoutCache, LayoutNode, LayoutPath, LayoutSnapshot, ScrollMetrics,
TextHitTarget, layout_snapshot, layout_snapshot_with_cache, layout_snapshot_with_text_system, TextHitTarget, element_min_size, layout_snapshot, layout_snapshot_with_cache,
layout_snapshot_with_text_system,
}; };
pub use layout::{layout_scene, layout_scene_with_text_system}; pub use layout::{layout_scene, layout_scene_with_text_system};
pub use platform::{ pub use platform::{
@@ -49,8 +51,8 @@ pub use text::{
TextStyle, TextSystem, TextWrap, TextStyle, TextSystem, TextWrap,
}; };
pub use tree::{ pub use tree::{
Border, BoxShadow, BoxShadowKind, CornerRadius, CursorIcon, Edges, Element, ElementId, AlignItems, Border, BoxShadow, BoxShadowKind, CornerRadius, CursorIcon, Edges, Element,
FlexDirection, ScrollbarStyle, Style, ElementId, FlexDirection, ScrollbarStyle, Style,
}; };
pub use window::{ pub use window::{
DecorationMode, WindowConfigured, WindowId, WindowLifecycle, WindowSpec, WindowUpdate, DecorationMode, WindowConfigured, WindowId, WindowLifecycle, WindowSpec, WindowUpdate,

View File

@@ -80,6 +80,9 @@ impl Color {
/// safe to reserve as a sentinel. No user-facing API sets this value directly. /// 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 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 { pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self {
Self { r, g, b, a } Self { r, g, b, a }
} }

View File

@@ -23,6 +23,15 @@ pub enum FlexDirection {
Column, Column,
} }
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum AlignItems {
#[default]
Stretch,
Start,
Center,
End,
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum CursorIcon { pub enum CursorIcon {
Default, Default,
@@ -180,8 +189,13 @@ impl Default for ScrollbarStyle {
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub struct Style { pub struct Style {
pub direction: FlexDirection, pub direction: FlexDirection,
pub align_items: AlignItems,
/// Per-element override of the parent container's `align_items`. `None` means inherit.
pub align_self: Option<AlignItems>,
pub width: Option<f32>, pub width: Option<f32>,
pub height: Option<f32>, pub height: Option<f32>,
pub min_width: Option<f32>,
pub min_height: Option<f32>,
pub flex_grow: f32, pub flex_grow: f32,
pub gap: f32, pub gap: f32,
pub padding: Edges, pub padding: Edges,
@@ -198,8 +212,12 @@ impl Default for Style {
fn default() -> Self { fn default() -> Self {
Self { Self {
direction: FlexDirection::Column, direction: FlexDirection::Column,
align_items: AlignItems::Stretch,
align_self: None,
width: None, width: None,
height: None, height: None,
min_width: None,
min_height: None,
flex_grow: 0.0, flex_grow: 0.0,
gap: 0.0, gap: 0.0,
padding: Edges::ZERO, padding: Edges::ZERO,
@@ -332,6 +350,20 @@ impl Element {
self self
} }
pub fn min_width(mut self, min_width: f32) -> Self {
self.style.min_width = Some(min_width.max(0.0));
self
}
pub fn min_height(mut self, min_height: f32) -> Self {
self.style.min_height = Some(min_height.max(0.0));
self
}
pub fn min_size(self, min_width: f32, min_height: f32) -> Self {
self.min_width(min_width).min_height(min_height)
}
pub fn flex(mut self, flex_grow: f32) -> Self { pub fn flex(mut self, flex_grow: f32) -> Self {
self.style.flex_grow = flex_grow.max(0.0); self.style.flex_grow = flex_grow.max(0.0);
self self
@@ -382,6 +414,16 @@ impl Element {
self self
} }
pub fn align_items(mut self, align: AlignItems) -> Self {
self.style.align_items = align;
self
}
pub fn align_self(mut self, align: AlignItems) -> Self {
self.style.align_self = Some(align);
self
}
pub fn cursor(mut self, cursor: CursorIcon) -> Self { pub fn cursor(mut self, cursor: CursorIcon) -> Self {
self.style.cursor = Some(cursor); self.style.cursor = Some(cursor);
self self
@@ -527,11 +569,21 @@ impl Hash for ScrollbarStyle {
} }
} }
impl Hash for AlignItems {
fn hash<H: Hasher>(&self, state: &mut H) {
(*self as u8).hash(state);
}
}
impl Hash for Style { impl Hash for Style {
fn hash<H: Hasher>(&self, state: &mut H) { fn hash<H: Hasher>(&self, state: &mut H) {
self.direction.hash(state); self.direction.hash(state);
self.align_items.hash(state);
self.align_self.hash(state);
self.width.map(f32::to_bits).hash(state); self.width.map(f32::to_bits).hash(state);
self.height.map(f32::to_bits).hash(state); self.height.map(f32::to_bits).hash(state);
self.min_width.map(f32::to_bits).hash(state);
self.min_height.map(f32::to_bits).hash(state);
self.flex_grow.to_bits().hash(state); self.flex_grow.to_bits().hash(state);
self.gap.to_bits().hash(state); self.gap.to_bits().hash(state);
self.padding.hash(state); self.padding.hash(state);

View File

@@ -1,6 +1,6 @@
//! Common window model types. //! Common window model types.
use crate::scene::UiSize; use crate::scene::{Color, UiSize};
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct WindowId(u64); pub struct WindowId(u64);
@@ -60,6 +60,7 @@ pub struct WindowSpec {
pub maximized: bool, pub maximized: bool,
pub fullscreen: bool, pub fullscreen: bool,
pub resizable: bool, pub resizable: bool,
pub base_color: Color,
} }
impl WindowSpec { impl WindowSpec {
@@ -77,9 +78,15 @@ impl WindowSpec {
maximized: false, maximized: false,
fullscreen: false, fullscreen: false,
resizable: true, resizable: true,
base_color: Color::rgb(0x08, 0x0C, 0x14),
} }
} }
pub fn base_color(mut self, color: Color) -> Self {
self.base_color = color;
self
}
pub fn key(mut self, key: impl Into<String>) -> Self { pub fn key(mut self, key: impl Into<String>) -> Self {
self.key = Some(key.into()); self.key = Some(key.into());
self self

View File

@@ -1620,6 +1620,7 @@ fn create_native_window(window: &MacosWindow, app: *mut AnyObject) -> Option<Nat
target, target,
(size.width * scale_factor).max(1.0) as u32, (size.width * scale_factor).max(1.0) as u32,
(size.height * scale_factor).max(1.0) as u32, (size.height * scale_factor).max(1.0) as u32,
window.spec.base_color,
) )
.ok(); .ok();
let mut renderer = renderer; let mut renderer = renderer;

View File

@@ -44,6 +44,9 @@ use wayland_protocols::wp::primary_selection::zv1::client::{
zwp_primary_selection_device_manager_v1, zwp_primary_selection_device_v1, zwp_primary_selection_device_manager_v1, zwp_primary_selection_device_v1,
zwp_primary_selection_offer_v1, zwp_primary_selection_source_v1, zwp_primary_selection_offer_v1, zwp_primary_selection_source_v1,
}; };
use wayland_protocols::xdg::decoration::zv1::client::{
zxdg_decoration_manager_v1, zxdg_toplevel_decoration_v1,
};
use wayland_protocols::xdg::shell::client::{xdg_surface, xdg_toplevel, xdg_wm_base}; use wayland_protocols::xdg::shell::client::{xdg_surface, xdg_toplevel, xdg_wm_base};
use xkbcommon::xkb; use xkbcommon::xkb;
@@ -145,6 +148,7 @@ enum InternalBackendEvent {
struct State { struct State {
running: bool, running: bool,
fixed_size_request: Option<(u32, u32)>,
_connection: Connection, _connection: Connection,
_compositor: wl_compositor::WlCompositor, _compositor: wl_compositor::WlCompositor,
_surface: wl_surface::WlSurface, _surface: wl_surface::WlSurface,
@@ -156,6 +160,8 @@ struct State {
clipboard_device: Option<wl_data_device::WlDataDevice>, clipboard_device: Option<wl_data_device::WlDataDevice>,
cursor_shape_manager: Option<wp_cursor_shape_manager_v1::WpCursorShapeManagerV1>, cursor_shape_manager: Option<wp_cursor_shape_manager_v1::WpCursorShapeManagerV1>,
cursor_shape_device: Option<wp_cursor_shape_device_v1::WpCursorShapeDeviceV1>, cursor_shape_device: Option<wp_cursor_shape_device_v1::WpCursorShapeDeviceV1>,
/// Server-side decoration handle. Kept alive so the compositor maintains decorations.
_decoration: Option<zxdg_toplevel_decoration_v1::ZxdgToplevelDecorationV1>,
primary_selection_manager: primary_selection_manager:
Option<zwp_primary_selection_device_manager_v1::ZwpPrimarySelectionDeviceManagerV1>, Option<zwp_primary_selection_device_manager_v1::ZwpPrimarySelectionDeviceManagerV1>,
primary_selection_device: Option<zwp_primary_selection_device_v1::ZwpPrimarySelectionDeviceV1>, primary_selection_device: Option<zwp_primary_selection_device_v1::ZwpPrimarySelectionDeviceV1>,
@@ -227,6 +233,21 @@ impl State {
} }
} }
fn fixed_size_request_for_spec(spec: &WindowSpec) -> Option<(u32, u32)> {
if spec.resizable {
return None;
}
let size = spec
.requested_inner_size
.or(spec.min_inner_size)
.or(spec.max_inner_size)
.unwrap_or_else(|| UiSize::new(800.0, 500.0));
Some((
size.width.max(1.0).round() as u32,
size.height.max(1.0).round() as u32,
))
}
fn wayland_cursor_shape(icon: CursorIcon) -> wp_cursor_shape_device_v1::Shape { fn wayland_cursor_shape(icon: CursorIcon) -> wp_cursor_shape_device_v1::Shape {
match icon { match icon {
CursorIcon::Default => wp_cursor_shape_device_v1::Shape::Default, CursorIcon::Default => wp_cursor_shape_device_v1::Shape::Default,
@@ -318,6 +339,8 @@ impl WaylandWindow {
manager.get_device(&seat, &qh, ()) manager.get_device(&seat, &qh, ())
}, },
); );
let decoration_manager: Option<zxdg_decoration_manager_v1::ZxdgDecorationManagerV1> =
globals.bind(&qh, 1..=1, ()).ok();
let surface = compositor.create_surface(&qh, ()); let surface = compositor.create_surface(&qh, ());
let xdg_surface = wm_base.get_xdg_surface(&surface, &qh, ()); let xdg_surface = wm_base.get_xdg_surface(&surface, &qh, ());
let toplevel = xdg_surface.get_toplevel(&qh, ()); let toplevel = xdg_surface.get_toplevel(&qh, ());
@@ -325,6 +348,11 @@ impl WaylandWindow {
if let Some(app_id) = spec.app_id.as_ref() { if let Some(app_id) = spec.app_id.as_ref() {
toplevel.set_app_id(app_id.clone()); toplevel.set_app_id(app_id.clone());
} }
let decoration = decoration_manager.as_ref().map(|dm| {
let deco = dm.get_toplevel_decoration(&toplevel, &qh, ());
deco.set_mode(zxdg_toplevel_decoration_v1::Mode::ServerSide);
deco
});
apply_size_constraints(&toplevel, &spec); apply_size_constraints(&toplevel, &spec);
if spec.maximized { if spec.maximized {
@@ -352,6 +380,7 @@ impl WaylandWindow {
surface_target, surface_target,
state: State { state: State {
running: true, running: true,
fixed_size_request: fixed_size_request_for_spec(&spec),
_connection: connection, _connection: connection,
_compositor: compositor, _compositor: compositor,
_surface: surface, _surface: surface,
@@ -395,6 +424,7 @@ impl WaylandWindow {
last_selection_serial: None, last_selection_serial: None,
last_pointer_enter_serial: None, last_pointer_enter_serial: None,
cursor_icon: CursorIcon::Default, cursor_icon: CursorIcon::Default,
_decoration: decoration,
}, },
}) })
} }
@@ -684,6 +714,7 @@ impl WaylandWindow {
} }
pub fn apply_spec(&mut self, spec: &WindowSpec) -> Result<(), Box<dyn Error>> { pub fn apply_spec(&mut self, spec: &WindowSpec) -> Result<(), Box<dyn Error>> {
self.state.fixed_size_request = fixed_size_request_for_spec(spec);
self.state._toplevel.set_title(spec.title.clone()); self.state._toplevel.set_title(spec.title.clone());
if let Some(app_id) = spec.app_id.as_ref() { if let Some(app_id) = spec.app_id.as_ref() {
self.state._toplevel.set_app_id(app_id.clone()); self.state._toplevel.set_app_id(app_id.clone());
@@ -719,6 +750,14 @@ impl WaylandWindow {
} }
if let Some((width, height)) = self.state.pending_size.take() { if let Some((width, height)) = self.state.pending_size.take() {
trace!(
target: "ruin_ui_platform_wayland::resize",
width,
height,
previous_width = self.state.current_size.0,
previous_height = self.state.current_size.1,
"prepare_frame observed pending size change"
);
self.state.current_size = (width, height); self.state.current_size = (width, height);
self.state.needs_redraw = false; self.state.needs_redraw = false;
return Some(FrameRequest { return Some(FrameRequest {
@@ -729,6 +768,12 @@ impl WaylandWindow {
} }
if self.state.needs_redraw { if self.state.needs_redraw {
trace!(
target: "ruin_ui_platform_wayland::resize",
width = self.state.current_size.0,
height = self.state.current_size.1,
"prepare_frame servicing redraw without size change"
);
self.state.needs_redraw = false; self.state.needs_redraw = false;
return Some(FrameRequest { return Some(FrameRequest {
width: self.state.current_size.0, width: self.state.current_size.0,
@@ -1083,6 +1128,7 @@ fn spawn_window_worker(
spec.requested_inner_size spec.requested_inner_size
.unwrap_or_else(|| UiSize::new(800.0, 500.0)) .unwrap_or_else(|| UiSize::new(800.0, 500.0))
.height as u32, .height as u32,
spec.base_color,
) { ) {
Ok(renderer) => renderer, Ok(renderer) => renderer,
Err(_) => { Err(_) => {
@@ -1359,6 +1405,19 @@ fn spawn_window_worker(
state_ref state_ref
.pending_viewport_since .pending_viewport_since
.get_or_insert_with(Instant::now); .get_or_insert_with(Instant::now);
// Deadlock prevention: if the app responded to the
// configure we sent (scene matches in_flight) but the
// viewport has since moved on, clear the in_flight
// marker so we can immediately send the next configure.
// Without this, viewport_request_in_flight stays set
// to the old size forever and the app is never told
// about the new viewport.
if state_ref.viewport_request_in_flight
== Some(scene.logical_size)
{
state_ref.viewport_request_in_flight = None;
maybe_request_pending_viewport(&mut state_ref);
}
state_ref.window.request_redraw(); state_ref.window.request_redraw();
} else if !state_ref.window.presentation_ready() { } else if !state_ref.window.presentation_ready() {
// Correct scene is ready but the compositor // Correct scene is ready but the compositor
@@ -1589,6 +1648,8 @@ delegate_noop!(
State: ignore State: ignore
zwp_primary_selection_device_manager_v1::ZwpPrimarySelectionDeviceManagerV1 zwp_primary_selection_device_manager_v1::ZwpPrimarySelectionDeviceManagerV1
); );
delegate_noop!(State: ignore zxdg_decoration_manager_v1::ZxdgDecorationManagerV1);
delegate_noop!(State: ignore zxdg_toplevel_decoration_v1::ZxdgToplevelDecorationV1);
impl Dispatch<wl_seat::WlSeat, ()> for State { impl Dispatch<wl_seat::WlSeat, ()> for State {
fn event( fn event(
state: &mut Self, state: &mut Self,
@@ -2079,6 +2140,14 @@ impl Dispatch<xdg_surface::XdgSurface, ()> for State {
if let xdg_surface::Event::Configure { serial } = event { if let xdg_surface::Event::Configure { serial } = event {
xdg_surface.ack_configure(serial); xdg_surface.ack_configure(serial);
state.configured = true; state.configured = true;
trace!(
target: "ruin_ui_platform_wayland::resize",
serial,
current_width = state.current_size.0,
current_height = state.current_size.1,
has_pending_size = state.pending_size.is_some(),
"received xdg_surface configure"
);
state.request_redraw(); state.request_redraw();
} }
} }
@@ -2131,7 +2200,37 @@ impl Dispatch<xdg_toplevel::XdgToplevel, ()> for State {
height, height,
"received Wayland toplevel configure" "received Wayland toplevel configure"
); );
state.pending_size = Some((width, height)); let next_size = (width, height);
if let Some(expected_size) = state.fixed_size_request {
if next_size != expected_size {
trace!(
target: "ruin_ui_platform_wayland::resize",
width,
height,
expected_width = expected_size.0,
expected_height = expected_size.1,
"ignoring stale toplevel configure for fixed-size window"
);
state.request_redraw();
return;
}
}
let size_changed =
next_size != state.current_size && state.pending_size != Some(next_size);
trace!(
target: "ruin_ui_platform_wayland::resize",
width,
height,
current_width = state.current_size.0,
current_height = state.current_size.1,
pending_width = state.pending_size.map(|size| size.0),
pending_height = state.pending_size.map(|size| size.1),
size_changed,
"processed Wayland toplevel configure"
);
if size_changed {
state.pending_size = Some(next_size);
}
state.request_redraw(); state.request_redraw();
} }
_ => {} _ => {}

View File

@@ -333,6 +333,7 @@ pub struct WgpuSceneRenderer {
#[allow(dead_code)] #[allow(dead_code)]
glyph_atlas: GlyphAtlas, glyph_atlas: GlyphAtlas,
scale_factor: f32, scale_factor: f32,
base_color: Color,
} }
const MAX_TEXT_CACHE_ENTRIES: usize = 64; const MAX_TEXT_CACHE_ENTRIES: usize = 64;
@@ -346,6 +347,7 @@ impl WgpuSceneRenderer {
target: impl HasDisplayHandle + HasWindowHandle + Send + Sync + 'static, target: impl HasDisplayHandle + HasWindowHandle + Send + Sync + 'static,
width: u32, width: u32,
height: u32, height: u32,
base_color: Color,
) -> Result<Self, Box<dyn Error>> { ) -> Result<Self, Box<dyn Error>> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let surface = instance.create_surface(target)?; let surface = instance.create_surface(target)?;
@@ -364,6 +366,15 @@ impl WgpuSceneRenderer {
.copied() .copied()
.find(wgpu::TextureFormat::is_srgb) .find(wgpu::TextureFormat::is_srgb)
.unwrap_or(caps.formats[0]); .unwrap_or(caps.formats[0]);
let alpha_mode = if base_color.a < 255
&& caps
.alpha_modes
.contains(&wgpu::CompositeAlphaMode::PreMultiplied)
{
wgpu::CompositeAlphaMode::PreMultiplied
} else {
caps.alpha_modes[0]
};
let config = wgpu::SurfaceConfiguration { let config = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT, usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
format, format,
@@ -374,7 +385,7 @@ impl WgpuSceneRenderer {
// power-conservative policy once presentation settings are exposed. // power-conservative policy once presentation settings are exposed.
present_mode: wgpu::PresentMode::AutoNoVsync, present_mode: wgpu::PresentMode::AutoNoVsync,
desired_maximum_frame_latency: 1, desired_maximum_frame_latency: 1,
alpha_mode: caps.alpha_modes[0], alpha_mode,
view_formats: vec![], view_formats: vec![],
}; };
surface.configure(&device, &config); surface.configure(&device, &config);
@@ -421,7 +432,7 @@ fn sd_round_rect(point: vec2<f32>, rect: vec4<f32>, radius: f32) -> f32 {
fn rounded_rect_alpha(point: vec2<f32>, rect: vec4<f32>, radius: f32) -> f32 { fn rounded_rect_alpha(point: vec2<f32>, rect: vec4<f32>, radius: f32) -> f32 {
let distance = sd_round_rect(point, rect, radius); let distance = sd_round_rect(point, rect, radius);
return 1.0 - smoothstep(0.0, 1.0, distance); return 1.0 - smoothstep(-0.5, 0.5, distance);
} }
fn blurred_round_rect_alpha(point: vec2<f32>, rect: vec4<f32>, radius: f32, blur: f32) -> f32 { fn blurred_round_rect_alpha(point: vec2<f32>, rect: vec4<f32>, radius: f32, blur: f32) -> f32 {
@@ -559,7 +570,7 @@ fn sd_round_rect(point: vec2<f32>, rect: vec4<f32>, radius: f32) -> f32 {
fn rounded_rect_alpha(point: vec2<f32>, rect: vec4<f32>, radius: f32) -> f32 { fn rounded_rect_alpha(point: vec2<f32>, rect: vec4<f32>, radius: f32) -> f32 {
let distance = sd_round_rect(point, rect, radius); let distance = sd_round_rect(point, rect, radius);
return 1.0 - smoothstep(0.0, 1.0, distance); return 1.0 - smoothstep(-0.5, 0.5, distance);
} }
fn apply_clip_alpha(point: vec2<f32>, clip_rect: vec4<f32>, rounded_clip_rect: vec4<f32>, clip_params: vec2<f32>) -> f32 { fn apply_clip_alpha(point: vec2<f32>, clip_rect: vec4<f32>, rounded_clip_rect: vec4<f32>, clip_params: vec2<f32>) -> f32 {
@@ -717,6 +728,7 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
image_cache_order: VecDeque::new(), image_cache_order: VecDeque::new(),
glyph_atlas, glyph_atlas,
scale_factor: 1.0, scale_factor: 1.0,
base_color,
}) })
} }
@@ -824,11 +836,16 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
depth_slice: None, depth_slice: None,
resolve_target: None, resolve_target: None,
ops: wgpu::Operations { ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color { load: wgpu::LoadOp::Clear({
r: 0.03, let a = self.base_color.a as f64 / 255.0;
g: 0.04, // Premultiplied: multiply RGB by alpha so the compositor
b: 0.08, // can blend correctly when alpha_mode is PreMultiplied.
a: 1.0, wgpu::Color {
r: (self.base_color.r as f64 / 255.0) * a,
g: (self.base_color.g as f64 / 255.0) * a,
b: (self.base_color.b as f64 / 255.0) * a,
a,
}
}), }),
store: wgpu::StoreOp::Store, store: wgpu::StoreOp::Store,
}, },