Compare commits
9 Commits
4193457fc4
...
6dcc2d0d00
| Author | SHA1 | Date | |
|---|---|---|---|
|
6dcc2d0d00
|
|||
|
e2c2563b7e
|
|||
|
cd55cf14c5
|
|||
|
341d07d82f
|
|||
|
5f67c06083
|
|||
|
e327516e5e
|
|||
|
6079117f2c
|
|||
|
d961dd7491
|
|||
|
ad5c233b15
|
85
Cargo.lock
generated
85
Cargo.lock
generated
@@ -150,7 +150,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"arrayvec",
|
||||
"log",
|
||||
"nom",
|
||||
"nom 8.0.0",
|
||||
"num-rational",
|
||||
"v_frame",
|
||||
]
|
||||
@@ -253,6 +253,17 @@ version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
|
||||
[[package]]
|
||||
name = "calculator"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"meval",
|
||||
"ruin-runtime",
|
||||
"ruin_app",
|
||||
"ruin_ui",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cast"
|
||||
version = "0.3.0"
|
||||
@@ -606,6 +617,12 @@ dependencies = [
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
@@ -834,6 +851,17 @@ dependencies = [
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.2"
|
||||
@@ -1165,6 +1193,16 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "meval"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f79496a5651c8d57cd033c5add8ca7ee4e3d5f7587a4777484640d9cb60392d9"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"nom 1.2.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -1226,6 +1264,12 @@ version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "1.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5b8c256fd9471521bcb84c3cdba98921497f1a331cbc15b8030fc63b82050ce"
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "8.0.0"
|
||||
@@ -1241,6 +1285,15 @@ version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.6"
|
||||
@@ -1793,6 +1846,14 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruin-reactivity-parsing"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ruin-runtime",
|
||||
"ruin_reactivity",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruin-runtime"
|
||||
version = "0.1.0"
|
||||
@@ -1831,6 +1892,7 @@ dependencies = [
|
||||
name = "ruin_reactivity"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"hashbrown 0.17.0",
|
||||
"ruin-runtime",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
@@ -2257,6 +2319,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"valuable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-log"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2266,12 +2340,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
|
||||
dependencies = [
|
||||
"matchers",
|
||||
"nu-ansi-term",
|
||||
"once_cell",
|
||||
"regex-automata",
|
||||
"sharded-slab",
|
||||
"smallvec",
|
||||
"thread_local",
|
||||
"tracing",
|
||||
"tracing-core",
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2336,6 +2413,12 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
|
||||
18
LICENSE.txt
Normal file
18
LICENSE.txt
Normal 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
14
README.md
Normal 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.
|
||||
15
examples/calculator/Cargo.toml
Normal file
15
examples/calculator/Cargo.toml
Normal 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"] }
|
||||
375
examples/calculator/src/components.rs
Normal file
375
examples/calculator/src/components.rs
Normal file
@@ -0,0 +1,375 @@
|
||||
use ruin_app::prelude::*;
|
||||
use ruin_ui::{BoxShadow, BoxShadowKind, Point, TextFontFamily};
|
||||
|
||||
use crate::state::{CalcMode, CalcState};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Display panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[component]
|
||||
pub fn Display(state: CalcState) -> impl IntoView {
|
||||
let expression = state.expression.clone();
|
||||
let is_error = state.is_error.clone();
|
||||
let history = state.history.clone();
|
||||
|
||||
let result_color = use_memo({
|
||||
let is_error = is_error.clone();
|
||||
move || {
|
||||
if is_error.get() {
|
||||
Color::rgba(200, 30, 30, 245)
|
||||
} else {
|
||||
Color::rgba(20, 140, 20, 245)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let result_text = use_memo({
|
||||
let result = state.result.clone();
|
||||
move || {
|
||||
let r = result.get();
|
||||
if r.is_empty() { " ".to_string() } else { format!("= {r}") }
|
||||
}
|
||||
});
|
||||
|
||||
view! {
|
||||
block(
|
||||
padding = 12.0,
|
||||
gap = 8.0,
|
||||
// min_height (content) sized to cover scroll_box + expression + result lines
|
||||
// so the window auto-sizes correctly despite text min-size being 0.
|
||||
// Content: scroll_box(100) + expr_text(31) + result_text(20) + 2×gap(16) = 167
|
||||
min_height = 167.0,
|
||||
align_self = AlignItems::Stretch,
|
||||
align_items = AlignItems::End,
|
||||
background = Color::rgba(255, 255, 255, 200),
|
||||
border_radius = 12.0,
|
||||
border = (1.0, Color::rgba(0, 0, 0, 60)),
|
||||
shadow = BoxShadow::new(Point::new(2.0, 2.0), 8.0, 2.0, Color::rgba(0, 0, 0, 40), BoxShadowKind::Outer),
|
||||
) {
|
||||
block(flex = 1.0, min_height = 0.0) {}
|
||||
|
||||
// History (oldest at top, newest at bottom, scrollable)
|
||||
scroll_box(
|
||||
height = 100.0,
|
||||
offset_y = state.history_scroll.clone(),
|
||||
align_self = AlignItems::Stretch,
|
||||
) {
|
||||
column(padding = 4.0, gap = 2.0, align_items = AlignItems::End) {
|
||||
history.with(|entries| {
|
||||
entries
|
||||
.iter()
|
||||
.rev()
|
||||
.map(|e| {
|
||||
let expr = e.expression.clone();
|
||||
let res = e.result.clone();
|
||||
let s = state.clone();
|
||||
let res_for_press = res.clone();
|
||||
view! {
|
||||
button(
|
||||
align_self = AlignItems::End,
|
||||
padding = 4.0,
|
||||
background = Color::rgba(0, 0, 0, 0),
|
||||
border_radius = 4.0,
|
||||
on_press = move |_| s.use_history_result(res_for_press.clone()),
|
||||
) {
|
||||
row(gap = 6.0, align_self = AlignItems::End) {
|
||||
text(size = 12.0, color = Color::rgba(0, 0, 0, 200), font_family = TextFontFamily::Monospace, wrap = TextWrap::None, weight = FontWeight::Normal) {
|
||||
expr.clone()
|
||||
}
|
||||
text(size = 12.0, color = Color::rgba(20, 130, 20, 230), font_family = TextFontFamily::Monospace, wrap = TextWrap::None, weight = FontWeight::Normal) {
|
||||
format!("= {res}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect::<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("x²".to_string(), bg, move || {
|
||||
s.expression.update(|e| {
|
||||
let wrapped = format!("({e})^2");
|
||||
*e = wrapped;
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
view! {
|
||||
column(gap = 6.0, align_self = AlignItems::Stretch) {
|
||||
btn_row(vec![sci!("sin(", "sin("), sci!("cos(", "cos("), sci!("tan(", "tan("), sci!("asin(", "asin(")])
|
||||
btn_row(vec![sci!("log(", "log("), sci!("ln(", "ln("), sci!("√(", "√("), sq_btn])
|
||||
btn_row(vec![sci!("xʸ", "^"), sci!("π", "π"), sci!("e", "e"), sci!("fact(", "fact(")])
|
||||
}
|
||||
}
|
||||
}
|
||||
161
examples/calculator/src/main.rs
Normal file
161
examples/calculator/src/main.rs
Normal file
@@ -0,0 +1,161 @@
|
||||
mod components;
|
||||
mod state;
|
||||
|
||||
use ruin_app::prelude::*;
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
use tracing_subscriber::{EnvFilter, fmt};
|
||||
|
||||
use components::{Display, Keypad, ModeToggle, ScientificPanel};
|
||||
use state::{CalcMode, CalcState};
|
||||
|
||||
#[ruin_runtime::async_main]
|
||||
async fn main() -> ruin_app::Result<()> {
|
||||
init_tracing();
|
||||
|
||||
App::new()
|
||||
.window(
|
||||
Window::new()
|
||||
.title("Calculator")
|
||||
.app_id("dev.ruin.calculator")
|
||||
.base_color(Color::rgba(255, 255, 255, 80))
|
||||
.resizable(false),
|
||||
)
|
||||
.mount(view! { CalculatorApp() {} })
|
||||
.run()
|
||||
.await
|
||||
}
|
||||
|
||||
fn init_tracing() {
|
||||
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
||||
EnvFilter::new(
|
||||
"info,ruin_app::resize=trace,ruin_app::interaction=trace,\
|
||||
ruin_ui_platform_wayland::resize=trace,ruin_ui_platform_wayland::perf=debug,\
|
||||
ruin_ui_platform_wayland::event_bridge=trace",
|
||||
)
|
||||
});
|
||||
|
||||
let _ = tracing_subscriber::registry()
|
||||
.with(fmt::layer().with_target(true).without_time())
|
||||
.with(filter)
|
||||
.try_init();
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn CalculatorApp() -> impl IntoView {
|
||||
let state = CalcState::new();
|
||||
|
||||
// ---- Keyboard shortcuts (application-scoped) ----
|
||||
|
||||
for ch in "0123456789.+-*/%()".chars() {
|
||||
let s = state.clone();
|
||||
let display = match ch {
|
||||
'*' => "×",
|
||||
'/' => "÷",
|
||||
_ => "",
|
||||
};
|
||||
let text = if display.is_empty() {
|
||||
ch.to_string()
|
||||
} else {
|
||||
display.to_string()
|
||||
};
|
||||
use_shortcut(Shortcut::new(Key::Character(ch)), ShortcutScope::Application, {
|
||||
let text = text.clone();
|
||||
move || s.input(&text)
|
||||
});
|
||||
}
|
||||
|
||||
// = key also evaluates
|
||||
{
|
||||
let s = state.clone();
|
||||
use_shortcut(Shortcut::new(Key::Character('=')), ShortcutScope::Application, move || s.evaluate());
|
||||
}
|
||||
{
|
||||
let s = state.clone();
|
||||
use_shortcut(Shortcut::new(Key::Enter), ShortcutScope::Application, move || s.evaluate());
|
||||
}
|
||||
{
|
||||
let s = state.clone();
|
||||
use_shortcut(Shortcut::new(Key::Backspace), ShortcutScope::Application, move || s.backspace());
|
||||
}
|
||||
{
|
||||
let s = state.clone();
|
||||
use_shortcut(Shortcut::new(Key::Escape), ShortcutScope::Application, move || s.clear());
|
||||
}
|
||||
{
|
||||
let s = state.clone();
|
||||
use_shortcut(Shortcut::new(Key::Delete), ShortcutScope::Application, move || s.clear());
|
||||
}
|
||||
|
||||
// ---- Layout ----
|
||||
|
||||
let mode = state.mode.clone();
|
||||
|
||||
let text_style = PartialTextStyle {
|
||||
color: Some(Color::BLACK),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
if mode.get() == CalcMode::Scientific {
|
||||
view! {
|
||||
column(
|
||||
gap = 10.0,
|
||||
padding = 12.0,
|
||||
align_items = AlignItems::Stretch,
|
||||
text_style = text_style,
|
||||
) {
|
||||
Display(state = state.clone()) {}
|
||||
ModeToggle(state = state.clone()) {}
|
||||
ScientificPanel(state = state.clone()) {}
|
||||
Keypad(state = state.clone()) {}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
view! {
|
||||
column(
|
||||
gap = 10.0,
|
||||
padding = 12.0,
|
||||
align_items = AlignItems::Stretch,
|
||||
text_style = text_style,
|
||||
) {
|
||||
Display(state = state.clone()) {}
|
||||
ModeToggle(state = state.clone()) {}
|
||||
Keypad(state = state.clone()) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[component]
|
||||
fn StateProbe(slot: Rc<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");
|
||||
}
|
||||
}
|
||||
198
examples/calculator/src/state.rs
Normal file
198
examples/calculator/src/state.rs
Normal 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()
|
||||
}
|
||||
@@ -101,6 +101,7 @@ async fn run_demo() -> Result<(), Box<dyn Error>> {
|
||||
window.surface_target(),
|
||||
initial_size.width as u32,
|
||||
initial_size.height as u32,
|
||||
ruin_ui::Color::rgb(0x08, 0x0C, 0x14),
|
||||
)
|
||||
.expect("wgpu renderer should initialize");
|
||||
let state = Rc::new(RefCell::new(WorkerState {
|
||||
|
||||
@@ -795,6 +795,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
||||
let LayoutSnapshot {
|
||||
scene,
|
||||
interaction_tree: next_interaction_tree,
|
||||
..
|
||||
} = build_snapshot(
|
||||
viewport,
|
||||
version,
|
||||
@@ -1077,6 +1078,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
||||
let LayoutSnapshot {
|
||||
scene,
|
||||
interaction_tree: next_interaction_tree,
|
||||
..
|
||||
} = build_snapshot(
|
||||
viewport,
|
||||
version,
|
||||
|
||||
@@ -36,6 +36,7 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
window.surface_target(),
|
||||
scene.logical_size.width as u32,
|
||||
scene.logical_size.height as u32,
|
||||
ruin_ui::Color::rgb(0x08, 0x0C, 0x14),
|
||||
)?;
|
||||
|
||||
println!("Opening RUIN Wayland / wgpu demo window...");
|
||||
|
||||
@@ -5,7 +5,12 @@ edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
ruin_runtime = { package = "ruin-runtime", path = "../runtime" }
|
||||
tracing = { version = "0.1", default-features = false, features = ["std"] }
|
||||
tracing = { version = "0.1", default-features = false }
|
||||
hashbrown = "0.17"
|
||||
|
||||
[dev-dependencies]
|
||||
tracing-subscriber = { version = "0.3", default-features = false, features = ["env-filter", "fmt", "std"] }
|
||||
tracing-subscriber = { version = "0.3", default-features = false, features = [
|
||||
"env-filter",
|
||||
"fmt",
|
||||
"std",
|
||||
] }
|
||||
|
||||
152
lib/reactivity/README.md
Normal file
152
lib/reactivity/README.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# RUIN Reactivity - Fine-Grained Reactor
|
||||
|
||||
Automatic thread-stack-based reactivity for RUIN-apps.
|
||||
|
||||
RUIN Reactivity provides an implementation of fine-grained reactivity primitives
|
||||
for dependency tracking and incremental computation. Those primitives are:
|
||||
|
||||
- `Cell<T>`: a tracked-state value cell, primitively observable (sometimes
|
||||
called a "signal" in other reactor implementations).
|
||||
- `effect`: a primitive observer that runs once, observes its dependencies, and
|
||||
runs again whenever its dependencies change.
|
||||
- `Thunk<T>`: a tracked-state recomputable value defined by a closure.
|
||||
Invalidated if any of its dependencies change. A `Thunk` is both an observer
|
||||
and an observable.
|
||||
- `Event<T>`: a push-style source of events of type `T`. Supports subscription
|
||||
and cancellation of interest in events.
|
||||
|
||||
RUIN reactivity requires the RUIN runtime to function, and cannot be used on
|
||||
threads not managed by the RUIN runtime.
|
||||
|
||||
RUIN reactivity does not function across thread boundaries. It tracks
|
||||
dependencies between entities on the same thread only.
|
||||
|
||||
## Examples
|
||||
|
||||
### Observe a cell using an effect
|
||||
|
||||
```rs
|
||||
use std::time::Duration;
|
||||
|
||||
use ruin_reactivity::{cell, effect};
|
||||
use ruin_runtime::{main, set_timeout};
|
||||
|
||||
#[main]
|
||||
fn main() {
|
||||
// Creates an observable value. Calling `.get` from within an observer will create a dependency, and calling `.set`
|
||||
// will trigger updates to any dependent observers.
|
||||
let v = cell(5);
|
||||
|
||||
// Creates an observer that prints the value of `v` whenever it changes.
|
||||
// Calling `.leak()` on the effect handle allows it to run for the lifetime of the program without automatically
|
||||
// disposing when dropped.
|
||||
effect({
|
||||
let v = v.clone();
|
||||
move || {
|
||||
println!("v is: {}", v.get());
|
||||
}
|
||||
})
|
||||
.leak();
|
||||
|
||||
// Queue a future to wait 5 seconds and then update `v`. This will trigger
|
||||
// the effect to run again and print the new value.
|
||||
set_timeout(Duration::from_secs(5), {
|
||||
let v = v.clone();
|
||||
move || {
|
||||
v.set(v.get() + 20);
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Observe a thunk using an effect
|
||||
|
||||
```rs
|
||||
use std::time::Duration;
|
||||
|
||||
use ruin_reactivity::{cell, effect, thunk};
|
||||
use ruin_runtime::{clear_interval, main, set_interval, set_timeout};
|
||||
|
||||
#[main]
|
||||
fn main() {
|
||||
// Two primitive observable values.
|
||||
let x = cell(5);
|
||||
let y = cell(10);
|
||||
|
||||
// A derived observable value that depends on `x` and `y`. The closure will only run when `x` or `y` change, and the
|
||||
// result will be cached until then.
|
||||
let z = thunk({
|
||||
let x = x.clone();
|
||||
let y = y.clone();
|
||||
move || {
|
||||
println!("calculating z...");
|
||||
x.get() + y.get()
|
||||
}
|
||||
});
|
||||
|
||||
// The effect observes `z`, so it will run whenever `z` changes. Because `z` depends on `x` and `y`, the effect will
|
||||
// run whenever `x` or `y` change.
|
||||
effect({
|
||||
let z = z.clone();
|
||||
move || {
|
||||
println!("z is: {}", z.get());
|
||||
}
|
||||
})
|
||||
.leak();
|
||||
|
||||
// Update `x` and `y` every second. This will trigger the effect to run and print the new value of `z`.
|
||||
let interval = set_interval(Duration::from_secs(1), {
|
||||
let x = x.clone();
|
||||
let y = y.clone();
|
||||
move || {
|
||||
x.update(|value| *value += 1);
|
||||
y.update(|value| *value += 2);
|
||||
}
|
||||
});
|
||||
|
||||
// After 10 seconds, clear the interval to stop updating `x` and `y`. Once the interval is cleared, the queue will
|
||||
// empty and the program will exit since there are no more pending tasks.
|
||||
set_timeout(Duration::from_secs(10), move || {
|
||||
println!("clearing interval...");
|
||||
clear_interval(&interval);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Use an event to handle intra-thread messaging
|
||||
|
||||
```rs
|
||||
use std::{cell::Cell, rc::Rc, time::Duration};
|
||||
|
||||
use ruin_reactivity::event;
|
||||
use ruin_runtime::{main, queue_future, set_interval, time::sleep};
|
||||
|
||||
#[main]
|
||||
fn main() {
|
||||
let my_event = event::<String>();
|
||||
|
||||
my_event.subscribe(|message| {
|
||||
println!("got event with message: {message}");
|
||||
});
|
||||
|
||||
// Emit an event every 250ms with an incrementing count.
|
||||
let interval = set_interval(Duration::from_millis(250), {
|
||||
let counter = Rc::new(Cell::new(0));
|
||||
move || {
|
||||
let count = counter.get();
|
||||
my_event.emit(format!("the count is {}", count));
|
||||
counter.set(count + 1);
|
||||
}
|
||||
});
|
||||
|
||||
// After 5 seconds, clear the interval to stop emitting events.
|
||||
queue_future(async move {
|
||||
sleep(Duration::from_secs(5)).await;
|
||||
interval.clear();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Licensed under the [MIT License](../../LICENSE.txt).
|
||||
31
lib/reactivity/examples/readme_md_01.rs
Normal file
31
lib/reactivity/examples/readme_md_01.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use ruin_reactivity::{cell, effect};
|
||||
use ruin_runtime::{main, set_timeout};
|
||||
|
||||
#[main]
|
||||
fn main() {
|
||||
// Creates an observable value. Calling `.get` from within an observer will create a dependency, and calling `.set`
|
||||
// will trigger updates to any dependent observers.
|
||||
let v = cell(5);
|
||||
|
||||
// Creates an observer that prints the value of `v` whenever it changes.
|
||||
// Calling `.leak()` on the effect handle allows it to run for the lifetime of the program without automatically
|
||||
// disposing when dropped.
|
||||
effect({
|
||||
let v = v.clone();
|
||||
move || {
|
||||
println!("v is: {}", v.get());
|
||||
}
|
||||
})
|
||||
.leak();
|
||||
|
||||
// Queue a future to wait 5 seconds and then update `v`. This will trigger
|
||||
// the effect to run again and print the new value.
|
||||
set_timeout(Duration::from_secs(5), {
|
||||
let v = v.clone();
|
||||
move || {
|
||||
v.set(v.get() + 20);
|
||||
}
|
||||
});
|
||||
}
|
||||
49
lib/reactivity/examples/readme_md_02.rs
Normal file
49
lib/reactivity/examples/readme_md_02.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use ruin_reactivity::{cell, effect, thunk};
|
||||
use ruin_runtime::{clear_interval, main, set_interval, set_timeout};
|
||||
|
||||
#[main]
|
||||
fn main() {
|
||||
// Two primitive observable values.
|
||||
let x = cell(5);
|
||||
let y = cell(10);
|
||||
|
||||
// A derived observable value that depends on `x` and `y`. The closure will only run when `x` or `y` change, and the
|
||||
// result will be cached until then.
|
||||
let z = thunk({
|
||||
let x = x.clone();
|
||||
let y = y.clone();
|
||||
move || {
|
||||
println!("calculating z...");
|
||||
x.get() + y.get()
|
||||
}
|
||||
});
|
||||
|
||||
// The effect observes `z`, so it will run whenever `z` changes. Because `z` depends on `x` and `y`, the effect will
|
||||
// run whenever `x` or `y` change.
|
||||
effect({
|
||||
let z = z.clone();
|
||||
move || {
|
||||
println!("z is: {}", z.get());
|
||||
}
|
||||
})
|
||||
.leak();
|
||||
|
||||
// Update `x` and `y` every second. This will trigger the effect to run and print the new value of `z`.
|
||||
let interval = set_interval(Duration::from_secs(1), {
|
||||
let x = x.clone();
|
||||
let y = y.clone();
|
||||
move || {
|
||||
x.update(|value| *value += 1);
|
||||
y.update(|value| *value += 2);
|
||||
}
|
||||
});
|
||||
|
||||
// After 10 seconds, clear the interval to stop updating `x` and `y`. Once the interval is cleared, the queue will
|
||||
// empty and the program will exit since there are no more pending tasks.
|
||||
set_timeout(Duration::from_secs(10), move || {
|
||||
println!("clearing interval...");
|
||||
clear_interval(&interval);
|
||||
});
|
||||
}
|
||||
29
lib/reactivity/examples/readme_md_03.rs
Normal file
29
lib/reactivity/examples/readme_md_03.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
use std::{cell::Cell, rc::Rc, time::Duration};
|
||||
|
||||
use ruin_reactivity::event;
|
||||
use ruin_runtime::{main, queue_future, set_interval, time::sleep};
|
||||
|
||||
#[main]
|
||||
fn main() {
|
||||
let my_event = event::<String>();
|
||||
|
||||
my_event.subscribe(|message| {
|
||||
println!("got event with message: {message}");
|
||||
});
|
||||
|
||||
// Emit an event every 250ms with an incrementing count.
|
||||
let interval = set_interval(Duration::from_millis(250), {
|
||||
let counter = Rc::new(Cell::new(0));
|
||||
move || {
|
||||
let count = counter.get();
|
||||
my_event.emit(format!("the count is {}", count));
|
||||
counter.set(count + 1);
|
||||
}
|
||||
});
|
||||
|
||||
// After 5 seconds, clear the interval to stop emitting events.
|
||||
queue_future(async move {
|
||||
sleep(Duration::from_secs(5)).await;
|
||||
interval.clear();
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use alloc::rc::Rc;
|
||||
use core::cell::RefCell;
|
||||
|
||||
use crate::{NodeId, Reactor, current, trace_targets};
|
||||
|
||||
@@ -114,7 +114,7 @@ impl<T: PartialEq + 'static> Cell<T> {
|
||||
return None;
|
||||
}
|
||||
|
||||
let previous = std::mem::replace(&mut *current, value);
|
||||
let previous = core::mem::replace(&mut *current, value);
|
||||
drop(current);
|
||||
tracing::debug!(
|
||||
target: trace_targets::CELL,
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::{Rc, Weak};
|
||||
use alloc::boxed::Box;
|
||||
use alloc::rc::{Rc, Weak};
|
||||
use core::cell::{Cell, RefCell};
|
||||
|
||||
use crate::reactor::ObserverHook;
|
||||
use crate::{NodeId, Reactor, current, trace_targets};
|
||||
|
||||
/// Creates an effect in the current thread's default reactor.
|
||||
///
|
||||
/// The effect is scheduled immediately and then re-scheduled whenever one of its dependencies
|
||||
/// changes.
|
||||
pub fn effect(f: impl Fn() + 'static) -> EffectHandle {
|
||||
current().effect(f)
|
||||
}
|
||||
@@ -16,6 +20,7 @@ pub fn effect_in(reactor: &Reactor, f: impl Fn() + 'static) -> EffectHandle {
|
||||
|
||||
/// Disposable handle for a reactive effect.
|
||||
#[derive(Clone)]
|
||||
#[must_use = "effects are automatically disposed when dropped, so you must use the handle or explicitly leak it"]
|
||||
pub struct EffectHandle {
|
||||
inner: Rc<EffectInner>,
|
||||
}
|
||||
@@ -55,6 +60,14 @@ impl EffectHandle {
|
||||
Self { inner }
|
||||
}
|
||||
|
||||
/// Consumes the effect and leaks it, allowing it to run for the remainder of the program without automatically
|
||||
/// disposing when dropped. Use this for effects that you want to run for the lifetime of the program. You CANNOT
|
||||
/// recover an EffectHandle after calling this method, so be sure to call it on an EffectHandle that you don't need
|
||||
/// to later dispose of.
|
||||
pub fn leak(self) {
|
||||
core::mem::forget(self);
|
||||
}
|
||||
|
||||
/// Disposes the effect immediately.
|
||||
pub fn dispose(&self) {
|
||||
self.inner.dispose();
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::BTreeMap;
|
||||
use std::rc::Rc;
|
||||
use alloc::boxed::Box;
|
||||
use alloc::rc::Rc;
|
||||
use alloc::vec::Vec;
|
||||
use core::cell::{Cell, RefCell};
|
||||
|
||||
use hashbrown::HashMap;
|
||||
|
||||
use crate::{NodeId, Reactor, current, trace_targets};
|
||||
|
||||
@@ -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.
|
||||
#[must_use = "subscriptions created with `on` must be used to stay active"]
|
||||
pub fn on<T: Clone + 'static>(event: &Event<T>, handler: impl Fn(&T) + 'static) -> Subscription {
|
||||
current().on(event, handler)
|
||||
}
|
||||
|
||||
/// Creates a reactive draining subscription for `event` associated with `reactor`.
|
||||
#[must_use = "subscriptions created with `on_in` must be used to stay active"]
|
||||
pub fn on_in<T: Clone + 'static>(
|
||||
reactor: &Reactor,
|
||||
event: &Event<T>,
|
||||
@@ -49,6 +54,7 @@ impl Reactor {
|
||||
}
|
||||
|
||||
/// Creates a reactive draining subscription for `event`.
|
||||
#[must_use = "subscriptions created with `on` must be used to stay active"]
|
||||
pub fn on<T: Clone + 'static>(
|
||||
&self,
|
||||
event: &Event<T>,
|
||||
@@ -103,7 +109,7 @@ impl<T: 'static> Event<T> {
|
||||
reactor,
|
||||
id,
|
||||
next_subscriber: Cell::new(1),
|
||||
subscribers: RefCell::new(BTreeMap::new()),
|
||||
subscribers: RefCell::new(Default::default()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -204,7 +210,7 @@ struct EventInner<T> {
|
||||
reactor: Reactor,
|
||||
id: NodeId,
|
||||
next_subscriber: Cell<usize>,
|
||||
subscribers: RefCell<BTreeMap<usize, Rc<SubscriberFn<T>>>>,
|
||||
subscribers: RefCell<HashMap<usize, Rc<SubscriberFn<T>>>>,
|
||||
}
|
||||
|
||||
struct SubscriptionInner {
|
||||
@@ -227,11 +233,12 @@ impl SubscriptionInner {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SubscriptionInner {
|
||||
fn drop(&mut self) {
|
||||
self.unsubscribe();
|
||||
}
|
||||
}
|
||||
// TODO: it's actually kind of hard to use events if subcscriptions have to be manually managed.
|
||||
// impl Drop for SubscriptionInner {
|
||||
// fn drop(&mut self) {
|
||||
// self.unsubscribe();
|
||||
// }
|
||||
// }
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
//! single-threaded and designed to live on a runtime-managed thread, while async work feeds it
|
||||
//! from the edges by updating state or emitting events.
|
||||
|
||||
#![feature(thread_local)]
|
||||
#![cfg_attr(not(test), no_std)]
|
||||
|
||||
extern crate alloc;
|
||||
|
||||
pub(crate) mod trace_targets {
|
||||
pub const GRAPH: &str = "ruin_reactivity::graph";
|
||||
pub const CELL: &str = "ruin_reactivity::cell";
|
||||
@@ -18,6 +23,7 @@ mod effect;
|
||||
mod event;
|
||||
mod id;
|
||||
mod reactor;
|
||||
mod source;
|
||||
mod thunk;
|
||||
|
||||
pub use cell::{Cell, cell, cell_in};
|
||||
@@ -25,4 +31,5 @@ pub use effect::{EffectHandle, effect, effect_in};
|
||||
pub use event::{Event, Subscription, event, event_in, on, on_in};
|
||||
pub use id::NodeId;
|
||||
pub use reactor::{ReactCycleError, Reactor, current};
|
||||
pub use source::{Source, source, source_in};
|
||||
pub use thunk::{Memo, Thunk, memo, memo_by, memo_by_in, memo_in, thunk, thunk_in};
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::{BTreeMap, BTreeSet, VecDeque};
|
||||
use std::error::Error;
|
||||
use std::fmt;
|
||||
use std::panic::panic_any;
|
||||
use std::rc::{Rc, Weak};
|
||||
use alloc::boxed::Box;
|
||||
use alloc::collections::VecDeque;
|
||||
use alloc::rc::{Rc, Weak};
|
||||
use alloc::vec::Vec;
|
||||
use core::cell::{Cell, RefCell};
|
||||
use core::error::Error;
|
||||
use core::fmt;
|
||||
|
||||
use hashbrown::{HashMap, HashSet};
|
||||
|
||||
use ruin_runtime::queue_microtask;
|
||||
|
||||
@@ -11,9 +14,8 @@ use crate::{NodeId, trace_targets};
|
||||
|
||||
type Job = Box<dyn FnOnce() + 'static>;
|
||||
|
||||
thread_local! {
|
||||
#[thread_local]
|
||||
static CURRENT_REACTOR: RefCell<Weak<ReactorInner>> = const { RefCell::new(Weak::new()) };
|
||||
}
|
||||
|
||||
/// Returns the current thread's default reactor.
|
||||
///
|
||||
@@ -28,7 +30,7 @@ pub(crate) trait ObserverHook {
|
||||
fn notify(&self);
|
||||
}
|
||||
|
||||
/// Panic payload emitted when the reactor detects a dependency cycle.
|
||||
/// Error type for cycles detected in the reactive graph. Contains the path of nodes that form the cycle.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ReactCycleError {
|
||||
cycle: Vec<NodeId>,
|
||||
@@ -85,8 +87,7 @@ impl Reactor {
|
||||
|
||||
/// Returns the current thread's default reactor.
|
||||
pub fn current() -> Self {
|
||||
CURRENT_REACTOR.with(|slot| {
|
||||
if let Some(inner) = slot.borrow().upgrade() {
|
||||
if let Some(inner) = CURRENT_REACTOR.borrow().upgrade() {
|
||||
#[cfg(debug_assertions)]
|
||||
tracing::trace!(
|
||||
target: trace_targets::GRAPH,
|
||||
@@ -97,14 +98,13 @@ impl Reactor {
|
||||
}
|
||||
|
||||
let reactor = Self::new();
|
||||
*slot.borrow_mut() = Rc::downgrade(&reactor.inner);
|
||||
*CURRENT_REACTOR.borrow_mut() = Rc::downgrade(&reactor.inner);
|
||||
tracing::debug!(
|
||||
target: trace_targets::GRAPH,
|
||||
event = "current_reactor_install",
|
||||
"installed current thread default reactor"
|
||||
);
|
||||
reactor
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs `f` in the dependency-tracking scope of `observer`.
|
||||
@@ -143,13 +143,9 @@ impl Reactor {
|
||||
f()
|
||||
}
|
||||
|
||||
/// Records that the currently executing observer depends on `observable`.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics with [`ReactCycleError`] if `observable` is already being computed in the current
|
||||
/// dependency stack.
|
||||
pub fn observe(&self, observable: NodeId) {
|
||||
/// Attempts to record a dependency on `observable` for the currently running observer, returning an
|
||||
/// error if doing so would create a dependency cycle.
|
||||
pub fn try_observe(&self, observable: NodeId) -> Result<(), ReactCycleError> {
|
||||
if self
|
||||
.inner
|
||||
.active_computations
|
||||
@@ -170,12 +166,12 @@ impl Reactor {
|
||||
cycle_len = cycle.len(),
|
||||
"reactive cycle detected"
|
||||
);
|
||||
panic_any(ReactCycleError::new(cycle));
|
||||
return Err(ReactCycleError::new(cycle));
|
||||
}
|
||||
|
||||
let current = self.inner.stack.borrow().last().copied();
|
||||
let Some(observer) = current else {
|
||||
return;
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
@@ -199,6 +195,18 @@ impl Reactor {
|
||||
.entry(observable)
|
||||
.or_default()
|
||||
.insert(observer);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Records a dependency on `observable` for the currently running observer.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if recording the dependency would create a cycle in the reactive graph.
|
||||
pub fn observe(&self, observable: NodeId) {
|
||||
if let Err(e) = self.try_observe(observable) {
|
||||
panic!("reactive cycle detected: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Notifies dependents of `observable`.
|
||||
@@ -344,11 +352,11 @@ impl fmt::Debug for Reactor {
|
||||
|
||||
struct ReactorInner {
|
||||
next_node: Cell<u64>,
|
||||
dependencies: RefCell<BTreeMap<NodeId, BTreeSet<NodeId>>>,
|
||||
dependents: RefCell<BTreeMap<NodeId, BTreeSet<NodeId>>>,
|
||||
observers: RefCell<BTreeMap<NodeId, Weak<dyn ObserverHook>>>,
|
||||
dependencies: RefCell<HashMap<NodeId, HashSet<NodeId>>>,
|
||||
dependents: RefCell<HashMap<NodeId, HashSet<NodeId>>>,
|
||||
observers: RefCell<HashMap<NodeId, Weak<dyn ObserverHook>>>,
|
||||
stack: RefCell<Vec<NodeId>>,
|
||||
active_computations: RefCell<BTreeSet<NodeId>>,
|
||||
active_computations: RefCell<HashSet<NodeId>>,
|
||||
pending_jobs: RefCell<VecDeque<Job>>,
|
||||
flush_scheduled: Cell<bool>,
|
||||
}
|
||||
@@ -357,11 +365,11 @@ impl ReactorInner {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
next_node: Cell::new(1),
|
||||
dependencies: RefCell::new(BTreeMap::new()),
|
||||
dependents: RefCell::new(BTreeMap::new()),
|
||||
observers: RefCell::new(BTreeMap::new()),
|
||||
dependencies: RefCell::new(HashMap::new()),
|
||||
dependents: RefCell::new(HashMap::new()),
|
||||
observers: RefCell::new(HashMap::new()),
|
||||
stack: RefCell::new(Vec::new()),
|
||||
active_computations: RefCell::new(BTreeSet::new()),
|
||||
active_computations: RefCell::new(HashSet::new()),
|
||||
pending_jobs: RefCell::new(VecDeque::new()),
|
||||
flush_scheduled: Cell::new(false),
|
||||
}
|
||||
@@ -421,7 +429,7 @@ mod tests {
|
||||
|
||||
use ruin_runtime::{queue_task, run};
|
||||
|
||||
use super::{ReactCycleError, Reactor, current};
|
||||
use super::{Reactor, current};
|
||||
|
||||
#[test]
|
||||
fn current_reactor_is_thread_local_singleton() {
|
||||
@@ -436,7 +444,11 @@ mod tests {
|
||||
let observer = reactor.allocate_node();
|
||||
let observable = reactor.allocate_node();
|
||||
|
||||
reactor.run_in_context(observer, || reactor.observe(observable));
|
||||
reactor.run_in_context(observer, || {
|
||||
reactor
|
||||
.try_observe(observable)
|
||||
.expect("should not detect cycle")
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
reactor.inner.dependencies.borrow().get(&observer),
|
||||
@@ -449,7 +461,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_detection_panics_with_structured_error() {
|
||||
fn cycle_detection_panics_with_expected() {
|
||||
let reactor = Reactor::new();
|
||||
let a = reactor.allocate_node();
|
||||
let b = reactor.allocate_node();
|
||||
@@ -457,15 +469,26 @@ mod tests {
|
||||
let panic = catch_unwind(AssertUnwindSafe(|| {
|
||||
reactor.run_in_context(a, || {
|
||||
reactor.observe(b);
|
||||
reactor.run_in_context(b, || reactor.observe(a));
|
||||
reactor.run_in_context(b, || {
|
||||
reactor.observe(a);
|
||||
});
|
||||
});
|
||||
}))
|
||||
.expect_err("cycle should panic");
|
||||
|
||||
let error = panic
|
||||
.downcast::<ReactCycleError>()
|
||||
.expect("panic payload should be ReactCycleError");
|
||||
assert_eq!(error.cycle(), &[a, b, a]);
|
||||
let Some(cycle_error) = panic.downcast_ref::<String>() else {
|
||||
panic!("panic should be a string");
|
||||
};
|
||||
|
||||
assert!(
|
||||
cycle_error.contains("reactive cycle detected"),
|
||||
"panic should indicate cycle detected"
|
||||
);
|
||||
|
||||
assert!(
|
||||
cycle_error.contains("1 -> 2 -> 1"),
|
||||
"panic should include cycle path"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
70
lib/reactivity/src/source.rs
Normal file
70
lib/reactivity/src/source.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
use alloc::boxed::Box;
|
||||
use alloc::rc::Rc;
|
||||
use core::cell::{Cell, RefCell};
|
||||
|
||||
use crate::reactor::ObserverHook;
|
||||
use crate::{NodeId, Reactor, current, trace_targets};
|
||||
@@ -428,9 +429,13 @@ mod tests {
|
||||
.expect_err("mutually recursive thunks should panic");
|
||||
|
||||
let error = panic
|
||||
.downcast::<crate::ReactCycleError>()
|
||||
.expect("panic payload should be ReactCycleError");
|
||||
assert_eq!(error.cycle().len(), 3);
|
||||
.downcast_ref::<String>()
|
||||
.expect("panic should be a string");
|
||||
|
||||
assert!(
|
||||
error.contains("reactive cycle detected"),
|
||||
"panic should indicate cycle detected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
13
lib/reactivity_parsing/Cargo.toml
Normal file
13
lib/reactivity_parsing/Cargo.toml
Normal 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" }
|
||||
745
lib/reactivity_parsing/examples/json_reactive_parser.rs
Normal file
745
lib/reactivity_parsing/examples/json_reactive_parser.rs
Normal file
@@ -0,0 +1,745 @@
|
||||
use std::env;
|
||||
use std::fmt;
|
||||
use std::rc::Rc;
|
||||
|
||||
use ruin_reactivity::{cell_in, effect_in};
|
||||
use ruin_reactivity_parsing::{
|
||||
Anchor, AnchorBias, EditDelta, ReactiveRope, RopeEdit, TextPoint, TextRange,
|
||||
};
|
||||
use ruin_runtime::{async_main, fs, stdio};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct ParseSnapshot {
|
||||
text: String,
|
||||
root_anchor: Anchor,
|
||||
root: AstNode,
|
||||
reused_nodes: usize,
|
||||
total_nodes: usize,
|
||||
diagnostics: Vec<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>()
|
||||
}
|
||||
207
lib/reactivity_parsing/examples/tokentree_reactive_parser.rs
Normal file
207
lib/reactivity_parsing/examples/tokentree_reactive_parser.rs
Normal file
@@ -0,0 +1,207 @@
|
||||
use std::env;
|
||||
use std::rc::Rc;
|
||||
|
||||
use ruin_reactivity::effect_in;
|
||||
use ruin_reactivity_parsing::{
|
||||
ReactiveRope, ReactiveTokenTree, RopeEdit, TextRange, TokenTreeNode, TokenTreeParse,
|
||||
TokenTreeUpdate,
|
||||
};
|
||||
use ruin_runtime::{async_main, fs, stdio};
|
||||
|
||||
enum Command {
|
||||
Insert { offset: usize, text: String },
|
||||
Replace { start: usize, end: usize, text: String },
|
||||
ReplaceAll { text: String },
|
||||
Delete { start: usize, end: usize },
|
||||
Print,
|
||||
Help,
|
||||
Quit,
|
||||
}
|
||||
|
||||
#[async_main]
|
||||
async fn main() {
|
||||
let path = env::args()
|
||||
.nth(1)
|
||||
.expect("usage: cargo run -p ruin-reactivity-parsing --example tokentree_reactive_parser -- <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);
|
||||
}
|
||||
}
|
||||
152
lib/reactivity_parsing/src/delta.rs
Normal file
152
lib/reactivity_parsing/src/delta.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
24
lib/reactivity_parsing/src/lib.rs
Normal file
24
lib/reactivity_parsing/src/lib.rs
Normal 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, ParserCheckpoint, ParseTree,
|
||||
SyntaxFragment,
|
||||
};
|
||||
pub use rope::{
|
||||
DEFAULT_LEAF_TARGET_BYTES, ReactiveRope, RopeBuilder, RopeChunk, RopeCursor,
|
||||
RopeSnapshotMetrics,
|
||||
};
|
||||
pub use tokentree::{
|
||||
DelimiterKind, NumberBase, NumberLiteral, NumberSuffix, StringDelimiter, StringFragment,
|
||||
ReactiveTokenTree, TokenTreeDiagnostic, TokenTreeDiagnosticKind, TokenTreeNode,
|
||||
TokenTreeNodeKind, TokenTreeParse, TokenTreeParser, TokenTreeStats, TokenTreeUpdate,
|
||||
TriviaComment, TriviaKind, TriviaStore,
|
||||
};
|
||||
139
lib/reactivity_parsing/src/metrics.rs
Normal file
139
lib/reactivity_parsing/src/metrics.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
162
lib/reactivity_parsing/src/parse.rs
Normal file
162
lib/reactivity_parsing/src/parse.rs
Normal file
@@ -0,0 +1,162 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
720
lib/reactivity_parsing/src/rope.rs
Normal file
720
lib/reactivity_parsing/src/rope.rs
Normal file
@@ -0,0 +1,720 @@
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::cmp::min;
|
||||
use std::collections::BTreeSet;
|
||||
use std::rc::Rc;
|
||||
|
||||
use ruin_reactivity::{Event, Reactor, Source, event_in, source_in};
|
||||
|
||||
use crate::delta::{EditDelta, RopeEdit, TextRange};
|
||||
use crate::metrics::{TextMetrics, TextPoint};
|
||||
|
||||
pub const DEFAULT_LEAF_TARGET_BYTES: usize = 1024;
|
||||
const DEFAULT_LEAF_MAX_BYTES: usize = DEFAULT_LEAF_TARGET_BYTES * 2;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ReactiveRope {
|
||||
inner: Rc<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())]);
|
||||
}
|
||||
}
|
||||
2050
lib/reactivity_parsing/src/tokentree.rs
Normal file
2050
lib/reactivity_parsing/src/tokentree.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
use ruin_app::prelude::*;
|
||||
use ruin_app::{PartialTextStyle, prelude::*};
|
||||
use ruin_ui::{BoxShadow, BoxShadowKind, Point};
|
||||
|
||||
#[ruin_runtime::async_main]
|
||||
async fn main() -> ruin_app::Result<()> {
|
||||
@@ -9,6 +10,8 @@ async fn main() -> ruin_app::Result<()> {
|
||||
Window::new()
|
||||
.title(title)
|
||||
.app_id("dev.ruin.counter")
|
||||
.base_color(Color::rgba(255, 255, 255, 80))
|
||||
.min_size(320.0, 240.0)
|
||||
.size(960.0, 640.0),
|
||||
)
|
||||
.mount(view! {
|
||||
@@ -31,9 +34,15 @@ fn CounterApp(title: &'static str) -> impl IntoView {
|
||||
move || format!("{title} ({})", count.get())
|
||||
});
|
||||
|
||||
let text_style = PartialTextStyle {
|
||||
color: Some(Color::BLACK),
|
||||
weight: Some(FontWeight::Bold),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
view! {
|
||||
column(gap = 16.0, padding = 24.0) {
|
||||
text(role = TextRole::Heading(1), size = 32.0, weight = FontWeight::Semibold) {
|
||||
column(gap = 16.0, padding = 24.0, align_items = AlignItems::Center, text_style = text_style) {
|
||||
text(role = TextRole::Heading(1), size = 32.0, weight = FontWeight::Semibold, color = Color::rgba(0, 0, 0, 255)) {
|
||||
title
|
||||
}
|
||||
|
||||
@@ -42,12 +51,14 @@ fn CounterApp(title: &'static str) -> impl IntoView {
|
||||
block(
|
||||
padding = 16.0,
|
||||
gap = 8.0,
|
||||
background = surfaces::raised(),
|
||||
align_self = AlignItems::Stretch,
|
||||
background = Color::rgba(255, 255, 255, 200),
|
||||
border_radius = 12.0,
|
||||
border = Border::new(2.0, Color::rgb(0, 0, 0))
|
||||
border = Border::new(2.0, Color::rgba(0, 0, 0, 120)),
|
||||
shadow = BoxShadow::new(Point::new(2.0, 2.0), 8.0, 2.0, Color::rgba(0, 0, 0, 60), BoxShadowKind::Outer),
|
||||
) {
|
||||
text(size = 18.0) { "count = "; count.clone() }
|
||||
text(color = colors::muted()) { "double = "; doubled.clone() }
|
||||
text(size = 18.0, color = Color::rgb(0, 0, 0)) { "count = "; count.clone() }
|
||||
text(color = Color::rgba(0, 0, 0, 200)) { "double = "; doubled.clone() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,8 +67,9 @@ fn CounterApp(title: &'static str) -> impl IntoView {
|
||||
#[component]
|
||||
fn CounterActions(count: Signal<i32>) -> impl IntoView {
|
||||
view! {
|
||||
row(gap = 8.0) {
|
||||
row(gap = 16.0) {
|
||||
button(
|
||||
background = Color::rgba(255, 255, 255, 90),
|
||||
on_press = {
|
||||
let count = count.clone();
|
||||
move |_| {
|
||||
@@ -65,10 +77,11 @@ fn CounterActions(count: Signal<i32>) -> impl IntoView {
|
||||
}
|
||||
},
|
||||
) {
|
||||
"-1"
|
||||
text(selectable = false, pointer_events = false) { "-1" }
|
||||
}
|
||||
|
||||
button(
|
||||
background = Color::rgba(255, 255, 255, 90),
|
||||
on_press = {
|
||||
let count = count.clone();
|
||||
move |_| {
|
||||
@@ -76,10 +89,11 @@ fn CounterActions(count: Signal<i32>) -> impl IntoView {
|
||||
}
|
||||
},
|
||||
) {
|
||||
"Reset"
|
||||
text(selectable = false, pointer_events = false) { "Reset" }
|
||||
}
|
||||
|
||||
button(
|
||||
background = Color::rgba(255, 255, 255, 90),
|
||||
on_press = {
|
||||
let count = count.clone();
|
||||
move |_| {
|
||||
@@ -87,7 +101,7 @@ fn CounterActions(count: Signal<i32>) -> impl IntoView {
|
||||
}
|
||||
},
|
||||
) {
|
||||
"+1"
|
||||
text(selectable = false, pointer_events = false) { "+1" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,7 @@ use tracing_subscriber::util::SubscriberInitExt;
|
||||
use tracing_subscriber::{EnvFilter, fmt};
|
||||
|
||||
fn install_tracing() {
|
||||
let filter = EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| EnvFilter::new("warn"));
|
||||
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn"));
|
||||
let fmt_layer = fmt::layer()
|
||||
.with_target(true)
|
||||
.with_thread_ids(true)
|
||||
|
||||
540
lib/ruin_app/src/app.rs
Normal file
540
lib/ruin_app/src/app.rs
Normal file
@@ -0,0 +1,540 @@
|
||||
use std::cell::Cell as StdCell;
|
||||
use std::cell::RefCell;
|
||||
use std::iter;
|
||||
use std::rc::Rc;
|
||||
use std::time::Instant;
|
||||
|
||||
use ruin_reactivity::effect;
|
||||
use ruin_ui::{
|
||||
Color, Element, InteractionTree, KeyboardEvent, LayoutCache, LayoutSnapshot, PlatformEvent,
|
||||
PointerButton, PointerEvent, PointerEventKind, PointerRouter, RoutedPointerEvent,
|
||||
RoutedPointerEventKind, TextSystem, UiSize, WindowController, WindowSpec, WindowUpdate,
|
||||
layout_snapshot_with_cache,
|
||||
};
|
||||
use ruin_ui_platform_wayland::start_wayland_ui;
|
||||
|
||||
use crate::Result;
|
||||
use crate::context::{
|
||||
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;
|
||||
|
||||
#[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: Rc::new(root),
|
||||
render_state: Rc::new(RenderState::default()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for App {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub trait Mountable: 'static {
|
||||
fn render(&self) -> View;
|
||||
}
|
||||
|
||||
pub trait Component: 'static {
|
||||
type Builder;
|
||||
|
||||
fn builder() -> Self::Builder;
|
||||
fn render(&self) -> View;
|
||||
}
|
||||
|
||||
pub trait ContextKey: 'static {
|
||||
type Value: Clone + 'static;
|
||||
}
|
||||
|
||||
impl<T: Component> Mountable for T {
|
||||
fn render(&self) -> View {
|
||||
Component::render(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl Mountable for View {
|
||||
fn render(&self) -> View {
|
||||
self.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Mountable for Element {
|
||||
fn render(&self) -> View {
|
||||
View::from_element(self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MountedApp<M: Mountable> {
|
||||
pub(crate) window: Window,
|
||||
pub(crate) root: Rc<M>,
|
||||
pub(crate) render_state: Rc<RenderState>,
|
||||
}
|
||||
|
||||
impl<M: Mountable> MountedApp<M> {
|
||||
pub async fn run(self) -> Result<()> {
|
||||
let MountedApp {
|
||||
window: app_window,
|
||||
root,
|
||||
render_state,
|
||||
} = self;
|
||||
|
||||
let mut ui = start_wayland_ui();
|
||||
let window = ui.create_window(app_window.spec.clone())?;
|
||||
let initial_viewport = app_window
|
||||
.spec
|
||||
.requested_inner_size
|
||||
.unwrap_or(UiSize::new(960.0, 640.0));
|
||||
|
||||
let viewport = ruin_reactivity::cell(initial_viewport);
|
||||
let scene_version = StdCell::new(0_u64);
|
||||
let text_system = Rc::new(RefCell::new(TextSystem::new()));
|
||||
let layout_cache = Rc::new(RefCell::new(LayoutCache::new()));
|
||||
let interaction_tree = Rc::new(RefCell::new(None::<InteractionTree>));
|
||||
let bindings = Rc::new(RefCell::new(EventBindings::default()));
|
||||
let shortcuts = Rc::new(RefCell::new(Vec::<ShortcutBinding>::new()));
|
||||
let current_title = Rc::new(RefCell::new(None::<String>));
|
||||
let last_min_size = Rc::new(RefCell::new(None::<UiSize>));
|
||||
let 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 text_system = Rc::clone(&text_system);
|
||||
let layout_cache = Rc::clone(&layout_cache);
|
||||
let interaction_tree = Rc::clone(&interaction_tree);
|
||||
let bindings = Rc::clone(&bindings);
|
||||
let shortcuts = Rc::clone(&shortcuts);
|
||||
let current_title = Rc::clone(¤t_title);
|
||||
let last_min_size = Rc::clone(&last_min_size);
|
||||
let last_fixed_size = Rc::clone(&last_fixed_size);
|
||||
let root = Rc::clone(&root);
|
||||
let render_state = Rc::clone(&render_state);
|
||||
let text_selection = Rc::clone(&input_state.text_selection);
|
||||
let hovered_elements = input_state.hovered_elements.clone();
|
||||
let pressed_elements = input_state.pressed_elements.clone();
|
||||
let interaction_version = input_state.interaction_version.clone();
|
||||
move || {
|
||||
let viewport = viewport.get();
|
||||
let version = scene_version.get().wrapping_add(1);
|
||||
scene_version.set(version);
|
||||
let _ = text_selection.version.get();
|
||||
let _ = interaction_version.get();
|
||||
|
||||
let t_effect = Instant::now();
|
||||
let interaction_snapshot = Rc::new(InteractionSnapshot {
|
||||
hovered: hovered_elements.get(),
|
||||
pressed: pressed_elements.get(),
|
||||
});
|
||||
|
||||
let render_output = render_with_context_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;
|
||||
};
|
||||
|
||||
for event in iter::once(event).chain(ui.take_pending_events()) {
|
||||
match event {
|
||||
PlatformEvent::Configured {
|
||||
window_id,
|
||||
configuration,
|
||||
} if window_id == window.id() => {
|
||||
tracing::debug!(
|
||||
target: "ruin_app::resize",
|
||||
width = configuration.actual_inner_size.width,
|
||||
height = configuration.actual_inner_size.height,
|
||||
"app received Configured, queuing layout effect"
|
||||
);
|
||||
let _ = viewport.set(configuration.actual_inner_size);
|
||||
}
|
||||
PlatformEvent::Pointer { window_id, event } if window_id == window.id() => {
|
||||
Self::handle_pointer_event(
|
||||
&window,
|
||||
&interaction_tree,
|
||||
&bindings,
|
||||
&mut pointer_router,
|
||||
&mut input_state,
|
||||
event,
|
||||
)?;
|
||||
}
|
||||
PlatformEvent::Keyboard { window_id, event } if window_id == window.id() => {
|
||||
Self::handle_keyboard_event(
|
||||
&interaction_tree,
|
||||
&bindings,
|
||||
&shortcuts,
|
||||
&input_state,
|
||||
event,
|
||||
)?;
|
||||
}
|
||||
PlatformEvent::CloseRequested { window_id } if window_id == window.id() => {
|
||||
let _ = window.update(WindowUpdate::new().open(false));
|
||||
}
|
||||
PlatformEvent::Closed { window_id } if window_id == window.id() => {
|
||||
ui.shutdown()?;
|
||||
return Ok(());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ui.shutdown()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn handle_pointer_event(
|
||||
window: &WindowController,
|
||||
interaction_tree: &RefCell<Option<InteractionTree>>,
|
||||
bindings: &RefCell<EventBindings>,
|
||||
pointer_router: &mut PointerRouter,
|
||||
input_state: &mut InputState,
|
||||
event: PointerEvent,
|
||||
) -> Result<()> {
|
||||
if matches!(
|
||||
event.kind,
|
||||
PointerEventKind::Down {
|
||||
button: PointerButton::Primary | PointerButton::Middle,
|
||||
}
|
||||
) {
|
||||
let interaction_tree = interaction_tree.borrow();
|
||||
if let Some(interaction_tree) = interaction_tree.as_ref() {
|
||||
input_state.focused_element = focused_element_for_pointer(interaction_tree, &event);
|
||||
}
|
||||
}
|
||||
|
||||
let routed = {
|
||||
let interaction_tree = interaction_tree.borrow();
|
||||
let Some(interaction_tree) = interaction_tree.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
pointer_router.route(interaction_tree, event)
|
||||
};
|
||||
|
||||
{
|
||||
let interaction_tree = interaction_tree.borrow();
|
||||
let Some(interaction_tree) = interaction_tree.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
let hovered_targets = pointer_router.hovered_targets();
|
||||
for routed_event in &routed {
|
||||
bindings
|
||||
.borrow()
|
||||
.dispatch(routed_event, interaction_tree, hovered_targets);
|
||||
Self::handle_text_selection_event(
|
||||
window,
|
||||
interaction_tree,
|
||||
routed_event,
|
||||
&input_state.text_selection,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
let next_cursor = pointer_router
|
||||
.hovered_targets()
|
||||
.last()
|
||||
.map(|target| target.cursor)
|
||||
.unwrap_or(ruin_ui::CursorIcon::Default);
|
||||
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
|
||||
}
|
||||
13
lib/ruin_app/src/colors.rs
Normal file
13
lib/ruin_app/src/colors.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
use ruin_ui::Color;
|
||||
|
||||
pub const fn text() -> Color {
|
||||
Color::rgb(0xFF, 0xFF, 0xFF)
|
||||
}
|
||||
|
||||
pub const fn muted() -> Color {
|
||||
Color::rgb(0xB6, 0xC2, 0xD9)
|
||||
}
|
||||
|
||||
pub const fn danger() -> Color {
|
||||
Color::rgb(0xFF, 0x7B, 0x72)
|
||||
}
|
||||
204
lib/ruin_app/src/context.rs
Normal file
204
lib/ruin_app/src/context.rs
Normal file
@@ -0,0 +1,204 @@
|
||||
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())()
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
43
lib/ruin_app/src/converters.rs
Normal file
43
lib/ruin_app/src/converters.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
use ruin_ui::{Border, BoxShadow, Color, Edges};
|
||||
|
||||
pub trait IntoEdges {
|
||||
fn into_edges(self) -> Edges;
|
||||
}
|
||||
|
||||
impl IntoEdges for Edges {
|
||||
fn into_edges(self) -> Edges {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoEdges for f32 {
|
||||
fn into_edges(self) -> Edges {
|
||||
Edges::all(self)
|
||||
}
|
||||
}
|
||||
|
||||
pub trait IntoBorder {
|
||||
fn into_border(self) -> Border;
|
||||
}
|
||||
|
||||
impl IntoBorder for Border {
|
||||
fn into_border(self) -> Border {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoBorder for (f32, Color) {
|
||||
fn into_border(self) -> Border {
|
||||
Border::new(self.0, self.1)
|
||||
}
|
||||
}
|
||||
|
||||
pub trait IntoShadow {
|
||||
fn into_shadow(self) -> BoxShadow;
|
||||
}
|
||||
|
||||
impl IntoShadow for BoxShadow {
|
||||
fn into_shadow(self) -> BoxShadow {
|
||||
self
|
||||
}
|
||||
}
|
||||
416
lib/ruin_app/src/hooks.rs
Normal file
416
lib/ruin_app/src/hooks.rs
Normal file
@@ -0,0 +1,416 @@
|
||||
use std::any::type_name;
|
||||
use std::any::TypeId;
|
||||
use std::cell::Cell as StdCell;
|
||||
use std::future::Future;
|
||||
use std::marker::PhantomData;
|
||||
use std::rc::Rc;
|
||||
|
||||
use ruin_ui::{ElementId, InteractionTree, KeyboardEvent, KeyboardEventKind, KeyboardKey};
|
||||
|
||||
use crate::ContextKey;
|
||||
use crate::context::{
|
||||
ContextEntry, MemoSlot, ResourceSlot, with_hook_slot,
|
||||
with_render_context, with_render_context_state,
|
||||
};
|
||||
use crate::input::ShortcutBinding;
|
||||
|
||||
pub struct SignalInner<T> {
|
||||
pub(crate) cell: ruin_reactivity::Cell<T>,
|
||||
}
|
||||
|
||||
pub struct Signal<T> {
|
||||
pub(crate) inner: Rc<SignalInner<T>>,
|
||||
}
|
||||
|
||||
impl<T> Clone for Signal<T> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
inner: Rc::clone(&self.inner),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: 'static> Signal<T> {
|
||||
pub(crate) fn new(initial: T) -> Self {
|
||||
Self {
|
||||
inner: Rc::new(SignalInner {
|
||||
cell: ruin_reactivity::cell(initial),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with<R>(&self, f: impl FnOnce(&T) -> R) -> R {
|
||||
self.inner.cell.with(f)
|
||||
}
|
||||
|
||||
pub fn replace(&self, value: T) -> T {
|
||||
self.inner.cell.replace(value)
|
||||
}
|
||||
|
||||
pub fn update<R>(&self, f: impl FnOnce(&mut T) -> R) -> R {
|
||||
self.inner.cell.update(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Clone + 'static> Signal<T> {
|
||||
pub fn get(&self) -> T {
|
||||
self.inner.cell.get()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: PartialEq + 'static> Signal<T> {
|
||||
pub fn set(&self, value: T) -> Option<T> {
|
||||
self.inner.cell.set(value)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Memo<T> {
|
||||
pub(crate) compute: Rc<dyn Fn() -> T>,
|
||||
}
|
||||
|
||||
impl<T> Clone for Memo<T> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
compute: Rc::clone(&self.compute),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Memo<T> {
|
||||
pub fn with<R>(&self, f: impl FnOnce(&T) -> R) -> R {
|
||||
let value = (self.compute)();
|
||||
f(&value)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Clone> Memo<T> {
|
||||
pub fn get(&self) -> T {
|
||||
(self.compute)()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn use_signal<T: 'static>(initial: impl FnOnce() -> T) -> Signal<T> {
|
||||
with_hook_slot(|| Signal::new(initial()), |signal| signal.clone())
|
||||
}
|
||||
|
||||
pub fn use_context<C: ContextKey>() -> C::Value {
|
||||
with_render_context_state(|context| {
|
||||
context
|
||||
.context_entries
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|entry| {
|
||||
(entry.key == TypeId::of::<C>())
|
||||
.then(|| entry.value.downcast_ref::<C::Value>())
|
||||
.flatten()
|
||||
.cloned()
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"missing context provider for {} while rendering",
|
||||
type_name::<C>()
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn provide<C: ContextKey>(value: C::Value, render: impl FnOnce() -> crate::view::View) -> crate::view::View {
|
||||
with_render_context_state(|context| {
|
||||
let mut context_entries = (*context.context_entries).clone();
|
||||
context_entries.push(ContextEntry::new::<C>(value));
|
||||
with_render_context(
|
||||
context.with_context_entries(Rc::new(context_entries)),
|
||||
render,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn use_memo<T: 'static>(compute: impl Fn() -> T + 'static) -> Memo<T> {
|
||||
use std::cell::RefCell;
|
||||
let compute: Rc<RefCell<Box<dyn Fn() -> T>>> =
|
||||
Rc::new(RefCell::new(Box::new(compute) as Box<dyn Fn() -> T>));
|
||||
with_hook_slot(
|
||||
{
|
||||
let compute = Rc::clone(&compute);
|
||||
move || MemoSlot::new(compute)
|
||||
},
|
||||
|slot: &mut MemoSlot<T>| {
|
||||
slot.replace_compute(Rc::clone(&compute));
|
||||
slot.handle.clone()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn use_effect(effect: impl Fn() + 'static) {
|
||||
with_hook_slot(|| ruin_reactivity::effect(effect), |_| ());
|
||||
}
|
||||
|
||||
pub fn use_window_title(compute: impl FnOnce() -> String) {
|
||||
with_render_context_state(|context| {
|
||||
context.side_effects.borrow_mut().window_title = Some(compute());
|
||||
});
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum Key {
|
||||
Character(char),
|
||||
Enter,
|
||||
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(),
|
||||
)
|
||||
}
|
||||
350
lib/ruin_app/src/input.rs
Normal file
350
lib/ruin_app/src/input.rs
Normal file
@@ -0,0 +1,350 @@
|
||||
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<()> {
|
||||
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
69
lib/ruin_app/src/primitives/button.rs
Normal file
69
lib/ruin_app/src/primitives/button.rs
Normal 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::text_style::{pop_text_style, push_text_style};
|
||||
use crate::view::{Children, View};
|
||||
use crate::surfaces;
|
||||
|
||||
pub struct ButtonBuilder {
|
||||
pub(crate) props: ContainerProps,
|
||||
pub(crate) on_press: Option<PressHandler>,
|
||||
}
|
||||
|
||||
pub fn button() -> ButtonBuilder {
|
||||
ButtonBuilder {
|
||||
props: ContainerProps::new(
|
||||
Element::column()
|
||||
.padding(Edges::symmetric(14.0, 10.0))
|
||||
.background(surfaces::interactive())
|
||||
.corner_radius(10.0)
|
||||
.cursor(CursorIcon::Pointer)
|
||||
.focusable(true),
|
||||
),
|
||||
on_press: None,
|
||||
}
|
||||
}
|
||||
|
||||
impl ButtonBuilder {
|
||||
impl_props_methods!();
|
||||
|
||||
pub fn 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
157
lib/ruin_app/src/primitives/container.rs
Normal file
157
lib/ruin_app/src/primitives/container.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
71
lib/ruin_app/src/primitives/mod.rs
Normal file
71
lib/ruin_app/src/primitives/mod.rs
Normal 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};
|
||||
160
lib/ruin_app/src/primitives/scroll_box.rs
Normal file
160
lib/ruin_app/src/primitives/scroll_box.rs
Normal file
@@ -0,0 +1,160 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use ruin_ui::{Element, KeyboardEventKind, PointerButton, RoutedPointerEventKind, ScrollbarStyle};
|
||||
|
||||
use crate::context::{allocate_element_id, with_hook_slot};
|
||||
use crate::hooks::Signal;
|
||||
use crate::input::{ScrollbarDrag, clamp_scroll_offset};
|
||||
use crate::primitives::ContainerProps;
|
||||
use crate::text_style::{pop_text_style, push_text_style};
|
||||
use crate::view::{Children, View};
|
||||
|
||||
pub struct ScrollBoxBuilder {
|
||||
pub(crate) props: ContainerProps,
|
||||
pub(crate) offset_y: Option<Signal<f32>>,
|
||||
pub(crate) drag: Signal<Option<ScrollbarDrag>>,
|
||||
}
|
||||
|
||||
pub fn scroll_box() -> ScrollBoxBuilder {
|
||||
ScrollBoxBuilder {
|
||||
props: ContainerProps::new(Element::scroll_box(0.0)),
|
||||
offset_y: None,
|
||||
drag: with_hook_slot(|| Signal::new(None::<ScrollbarDrag>), |drag| drag.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
impl ScrollBoxBuilder {
|
||||
impl_props_methods!();
|
||||
|
||||
pub fn text_style(mut self, style: crate::text_style::PartialTextStyle) -> Self {
|
||||
self.props = self.props.text_style(style);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn scrollbar_style(mut self, style: ScrollbarStyle) -> Self {
|
||||
self.props.element = self.props.element.scrollbar_style(style);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn offset_y(mut self, offset_y: Signal<f32>) -> Self {
|
||||
self.props.element = self.props.element.scroll_offset(offset_y.get());
|
||||
self.offset_y = Some(offset_y);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn children(mut self, children: impl Children) -> View {
|
||||
let element_id = allocate_element_id();
|
||||
self.props.element = self.props.element.id(element_id);
|
||||
if let Some(widget_ref) = &self.props.widget_ref {
|
||||
let _ = widget_ref.set(Some(element_id));
|
||||
}
|
||||
|
||||
let had_style = self.props.partial_text_style.is_some();
|
||||
if let Some(style) = self.props.partial_text_style.take() {
|
||||
push_text_style(style);
|
||||
}
|
||||
let children_views = children.into_views();
|
||||
if had_style {
|
||||
pop_text_style();
|
||||
}
|
||||
let mut view = View::from_container(self.props.element, children_views);
|
||||
if let Some(offset_y) = self.offset_y {
|
||||
let drag = self.drag;
|
||||
let offset_y_for_pointer = offset_y.clone();
|
||||
let offset_y_for_keys = offset_y.clone();
|
||||
view = view.with_scroll_handler(
|
||||
element_id,
|
||||
Rc::new(move |event, interaction_tree| {
|
||||
let Some(metrics) = interaction_tree.scroll_metrics_for_element(element_id)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
match event.kind {
|
||||
RoutedPointerEventKind::Scroll { delta } => {
|
||||
offset_y_for_pointer.update(|value| {
|
||||
*value =
|
||||
clamp_scroll_offset(*value + delta.y, metrics.max_offset_y);
|
||||
});
|
||||
}
|
||||
RoutedPointerEventKind::Down {
|
||||
button: PointerButton::Primary,
|
||||
} => {
|
||||
let Some(thumb_rect) = metrics.scrollbar_thumb else {
|
||||
return;
|
||||
};
|
||||
if !thumb_rect.contains(event.position) {
|
||||
return;
|
||||
}
|
||||
let _ = drag.set(Some(ScrollbarDrag {
|
||||
start_pointer_y: event.position.y,
|
||||
start_offset_y: offset_y_for_pointer.get(),
|
||||
}));
|
||||
}
|
||||
RoutedPointerEventKind::Move => {
|
||||
let Some(drag_state) = drag.get() else {
|
||||
return;
|
||||
};
|
||||
let Some(track_rect) = metrics.scrollbar_track else {
|
||||
return;
|
||||
};
|
||||
let Some(thumb_rect) = metrics.scrollbar_thumb else {
|
||||
return;
|
||||
};
|
||||
let thumb_travel =
|
||||
(track_rect.size.height - thumb_rect.size.height).max(0.0);
|
||||
if thumb_travel <= 0.0 || metrics.max_offset_y <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let pointer_delta = event.position.y - drag_state.start_pointer_y;
|
||||
let next_offset = drag_state.start_offset_y
|
||||
+ pointer_delta * (metrics.max_offset_y / thumb_travel);
|
||||
offset_y_for_pointer.update(|value| {
|
||||
*value = clamp_scroll_offset(next_offset, metrics.max_offset_y);
|
||||
});
|
||||
}
|
||||
RoutedPointerEventKind::Up {
|
||||
button: PointerButton::Primary,
|
||||
} => {
|
||||
let _ = drag.set(None);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}),
|
||||
);
|
||||
view = view.with_key_handler(
|
||||
element_id,
|
||||
Rc::new(move |event, interaction_tree| {
|
||||
if event.kind != KeyboardEventKind::Pressed
|
||||
|| event.modifiers.control
|
||||
|| event.modifiers.alt
|
||||
|| event.modifiers.super_key
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(metrics) = interaction_tree.scroll_metrics_for_element(element_id)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let line_step = (metrics.viewport_rect.size.height * 0.12).clamp(28.0, 52.0);
|
||||
let next_offset = match event.key {
|
||||
ruin_ui::KeyboardKey::ArrowUp => offset_y_for_keys.get() - line_step,
|
||||
ruin_ui::KeyboardKey::ArrowDown => offset_y_for_keys.get() + line_step,
|
||||
ruin_ui::KeyboardKey::Home => 0.0,
|
||||
ruin_ui::KeyboardKey::End => metrics.max_offset_y,
|
||||
_ => return false,
|
||||
};
|
||||
let next_offset = clamp_scroll_offset(next_offset, metrics.max_offset_y);
|
||||
let changed = (offset_y_for_keys.get() - next_offset).abs() > f32::EPSILON;
|
||||
if changed {
|
||||
let _ = offset_y_for_keys.set(next_offset);
|
||||
}
|
||||
changed
|
||||
}),
|
||||
);
|
||||
}
|
||||
view
|
||||
}
|
||||
}
|
||||
129
lib/ruin_app/src/primitives/text.rs
Normal file
129
lib/ruin_app/src/primitives/text.rs
Normal file
@@ -0,0 +1,129 @@
|
||||
use ruin_ui::{Color, Element, TextFontFamily, TextSpan, TextSpanWeight, TextStyle, TextWrap};
|
||||
|
||||
use crate::colors;
|
||||
use crate::context::allocate_element_id;
|
||||
use crate::text_style::current_text_style;
|
||||
use crate::view::{TextChildren, View};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum TextRole {
|
||||
Body,
|
||||
Heading(u8),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum FontWeight {
|
||||
Normal,
|
||||
Medium,
|
||||
Semibold,
|
||||
Bold,
|
||||
}
|
||||
|
||||
impl FontWeight {
|
||||
pub(crate) const fn to_text_span_weight(self) -> TextSpanWeight {
|
||||
match self {
|
||||
Self::Normal => TextSpanWeight::Normal,
|
||||
Self::Medium => TextSpanWeight::Medium,
|
||||
Self::Semibold => TextSpanWeight::Semibold,
|
||||
Self::Bold => TextSpanWeight::Bold,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct TextBuilder {
|
||||
pub(crate) role: Option<TextRole>,
|
||||
pub(crate) size: Option<f32>,
|
||||
pub(crate) weight: Option<FontWeight>,
|
||||
pub(crate) color: Option<Color>,
|
||||
pub(crate) font_family: Option<TextFontFamily>,
|
||||
pub(crate) wrap: Option<TextWrap>,
|
||||
pub(crate) selectable: Option<bool>,
|
||||
pub(crate) pointer_events: Option<bool>,
|
||||
}
|
||||
|
||||
pub fn text() -> TextBuilder {
|
||||
TextBuilder::default()
|
||||
}
|
||||
|
||||
impl TextBuilder {
|
||||
pub fn role(mut self, role: TextRole) -> Self {
|
||||
self.role = Some(role);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn size(mut self, size: f32) -> Self {
|
||||
self.size = Some(size);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn weight(mut self, weight: FontWeight) -> Self {
|
||||
self.weight = Some(weight);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn color(mut self, color: Color) -> Self {
|
||||
self.color = Some(color);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn font_family(mut self, family: TextFontFamily) -> Self {
|
||||
self.font_family = Some(family);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn wrap(mut self, wrap: TextWrap) -> Self {
|
||||
self.wrap = Some(wrap);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn selectable(mut self, selectable: bool) -> Self {
|
||||
self.selectable = Some(selectable);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn pointer_events(mut self, pointer_events: bool) -> Self {
|
||||
self.pointer_events = Some(pointer_events);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn children(self, children: impl TextChildren) -> View {
|
||||
let inherited = current_text_style();
|
||||
|
||||
let role = self.role.unwrap_or(TextRole::Body);
|
||||
let size = self.size.or(inherited.size).unwrap_or(match role {
|
||||
TextRole::Body => 18.0,
|
||||
TextRole::Heading(1) => 32.0,
|
||||
TextRole::Heading(2) => 28.0,
|
||||
TextRole::Heading(_) => 24.0,
|
||||
});
|
||||
let color = self.color.or(inherited.color).unwrap_or(colors::text());
|
||||
let weight = self.weight.or(inherited.weight).unwrap_or(match role {
|
||||
TextRole::Body => FontWeight::Normal,
|
||||
TextRole::Heading(_) => FontWeight::Semibold,
|
||||
});
|
||||
let wrap = self.wrap.or(inherited.wrap).unwrap_or(TextWrap::None);
|
||||
let line_height = inherited.line_height.unwrap_or(size * 1.2);
|
||||
|
||||
let mut style = TextStyle::new(size, color)
|
||||
.with_line_height(line_height)
|
||||
.with_wrap(wrap);
|
||||
|
||||
let font_family = self.font_family.or(inherited.font_family);
|
||||
if let Some(font_family) = font_family {
|
||||
style = style.with_font_family(font_family);
|
||||
}
|
||||
|
||||
let span = TextSpan::new(children.into_text())
|
||||
.color(color)
|
||||
.weight(weight.to_text_span_weight());
|
||||
View::from_element(
|
||||
Element::spans(
|
||||
[span],
|
||||
style.with_selectable(self.selectable.unwrap_or(true)),
|
||||
)
|
||||
.pointer_events(self.pointer_events.unwrap_or(true))
|
||||
.id(allocate_element_id()),
|
||||
)
|
||||
}
|
||||
}
|
||||
17
lib/ruin_app/src/surfaces.rs
Normal file
17
lib/ruin_app/src/surfaces.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
use ruin_ui::Color;
|
||||
|
||||
pub const fn canvas() -> Color {
|
||||
Color::rgb(0x0F, 0x16, 0x25)
|
||||
}
|
||||
|
||||
pub const fn raised() -> Color {
|
||||
Color::rgb(0x1B, 0x26, 0x3D)
|
||||
}
|
||||
|
||||
pub const fn interactive() -> Color {
|
||||
Color::rgb(0x2B, 0x3A, 0x67)
|
||||
}
|
||||
|
||||
pub const fn interactive_muted() -> Color {
|
||||
Color::rgb(0x3D, 0x4B, 0x72)
|
||||
}
|
||||
61
lib/ruin_app/src/text_style.rs
Normal file
61
lib/ruin_app/src/text_style.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
use std::cell::RefCell;
|
||||
|
||||
use ruin_ui::{Color, TextFontFamily, TextWrap};
|
||||
|
||||
use crate::primitives::FontWeight;
|
||||
|
||||
/// Partially-specified text style for context-based inheritance.
|
||||
///
|
||||
/// Set on a container via `.text_style(...)`. All descendant `text()` nodes inherit
|
||||
/// any fields that are `Some`, falling back to their own defaults for fields that are `None`.
|
||||
/// Inner (closer) ancestors take precedence over outer ancestors.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct PartialTextStyle {
|
||||
pub size: Option<f32>,
|
||||
pub color: Option<Color>,
|
||||
pub weight: Option<FontWeight>,
|
||||
pub font_family: Option<TextFontFamily>,
|
||||
pub wrap: Option<TextWrap>,
|
||||
pub line_height: Option<f32>,
|
||||
}
|
||||
|
||||
impl PartialTextStyle {
|
||||
/// Merge `other` on top of `self` — `other`'s `Some` fields win.
|
||||
fn merge_over(self, other: &PartialTextStyle) -> Self {
|
||||
Self {
|
||||
size: other.size.or(self.size),
|
||||
color: other.color.or(self.color),
|
||||
weight: other.weight.or(self.weight),
|
||||
font_family: other.font_family.clone().or(self.font_family),
|
||||
wrap: other.wrap.or(self.wrap),
|
||||
line_height: other.line_height.or(self.line_height),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static TEXT_STYLE_STACK: RefCell<Vec<PartialTextStyle>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
/// Push a partial style onto the context stack. Must be paired with `pop_text_style`.
|
||||
pub(crate) fn push_text_style(style: PartialTextStyle) {
|
||||
TEXT_STYLE_STACK.with(|stack| stack.borrow_mut().push(style));
|
||||
}
|
||||
|
||||
/// Pop the top partial style from the context stack.
|
||||
pub(crate) fn pop_text_style() {
|
||||
TEXT_STYLE_STACK.with(|stack| {
|
||||
stack.borrow_mut().pop();
|
||||
});
|
||||
}
|
||||
|
||||
/// Returns the effective `PartialTextStyle` at the current render position by merging
|
||||
/// all layers from outermost to innermost (innermost wins).
|
||||
pub(crate) fn current_text_style() -> PartialTextStyle {
|
||||
TEXT_STYLE_STACK.with(|stack| {
|
||||
stack
|
||||
.borrow()
|
||||
.iter()
|
||||
.fold(PartialTextStyle::default(), |acc, layer| acc.merge_over(layer))
|
||||
})
|
||||
}
|
||||
239
lib/ruin_app/src/view.rs
Normal file
239
lib/ruin_app/src/view.rs
Normal file
@@ -0,0 +1,239 @@
|
||||
use ruin_ui::Element;
|
||||
|
||||
use crate::Component;
|
||||
use crate::input::EventBindings;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct View {
|
||||
pub(crate) element: Element,
|
||||
pub(crate) bindings: EventBindings,
|
||||
}
|
||||
|
||||
impl View {
|
||||
pub fn from_element(element: Element) -> Self {
|
||||
Self {
|
||||
element,
|
||||
bindings: EventBindings::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn element(&self) -> &Element {
|
||||
&self.element
|
||||
}
|
||||
|
||||
pub fn from_container(element: Element, children: Vec<View>) -> Self {
|
||||
let mut composed = Self::from_element(element);
|
||||
let mut element = composed.element;
|
||||
|
||||
for child in children {
|
||||
element = element.child(child.element);
|
||||
composed.bindings.extend(child.bindings);
|
||||
}
|
||||
|
||||
composed.element = element;
|
||||
composed
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Element> for View {
|
||||
fn from(element: Element) -> Self {
|
||||
Self::from_element(element)
|
||||
}
|
||||
}
|
||||
|
||||
pub trait IntoView {
|
||||
fn into_view(self) -> View;
|
||||
}
|
||||
|
||||
impl IntoView for View {
|
||||
fn into_view(self) -> View {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoView for Element {
|
||||
fn into_view(self) -> View {
|
||||
View::from_element(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Component> IntoView for T {
|
||||
fn into_view(self) -> View {
|
||||
self.render()
|
||||
}
|
||||
}
|
||||
|
||||
pub trait Children {
|
||||
fn into_views(self) -> Vec<View>;
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ChildViews(pub(crate) Vec<View>);
|
||||
|
||||
impl ChildViews {
|
||||
pub fn from_children(children: impl Children) -> Self {
|
||||
Self(children.into_views())
|
||||
}
|
||||
|
||||
pub fn into_vec(self) -> Vec<View> {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Children for () {
|
||||
fn into_views(self) -> Vec<View> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
impl<T: IntoView> Children for T {
|
||||
fn into_views(self) -> Vec<View> {
|
||||
vec![self.into_view()]
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: IntoView> Children for Vec<T> {
|
||||
fn into_views(self) -> Vec<View> {
|
||||
self.into_iter().map(IntoView::into_view).collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Children for ChildViews {
|
||||
fn into_views(self) -> Vec<View> {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps a lazy closure so it can be used as container children.
|
||||
///
|
||||
/// The closure is called exactly once, inside the builder's `.children()` method,
|
||||
/// after any `PartialTextStyle` context has been pushed. This enables text style
|
||||
/// inheritance from ancestor containers.
|
||||
///
|
||||
/// The `view!` macro automatically generates `LazyChildren(|| vec![...])` for
|
||||
/// primitive container nodes so that style context propagates correctly.
|
||||
pub struct LazyChildren<F: FnOnce() -> Vec<View>>(pub F);
|
||||
|
||||
impl<F: FnOnce() -> Vec<View>> Children for LazyChildren<F> {
|
||||
fn into_views(self) -> Vec<View> {
|
||||
(self.0)()
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! impl_children_tuple {
|
||||
($($name:ident),+ $(,)?) => {
|
||||
#[allow(non_camel_case_types)]
|
||||
impl<$($name: IntoView),+> Children for ($($name,)+) {
|
||||
fn into_views(self) -> Vec<View> {
|
||||
let ($($name,)+) = self;
|
||||
vec![$($name.into_view(),)+]
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
impl_children_tuple!(a, b);
|
||||
impl_children_tuple!(a, b, c);
|
||||
impl_children_tuple!(a, b, c, d);
|
||||
impl_children_tuple!(a, b, c, d, e);
|
||||
impl_children_tuple!(a, b, c, d, e, f);
|
||||
impl_children_tuple!(a, b, c, d, e, f, g);
|
||||
impl_children_tuple!(a, b, c, d, e, f, g, h);
|
||||
|
||||
pub trait TextChildren {
|
||||
fn into_text(self) -> String;
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct TextValue(pub(crate) String);
|
||||
|
||||
impl TextValue {
|
||||
pub fn from_text(children: impl TextChildren) -> Self {
|
||||
Self(children.into_text())
|
||||
}
|
||||
|
||||
pub fn into_string(self) -> String {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl TextChildren for TextValue {
|
||||
fn into_text(self) -> String {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl TextChildren for &'static str {
|
||||
fn into_text(self) -> String {
|
||||
self.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl TextChildren for String {
|
||||
fn into_text(self) -> String {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl TextChildren for i32 {
|
||||
fn into_text(self) -> String {
|
||||
self.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl TextChildren for i64 {
|
||||
fn into_text(self) -> String {
|
||||
self.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl TextChildren for usize {
|
||||
fn into_text(self) -> String {
|
||||
self.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl TextChildren for u64 {
|
||||
fn into_text(self) -> String {
|
||||
self.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl TextChildren for f32 {
|
||||
fn into_text(self) -> String {
|
||||
self.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: std::fmt::Display + 'static> TextChildren for crate::hooks::Signal<T> {
|
||||
fn into_text(self) -> String {
|
||||
self.with(|value| value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: std::fmt::Display + 'static> TextChildren for crate::hooks::Memo<T> {
|
||||
fn into_text(self) -> String {
|
||||
self.with(|value| value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! impl_text_children_tuple {
|
||||
($($name:ident),+ $(,)?) => {
|
||||
#[allow(non_camel_case_types)]
|
||||
impl<$($name: TextChildren),+> TextChildren for ($($name,)+) {
|
||||
fn into_text(self) -> String {
|
||||
let ($($name,)+) = self;
|
||||
let mut text = String::new();
|
||||
$(text.push_str(&$name.into_text());)+
|
||||
text
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
impl_text_children_tuple!(a, b);
|
||||
impl_text_children_tuple!(a, b, c);
|
||||
impl_text_children_tuple!(a, b, c, d);
|
||||
impl_text_children_tuple!(a, b, c, d, e);
|
||||
impl_text_children_tuple!(a, b, c, d, e, f);
|
||||
@@ -686,22 +686,48 @@ fn expand_node(node: &Node) -> proc_macro2::TokenStream {
|
||||
let value = &property.value;
|
||||
quote! { .#name(#value) }
|
||||
});
|
||||
let children = expand_children(&node.children);
|
||||
|
||||
if is_component_path(path) {
|
||||
// Component nodes: eager children so #[component] builder impls keep working.
|
||||
let children = expand_children(&node.children);
|
||||
quote! {
|
||||
#path::builder()
|
||||
#(#prop_calls)*
|
||||
.children(#children)
|
||||
}
|
||||
} else {
|
||||
} else if is_text_path(path) {
|
||||
// text(): children are TextChildren (string/value), not Views — keep eager.
|
||||
let children = expand_children(&node.children);
|
||||
quote! {
|
||||
::ruin_app::#path()
|
||||
#(#prop_calls)*
|
||||
.children(#children)
|
||||
}
|
||||
} else {
|
||||
// Primitive container nodes (column, row, block, button, scroll_box, …):
|
||||
// Wrap children in a LazyChildren closure so that PartialTextStyle context is
|
||||
// pushed BEFORE children run, enabling correct text style inheritance.
|
||||
//
|
||||
// Each child is spread via Children::into_views(), which handles:
|
||||
// - Child::Node results (View) via impl<T: IntoView> Children for T
|
||||
// - &str literals via Children for &str
|
||||
// - ChildViews (slot values) via Children for ChildViews (flattens)
|
||||
let items = node.children.iter().map(expand_child);
|
||||
quote! {
|
||||
::ruin_app::#path()
|
||||
#(#prop_calls)*
|
||||
.children(::ruin_app::LazyChildren(|| {
|
||||
let mut __v: ::std::vec::Vec<::ruin_app::View> = ::std::vec::Vec::new();
|
||||
#(__v.extend(::ruin_app::Children::into_views(#items));)*
|
||||
__v
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_text_path(path: &Path) -> bool {
|
||||
path.segments.len() == 1 && path.segments[0].ident == "text"
|
||||
}
|
||||
|
||||
fn expand_provider_node(node: &Node) -> proc_macro2::TokenStream {
|
||||
let path = &node.path;
|
||||
|
||||
4
lib/runtime/README.md
Normal file
4
lib/runtime/README.md
Normal 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
|
||||
@@ -8,11 +8,12 @@
|
||||
//! The public surface intentionally mirrors `std::fs` where that shape makes sense, while using
|
||||
//! async methods for operations that may block the caller.
|
||||
|
||||
use alloc::sync::Arc;
|
||||
|
||||
use std::ffi::OsStr;
|
||||
use std::io;
|
||||
use std::os::fd::{AsRawFd, OwnedFd};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::op::fs::{
|
||||
FileType as RawFileType, FsOp, MetadataTarget, OpenOptions as OpOpenOptions,
|
||||
|
||||
@@ -29,7 +29,10 @@ pub(crate) mod trace_targets {
|
||||
pub const DRIVER: &str = "ruin_runtime::driver";
|
||||
pub const RUNTIME: &str = "ruin_runtime::runtime";
|
||||
pub const SCHEDULER: &str = "ruin_runtime::scheduler";
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
pub const TIMER: &str = "ruin_runtime::timer";
|
||||
#[cfg(debug_assertions)]
|
||||
pub const ASYNC: &str = "ruin_runtime::async";
|
||||
}
|
||||
|
||||
@@ -41,6 +44,7 @@ pub mod net;
|
||||
pub mod op;
|
||||
#[doc(hidden)]
|
||||
pub mod platform;
|
||||
pub mod stdio;
|
||||
#[doc(hidden)]
|
||||
pub mod sys;
|
||||
pub mod time;
|
||||
|
||||
@@ -3,14 +3,15 @@
|
||||
//! The public surface follows the general shape of `std::net`, but uses async methods for socket
|
||||
//! operations that would otherwise block the caller.
|
||||
|
||||
use std::future::Future;
|
||||
use core::future::Future;
|
||||
use core::pin::Pin;
|
||||
use core::task::{Context, Poll};
|
||||
use core::time::Duration;
|
||||
|
||||
use std::io;
|
||||
use std::net::{Shutdown, SocketAddr, ToSocketAddrs};
|
||||
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
|
||||
use std::pin::Pin;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::task::{Context, Poll};
|
||||
use std::time::Duration;
|
||||
|
||||
use hyper::rt::{Read as HyperRead, ReadBufCursor, Write as HyperWrite};
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
//! Public runtime driver primitives.
|
||||
//! RUIN Runtime Driver for Linux x86_64.
|
||||
//!
|
||||
//!
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::cell::RefCell;
|
||||
@@ -88,12 +90,21 @@ pub struct ReadyEvents {
|
||||
|
||||
/// Low-level Linux runtime driver backed by `io_uring`.
|
||||
pub struct Driver {
|
||||
/// The `io_uring` instance driving this runtime thread.
|
||||
ring: IoUring,
|
||||
/// Shared notifier that other threads can use to wake this runtime thread.
|
||||
notifier: Arc<NotifierInner>,
|
||||
/// Next sequence number for generated completion tokens.
|
||||
next_token: Cell<u64>,
|
||||
/// The token of the currently active timer, if any timer is armed.
|
||||
active_timer_token: Cell<Option<u64>>,
|
||||
/// Accumulated count of pending wake notifications that have not yet been triggered.
|
||||
pending_wakes: Cell<u64>,
|
||||
/// Accumulated count of pending timer expirations that have not yet been triggered.
|
||||
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>>,
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,12 @@ pub struct TimeoutHandle {
|
||||
_local: Rc<()>,
|
||||
}
|
||||
|
||||
impl TimeoutHandle {
|
||||
pub fn clear(&self) {
|
||||
clear_timeout(self);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
/// Handle returned by [`set_interval`].
|
||||
pub struct IntervalHandle {
|
||||
@@ -62,6 +68,12 @@ pub struct IntervalHandle {
|
||||
_local: Rc<()>,
|
||||
}
|
||||
|
||||
impl IntervalHandle {
|
||||
pub fn clear(&self) {
|
||||
clear_interval(self);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle returned by [`queue_future`].
|
||||
///
|
||||
/// Awaiting a join handle yields the output of the queued future.
|
||||
@@ -72,9 +84,10 @@ pub struct JoinHandle<T> {
|
||||
/// Future returned by [`yield_now`].
|
||||
///
|
||||
/// Awaiting this future will immediately yield control back to the runtime scheduler, allowing other queued microtasks
|
||||
/// to run before the current task continues executing. Note that continuation of futures runs as a microtask, so this
|
||||
/// to run before the current task continues executing. Note that continuations of futures run as microtasks, so this
|
||||
/// can only yield to other microtasks and not to macrotasks (driver events such as file or network I/O, timers, or
|
||||
/// channel messages).
|
||||
/// channel messages). To yield to macrotasks, you must allow the flow of execution to return to the runtime event loop
|
||||
/// and flush the full microtask queue, for example by awaiting a timer.
|
||||
pub struct YieldNow {
|
||||
yielded: bool,
|
||||
}
|
||||
@@ -217,17 +230,19 @@ where
|
||||
);
|
||||
|
||||
if delay.is_zero() {
|
||||
unimplemented!("Zero-delay intervals are not yet implemented.")
|
||||
// TODO: vibeshit got this completely wrong
|
||||
// Zero-delay intervals never touch the timer heap or arm an io_uring
|
||||
// timer (a past-deadline kernel timer would fire on every event loop
|
||||
// iteration, spinning the runtime at 100 % CPU). Instead the callback
|
||||
// is stored in `immediate_intervals` and self-schedules as a macrotask
|
||||
// each turn, mirroring JS `setInterval(f, 0)` semantics.
|
||||
let callback = Rc::new(RefCell::new(Box::new(callback) as Box<dyn FnMut()>));
|
||||
current_thread()
|
||||
.immediate_intervals
|
||||
.borrow_mut()
|
||||
.insert(id, Rc::clone(&callback));
|
||||
schedule_immediate_interval(owner, id);
|
||||
// let callback = Rc::new(RefCell::new(Box::new(callback) as Box<dyn FnMut()>));
|
||||
// current_thread()
|
||||
// .immediate_intervals
|
||||
// .borrow_mut()
|
||||
// .insert(id, Rc::clone(&callback));
|
||||
// schedule_immediate_interval(owner, id);
|
||||
} else {
|
||||
let deadline = deadline_from_now(delay);
|
||||
#[cfg(debug_assertions)]
|
||||
@@ -571,6 +586,8 @@ impl Future for YieldNow {
|
||||
}
|
||||
}
|
||||
|
||||
type ImmediateIntervals = RefCell<HashMap<usize, Rc<RefCell<Box<dyn FnMut()>>>>>;
|
||||
|
||||
struct ThreadState {
|
||||
driver: Driver,
|
||||
shared: Arc<ThreadShared>,
|
||||
@@ -580,7 +597,7 @@ struct ThreadState {
|
||||
timers: RefCell<TimerHeap>,
|
||||
/// Zero-delay intervals bypasses the timer heap entirely. Each entry
|
||||
/// re-enqueues itself as a macrotask on every turn.
|
||||
immediate_intervals: RefCell<HashMap<usize, Rc<RefCell<Box<dyn FnMut()>>>>>,
|
||||
immediate_intervals: ImmediateIntervals,
|
||||
next_timer_id: Cell<usize>,
|
||||
children: RefCell<Vec<ChildWorker>>,
|
||||
}
|
||||
@@ -1162,27 +1179,32 @@ fn clear_timer(owner: *const ThreadState, id: usize) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Enqueues one macrotask turn for a zero-delay interval.
|
||||
///
|
||||
/// The macrotask checks whether the interval is still live (not cleared), fires
|
||||
/// the callback, and re-enqueues itself for the next turn.
|
||||
fn schedule_immediate_interval(owner: *const ThreadState, id: usize) {
|
||||
push_local_macrotask(Box::new(move || {
|
||||
let callback = current_thread()
|
||||
.immediate_intervals
|
||||
.borrow()
|
||||
.get(&id)
|
||||
.map(Rc::clone);
|
||||
let Some(callback) = callback else {
|
||||
return; // interval was cleared before this turn ran
|
||||
};
|
||||
(callback.borrow_mut())();
|
||||
// Re-enqueue for the next turn if still live.
|
||||
if current_thread().immediate_intervals.borrow().contains_key(&id) {
|
||||
schedule_immediate_interval(owner, id);
|
||||
}
|
||||
}));
|
||||
}
|
||||
// TODO: vibeshit got this completely wrong
|
||||
// /// Enqueues one macrotask turn for a zero-delay interval.
|
||||
// ///
|
||||
// /// The macrotask checks whether the interval is still live (not cleared), fires
|
||||
// /// the callback, and re-enqueues itself for the next turn.
|
||||
// fn schedule_immediate_interval(owner: *const ThreadState, id: usize) {
|
||||
// push_local_macrotask(Box::new(move || {
|
||||
// let callback = current_thread()
|
||||
// .immediate_intervals
|
||||
// .borrow()
|
||||
// .get(&id)
|
||||
// .map(Rc::clone);
|
||||
// let Some(callback) = callback else {
|
||||
// return; // interval was cleared before this turn ran
|
||||
// };
|
||||
// (callback.borrow_mut())();
|
||||
// // Re-enqueue for the next turn if still live.
|
||||
// if current_thread()
|
||||
// .immediate_intervals
|
||||
// .borrow()
|
||||
// .contains_key(&id)
|
||||
// {
|
||||
// schedule_immediate_interval(owner, id);
|
||||
// }
|
||||
// }));
|
||||
// }
|
||||
|
||||
fn dispatch_expired_timers() {
|
||||
let now = deadline_from_now(Duration::ZERO);
|
||||
@@ -1207,12 +1229,8 @@ fn dispatch_expired_timers() {
|
||||
.deadline
|
||||
.checked_add(interval)
|
||||
.unwrap_or(Duration::MAX);
|
||||
let next_timer = TimerNode::interval(
|
||||
timer.id,
|
||||
next_deadline,
|
||||
interval,
|
||||
Rc::clone(&callback),
|
||||
);
|
||||
let next_timer =
|
||||
TimerNode::interval(timer.id, next_deadline, interval, Rc::clone(&callback));
|
||||
current_thread().timers.borrow_mut().insert(next_timer);
|
||||
|
||||
push_local_macrotask(Box::new(move || {
|
||||
@@ -1394,8 +1412,7 @@ mod tests {
|
||||
// turns by awaiting a sleep between checks.
|
||||
let count = Rc::new(Cell::new(0usize));
|
||||
let count_clone = Rc::clone(&count);
|
||||
let handle_slot: Rc<RefCell<Option<super::IntervalHandle>>> =
|
||||
Rc::new(RefCell::new(None));
|
||||
let handle_slot: Rc<RefCell<Option<super::IntervalHandle>>> = Rc::new(RefCell::new(None));
|
||||
let handle_slot_clone = Rc::clone(&handle_slot);
|
||||
|
||||
let handle = set_interval(Duration::ZERO, move || {
|
||||
|
||||
191
lib/runtime/src/stdio.rs
Normal file
191
lib/runtime/src/stdio.rs
Normal file
@@ -0,0 +1,191 @@
|
||||
//! Async standard-input helpers.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::io;
|
||||
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
|
||||
use std::thread;
|
||||
|
||||
use crate::op::completion::completion_for_current_thread;
|
||||
use crate::platform::linux_x86_64::runtime::with_current_driver;
|
||||
use crate::platform::linux_x86_64::uring::{IORING_OP_READ, IoUringCqe, IoUringSqe};
|
||||
|
||||
thread_local! {
|
||||
static STDIN_URING_SUPPORTED: Cell<Option<bool>> = const { Cell::new(None) };
|
||||
}
|
||||
|
||||
const FILE_CURSOR: u64 = u64::MAX;
|
||||
const READ_CHUNK_BYTES: usize = 1024;
|
||||
|
||||
/// Async line-oriented stdin reader.
|
||||
///
|
||||
/// The reader is `io_uring`-first. When the active stdin fd rejects `IORING_OP_READ`, the module
|
||||
/// falls back to a helper-thread blocking read on the same duplicated fd.
|
||||
pub struct Stdin {
|
||||
fd: OwnedFd,
|
||||
buffer: Vec<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();
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,14 @@
|
||||
//! These helpers integrate with the runtime's timer queue and are designed to be used from
|
||||
//! futures scheduled with [`crate::queue_future`] or one of the runtime entry macros.
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::fmt;
|
||||
use std::future::{Future, poll_fn};
|
||||
use std::pin::Pin;
|
||||
use std::rc::Rc;
|
||||
use std::task::Waker;
|
||||
use std::task::{Context, Poll};
|
||||
use std::time::Duration;
|
||||
use alloc::rc::Rc;
|
||||
use core::cell::{Cell, RefCell};
|
||||
use core::fmt;
|
||||
use core::future::{Future, poll_fn};
|
||||
use core::pin::Pin;
|
||||
use core::task::Waker;
|
||||
use core::task::{Context, Poll};
|
||||
use core::time::Duration;
|
||||
|
||||
use crate::{clear_timeout, set_timeout};
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ pub struct RoutedPointerEvent {
|
||||
#[derive(Default)]
|
||||
pub struct PointerRouter {
|
||||
hovered: Vec<HitTarget>,
|
||||
pressed: Option<HitTarget>,
|
||||
pressed: Option<Vec<HitTarget>>,
|
||||
}
|
||||
|
||||
impl PointerRouter {
|
||||
@@ -71,7 +71,11 @@ impl PointerRouter {
|
||||
}
|
||||
|
||||
pub fn pressed_target(&self) -> Option<&HitTarget> {
|
||||
self.pressed.as_ref()
|
||||
self.pressed.as_ref().and_then(|targets| targets.last())
|
||||
}
|
||||
|
||||
pub fn pressed_targets(&self) -> &[HitTarget] {
|
||||
self.pressed.as_deref().unwrap_or(&[])
|
||||
}
|
||||
|
||||
pub fn route(
|
||||
@@ -114,7 +118,12 @@ impl PointerRouter {
|
||||
|
||||
match event.kind {
|
||||
PointerEventKind::Move => {
|
||||
if let Some(target) = self.pressed.clone().or(hit_target.clone()) {
|
||||
if let Some(target) = self
|
||||
.pressed
|
||||
.as_ref()
|
||||
.and_then(|targets| targets.last().cloned())
|
||||
.or(hit_target.clone())
|
||||
{
|
||||
routed.push(RoutedPointerEvent {
|
||||
kind: RoutedPointerEventKind::Move,
|
||||
target,
|
||||
@@ -125,7 +134,7 @@ impl PointerRouter {
|
||||
}
|
||||
PointerEventKind::Down { button } => {
|
||||
if let Some(target) = hit_target {
|
||||
self.pressed = Some(target.clone());
|
||||
self.pressed = Some(self.hovered.clone());
|
||||
routed.push(RoutedPointerEvent {
|
||||
kind: RoutedPointerEventKind::Down { button },
|
||||
target,
|
||||
@@ -135,7 +144,12 @@ impl PointerRouter {
|
||||
}
|
||||
}
|
||||
PointerEventKind::Up { button } => {
|
||||
if let Some(target) = self.pressed.take().or(hit_target) {
|
||||
if let Some(target) = self
|
||||
.pressed
|
||||
.take()
|
||||
.and_then(|targets| targets.last().cloned())
|
||||
.or(hit_target)
|
||||
{
|
||||
routed.push(RoutedPointerEvent {
|
||||
kind: RoutedPointerEventKind::Up { button },
|
||||
target,
|
||||
|
||||
@@ -7,7 +7,7 @@ use crate::scene::{
|
||||
UiSize,
|
||||
};
|
||||
use crate::text::TextSystem;
|
||||
use crate::tree::{CursorIcon, Edges, Element, ElementId, FlexDirection, ImageNode, Style};
|
||||
use crate::tree::{AlignItems, CursorIcon, Edges, Element, ElementId, FlexDirection, ImageNode, Style};
|
||||
|
||||
pub fn layout_scene(version: u64, logical_size: UiSize, root: &Element) -> SceneSnapshot {
|
||||
let mut text_system = TextSystem::new();
|
||||
@@ -27,6 +27,11 @@ pub fn layout_scene_with_text_system(
|
||||
pub struct LayoutSnapshot {
|
||||
pub scene: SceneSnapshot,
|
||||
pub interaction_tree: InteractionTree,
|
||||
/// Minimum size the root element needs in logical units. Accounts for
|
||||
/// `min_width`/`min_height` constraints and the natural minimum of fixed-
|
||||
/// size content. Scroll boxes contribute only their own explicit/min size
|
||||
/// on the scroll axis, not their content.
|
||||
pub root_min_size: UiSize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
@@ -128,7 +133,12 @@ impl LayoutNode {
|
||||
/// Return a copy of this node (and all descendants) with all positions shifted by `offset`.
|
||||
pub fn translated(self, offset: Point) -> Self {
|
||||
fn translate_rect(r: Rect, o: Point) -> Rect {
|
||||
Rect::new(r.origin.x + o.x, r.origin.y + o.y, r.size.width, r.size.height)
|
||||
Rect::new(
|
||||
r.origin.x + o.x,
|
||||
r.origin.y + o.y,
|
||||
r.size.width,
|
||||
r.size.height,
|
||||
)
|
||||
}
|
||||
let rect = translate_rect(self.rect, offset);
|
||||
let clip_rect = self.clip_rect.map(|r| translate_rect(r, offset));
|
||||
@@ -143,7 +153,11 @@ impl LayoutNode {
|
||||
rect: translate_rect(img.rect, offset),
|
||||
..img
|
||||
});
|
||||
let children = self.children.into_iter().map(|c| c.translated(offset)).collect();
|
||||
let children = self
|
||||
.children
|
||||
.into_iter()
|
||||
.map(|c| c.translated(offset))
|
||||
.collect();
|
||||
LayoutNode {
|
||||
rect,
|
||||
clip_rect,
|
||||
@@ -233,14 +247,17 @@ pub fn layout_snapshot_with_cache(
|
||||
"layout snapshot perf"
|
||||
);
|
||||
}
|
||||
let root_min_size = element_min_size(root);
|
||||
LayoutSnapshot {
|
||||
scene,
|
||||
interaction_tree: InteractionTree {
|
||||
root: interaction_root,
|
||||
},
|
||||
root_min_size,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn layout_element(
|
||||
element: &Element,
|
||||
rect: Rect,
|
||||
@@ -254,10 +271,12 @@ fn layout_element(
|
||||
clip_rect: Option<Rect>,
|
||||
) -> LayoutNode {
|
||||
perf_stats.nodes += 1;
|
||||
let rect = snap_rect(rect);
|
||||
|
||||
// Viewport culling: skip fully off-screen elements inside a scroll box.
|
||||
if let Some(clip) = clip_rect {
|
||||
if !rects_overlap(rect, clip) {
|
||||
if let Some(clip) = clip_rect
|
||||
&& !rects_overlap(rect, clip)
|
||||
{
|
||||
perf_stats.viewport_culled += 1;
|
||||
return LayoutNode {
|
||||
path,
|
||||
@@ -274,7 +293,6 @@ fn layout_element(
|
||||
children: Vec::new(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Incremental layout cache check.
|
||||
// Round to nearest pixel to eliminate floating-point representation
|
||||
@@ -368,7 +386,13 @@ fn layout_element(
|
||||
if pushed_clip {
|
||||
scene.pop_clip();
|
||||
}
|
||||
cache_layout(layout_cache, cache_key, &interaction, &scene.items[scene_start..], rect.origin);
|
||||
cache_layout(
|
||||
layout_cache,
|
||||
cache_key,
|
||||
&interaction,
|
||||
&scene.items[scene_start..],
|
||||
rect.origin,
|
||||
);
|
||||
return interaction;
|
||||
}
|
||||
|
||||
@@ -382,7 +406,13 @@ fn layout_element(
|
||||
if pushed_clip {
|
||||
scene.pop_clip();
|
||||
}
|
||||
cache_layout(layout_cache, cache_key, &interaction, &scene.items[scene_start..], rect.origin);
|
||||
cache_layout(
|
||||
layout_cache,
|
||||
cache_key,
|
||||
&interaction,
|
||||
&scene.items[scene_start..],
|
||||
rect.origin,
|
||||
);
|
||||
return interaction;
|
||||
}
|
||||
|
||||
@@ -508,7 +538,13 @@ fn layout_element(
|
||||
if pushed_clip {
|
||||
scene.pop_clip();
|
||||
}
|
||||
cache_layout(layout_cache, cache_key, &interaction, &scene.items[scene_start..], rect.origin);
|
||||
cache_layout(
|
||||
layout_cache,
|
||||
cache_key,
|
||||
&interaction,
|
||||
&scene.items[scene_start..],
|
||||
rect.origin,
|
||||
);
|
||||
return interaction;
|
||||
}
|
||||
|
||||
@@ -518,7 +554,13 @@ fn layout_element(
|
||||
if pushed_clip {
|
||||
scene.pop_clip();
|
||||
}
|
||||
cache_layout(layout_cache, cache_key, &interaction, &scene.items[scene_start..], rect.origin);
|
||||
cache_layout(
|
||||
layout_cache,
|
||||
cache_key,
|
||||
&interaction,
|
||||
&scene.items[scene_start..],
|
||||
rect.origin,
|
||||
);
|
||||
return interaction;
|
||||
}
|
||||
|
||||
@@ -527,7 +569,13 @@ fn layout_element(
|
||||
if pushed_clip {
|
||||
scene.pop_clip();
|
||||
}
|
||||
cache_layout(layout_cache, cache_key, &interaction, &scene.items[scene_start..], rect.origin);
|
||||
cache_layout(
|
||||
layout_cache,
|
||||
cache_key,
|
||||
&interaction,
|
||||
&scene.items[scene_start..],
|
||||
rect.origin,
|
||||
);
|
||||
return interaction;
|
||||
}
|
||||
interaction.children = layout_container_children(
|
||||
@@ -538,14 +586,26 @@ fn layout_element(
|
||||
text_system,
|
||||
perf_stats,
|
||||
layout_cache,
|
||||
clip_rect,
|
||||
// Do NOT propagate clip_rect into children: the scroll-box PushClip already clips
|
||||
// them visually, and propagating the clip causes the layout cache to bake in
|
||||
// culling results for a specific scroll position. When the scroll changes, the
|
||||
// same cache entry would replay with stale "empty" children that were culled at
|
||||
// the previous offset. Culling applies only at the direct-child level of the
|
||||
// scroll box (which passes Some(viewport_rect) to layout_container_children).
|
||||
None,
|
||||
);
|
||||
|
||||
if pushed_clip {
|
||||
scene.pop_clip();
|
||||
}
|
||||
|
||||
cache_layout(layout_cache, cache_key, &interaction, &scene.items[scene_start..], rect.origin);
|
||||
cache_layout(
|
||||
layout_cache,
|
||||
cache_key,
|
||||
&interaction,
|
||||
&scene.items[scene_start..],
|
||||
rect.origin,
|
||||
);
|
||||
interaction
|
||||
}
|
||||
|
||||
@@ -558,10 +618,13 @@ fn cache_layout(
|
||||
origin: Point,
|
||||
) {
|
||||
let neg = Point::new(-origin.x, -origin.y);
|
||||
cache.results.insert(key, CachedLayout {
|
||||
cache.results.insert(
|
||||
key,
|
||||
CachedLayout {
|
||||
interaction_node: interaction.clone().translated(neg),
|
||||
scene_items: scene_items.iter().map(|i| i.translated(neg)).collect(),
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn hit_path_node(node: &LayoutNode, point: crate::scene::Point) -> Option<Vec<HitTarget>> {
|
||||
@@ -730,11 +793,16 @@ fn point_in_rounded_rect(point: crate::scene::Point, rect: Rect, radius: f32) ->
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct MeasuredChild {
|
||||
/// Offered cross-axis size (full available_cross for stretch; explicit value otherwise).
|
||||
cross: f32,
|
||||
/// Natural cross-axis size: set when parent uses non-stretch alignment, child has no
|
||||
/// explicit cross size, and the child is not flex. `None` means use `cross`.
|
||||
natural_cross: Option<f32>,
|
||||
main: f32,
|
||||
is_flex: bool,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn layout_container_children(
|
||||
element: &Element,
|
||||
content: Rect,
|
||||
@@ -759,15 +827,29 @@ fn layout_container_children(
|
||||
let mut fixed_total = 0.0;
|
||||
let mut flex_total = 0.0;
|
||||
for child in &element.children {
|
||||
let child_insets = content_insets(&child.style);
|
||||
let main_inset = main_axis_padding(child_insets, element.style.direction);
|
||||
let cross_inset = cross_axis_padding(child_insets, element.style.direction);
|
||||
// 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)
|
||||
.unwrap_or(available_cross)
|
||||
.clamp(0.0, available_cross);
|
||||
let explicit_main =
|
||||
child_main_size(child, element.style.direction).map(|main| main.max(0.0));
|
||||
.map(|c| (c + cross_inset).max(0.0).max(min_cross_outer))
|
||||
.unwrap_or(available_cross.max(min_cross_outer))
|
||||
.min(available_cross.max(min_cross_outer));
|
||||
let explicit_main = child_main_size(child, element.style.direction)
|
||||
.map(|main| (main + main_inset).max(0.0).max(min_main_outer));
|
||||
let is_flex = explicit_main.is_none() && child.style.flex_grow > 0.0;
|
||||
let measured_main = explicit_main.unwrap_or_else(|| {
|
||||
if is_flex {
|
||||
0.0
|
||||
// Flex items get their share of remaining space later; record min floor now.
|
||||
min_main_outer
|
||||
} else {
|
||||
perf_stats.intrinsic_calls += 1;
|
||||
if child.text_node().is_some() {
|
||||
@@ -788,9 +870,35 @@ fn layout_container_children(
|
||||
if let Some(intrinsic_started) = intrinsic_started {
|
||||
perf_stats.intrinsic_ms += intrinsic_started.elapsed().as_secs_f64() * 1_000.0;
|
||||
}
|
||||
intrinsic
|
||||
// Apply min-main floor to intrinsic result.
|
||||
intrinsic.max(min_main_outer)
|
||||
}
|
||||
});
|
||||
// For non-stretch alignment, measure the child's natural cross size so it is not
|
||||
// forcibly stretched to fill the container's cross axis. Only done for non-flex
|
||||
// children with no explicit cross-axis style, since flex main size is unknown here.
|
||||
// align_self on the child overrides the parent's align_items for this child.
|
||||
let effective_align = child.style.align_self.unwrap_or(element.style.align_items);
|
||||
let natural_cross =
|
||||
if effective_align != AlignItems::Stretch
|
||||
&& !is_flex
|
||||
&& child_cross_size(child, element.style.direction).is_none()
|
||||
{
|
||||
let offered = match element.style.direction {
|
||||
FlexDirection::Row => UiSize::new(measured_main, available_cross),
|
||||
FlexDirection::Column => UiSize::new(available_cross, measured_main),
|
||||
};
|
||||
let natural =
|
||||
intrinsic_size(child, offered, text_system, perf_stats, layout_cache);
|
||||
Some(
|
||||
cross_axis_size(natural, element.style.direction)
|
||||
.max(min_cross_outer)
|
||||
.min(available_cross.max(min_cross_outer)),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if is_flex {
|
||||
flex_total += child_flex_weight(child);
|
||||
} else {
|
||||
@@ -798,6 +906,7 @@ fn layout_container_children(
|
||||
}
|
||||
measured_children.push(MeasuredChild {
|
||||
cross,
|
||||
natural_cross,
|
||||
main: measured_main,
|
||||
is_flex,
|
||||
});
|
||||
@@ -808,20 +917,39 @@ fn layout_container_children(
|
||||
let mut children = Vec::with_capacity(element.children.len());
|
||||
for (index, (child, measured)) in element.children.iter().zip(measured_children).enumerate() {
|
||||
let child_main = if measured.is_flex {
|
||||
if flex_total <= 0.0 {
|
||||
let allocated = if flex_total <= 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
remaining_main * (child_flex_weight(child) / flex_total)
|
||||
}
|
||||
};
|
||||
// Apply min-main floor to flex allocation.
|
||||
let child_insets = content_insets(&child.style);
|
||||
let main_inset = main_axis_padding(child_insets, element.style.direction);
|
||||
let min_main_outer = child_min_main_size(child, element.style.direction)
|
||||
.map(|m| (m + main_inset).max(0.0))
|
||||
.unwrap_or(0.0);
|
||||
allocated.max(min_main_outer)
|
||||
} else {
|
||||
measured.main
|
||||
};
|
||||
// For non-stretch alignment, use the child's natural cross size; otherwise stretch.
|
||||
// align_self on the child overrides the parent's align_items for this child.
|
||||
let effective_align = child.style.align_self.unwrap_or(element.style.align_items);
|
||||
let actual_cross = measured.natural_cross.unwrap_or(measured.cross);
|
||||
let cross_origin = {
|
||||
let base = cross_axis_origin(content, element.style.direction);
|
||||
match effective_align {
|
||||
AlignItems::Stretch | AlignItems::Start => base,
|
||||
AlignItems::Center => base + (available_cross - actual_cross) * 0.5,
|
||||
AlignItems::End => base + available_cross - actual_cross,
|
||||
}
|
||||
};
|
||||
let child_rect = child_rect(
|
||||
content,
|
||||
cross_origin,
|
||||
element.style.direction,
|
||||
cursor,
|
||||
child_main.max(0.0),
|
||||
measured.cross,
|
||||
actual_cross,
|
||||
);
|
||||
children.push(layout_element(
|
||||
child,
|
||||
@@ -848,13 +976,22 @@ fn intrinsic_main_size(
|
||||
layout_cache: &mut LayoutCache,
|
||||
) -> f32 {
|
||||
if let Some(text) = child.text_node() {
|
||||
// cross_size and available_main are outer sizes; subtract insets to get content dimensions
|
||||
// for text measurement so wrapping is constrained to the actual content area.
|
||||
let child_insets = content_insets(&child.style);
|
||||
let constraints = match direction {
|
||||
FlexDirection::Row => (Some(available_main.max(0.0)), Some(cross_size.max(0.0))),
|
||||
FlexDirection::Column => (Some(cross_size.max(0.0)), None),
|
||||
FlexDirection::Row => (
|
||||
Some((available_main - main_axis_padding(child_insets, direction)).max(0.0)),
|
||||
Some((cross_size - cross_axis_padding(child_insets, direction)).max(0.0)),
|
||||
),
|
||||
FlexDirection::Column => (
|
||||
Some((cross_size - cross_axis_padding(child_insets, direction)).max(0.0)),
|
||||
None,
|
||||
),
|
||||
};
|
||||
let content =
|
||||
text_system.measure_spans(&text.spans, &text.style, constraints.0, constraints.1);
|
||||
let padding = main_axis_padding(content_insets(&child.style), direction);
|
||||
let padding = main_axis_padding(child_insets, direction);
|
||||
return main_axis_size(content, direction) + padding;
|
||||
}
|
||||
|
||||
@@ -891,19 +1028,29 @@ fn intrinsic_container_content_size(
|
||||
let mut height = gap_total;
|
||||
for child in &element.children {
|
||||
let skip_main = child.style.flex_grow > 0.0 && child.style.height.is_none();
|
||||
let child_insets = content_insets(&child.style);
|
||||
// Offer outer size: content-box style.w/h + insets, or parent's content width.
|
||||
let offered_w = child
|
||||
.style
|
||||
.width
|
||||
.map(|w| w + horizontal_insets(child_insets))
|
||||
.unwrap_or(content_size.width);
|
||||
let offered_h = child
|
||||
.style
|
||||
.height
|
||||
.map(|h| h + vertical_insets(child_insets))
|
||||
.unwrap_or(content_size.height);
|
||||
let child_size = intrinsic_size(
|
||||
child,
|
||||
UiSize::new(
|
||||
child.style.width.unwrap_or(content_size.width),
|
||||
child.style.height.unwrap_or(content_size.height),
|
||||
),
|
||||
UiSize::new(offered_w, offered_h),
|
||||
text_system,
|
||||
perf_stats,
|
||||
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 {
|
||||
height += child.style.height.unwrap_or(child_size.height);
|
||||
height += child_size.height;
|
||||
}
|
||||
}
|
||||
UiSize::new(width, height)
|
||||
@@ -921,17 +1068,26 @@ fn intrinsic_container_content_size(
|
||||
child_main_sizes.push(None);
|
||||
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(
|
||||
child,
|
||||
UiSize::new(
|
||||
child.style.width.unwrap_or(content_size.width),
|
||||
child.style.height.unwrap_or(content_size.height),
|
||||
),
|
||||
UiSize::new(offered_w, offered_h),
|
||||
text_system,
|
||||
perf_stats,
|
||||
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;
|
||||
child_main_sizes.push(Some(child_main));
|
||||
}
|
||||
@@ -945,12 +1101,15 @@ fn intrinsic_container_content_size(
|
||||
remaining_main * (child_flex_weight(child) / flex_total)
|
||||
}
|
||||
});
|
||||
let child_insets = content_insets(&child.style);
|
||||
let offered_h = child
|
||||
.style
|
||||
.height
|
||||
.map(|h| h + vertical_insets(child_insets))
|
||||
.unwrap_or(content_size.height);
|
||||
let child_size = intrinsic_size(
|
||||
child,
|
||||
UiSize::new(
|
||||
child_main,
|
||||
child.style.height.unwrap_or(content_size.height),
|
||||
),
|
||||
UiSize::new(child_main, offered_h),
|
||||
text_system,
|
||||
perf_stats,
|
||||
layout_cache,
|
||||
@@ -958,7 +1117,7 @@ fn intrinsic_container_content_size(
|
||||
if !skip_main {
|
||||
width += child_main;
|
||||
}
|
||||
height = height.max(child.style.height.unwrap_or(child_size.height));
|
||||
height = height.max(child_size.height);
|
||||
}
|
||||
UiSize::new(width, height)
|
||||
}
|
||||
@@ -986,7 +1145,14 @@ fn intrinsic_size(
|
||||
return cached;
|
||||
}
|
||||
let insets = content_insets(&element.style);
|
||||
let result = intrinsic_size_inner(element, available_size, insets, text_system, perf_stats, layout_cache);
|
||||
let result = intrinsic_size_inner(
|
||||
element,
|
||||
available_size,
|
||||
insets,
|
||||
text_system,
|
||||
perf_stats,
|
||||
layout_cache,
|
||||
);
|
||||
layout_cache.intrinsic_cache.insert(cache_key, result);
|
||||
result
|
||||
}
|
||||
@@ -999,30 +1165,51 @@ fn intrinsic_size_inner(
|
||||
perf_stats: &mut LayoutPerfStats,
|
||||
layout_cache: &mut LayoutCache,
|
||||
) -> UiSize {
|
||||
// style.width/height are content-box (content dimensions).
|
||||
// available_size is the outer size the parent is offering.
|
||||
// All returned sizes are outer (content + insets).
|
||||
|
||||
let min_outer_w = element
|
||||
.style
|
||||
.min_width
|
||||
.map(|mw| mw + horizontal_insets(insets))
|
||||
.unwrap_or(0.0);
|
||||
let min_outer_h = element
|
||||
.style
|
||||
.min_height
|
||||
.map(|mh| mh + vertical_insets(insets))
|
||||
.unwrap_or(0.0);
|
||||
|
||||
if let Some(text) = element.text_node() {
|
||||
// Measure at content dimensions: use explicit if set, else available minus insets.
|
||||
let content_w = element
|
||||
.style
|
||||
.width
|
||||
.unwrap_or_else(|| (available_size.width - horizontal_insets(insets)).max(0.0));
|
||||
let content_h = element
|
||||
.style
|
||||
.height
|
||||
.unwrap_or_else(|| (available_size.height - vertical_insets(insets)).max(0.0));
|
||||
let measured = text_system.measure_spans(
|
||||
&text.spans,
|
||||
&text.style,
|
||||
Some(available_size.width.max(0.0)),
|
||||
Some(available_size.height.max(0.0)),
|
||||
Some(content_w.max(0.0)),
|
||||
Some(content_h.max(0.0)),
|
||||
);
|
||||
// Return outer: explicit (content) + insets, or measured + insets for auto.
|
||||
return UiSize::new(
|
||||
element
|
||||
.style
|
||||
.width
|
||||
.unwrap_or(measured.width + horizontal_insets(insets)),
|
||||
element
|
||||
.style
|
||||
.height
|
||||
.unwrap_or(measured.height + vertical_insets(insets)),
|
||||
(element.style.width.unwrap_or(measured.width) + horizontal_insets(insets))
|
||||
.max(min_outer_w),
|
||||
(element.style.height.unwrap_or(measured.height) + vertical_insets(insets))
|
||||
.max(min_outer_h),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(image) = element.image_node() {
|
||||
let resolved = resolve_image_element_size(element, image.resource.size());
|
||||
return UiSize::new(
|
||||
resolved.width + horizontal_insets(insets),
|
||||
resolved.height + vertical_insets(insets),
|
||||
(resolved.width + horizontal_insets(insets)).max(min_outer_w),
|
||||
(resolved.height + vertical_insets(insets)).max(min_outer_h),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1031,10 +1218,13 @@ fn intrinsic_size_inner(
|
||||
let scroll_gutter = element
|
||||
.scroll_box_node()
|
||||
.map_or(0.0, |scroll_box| scroll_box.scrollbar.gutter_width.max(0.0));
|
||||
let content_width =
|
||||
explicit_width.unwrap_or(available_size.width).max(0.0) - horizontal_insets(insets);
|
||||
let content_height =
|
||||
explicit_height.unwrap_or(available_size.height).max(0.0) - vertical_insets(insets);
|
||||
// Content dimensions: explicit = already content; auto = available_outer - insets.
|
||||
let content_width = explicit_width
|
||||
.map(|w| w.max(0.0))
|
||||
.unwrap_or_else(|| (available_size.width - horizontal_insets(insets)).max(0.0));
|
||||
let content_height = explicit_height
|
||||
.map(|h| h.max(0.0))
|
||||
.unwrap_or_else(|| (available_size.height - vertical_insets(insets)).max(0.0));
|
||||
let content_size = UiSize::new(
|
||||
(content_width - scroll_gutter).max(0.0),
|
||||
content_height.max(0.0),
|
||||
@@ -1042,18 +1232,179 @@ fn intrinsic_size_inner(
|
||||
|
||||
if element.children.is_empty() {
|
||||
return UiSize::new(
|
||||
explicit_width.unwrap_or(horizontal_insets(insets) + scroll_gutter),
|
||||
explicit_height.unwrap_or(vertical_insets(insets)),
|
||||
(explicit_width
|
||||
.map(|w| w + horizontal_insets(insets) + scroll_gutter)
|
||||
.unwrap_or(horizontal_insets(insets) + scroll_gutter))
|
||||
.max(min_outer_w),
|
||||
(explicit_height
|
||||
.map(|h| h + vertical_insets(insets))
|
||||
.unwrap_or(vertical_insets(insets)))
|
||||
.max(min_outer_h),
|
||||
);
|
||||
}
|
||||
|
||||
let intrinsic_content =
|
||||
intrinsic_container_content_size(element, content_size, text_system, perf_stats, layout_cache);
|
||||
let intrinsic_content = intrinsic_container_content_size(
|
||||
element,
|
||||
content_size,
|
||||
text_system,
|
||||
perf_stats,
|
||||
layout_cache,
|
||||
);
|
||||
|
||||
UiSize::new(
|
||||
explicit_width
|
||||
.unwrap_or(intrinsic_content.width + horizontal_insets(insets) + scroll_gutter),
|
||||
explicit_height.unwrap_or(intrinsic_content.height + vertical_insets(insets)),
|
||||
(explicit_width
|
||||
.map(|w| w + horizontal_insets(insets) + scroll_gutter)
|
||||
.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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1486,6 +1837,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 {
|
||||
if child.style.flex_grow > 0.0 {
|
||||
child.style.flex_grow
|
||||
@@ -1501,6 +1866,26 @@ fn main_axis_padding(edges: Edges, direction: FlexDirection) -> f32 {
|
||||
}
|
||||
}
|
||||
|
||||
fn cross_axis_padding(edges: Edges, direction: FlexDirection) -> f32 {
|
||||
match direction {
|
||||
FlexDirection::Row => edges.top + edges.bottom,
|
||||
FlexDirection::Column => edges.left + edges.right,
|
||||
}
|
||||
}
|
||||
|
||||
/// Snap a rect to the integer pixel grid.
|
||||
///
|
||||
/// Origin and far edge are each rounded independently so that adjacent
|
||||
/// elements (where one's far edge equals the other's near edge) always
|
||||
/// butt flush against each other with no gap or overlap.
|
||||
fn snap_rect(r: Rect) -> Rect {
|
||||
let x0 = r.origin.x.round();
|
||||
let y0 = r.origin.y.round();
|
||||
let x1 = (r.origin.x + r.size.width).round();
|
||||
let y1 = (r.origin.y + r.size.height).round();
|
||||
Rect::new(x0, y0, (x1 - x0).max(0.0), (y1 - y0).max(0.0))
|
||||
}
|
||||
|
||||
fn main_axis_size(size: UiSize, direction: FlexDirection) -> f32 {
|
||||
match direction {
|
||||
FlexDirection::Row => size.width,
|
||||
@@ -1522,16 +1907,23 @@ fn main_axis_origin(rect: Rect, direction: FlexDirection) -> f32 {
|
||||
}
|
||||
}
|
||||
|
||||
fn cross_axis_origin(content: Rect, direction: FlexDirection) -> f32 {
|
||||
match direction {
|
||||
FlexDirection::Row => content.origin.y,
|
||||
FlexDirection::Column => content.origin.x,
|
||||
}
|
||||
}
|
||||
|
||||
fn child_rect(
|
||||
content: Rect,
|
||||
cross_origin: f32,
|
||||
direction: FlexDirection,
|
||||
main_origin: f32,
|
||||
main_size: f32,
|
||||
cross_size: f32,
|
||||
) -> Rect {
|
||||
match direction {
|
||||
FlexDirection::Row => Rect::new(main_origin, content.origin.y, main_size, cross_size),
|
||||
FlexDirection::Column => Rect::new(content.origin.x, main_origin, cross_size, main_size),
|
||||
FlexDirection::Row => Rect::new(main_origin, cross_origin, main_size, cross_size),
|
||||
FlexDirection::Column => Rect::new(cross_origin, main_origin, cross_size, main_size),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1539,9 +1931,9 @@ fn child_rect(
|
||||
mod tests {
|
||||
use super::{LayoutCache, layout_scene, layout_snapshot, layout_snapshot_with_cache};
|
||||
use crate::scene::{Color, DisplayItem, Point, Quad, Rect, UiSize};
|
||||
use crate::text::TextSystem;
|
||||
use crate::text::{TextStyle, TextWrap};
|
||||
use crate::tree::{Edges, Element, ElementId, FlexDirection};
|
||||
use crate::text::TextSystem;
|
||||
|
||||
#[test]
|
||||
fn row_layout_apportions_fixed_and_flex_children() {
|
||||
@@ -2156,10 +2548,12 @@ mod tests {
|
||||
let size = UiSize::new(400.0, 600.0);
|
||||
let snap1 = layout_snapshot_with_cache(1, size, &root, &mut text_system, &mut layout_cache);
|
||||
let snap2 = layout_snapshot_with_cache(2, size, &root, &mut text_system, &mut layout_cache);
|
||||
assert_eq!(snap1.scene.items, snap2.scene.items, "scene items should be identical");
|
||||
assert_eq!(
|
||||
snap1.interaction_tree.root,
|
||||
snap2.interaction_tree.root,
|
||||
snap1.scene.items, snap2.scene.items,
|
||||
"scene items should be identical"
|
||||
);
|
||||
assert_eq!(
|
||||
snap1.interaction_tree.root, snap2.interaction_tree.root,
|
||||
"interaction trees should be identical"
|
||||
);
|
||||
}
|
||||
@@ -2190,7 +2584,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn scroll_culling_skips_off_screen_children() {
|
||||
use crate::tree::ScrollbarStyle;
|
||||
let mut text_system = TextSystem::new();
|
||||
let mut layout_cache = LayoutCache::new();
|
||||
|
||||
@@ -2198,12 +2591,14 @@ mod tests {
|
||||
let item_height = 40.0;
|
||||
let viewport_height = 200.0;
|
||||
let n = 100;
|
||||
let mut scroll_box = Element::scroll_box(0.0).width(400.0).height(viewport_height);
|
||||
let mut scroll_box = Element::scroll_box(0.0)
|
||||
.width(400.0)
|
||||
.height(viewport_height);
|
||||
for i in 0..n {
|
||||
scroll_box = scroll_box.child(
|
||||
Element::new()
|
||||
.height(item_height)
|
||||
.child(Element::text(format!("item {i}"), text_style()))
|
||||
.child(Element::text(format!("item {i}"), text_style())),
|
||||
);
|
||||
}
|
||||
let root = Element::new().child(scroll_box);
|
||||
@@ -2214,11 +2609,20 @@ mod tests {
|
||||
let snap = layout_snapshot_with_cache(1, size, &root, &mut text_system, &mut layout_cache);
|
||||
|
||||
// Only text items within the 200px viewport should appear in the scene.
|
||||
let text_items = snap.scene.items.iter().filter(|item| {
|
||||
matches!(item, DisplayItem::Text(_))
|
||||
}).count();
|
||||
let text_items = snap
|
||||
.scene
|
||||
.items
|
||||
.iter()
|
||||
.filter(|item| matches!(item, DisplayItem::Text(_)))
|
||||
.count();
|
||||
// At most 6 text items should be visible (5 fit + 1 partial).
|
||||
assert!(text_items <= 6, "expected <= 6 text items in scene, got {text_items} (culling not working)");
|
||||
assert!(text_items >= 4, "expected >= 4 text items visible, got {text_items}");
|
||||
assert!(
|
||||
text_items <= 6,
|
||||
"expected <= 6 text items in scene, got {text_items} (culling not working)"
|
||||
);
|
||||
assert!(
|
||||
text_items >= 4,
|
||||
"expected >= 4 text items visible, got {text_items}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//! sibling crates.
|
||||
|
||||
pub(crate) mod trace_targets {
|
||||
#![allow(dead_code)]
|
||||
pub const PLATFORM: &str = "ruin_ui::platform";
|
||||
pub const SCENE: &str = "ruin_ui::scene";
|
||||
pub const TEXT_PERF: &str = "ruin_ui::text_perf";
|
||||
@@ -30,7 +31,8 @@ pub use interaction::{
|
||||
pub use keyboard::{KeyboardEvent, KeyboardEventKind, KeyboardKey, KeyboardModifiers};
|
||||
pub use layout::{
|
||||
HitTarget, InteractionTree, LayoutCache, LayoutNode, LayoutPath, LayoutSnapshot, ScrollMetrics,
|
||||
TextHitTarget, layout_snapshot, layout_snapshot_with_cache, layout_snapshot_with_text_system,
|
||||
TextHitTarget, element_min_size, layout_snapshot, layout_snapshot_with_cache,
|
||||
layout_snapshot_with_text_system,
|
||||
};
|
||||
pub use layout::{layout_scene, layout_scene_with_text_system};
|
||||
pub use platform::{
|
||||
@@ -48,8 +50,8 @@ pub use text::{
|
||||
TextStyle, TextSystem, TextWrap,
|
||||
};
|
||||
pub use tree::{
|
||||
Border, BoxShadow, BoxShadowKind, CornerRadius, CursorIcon, Edges, Element, ElementId,
|
||||
FlexDirection, ScrollbarStyle, Style,
|
||||
AlignItems, Border, BoxShadow, BoxShadowKind, CornerRadius, CursorIcon, Edges, Element,
|
||||
ElementId, FlexDirection, ScrollbarStyle, Style,
|
||||
};
|
||||
pub use window::{
|
||||
DecorationMode, WindowConfigured, WindowId, WindowLifecycle, WindowSpec, WindowUpdate,
|
||||
|
||||
@@ -80,6 +80,9 @@ impl Color {
|
||||
/// safe to reserve as a sentinel. No user-facing API sets this value directly.
|
||||
pub const SENTINEL: Self = Self::rgba(0, 0, 0, 0);
|
||||
|
||||
pub const BLACK: Self = Self::rgb(0, 0, 0);
|
||||
pub const WHITE: Self = Self::rgb(255, 255, 255);
|
||||
|
||||
pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self {
|
||||
Self { r, g, b, a }
|
||||
}
|
||||
@@ -219,6 +222,7 @@ impl Deref for PreparedText {
|
||||
|
||||
impl PreparedText {
|
||||
/// Construct a `PreparedText` from shaped data. Called by `TextSystem::prepare_spans`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn from_layout(
|
||||
element_id: Option<ElementId>,
|
||||
text: String,
|
||||
@@ -623,10 +627,18 @@ impl DisplayItem {
|
||||
/// Return a copy of this item with all positions shifted by `offset`.
|
||||
pub fn translated(&self, offset: Point) -> Self {
|
||||
fn translate_rect(r: Rect, o: Point) -> Rect {
|
||||
Rect::new(r.origin.x + o.x, r.origin.y + o.y, r.size.width, r.size.height)
|
||||
Rect::new(
|
||||
r.origin.x + o.x,
|
||||
r.origin.y + o.y,
|
||||
r.size.width,
|
||||
r.size.height,
|
||||
)
|
||||
}
|
||||
match self {
|
||||
Self::Quad(q) => Self::Quad(Quad { rect: translate_rect(q.rect, offset), ..*q }),
|
||||
Self::Quad(q) => Self::Quad(Quad {
|
||||
rect: translate_rect(q.rect, offset),
|
||||
..*q
|
||||
}),
|
||||
Self::RoundedRect(r) => Self::RoundedRect(RoundedRect {
|
||||
rect: translate_rect(r.rect, offset),
|
||||
..*r
|
||||
@@ -838,8 +850,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn prepared_text_vertical_offset_moves_between_lines() {
|
||||
use std::sync::Arc;
|
||||
use super::{PreparedTextLine, TextLayoutData};
|
||||
use std::sync::Arc;
|
||||
|
||||
let mut text = PreparedText::monospace(
|
||||
"abcdwxyz",
|
||||
@@ -873,7 +885,11 @@ mod tests {
|
||||
}
|
||||
}
|
||||
let orig_size = text.layout.size;
|
||||
text.layout = Arc::new(TextLayoutData { lines, glyphs, size: orig_size });
|
||||
text.layout = Arc::new(TextLayoutData {
|
||||
lines,
|
||||
glyphs,
|
||||
size: orig_size,
|
||||
});
|
||||
|
||||
assert_eq!(text.vertical_offset(2, 1), Some(6));
|
||||
assert_eq!(text.vertical_offset(6, -1), Some(2));
|
||||
@@ -892,8 +908,12 @@ mod tests {
|
||||
let target_line = &text.lines[1];
|
||||
// Lines store LOCAL coords; add text.origin to get absolute window coords for the query.
|
||||
let y = text.origin.y + target_line.rect.origin.y + target_line.rect.size.height * 0.5;
|
||||
let start = text.byte_offset_for_position(Point::new(text.origin.x + target_line.rect.origin.x, y));
|
||||
let end = text.byte_offset_for_position(Point::new(text.origin.x + target_line.rect.origin.x + 16.0, y));
|
||||
let start =
|
||||
text.byte_offset_for_position(Point::new(text.origin.x + target_line.rect.origin.x, y));
|
||||
let end = text.byte_offset_for_position(Point::new(
|
||||
text.origin.x + target_line.rect.origin.x + 16.0,
|
||||
y,
|
||||
));
|
||||
let rects = text.selection_rects(start, end);
|
||||
|
||||
assert_eq!(rects.len(), 1);
|
||||
|
||||
@@ -23,6 +23,15 @@ pub enum FlexDirection {
|
||||
Column,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub enum AlignItems {
|
||||
#[default]
|
||||
Stretch,
|
||||
Start,
|
||||
Center,
|
||||
End,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum CursorIcon {
|
||||
Default,
|
||||
@@ -180,8 +189,13 @@ impl Default for ScrollbarStyle {
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Style {
|
||||
pub direction: FlexDirection,
|
||||
pub align_items: AlignItems,
|
||||
/// Per-element override of the parent container's `align_items`. `None` means inherit.
|
||||
pub align_self: Option<AlignItems>,
|
||||
pub width: Option<f32>,
|
||||
pub height: Option<f32>,
|
||||
pub min_width: Option<f32>,
|
||||
pub min_height: Option<f32>,
|
||||
pub flex_grow: f32,
|
||||
pub gap: f32,
|
||||
pub padding: Edges,
|
||||
@@ -198,8 +212,12 @@ impl Default for Style {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
direction: FlexDirection::Column,
|
||||
align_items: AlignItems::Stretch,
|
||||
align_self: None,
|
||||
width: None,
|
||||
height: None,
|
||||
min_width: None,
|
||||
min_height: None,
|
||||
flex_grow: 0.0,
|
||||
gap: 0.0,
|
||||
padding: Edges::ZERO,
|
||||
@@ -332,6 +350,20 @@ impl Element {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn min_width(mut self, min_width: f32) -> Self {
|
||||
self.style.min_width = Some(min_width.max(0.0));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn min_height(mut self, min_height: f32) -> Self {
|
||||
self.style.min_height = Some(min_height.max(0.0));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn min_size(self, min_width: f32, min_height: f32) -> Self {
|
||||
self.min_width(min_width).min_height(min_height)
|
||||
}
|
||||
|
||||
pub fn flex(mut self, flex_grow: f32) -> Self {
|
||||
self.style.flex_grow = flex_grow.max(0.0);
|
||||
self
|
||||
@@ -382,6 +414,16 @@ impl Element {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn align_items(mut self, align: AlignItems) -> Self {
|
||||
self.style.align_items = align;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn align_self(mut self, align: AlignItems) -> Self {
|
||||
self.style.align_self = Some(align);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn cursor(mut self, cursor: CursorIcon) -> Self {
|
||||
self.style.cursor = Some(cursor);
|
||||
self
|
||||
@@ -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 {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
self.direction.hash(state);
|
||||
self.align_items.hash(state);
|
||||
self.align_self.hash(state);
|
||||
self.width.map(f32::to_bits).hash(state);
|
||||
self.height.map(f32::to_bits).hash(state);
|
||||
self.min_width.map(f32::to_bits).hash(state);
|
||||
self.min_height.map(f32::to_bits).hash(state);
|
||||
self.flex_grow.to_bits().hash(state);
|
||||
self.gap.to_bits().hash(state);
|
||||
self.padding.hash(state);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Common window model types.
|
||||
|
||||
use crate::scene::UiSize;
|
||||
use crate::scene::{Color, UiSize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
pub struct WindowId(u64);
|
||||
@@ -55,6 +55,7 @@ pub struct WindowSpec {
|
||||
pub maximized: bool,
|
||||
pub fullscreen: bool,
|
||||
pub resizable: bool,
|
||||
pub base_color: Color,
|
||||
}
|
||||
|
||||
impl WindowSpec {
|
||||
@@ -72,9 +73,15 @@ impl WindowSpec {
|
||||
maximized: false,
|
||||
fullscreen: false,
|
||||
resizable: true,
|
||||
base_color: Color::rgb(0x08, 0x0C, 0x14),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn base_color(mut self, color: Color) -> Self {
|
||||
self.base_color = color;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn key(mut self, key: impl Into<String>) -> Self {
|
||||
self.key = Some(key.into());
|
||||
self
|
||||
|
||||
@@ -40,6 +40,9 @@ use wayland_client::{
|
||||
use wayland_protocols::wp::cursor_shape::v1::client::{
|
||||
wp_cursor_shape_device_v1, wp_cursor_shape_manager_v1,
|
||||
};
|
||||
use wayland_protocols::xdg::decoration::zv1::client::{
|
||||
zxdg_decoration_manager_v1, zxdg_toplevel_decoration_v1,
|
||||
};
|
||||
use wayland_protocols::wp::primary_selection::zv1::client::{
|
||||
zwp_primary_selection_device_manager_v1, zwp_primary_selection_device_v1,
|
||||
zwp_primary_selection_offer_v1, zwp_primary_selection_source_v1,
|
||||
@@ -145,6 +148,7 @@ enum InternalBackendEvent {
|
||||
|
||||
struct State {
|
||||
running: bool,
|
||||
fixed_size_request: Option<(u32, u32)>,
|
||||
_connection: Connection,
|
||||
_compositor: wl_compositor::WlCompositor,
|
||||
_surface: wl_surface::WlSurface,
|
||||
@@ -156,6 +160,8 @@ struct State {
|
||||
clipboard_device: Option<wl_data_device::WlDataDevice>,
|
||||
cursor_shape_manager: Option<wp_cursor_shape_manager_v1::WpCursorShapeManagerV1>,
|
||||
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:
|
||||
Option<zwp_primary_selection_device_manager_v1::ZwpPrimarySelectionDeviceManagerV1>,
|
||||
primary_selection_device: Option<zwp_primary_selection_device_v1::ZwpPrimarySelectionDeviceV1>,
|
||||
@@ -227,6 +233,18 @@ impl State {
|
||||
}
|
||||
}
|
||||
|
||||
fn fixed_size_request_for_spec(spec: &WindowSpec) -> Option<(u32, u32)> {
|
||||
if spec.resizable {
|
||||
return None;
|
||||
}
|
||||
let size = spec
|
||||
.requested_inner_size
|
||||
.or(spec.min_inner_size)
|
||||
.or(spec.max_inner_size)
|
||||
.unwrap_or_else(|| UiSize::new(800.0, 500.0));
|
||||
Some((size.width.max(1.0).round() as u32, size.height.max(1.0).round() as u32))
|
||||
}
|
||||
|
||||
fn wayland_cursor_shape(icon: CursorIcon) -> wp_cursor_shape_device_v1::Shape {
|
||||
match icon {
|
||||
CursorIcon::Default => wp_cursor_shape_device_v1::Shape::Default,
|
||||
@@ -318,6 +336,8 @@ impl WaylandWindow {
|
||||
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 xdg_surface = wm_base.get_xdg_surface(&surface, &qh, ());
|
||||
let toplevel = xdg_surface.get_toplevel(&qh, ());
|
||||
@@ -325,6 +345,11 @@ impl WaylandWindow {
|
||||
if let Some(app_id) = spec.app_id.as_ref() {
|
||||
toplevel.set_app_id(app_id.clone());
|
||||
}
|
||||
let decoration = decoration_manager.as_ref().map(|dm| {
|
||||
let deco = dm.get_toplevel_decoration(&toplevel, &qh, ());
|
||||
deco.set_mode(zxdg_toplevel_decoration_v1::Mode::ServerSide);
|
||||
deco
|
||||
});
|
||||
|
||||
apply_size_constraints(&toplevel, &spec);
|
||||
if spec.maximized {
|
||||
@@ -352,6 +377,7 @@ impl WaylandWindow {
|
||||
surface_target,
|
||||
state: State {
|
||||
running: true,
|
||||
fixed_size_request: fixed_size_request_for_spec(&spec),
|
||||
_connection: connection,
|
||||
_compositor: compositor,
|
||||
_surface: surface,
|
||||
@@ -395,6 +421,7 @@ impl WaylandWindow {
|
||||
last_selection_serial: None,
|
||||
last_pointer_enter_serial: None,
|
||||
cursor_icon: CursorIcon::Default,
|
||||
_decoration: decoration,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -684,6 +711,7 @@ impl WaylandWindow {
|
||||
}
|
||||
|
||||
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());
|
||||
if let Some(app_id) = spec.app_id.as_ref() {
|
||||
self.state._toplevel.set_app_id(app_id.clone());
|
||||
@@ -719,6 +747,14 @@ impl WaylandWindow {
|
||||
}
|
||||
|
||||
if let Some((width, height)) = self.state.pending_size.take() {
|
||||
trace!(
|
||||
target: "ruin_ui_platform_wayland::resize",
|
||||
width,
|
||||
height,
|
||||
previous_width = self.state.current_size.0,
|
||||
previous_height = self.state.current_size.1,
|
||||
"prepare_frame observed pending size change"
|
||||
);
|
||||
self.state.current_size = (width, height);
|
||||
self.state.needs_redraw = false;
|
||||
return Some(FrameRequest {
|
||||
@@ -729,6 +765,12 @@ impl WaylandWindow {
|
||||
}
|
||||
|
||||
if self.state.needs_redraw {
|
||||
trace!(
|
||||
target: "ruin_ui_platform_wayland::resize",
|
||||
width = self.state.current_size.0,
|
||||
height = self.state.current_size.1,
|
||||
"prepare_frame servicing redraw without size change"
|
||||
);
|
||||
self.state.needs_redraw = false;
|
||||
return Some(FrameRequest {
|
||||
width: self.state.current_size.0,
|
||||
@@ -1083,6 +1125,7 @@ fn spawn_window_worker(
|
||||
spec.requested_inner_size
|
||||
.unwrap_or_else(|| UiSize::new(800.0, 500.0))
|
||||
.height as u32,
|
||||
spec.base_color,
|
||||
) {
|
||||
Ok(renderer) => renderer,
|
||||
Err(_) => {
|
||||
@@ -1355,6 +1398,19 @@ fn spawn_window_worker(
|
||||
);
|
||||
state_ref.pending_viewport = Some(current_viewport);
|
||||
state_ref.pending_viewport_since.get_or_insert_with(Instant::now);
|
||||
// Deadlock prevention: if the app responded to the
|
||||
// configure we sent (scene matches in_flight) but the
|
||||
// viewport has since moved on, clear the in_flight
|
||||
// marker so we can immediately send the next configure.
|
||||
// Without this, viewport_request_in_flight stays set
|
||||
// to the old size forever and the app is never told
|
||||
// about the new viewport.
|
||||
if state_ref.viewport_request_in_flight
|
||||
== Some(scene.logical_size)
|
||||
{
|
||||
state_ref.viewport_request_in_flight = None;
|
||||
maybe_request_pending_viewport(&mut state_ref);
|
||||
}
|
||||
state_ref.window.request_redraw();
|
||||
} else if !state_ref.window.presentation_ready() {
|
||||
// Correct scene is ready but the compositor
|
||||
@@ -1582,6 +1638,8 @@ delegate_noop!(
|
||||
State: ignore
|
||||
zwp_primary_selection_device_manager_v1::ZwpPrimarySelectionDeviceManagerV1
|
||||
);
|
||||
delegate_noop!(State: ignore zxdg_decoration_manager_v1::ZxdgDecorationManagerV1);
|
||||
delegate_noop!(State: ignore zxdg_toplevel_decoration_v1::ZxdgToplevelDecorationV1);
|
||||
impl Dispatch<wl_seat::WlSeat, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
@@ -2072,6 +2130,14 @@ impl Dispatch<xdg_surface::XdgSurface, ()> for State {
|
||||
if let xdg_surface::Event::Configure { serial } = event {
|
||||
xdg_surface.ack_configure(serial);
|
||||
state.configured = true;
|
||||
trace!(
|
||||
target: "ruin_ui_platform_wayland::resize",
|
||||
serial,
|
||||
current_width = state.current_size.0,
|
||||
current_height = state.current_size.1,
|
||||
has_pending_size = state.pending_size.is_some(),
|
||||
"received xdg_surface configure"
|
||||
);
|
||||
state.request_redraw();
|
||||
}
|
||||
}
|
||||
@@ -2124,7 +2190,37 @@ impl Dispatch<xdg_toplevel::XdgToplevel, ()> for State {
|
||||
height,
|
||||
"received Wayland toplevel configure"
|
||||
);
|
||||
state.pending_size = Some((width, height));
|
||||
let next_size = (width, height);
|
||||
if let Some(expected_size) = state.fixed_size_request {
|
||||
if next_size != expected_size {
|
||||
trace!(
|
||||
target: "ruin_ui_platform_wayland::resize",
|
||||
width,
|
||||
height,
|
||||
expected_width = expected_size.0,
|
||||
expected_height = expected_size.1,
|
||||
"ignoring stale toplevel configure for fixed-size window"
|
||||
);
|
||||
state.request_redraw();
|
||||
return;
|
||||
}
|
||||
}
|
||||
let size_changed =
|
||||
next_size != state.current_size && state.pending_size != Some(next_size);
|
||||
trace!(
|
||||
target: "ruin_ui_platform_wayland::resize",
|
||||
width,
|
||||
height,
|
||||
current_width = state.current_size.0,
|
||||
current_height = state.current_size.1,
|
||||
pending_width = state.pending_size.map(|size| size.0),
|
||||
pending_height = state.pending_size.map(|size| size.1),
|
||||
size_changed,
|
||||
"processed Wayland toplevel configure"
|
||||
);
|
||||
if size_changed {
|
||||
state.pending_size = Some(next_size);
|
||||
}
|
||||
state.request_redraw();
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -330,6 +330,7 @@ pub struct WgpuSceneRenderer {
|
||||
image_cache_order: VecDeque<u64>,
|
||||
#[allow(dead_code)]
|
||||
glyph_atlas: GlyphAtlas,
|
||||
base_color: Color,
|
||||
}
|
||||
|
||||
const MAX_TEXT_CACHE_ENTRIES: usize = 64;
|
||||
@@ -343,6 +344,7 @@ impl WgpuSceneRenderer {
|
||||
target: impl HasDisplayHandle + HasWindowHandle + Send + Sync + 'static,
|
||||
width: u32,
|
||||
height: u32,
|
||||
base_color: Color,
|
||||
) -> Result<Self, Box<dyn Error>> {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let surface = instance.create_surface(target)?;
|
||||
@@ -361,6 +363,15 @@ impl WgpuSceneRenderer {
|
||||
.copied()
|
||||
.find(wgpu::TextureFormat::is_srgb)
|
||||
.unwrap_or(caps.formats[0]);
|
||||
let alpha_mode = if base_color.a < 255
|
||||
&& caps
|
||||
.alpha_modes
|
||||
.contains(&wgpu::CompositeAlphaMode::PreMultiplied)
|
||||
{
|
||||
wgpu::CompositeAlphaMode::PreMultiplied
|
||||
} else {
|
||||
caps.alpha_modes[0]
|
||||
};
|
||||
let config = wgpu::SurfaceConfiguration {
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
format,
|
||||
@@ -368,7 +379,7 @@ impl WgpuSceneRenderer {
|
||||
height: height.max(1),
|
||||
present_mode: wgpu::PresentMode::AutoVsync,
|
||||
desired_maximum_frame_latency: 2,
|
||||
alpha_mode: caps.alpha_modes[0],
|
||||
alpha_mode,
|
||||
view_formats: vec![],
|
||||
};
|
||||
surface.configure(&device, &config);
|
||||
@@ -415,7 +426,7 @@ fn sd_round_rect(point: vec2<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);
|
||||
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 {
|
||||
@@ -553,7 +564,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 {
|
||||
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 {
|
||||
@@ -710,6 +721,7 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
|
||||
image_cache: HashMap::new(),
|
||||
image_cache_order: VecDeque::new(),
|
||||
glyph_atlas,
|
||||
base_color,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -792,11 +804,16 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color {
|
||||
r: 0.03,
|
||||
g: 0.04,
|
||||
b: 0.08,
|
||||
a: 1.0,
|
||||
load: wgpu::LoadOp::Clear({
|
||||
let a = self.base_color.a as f64 / 255.0;
|
||||
// Premultiplied: multiply RGB by alpha so the compositor
|
||||
// can blend correctly when alpha_mode is PreMultiplied.
|
||||
wgpu::Color {
|
||||
r: (self.base_color.r as f64 / 255.0) * a,
|
||||
g: (self.base_color.g as f64 / 255.0) * a,
|
||||
b: (self.base_color.b as f64 / 255.0) * a,
|
||||
a,
|
||||
}
|
||||
}),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
@@ -990,7 +1007,9 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
|
||||
bottom: physical.y - image.placement.top + height,
|
||||
},
|
||||
content: image.content,
|
||||
color: glyph.color_opt.map_or(text.default_color, color_from_cosmic),
|
||||
color: glyph
|
||||
.color_opt
|
||||
.map_or(text.default_color, color_from_cosmic),
|
||||
data: image.data.clone(),
|
||||
});
|
||||
}
|
||||
@@ -1470,7 +1489,11 @@ fn create_glyph_atlas(
|
||||
/// Resolve `Color::SENTINEL` to `default_color`; return other colors unchanged.
|
||||
#[inline]
|
||||
fn resolve_glyph_color(color: Color, default_color: Color) -> Color {
|
||||
if color == Color::SENTINEL { default_color } else { color }
|
||||
if color == Color::SENTINEL {
|
||||
default_color
|
||||
} else {
|
||||
color
|
||||
}
|
||||
}
|
||||
|
||||
fn color_from_cosmic(color: cosmic_text::Color) -> Color {
|
||||
@@ -2297,7 +2320,7 @@ fn clip_textured_rect(
|
||||
/// return only those glyph indices. This turns O(all_glyphs) iteration into
|
||||
/// O(log(lines) + visible_glyphs) — critical for large text nodes in scroll
|
||||
/// boxes where only a few lines are on screen at once.
|
||||
fn clip_visible_glyphs<'a>(text: &'a PreparedText, clip: Option<Rect>) -> &'a [GlyphInstance] {
|
||||
fn clip_visible_glyphs(text: &PreparedText, clip: Option<Rect>) -> &[GlyphInstance] {
|
||||
let Some(clip) = clip else {
|
||||
return &text.glyphs;
|
||||
};
|
||||
@@ -2332,7 +2355,12 @@ fn text_texture_key(text: &PreparedText) -> TextTextureKey {
|
||||
.map(|bounds| (bounds.width.to_bits(), bounds.height.to_bits())),
|
||||
font_size_bits: text.font_size.to_bits(),
|
||||
line_height_bits: text.line_height.to_bits(),
|
||||
color: (text.default_color.r, text.default_color.g, text.default_color.b, text.default_color.a),
|
||||
color: (
|
||||
text.default_color.r,
|
||||
text.default_color.g,
|
||||
text.default_color.b,
|
||||
text.default_color.a,
|
||||
),
|
||||
glyphs: text
|
||||
.glyphs
|
||||
.iter()
|
||||
@@ -2354,10 +2382,7 @@ mod tests {
|
||||
ActiveClip, blend_rgba, build_text_vertices, build_vertices, clip_rect_array,
|
||||
push_clip_state, rounded_clip_arrays, text_texture_key,
|
||||
};
|
||||
use ruin_ui::{
|
||||
ClipRegion, Color, GlyphInstance, Point, PreparedText, Rect, SceneSnapshot,
|
||||
TextSelectionStyle, UiSize,
|
||||
};
|
||||
use ruin_ui::{ClipRegion, Color, Point, PreparedText, Rect, SceneSnapshot, UiSize};
|
||||
|
||||
#[test]
|
||||
fn quad_scenes_expand_to_six_vertices_per_quad() {
|
||||
|
||||
Reference in New Issue
Block a user