Compare commits
14 Commits
bfda24ad0a
...
macos
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccc3984b01 | ||
|
6dcc2d0d00
|
|||
|
|
23d9f02e3a | ||
|
|
d9ac6bfeb8 | ||
|
|
056f356065 | ||
|
|
1a083ee12c | ||
|
e2c2563b7e
|
|||
|
cd55cf14c5
|
|||
|
341d07d82f
|
|||
|
5f67c06083
|
|||
|
e327516e5e
|
|||
|
6079117f2c
|
|||
|
d961dd7491
|
|||
|
ad5c233b15
|
85
Cargo.lock
generated
85
Cargo.lock
generated
@@ -150,7 +150,7 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"arrayvec",
|
"arrayvec",
|
||||||
"log",
|
"log",
|
||||||
"nom",
|
"nom 8.0.0",
|
||||||
"num-rational",
|
"num-rational",
|
||||||
"v_frame",
|
"v_frame",
|
||||||
]
|
]
|
||||||
@@ -253,6 +253,17 @@ version = "1.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "calculator"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"meval",
|
||||||
|
"ruin-runtime",
|
||||||
|
"ruin_app",
|
||||||
|
"ruin_ui",
|
||||||
|
"tracing-subscriber",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cast"
|
name = "cast"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@@ -606,6 +617,12 @@ dependencies = [
|
|||||||
"miniz_oxide",
|
"miniz_oxide",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fnv"
|
||||||
|
version = "1.0.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foldhash"
|
name = "foldhash"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -834,6 +851,17 @@ dependencies = [
|
|||||||
"foldhash 0.2.0",
|
"foldhash 0.2.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.17.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
|
||||||
|
dependencies = [
|
||||||
|
"allocator-api2",
|
||||||
|
"equivalent",
|
||||||
|
"foldhash 0.2.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hermit-abi"
|
name = "hermit-abi"
|
||||||
version = "0.5.2"
|
version = "0.5.2"
|
||||||
@@ -1165,6 +1193,16 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "meval"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f79496a5651c8d57cd033c5add8ca7ee4e3d5f7587a4777484640d9cb60392d9"
|
||||||
|
dependencies = [
|
||||||
|
"fnv",
|
||||||
|
"nom 1.2.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "miniz_oxide"
|
name = "miniz_oxide"
|
||||||
version = "0.8.9"
|
version = "0.8.9"
|
||||||
@@ -1226,6 +1264,12 @@ version = "1.0.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nom"
|
||||||
|
version = "1.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a5b8c256fd9471521bcb84c3cdba98921497f1a331cbc15b8030fc63b82050ce"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nom"
|
name = "nom"
|
||||||
version = "8.0.0"
|
version = "8.0.0"
|
||||||
@@ -1241,6 +1285,15 @@ version = "0.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
|
checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nu-ansi-term"
|
||||||
|
version = "0.50.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-bigint"
|
name = "num-bigint"
|
||||||
version = "0.4.6"
|
version = "0.4.6"
|
||||||
@@ -1838,6 +1891,14 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ruin-reactivity-parsing"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"ruin-runtime",
|
||||||
|
"ruin_reactivity",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ruin-runtime"
|
name = "ruin-runtime"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -1877,6 +1938,7 @@ dependencies = [
|
|||||||
name = "ruin_reactivity"
|
name = "ruin_reactivity"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"hashbrown 0.17.0",
|
||||||
"ruin-runtime",
|
"ruin-runtime",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
@@ -2320,6 +2382,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"once_cell",
|
"once_cell",
|
||||||
|
"valuable",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-log"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"once_cell",
|
||||||
|
"tracing-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2329,12 +2403,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
|
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"matchers",
|
"matchers",
|
||||||
|
"nu-ansi-term",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"regex-automata",
|
"regex-automata",
|
||||||
"sharded-slab",
|
"sharded-slab",
|
||||||
|
"smallvec",
|
||||||
"thread_local",
|
"thread_local",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-core",
|
"tracing-core",
|
||||||
|
"tracing-log",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2399,6 +2476,12 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "valuable"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "version_check"
|
name = "version_check"
|
||||||
version = "0.9.5"
|
version = "0.9.5"
|
||||||
|
|||||||
18
LICENSE.txt
Normal file
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"] }
|
||||||
379
examples/calculator/src/components.rs
Normal file
379
examples/calculator/src/components.rs
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
use ruin_app::prelude::*;
|
||||||
|
use ruin_ui::{BoxShadow, BoxShadowKind, Point, TextFontFamily};
|
||||||
|
|
||||||
|
use crate::state::{CalcMode, CalcState};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Display panel
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Display(state: CalcState) -> impl IntoView {
|
||||||
|
let expression = state.expression.clone();
|
||||||
|
let is_error = state.is_error.clone();
|
||||||
|
let history = state.history.clone();
|
||||||
|
|
||||||
|
let result_color = use_memo({
|
||||||
|
let is_error = is_error.clone();
|
||||||
|
move || {
|
||||||
|
if is_error.get() {
|
||||||
|
Color::rgba(200, 30, 30, 245)
|
||||||
|
} else {
|
||||||
|
Color::rgba(20, 140, 20, 245)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let result_text = use_memo({
|
||||||
|
let result = state.result.clone();
|
||||||
|
move || {
|
||||||
|
let r = result.get();
|
||||||
|
if r.is_empty() {
|
||||||
|
" ".to_string()
|
||||||
|
} else {
|
||||||
|
format!("= {r}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
view! {
|
||||||
|
block(
|
||||||
|
padding = 12.0,
|
||||||
|
gap = 8.0,
|
||||||
|
// min_height (content) sized to cover scroll_box + expression + result lines
|
||||||
|
// so the window auto-sizes correctly despite text min-size being 0.
|
||||||
|
// Content: scroll_box(100) + expr_text(31) + result_text(20) + 2×gap(16) = 167
|
||||||
|
min_height = 167.0,
|
||||||
|
align_self = AlignItems::Stretch,
|
||||||
|
align_items = AlignItems::End,
|
||||||
|
background = Color::rgba(255, 255, 255, 200),
|
||||||
|
border_radius = 12.0,
|
||||||
|
border = (1.0, Color::rgba(0, 0, 0, 60)),
|
||||||
|
shadow = BoxShadow::new(Point::new(2.0, 2.0), 8.0, 2.0, Color::rgba(0, 0, 0, 40), BoxShadowKind::Outer),
|
||||||
|
) {
|
||||||
|
block(flex = 1.0, min_height = 0.0) {}
|
||||||
|
|
||||||
|
// History (oldest at top, newest at bottom, scrollable)
|
||||||
|
scroll_box(
|
||||||
|
height = 100.0,
|
||||||
|
offset_y = state.history_scroll.clone(),
|
||||||
|
align_self = AlignItems::Stretch,
|
||||||
|
) {
|
||||||
|
column(padding = 4.0, gap = 2.0, align_items = AlignItems::End) {
|
||||||
|
history.with(|entries| {
|
||||||
|
entries
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.map(|e| {
|
||||||
|
let expr = e.expression.clone();
|
||||||
|
let res = e.result.clone();
|
||||||
|
let s = state.clone();
|
||||||
|
let res_for_press = res.clone();
|
||||||
|
view! {
|
||||||
|
button(
|
||||||
|
align_self = AlignItems::End,
|
||||||
|
padding = 4.0,
|
||||||
|
background = Color::rgba(0, 0, 0, 0),
|
||||||
|
border_radius = 4.0,
|
||||||
|
on_press = move |_| s.use_history_result(res_for_press.clone()),
|
||||||
|
) {
|
||||||
|
row(gap = 6.0, align_self = AlignItems::End) {
|
||||||
|
text(size = 12.0, color = Color::rgba(0, 0, 0, 200), font_family = TextFontFamily::Monospace, wrap = TextWrap::None, weight = FontWeight::Normal) {
|
||||||
|
expr.clone()
|
||||||
|
}
|
||||||
|
text(size = 12.0, color = Color::rgba(20, 130, 20, 230), font_family = TextFontFamily::Monospace, wrap = TextWrap::None, weight = FontWeight::Normal) {
|
||||||
|
format!("= {res}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current expression
|
||||||
|
text(
|
||||||
|
size = 26.0,
|
||||||
|
font_family = TextFontFamily::Monospace,
|
||||||
|
color = Color::rgba(0, 0, 0, 245),
|
||||||
|
wrap = TextWrap::None,
|
||||||
|
weight = FontWeight::Normal,
|
||||||
|
pointer_events = false,
|
||||||
|
) {
|
||||||
|
expression.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result / error line
|
||||||
|
if is_error.get() {
|
||||||
|
view! {
|
||||||
|
text(
|
||||||
|
size = 16.0,
|
||||||
|
font_family = TextFontFamily::Monospace,
|
||||||
|
color = result_color.get(),
|
||||||
|
wrap = TextWrap::None,
|
||||||
|
weight = FontWeight::Normal,
|
||||||
|
pointer_events = false,
|
||||||
|
) {
|
||||||
|
result_text.get()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
view! {
|
||||||
|
block(min_height = 0.0) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mode toggle
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn ModeToggle(state: CalcState) -> impl IntoView {
|
||||||
|
let mode = state.mode.clone();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
row(gap = 6.0, align_self = AlignItems::Stretch) {
|
||||||
|
toggle_button("Basic", mode.get() == CalcMode::Basic, {
|
||||||
|
let state = state.clone();
|
||||||
|
move || state.toggle_mode()
|
||||||
|
})
|
||||||
|
toggle_button("Scientific", mode.get() == CalcMode::Scientific, {
|
||||||
|
let state = state.clone();
|
||||||
|
move || state.toggle_mode()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Button helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn calc_button(label: String, bg: Color, on_press: impl Fn() + 'static) -> View {
|
||||||
|
let widget_ref = use_widget_ref::<BlockWidget>();
|
||||||
|
let interaction = use_interaction_state(widget_ref.clone());
|
||||||
|
|
||||||
|
button()
|
||||||
|
.widget_ref(widget_ref)
|
||||||
|
.flex(1.0)
|
||||||
|
.min_width(54.0)
|
||||||
|
// min_height = content height for 20px bold text (20 * 1.2 = 24).
|
||||||
|
// This matches the actual rendered line height so window sizing is accurate
|
||||||
|
// and the text is visually centered by the symmetric 8px top/bottom padding.
|
||||||
|
.min_height(24.0)
|
||||||
|
.padding(Edges::symmetric(8.0, 12.0))
|
||||||
|
.align_items(AlignItems::Center)
|
||||||
|
.background(interactive_background(bg, interaction))
|
||||||
|
.border_radius(8.0)
|
||||||
|
.border((0.5, interactive_border(interaction)))
|
||||||
|
.shadow(interactive_shadow(interaction))
|
||||||
|
.on_press(move |_| on_press())
|
||||||
|
.children(
|
||||||
|
text()
|
||||||
|
.size(20.0)
|
||||||
|
.weight(FontWeight::Bold)
|
||||||
|
.selectable(false)
|
||||||
|
.pointer_events(false)
|
||||||
|
.children(label),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn btn_row(buttons: Vec<View>) -> View {
|
||||||
|
row().gap(6.0).children(buttons)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn toggle_button(label: &'static str, active: bool, on_press: impl Fn() + 'static) -> View {
|
||||||
|
let widget_ref = use_widget_ref::<BlockWidget>();
|
||||||
|
let interaction = use_interaction_state(widget_ref.clone());
|
||||||
|
let base = if active {
|
||||||
|
Color::rgba(60, 100, 220, 180)
|
||||||
|
} else {
|
||||||
|
Color::rgba(255, 255, 255, 90)
|
||||||
|
};
|
||||||
|
|
||||||
|
button()
|
||||||
|
.widget_ref(widget_ref)
|
||||||
|
.flex(1.0)
|
||||||
|
.padding(8.0)
|
||||||
|
.min_height(17.0)
|
||||||
|
.align_items(AlignItems::Center)
|
||||||
|
.background(interactive_background(base, interaction))
|
||||||
|
.border_radius(8.0)
|
||||||
|
.border((0.5, interactive_border(interaction)))
|
||||||
|
.shadow(interactive_shadow(interaction))
|
||||||
|
.on_press(move |_| on_press())
|
||||||
|
.children(
|
||||||
|
text()
|
||||||
|
.size(14.0)
|
||||||
|
.weight(FontWeight::Bold)
|
||||||
|
.selectable(false)
|
||||||
|
.pointer_events(false)
|
||||||
|
.children(label),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn interactive_background(base: Color, interaction: InteractionState) -> Color {
|
||||||
|
if interaction.pressed {
|
||||||
|
mix(base, Color::BLACK, 0.22)
|
||||||
|
} else if interaction.hovered {
|
||||||
|
mix(base, Color::WHITE, 0.22)
|
||||||
|
} else {
|
||||||
|
base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn interactive_border(interaction: InteractionState) -> Color {
|
||||||
|
if interaction.pressed {
|
||||||
|
Color::rgba(0, 0, 0, 96)
|
||||||
|
} else if interaction.hovered {
|
||||||
|
Color::rgba(0, 0, 0, 72)
|
||||||
|
} else {
|
||||||
|
Color::rgba(0, 0, 0, 35)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn interactive_shadow(interaction: InteractionState) -> BoxShadow {
|
||||||
|
if interaction.pressed {
|
||||||
|
BoxShadow::new(
|
||||||
|
Point::new(0.0, 0.0),
|
||||||
|
2.0,
|
||||||
|
0.0,
|
||||||
|
Color::rgba(0, 0, 0, 36),
|
||||||
|
BoxShadowKind::Outer,
|
||||||
|
)
|
||||||
|
} else if interaction.hovered {
|
||||||
|
BoxShadow::new(
|
||||||
|
Point::new(0.0, 3.0),
|
||||||
|
14.0,
|
||||||
|
0.0,
|
||||||
|
Color::rgba(0, 0, 0, 42),
|
||||||
|
BoxShadowKind::Outer,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
BoxShadow::new(
|
||||||
|
Point::new(0.0, 1.0),
|
||||||
|
6.0,
|
||||||
|
0.0,
|
||||||
|
Color::rgba(0, 0, 0, 18),
|
||||||
|
BoxShadowKind::Outer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mix(base: Color, overlay: Color, amount: f32) -> Color {
|
||||||
|
let amount = amount.clamp(0.0, 1.0);
|
||||||
|
let blend = |from: u8, to: u8| -> u8 {
|
||||||
|
((from as f32) + ((to as f32) - (from as f32)) * amount).round() as u8
|
||||||
|
};
|
||||||
|
|
||||||
|
Color::rgba(
|
||||||
|
blend(base.r, overlay.r),
|
||||||
|
blend(base.g, overlay.g),
|
||||||
|
blend(base.b, overlay.b),
|
||||||
|
base.a,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Keypad (basic)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Keypad(state: CalcState) -> impl IntoView {
|
||||||
|
let bg_num = Color::rgba(255, 255, 255, 90);
|
||||||
|
let bg_op = Color::rgba(210, 230, 255, 140);
|
||||||
|
let bg_eq = Color::rgba(160, 230, 160, 160);
|
||||||
|
let bg_fn = Color::rgba(255, 200, 180, 140);
|
||||||
|
|
||||||
|
macro_rules! digit {
|
||||||
|
($ch:expr) => {{
|
||||||
|
let s = state.clone();
|
||||||
|
let t = $ch.to_string();
|
||||||
|
let label = t.clone();
|
||||||
|
calc_button(label, bg_num, move || s.input(&t))
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
macro_rules! op {
|
||||||
|
($ch:expr) => {{
|
||||||
|
let s = state.clone();
|
||||||
|
let t = $ch.to_string();
|
||||||
|
let label = t.clone();
|
||||||
|
calc_button(label, bg_op, move || s.input(&t))
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
|
let clear_btn = {
|
||||||
|
let s = state.clone();
|
||||||
|
calc_button("C".to_string(), bg_fn, move || s.clear())
|
||||||
|
};
|
||||||
|
let paren_btn = {
|
||||||
|
let s = state.clone();
|
||||||
|
calc_button("( )".to_string(), bg_num, move || s.input_paren())
|
||||||
|
};
|
||||||
|
let pct_btn = {
|
||||||
|
let s = state.clone();
|
||||||
|
calc_button("%".to_string(), bg_num, move || s.input("%"))
|
||||||
|
};
|
||||||
|
let bs_btn = {
|
||||||
|
let s = state.clone();
|
||||||
|
calc_button("⌫".to_string(), bg_fn, move || s.backspace())
|
||||||
|
};
|
||||||
|
let dot_btn = {
|
||||||
|
let s = state.clone();
|
||||||
|
calc_button(".".to_string(), bg_num, move || s.input("."))
|
||||||
|
};
|
||||||
|
let eq_btn = {
|
||||||
|
let s = state.clone();
|
||||||
|
calc_button("=".to_string(), bg_eq, move || s.evaluate())
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
column(gap = 6.0, align_self = AlignItems::Stretch) {
|
||||||
|
btn_row(vec![clear_btn, paren_btn, pct_btn, bs_btn])
|
||||||
|
btn_row(vec![digit!('7'), digit!('8'), digit!('9'), op!('÷')])
|
||||||
|
btn_row(vec![digit!('4'), digit!('5'), digit!('6'), op!('×')])
|
||||||
|
btn_row(vec![digit!('1'), digit!('2'), digit!('3'), op!('-')])
|
||||||
|
btn_row(vec![digit!('0'), dot_btn, eq_btn, op!('+')])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Scientific panel
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn ScientificPanel(state: CalcState) -> impl IntoView {
|
||||||
|
let bg = Color::rgba(230, 215, 255, 140);
|
||||||
|
|
||||||
|
macro_rules! sci {
|
||||||
|
($label:expr, $insert:expr) => {{
|
||||||
|
let s = state.clone();
|
||||||
|
let ins: &'static str = $insert;
|
||||||
|
calc_button($label.to_string(), bg, move || s.input(ins))
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
|
let sq_btn = {
|
||||||
|
let s = state.clone();
|
||||||
|
calc_button("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(")])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
185
examples/calculator/src/main.rs
Normal file
185
examples/calculator/src/main.rs
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
mod components;
|
||||||
|
mod state;
|
||||||
|
|
||||||
|
use ruin_app::prelude::*;
|
||||||
|
use tracing_subscriber::layer::SubscriberExt;
|
||||||
|
use tracing_subscriber::util::SubscriberInitExt;
|
||||||
|
use tracing_subscriber::{fmt, EnvFilter};
|
||||||
|
|
||||||
|
use components::{Display, Keypad, ModeToggle, ScientificPanel};
|
||||||
|
use state::{CalcMode, CalcState};
|
||||||
|
|
||||||
|
#[ruin_runtime::async_main]
|
||||||
|
async fn main() -> ruin_app::Result<()> {
|
||||||
|
init_tracing();
|
||||||
|
|
||||||
|
App::new()
|
||||||
|
.window(
|
||||||
|
Window::new()
|
||||||
|
.title("Calculator")
|
||||||
|
.app_id("dev.ruin.calculator")
|
||||||
|
.base_color(Color::rgba(255, 255, 255, 80))
|
||||||
|
.resizable(false),
|
||||||
|
)
|
||||||
|
.mount(view! { CalculatorApp() {} })
|
||||||
|
.run()
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_tracing() {
|
||||||
|
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
||||||
|
EnvFilter::new(
|
||||||
|
"info,ruin_app::resize=trace,ruin_app::interaction=trace,\
|
||||||
|
ruin_ui_platform_wayland::resize=trace,ruin_ui_platform_wayland::perf=debug,\
|
||||||
|
ruin_ui_platform_wayland::event_bridge=trace",
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
let _ = tracing_subscriber::registry()
|
||||||
|
.with(fmt::layer().with_target(true).without_time())
|
||||||
|
.with(filter)
|
||||||
|
.try_init();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn CalculatorApp() -> impl IntoView {
|
||||||
|
let state = CalcState::new();
|
||||||
|
|
||||||
|
// ---- Keyboard shortcuts (application-scoped) ----
|
||||||
|
|
||||||
|
for ch in "0123456789.+-*/%()".chars() {
|
||||||
|
let s = state.clone();
|
||||||
|
let display = match ch {
|
||||||
|
'*' => "×",
|
||||||
|
'/' => "÷",
|
||||||
|
_ => "",
|
||||||
|
};
|
||||||
|
let text = if display.is_empty() {
|
||||||
|
ch.to_string()
|
||||||
|
} else {
|
||||||
|
display.to_string()
|
||||||
|
};
|
||||||
|
use_shortcut(
|
||||||
|
Shortcut::new(Key::Character(ch)),
|
||||||
|
ShortcutScope::Application,
|
||||||
|
{
|
||||||
|
let text = text.clone();
|
||||||
|
move || s.input(&text)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// = key also evaluates
|
||||||
|
{
|
||||||
|
let s = state.clone();
|
||||||
|
use_shortcut(
|
||||||
|
Shortcut::new(Key::Character('=')),
|
||||||
|
ShortcutScope::Application,
|
||||||
|
move || s.evaluate(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let s = state.clone();
|
||||||
|
use_shortcut(
|
||||||
|
Shortcut::new(Key::Enter),
|
||||||
|
ShortcutScope::Application,
|
||||||
|
move || s.evaluate(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let s = state.clone();
|
||||||
|
use_shortcut(
|
||||||
|
Shortcut::new(Key::Backspace),
|
||||||
|
ShortcutScope::Application,
|
||||||
|
move || s.backspace(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let s = state.clone();
|
||||||
|
use_shortcut(
|
||||||
|
Shortcut::new(Key::Escape),
|
||||||
|
ShortcutScope::Application,
|
||||||
|
move || s.clear(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let s = state.clone();
|
||||||
|
use_shortcut(
|
||||||
|
Shortcut::new(Key::Delete),
|
||||||
|
ShortcutScope::Application,
|
||||||
|
move || s.clear(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Layout ----
|
||||||
|
|
||||||
|
let mode = state.mode.clone();
|
||||||
|
|
||||||
|
let text_style = PartialTextStyle {
|
||||||
|
color: Some(Color::BLACK),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
if mode.get() == CalcMode::Scientific {
|
||||||
|
view! {
|
||||||
|
column(
|
||||||
|
gap = 10.0,
|
||||||
|
padding = 12.0,
|
||||||
|
align_items = AlignItems::Stretch,
|
||||||
|
text_style = text_style,
|
||||||
|
) {
|
||||||
|
Display(state = state.clone()) {}
|
||||||
|
ModeToggle(state = state.clone()) {}
|
||||||
|
ScientificPanel(state = state.clone()) {}
|
||||||
|
Keypad(state = state.clone()) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
view! {
|
||||||
|
column(
|
||||||
|
gap = 10.0,
|
||||||
|
padding = 12.0,
|
||||||
|
align_items = AlignItems::Stretch,
|
||||||
|
text_style = text_style,
|
||||||
|
) {
|
||||||
|
Display(state = state.clone()) {}
|
||||||
|
ModeToggle(state = state.clone()) {}
|
||||||
|
Keypad(state = state.clone()) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn StateProbe(slot: Rc<RefCell<Option<CalcState>>>) -> impl IntoView {
|
||||||
|
let state = CalcState::new();
|
||||||
|
*slot.borrow_mut() = Some(state);
|
||||||
|
block().children(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn successful_evaluation_replaces_expression_and_preserves_history() {
|
||||||
|
let slot = Rc::new(RefCell::new(None::<CalcState>));
|
||||||
|
let _ = ruin_app::__render_mountable_for_test(&view! {
|
||||||
|
StateProbe(slot = Rc::clone(&slot)) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
let state = slot.borrow().clone().expect("state should be captured");
|
||||||
|
let _ = state.expression.set("2+2".to_string());
|
||||||
|
state.evaluate();
|
||||||
|
|
||||||
|
assert_eq!(state.expression.get(), "4");
|
||||||
|
assert_eq!(state.result.get(), "");
|
||||||
|
assert_eq!(state.is_error.get(), false);
|
||||||
|
assert_eq!(state.history.get().len(), 1);
|
||||||
|
assert_eq!(state.history.get()[0].expression, "2+2");
|
||||||
|
assert_eq!(state.history.get()[0].result, "4");
|
||||||
|
}
|
||||||
|
}
|
||||||
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(),
|
window.surface_target(),
|
||||||
initial_size.width as u32,
|
initial_size.width as u32,
|
||||||
initial_size.height as u32,
|
initial_size.height as u32,
|
||||||
|
ruin_ui::Color::rgb(0x08, 0x0C, 0x14),
|
||||||
)
|
)
|
||||||
.expect("wgpu renderer should initialize");
|
.expect("wgpu renderer should initialize");
|
||||||
let state = Rc::new(RefCell::new(WorkerState {
|
let state = Rc::new(RefCell::new(WorkerState {
|
||||||
|
|||||||
@@ -795,6 +795,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
let LayoutSnapshot {
|
let LayoutSnapshot {
|
||||||
scene,
|
scene,
|
||||||
interaction_tree: next_interaction_tree,
|
interaction_tree: next_interaction_tree,
|
||||||
|
..
|
||||||
} = build_snapshot(
|
} = build_snapshot(
|
||||||
viewport,
|
viewport,
|
||||||
version,
|
version,
|
||||||
@@ -1077,6 +1078,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
let LayoutSnapshot {
|
let LayoutSnapshot {
|
||||||
scene,
|
scene,
|
||||||
interaction_tree: next_interaction_tree,
|
interaction_tree: next_interaction_tree,
|
||||||
|
..
|
||||||
} = build_snapshot(
|
} = build_snapshot(
|
||||||
viewport,
|
viewport,
|
||||||
version,
|
version,
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
window.surface_target(),
|
window.surface_target(),
|
||||||
scene.logical_size.width as u32,
|
scene.logical_size.width as u32,
|
||||||
scene.logical_size.height as u32,
|
scene.logical_size.height as u32,
|
||||||
|
ruin_ui::Color::rgb(0x08, 0x0C, 0x14),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
println!("Opening RUIN Wayland / wgpu demo window...");
|
println!("Opening RUIN Wayland / wgpu demo window...");
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ edition = "2024"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
ruin_runtime = { package = "ruin-runtime", path = "../runtime" }
|
ruin_runtime = { package = "ruin-runtime", path = "../runtime" }
|
||||||
tracing = { version = "0.1", default-features = false, features = ["std"] }
|
tracing = { version = "0.1", default-features = false }
|
||||||
|
hashbrown = "0.17"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tracing-subscriber = { version = "0.3", default-features = false, features = ["env-filter", "fmt", "std"] }
|
tracing-subscriber = { version = "0.3", default-features = false, features = [
|
||||||
|
"env-filter",
|
||||||
|
"fmt",
|
||||||
|
"std",
|
||||||
|
] }
|
||||||
|
|||||||
152
lib/reactivity/README.md
Normal file
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 alloc::rc::Rc;
|
||||||
use std::rc::Rc;
|
use core::cell::RefCell;
|
||||||
|
|
||||||
use crate::{NodeId, Reactor, current, trace_targets};
|
use crate::{NodeId, Reactor, current, trace_targets};
|
||||||
|
|
||||||
@@ -114,7 +114,7 @@ impl<T: PartialEq + 'static> Cell<T> {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let previous = std::mem::replace(&mut *current, value);
|
let previous = core::mem::replace(&mut *current, value);
|
||||||
drop(current);
|
drop(current);
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
target: trace_targets::CELL,
|
target: trace_targets::CELL,
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
use std::cell::{Cell, RefCell};
|
use alloc::boxed::Box;
|
||||||
use std::rc::{Rc, Weak};
|
use alloc::rc::{Rc, Weak};
|
||||||
|
use core::cell::{Cell, RefCell};
|
||||||
|
|
||||||
use crate::reactor::ObserverHook;
|
use crate::reactor::ObserverHook;
|
||||||
use crate::{NodeId, Reactor, current, trace_targets};
|
use crate::{NodeId, Reactor, current, trace_targets};
|
||||||
|
|
||||||
/// Creates an effect in the current thread's default reactor.
|
/// Creates an effect in the current thread's default reactor.
|
||||||
|
///
|
||||||
|
/// The effect is scheduled immediately and then re-scheduled whenever one of its dependencies
|
||||||
|
/// changes.
|
||||||
pub fn effect(f: impl Fn() + 'static) -> EffectHandle {
|
pub fn effect(f: impl Fn() + 'static) -> EffectHandle {
|
||||||
current().effect(f)
|
current().effect(f)
|
||||||
}
|
}
|
||||||
@@ -16,6 +20,7 @@ pub fn effect_in(reactor: &Reactor, f: impl Fn() + 'static) -> EffectHandle {
|
|||||||
|
|
||||||
/// Disposable handle for a reactive effect.
|
/// Disposable handle for a reactive effect.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
#[must_use = "effects are automatically disposed when dropped, so you must use the handle or explicitly leak it"]
|
||||||
pub struct EffectHandle {
|
pub struct EffectHandle {
|
||||||
inner: Rc<EffectInner>,
|
inner: Rc<EffectInner>,
|
||||||
}
|
}
|
||||||
@@ -55,6 +60,14 @@ impl EffectHandle {
|
|||||||
Self { inner }
|
Self { inner }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Consumes the effect and leaks it, allowing it to run for the remainder of the program without automatically
|
||||||
|
/// disposing when dropped. Use this for effects that you want to run for the lifetime of the program. You CANNOT
|
||||||
|
/// recover an EffectHandle after calling this method, so be sure to call it on an EffectHandle that you don't need
|
||||||
|
/// to later dispose of.
|
||||||
|
pub fn leak(self) {
|
||||||
|
core::mem::forget(self);
|
||||||
|
}
|
||||||
|
|
||||||
/// Disposes the effect immediately.
|
/// Disposes the effect immediately.
|
||||||
pub fn dispose(&self) {
|
pub fn dispose(&self) {
|
||||||
self.inner.dispose();
|
self.inner.dispose();
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
use std::cell::{Cell, RefCell};
|
use alloc::boxed::Box;
|
||||||
use std::collections::BTreeMap;
|
use alloc::rc::Rc;
|
||||||
use std::rc::Rc;
|
use alloc::vec::Vec;
|
||||||
|
use core::cell::{Cell, RefCell};
|
||||||
|
|
||||||
|
use hashbrown::HashMap;
|
||||||
|
|
||||||
use crate::{NodeId, Reactor, current, trace_targets};
|
use crate::{NodeId, Reactor, current, trace_targets};
|
||||||
|
|
||||||
@@ -17,11 +20,13 @@ pub fn event_in<T: 'static>(reactor: &Reactor) -> Event<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a reactive draining subscription for `event` in the current reactor.
|
/// Creates a reactive draining subscription for `event` in the current reactor.
|
||||||
|
#[must_use = "subscriptions created with `on` must be used to stay active"]
|
||||||
pub fn on<T: Clone + 'static>(event: &Event<T>, handler: impl Fn(&T) + 'static) -> Subscription {
|
pub fn on<T: Clone + 'static>(event: &Event<T>, handler: impl Fn(&T) + 'static) -> Subscription {
|
||||||
current().on(event, handler)
|
current().on(event, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a reactive draining subscription for `event` associated with `reactor`.
|
/// Creates a reactive draining subscription for `event` associated with `reactor`.
|
||||||
|
#[must_use = "subscriptions created with `on_in` must be used to stay active"]
|
||||||
pub fn on_in<T: Clone + 'static>(
|
pub fn on_in<T: Clone + 'static>(
|
||||||
reactor: &Reactor,
|
reactor: &Reactor,
|
||||||
event: &Event<T>,
|
event: &Event<T>,
|
||||||
@@ -49,6 +54,7 @@ impl Reactor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a reactive draining subscription for `event`.
|
/// Creates a reactive draining subscription for `event`.
|
||||||
|
#[must_use = "subscriptions created with `on` must be used to stay active"]
|
||||||
pub fn on<T: Clone + 'static>(
|
pub fn on<T: Clone + 'static>(
|
||||||
&self,
|
&self,
|
||||||
event: &Event<T>,
|
event: &Event<T>,
|
||||||
@@ -103,7 +109,7 @@ impl<T: 'static> Event<T> {
|
|||||||
reactor,
|
reactor,
|
||||||
id,
|
id,
|
||||||
next_subscriber: Cell::new(1),
|
next_subscriber: Cell::new(1),
|
||||||
subscribers: RefCell::new(BTreeMap::new()),
|
subscribers: RefCell::new(Default::default()),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -204,7 +210,7 @@ struct EventInner<T> {
|
|||||||
reactor: Reactor,
|
reactor: Reactor,
|
||||||
id: NodeId,
|
id: NodeId,
|
||||||
next_subscriber: Cell<usize>,
|
next_subscriber: Cell<usize>,
|
||||||
subscribers: RefCell<BTreeMap<usize, Rc<SubscriberFn<T>>>>,
|
subscribers: RefCell<HashMap<usize, Rc<SubscriberFn<T>>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SubscriptionInner {
|
struct SubscriptionInner {
|
||||||
@@ -227,11 +233,12 @@ impl SubscriptionInner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for SubscriptionInner {
|
// TODO: it's actually kind of hard to use events if subcscriptions have to be manually managed.
|
||||||
fn drop(&mut self) {
|
// impl Drop for SubscriptionInner {
|
||||||
self.unsubscribe();
|
// fn drop(&mut self) {
|
||||||
}
|
// self.unsubscribe();
|
||||||
}
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
|||||||
@@ -4,6 +4,11 @@
|
|||||||
//! single-threaded and designed to live on a runtime-managed thread, while async work feeds it
|
//! single-threaded and designed to live on a runtime-managed thread, while async work feeds it
|
||||||
//! from the edges by updating state or emitting events.
|
//! from the edges by updating state or emitting events.
|
||||||
|
|
||||||
|
#![feature(thread_local)]
|
||||||
|
#![cfg_attr(not(test), no_std)]
|
||||||
|
|
||||||
|
extern crate alloc;
|
||||||
|
|
||||||
pub(crate) mod trace_targets {
|
pub(crate) mod trace_targets {
|
||||||
pub const GRAPH: &str = "ruin_reactivity::graph";
|
pub const GRAPH: &str = "ruin_reactivity::graph";
|
||||||
pub const CELL: &str = "ruin_reactivity::cell";
|
pub const CELL: &str = "ruin_reactivity::cell";
|
||||||
@@ -18,6 +23,7 @@ mod effect;
|
|||||||
mod event;
|
mod event;
|
||||||
mod id;
|
mod id;
|
||||||
mod reactor;
|
mod reactor;
|
||||||
|
mod source;
|
||||||
mod thunk;
|
mod thunk;
|
||||||
|
|
||||||
pub use cell::{Cell, cell, cell_in};
|
pub use cell::{Cell, cell, cell_in};
|
||||||
@@ -25,4 +31,5 @@ pub use effect::{EffectHandle, effect, effect_in};
|
|||||||
pub use event::{Event, Subscription, event, event_in, on, on_in};
|
pub use event::{Event, Subscription, event, event_in, on, on_in};
|
||||||
pub use id::NodeId;
|
pub use id::NodeId;
|
||||||
pub use reactor::{ReactCycleError, Reactor, current};
|
pub use reactor::{ReactCycleError, Reactor, current};
|
||||||
|
pub use source::{Source, source, source_in};
|
||||||
pub use thunk::{Memo, Thunk, memo, memo_by, memo_by_in, memo_in, thunk, thunk_in};
|
pub use thunk::{Memo, Thunk, memo, memo_by, memo_by_in, memo_in, thunk, thunk_in};
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
use std::cell::{Cell, RefCell};
|
use alloc::boxed::Box;
|
||||||
use std::collections::{BTreeMap, BTreeSet, VecDeque};
|
use alloc::collections::VecDeque;
|
||||||
use std::error::Error;
|
use alloc::rc::{Rc, Weak};
|
||||||
use std::fmt;
|
use alloc::vec::Vec;
|
||||||
use std::panic::panic_any;
|
use core::cell::{Cell, RefCell};
|
||||||
use std::rc::{Rc, Weak};
|
use core::error::Error;
|
||||||
|
use core::fmt;
|
||||||
|
|
||||||
|
use hashbrown::{HashMap, HashSet};
|
||||||
|
|
||||||
use ruin_runtime::queue_microtask;
|
use ruin_runtime::queue_microtask;
|
||||||
|
|
||||||
@@ -11,9 +14,8 @@ use crate::{NodeId, trace_targets};
|
|||||||
|
|
||||||
type Job = Box<dyn FnOnce() + 'static>;
|
type Job = Box<dyn FnOnce() + 'static>;
|
||||||
|
|
||||||
thread_local! {
|
#[thread_local]
|
||||||
static CURRENT_REACTOR: RefCell<Weak<ReactorInner>> = const { RefCell::new(Weak::new()) };
|
static CURRENT_REACTOR: RefCell<Weak<ReactorInner>> = const { RefCell::new(Weak::new()) };
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the current thread's default reactor.
|
/// Returns the current thread's default reactor.
|
||||||
///
|
///
|
||||||
@@ -28,7 +30,7 @@ pub(crate) trait ObserverHook {
|
|||||||
fn notify(&self);
|
fn notify(&self);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Panic payload emitted when the reactor detects a dependency cycle.
|
/// Error type for cycles detected in the reactive graph. Contains the path of nodes that form the cycle.
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub struct ReactCycleError {
|
pub struct ReactCycleError {
|
||||||
cycle: Vec<NodeId>,
|
cycle: Vec<NodeId>,
|
||||||
@@ -85,26 +87,24 @@ impl Reactor {
|
|||||||
|
|
||||||
/// Returns the current thread's default reactor.
|
/// Returns the current thread's default reactor.
|
||||||
pub fn current() -> Self {
|
pub fn current() -> Self {
|
||||||
CURRENT_REACTOR.with(|slot| {
|
if let Some(inner) = CURRENT_REACTOR.borrow().upgrade() {
|
||||||
if let Some(inner) = slot.borrow().upgrade() {
|
#[cfg(debug_assertions)]
|
||||||
#[cfg(debug_assertions)]
|
tracing::trace!(
|
||||||
tracing::trace!(
|
|
||||||
target: trace_targets::GRAPH,
|
|
||||||
event = "current_reactor_reuse",
|
|
||||||
"reusing current thread default reactor"
|
|
||||||
);
|
|
||||||
return Self { inner };
|
|
||||||
}
|
|
||||||
|
|
||||||
let reactor = Self::new();
|
|
||||||
*slot.borrow_mut() = Rc::downgrade(&reactor.inner);
|
|
||||||
tracing::debug!(
|
|
||||||
target: trace_targets::GRAPH,
|
target: trace_targets::GRAPH,
|
||||||
event = "current_reactor_install",
|
event = "current_reactor_reuse",
|
||||||
"installed current thread default reactor"
|
"reusing current thread default reactor"
|
||||||
);
|
);
|
||||||
reactor
|
return Self { inner };
|
||||||
})
|
}
|
||||||
|
|
||||||
|
let reactor = Self::new();
|
||||||
|
*CURRENT_REACTOR.borrow_mut() = Rc::downgrade(&reactor.inner);
|
||||||
|
tracing::debug!(
|
||||||
|
target: trace_targets::GRAPH,
|
||||||
|
event = "current_reactor_install",
|
||||||
|
"installed current thread default reactor"
|
||||||
|
);
|
||||||
|
reactor
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs `f` in the dependency-tracking scope of `observer`.
|
/// Runs `f` in the dependency-tracking scope of `observer`.
|
||||||
@@ -143,13 +143,9 @@ impl Reactor {
|
|||||||
f()
|
f()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Records that the currently executing observer depends on `observable`.
|
/// Attempts to record a dependency on `observable` for the currently running observer, returning an
|
||||||
///
|
/// error if doing so would create a dependency cycle.
|
||||||
/// # Panics
|
pub fn try_observe(&self, observable: NodeId) -> Result<(), ReactCycleError> {
|
||||||
///
|
|
||||||
/// Panics with [`ReactCycleError`] if `observable` is already being computed in the current
|
|
||||||
/// dependency stack.
|
|
||||||
pub fn observe(&self, observable: NodeId) {
|
|
||||||
if self
|
if self
|
||||||
.inner
|
.inner
|
||||||
.active_computations
|
.active_computations
|
||||||
@@ -170,12 +166,12 @@ impl Reactor {
|
|||||||
cycle_len = cycle.len(),
|
cycle_len = cycle.len(),
|
||||||
"reactive cycle detected"
|
"reactive cycle detected"
|
||||||
);
|
);
|
||||||
panic_any(ReactCycleError::new(cycle));
|
return Err(ReactCycleError::new(cycle));
|
||||||
}
|
}
|
||||||
|
|
||||||
let current = self.inner.stack.borrow().last().copied();
|
let current = self.inner.stack.borrow().last().copied();
|
||||||
let Some(observer) = current else {
|
let Some(observer) = current else {
|
||||||
return;
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
@@ -199,6 +195,18 @@ impl Reactor {
|
|||||||
.entry(observable)
|
.entry(observable)
|
||||||
.or_default()
|
.or_default()
|
||||||
.insert(observer);
|
.insert(observer);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records a dependency on `observable` for the currently running observer.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if recording the dependency would create a cycle in the reactive graph.
|
||||||
|
pub fn observe(&self, observable: NodeId) {
|
||||||
|
if let Err(e) = self.try_observe(observable) {
|
||||||
|
panic!("reactive cycle detected: {e}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Notifies dependents of `observable`.
|
/// Notifies dependents of `observable`.
|
||||||
@@ -352,11 +360,11 @@ impl fmt::Debug for Reactor {
|
|||||||
|
|
||||||
struct ReactorInner {
|
struct ReactorInner {
|
||||||
next_node: Cell<u64>,
|
next_node: Cell<u64>,
|
||||||
dependencies: RefCell<BTreeMap<NodeId, BTreeSet<NodeId>>>,
|
dependencies: RefCell<HashMap<NodeId, HashSet<NodeId>>>,
|
||||||
dependents: RefCell<BTreeMap<NodeId, BTreeSet<NodeId>>>,
|
dependents: RefCell<HashMap<NodeId, HashSet<NodeId>>>,
|
||||||
observers: RefCell<BTreeMap<NodeId, Weak<dyn ObserverHook>>>,
|
observers: RefCell<HashMap<NodeId, Weak<dyn ObserverHook>>>,
|
||||||
stack: RefCell<Vec<NodeId>>,
|
stack: RefCell<Vec<NodeId>>,
|
||||||
active_computations: RefCell<BTreeSet<NodeId>>,
|
active_computations: RefCell<HashSet<NodeId>>,
|
||||||
pending_jobs: RefCell<VecDeque<Job>>,
|
pending_jobs: RefCell<VecDeque<Job>>,
|
||||||
flush_scheduled: Cell<bool>,
|
flush_scheduled: Cell<bool>,
|
||||||
}
|
}
|
||||||
@@ -365,11 +373,11 @@ impl ReactorInner {
|
|||||||
fn new() -> Self {
|
fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
next_node: Cell::new(1),
|
next_node: Cell::new(1),
|
||||||
dependencies: RefCell::new(BTreeMap::new()),
|
dependencies: RefCell::new(HashMap::new()),
|
||||||
dependents: RefCell::new(BTreeMap::new()),
|
dependents: RefCell::new(HashMap::new()),
|
||||||
observers: RefCell::new(BTreeMap::new()),
|
observers: RefCell::new(HashMap::new()),
|
||||||
stack: RefCell::new(Vec::new()),
|
stack: RefCell::new(Vec::new()),
|
||||||
active_computations: RefCell::new(BTreeSet::new()),
|
active_computations: RefCell::new(HashSet::new()),
|
||||||
pending_jobs: RefCell::new(VecDeque::new()),
|
pending_jobs: RefCell::new(VecDeque::new()),
|
||||||
flush_scheduled: Cell::new(false),
|
flush_scheduled: Cell::new(false),
|
||||||
}
|
}
|
||||||
@@ -429,7 +437,7 @@ mod tests {
|
|||||||
|
|
||||||
use ruin_runtime::{queue_task, run};
|
use ruin_runtime::{queue_task, run};
|
||||||
|
|
||||||
use super::{ReactCycleError, Reactor, current};
|
use super::{Reactor, current};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn current_reactor_is_thread_local_singleton() {
|
fn current_reactor_is_thread_local_singleton() {
|
||||||
@@ -444,7 +452,11 @@ mod tests {
|
|||||||
let observer = reactor.allocate_node();
|
let observer = reactor.allocate_node();
|
||||||
let observable = reactor.allocate_node();
|
let observable = reactor.allocate_node();
|
||||||
|
|
||||||
reactor.run_in_context(observer, || reactor.observe(observable));
|
reactor.run_in_context(observer, || {
|
||||||
|
reactor
|
||||||
|
.try_observe(observable)
|
||||||
|
.expect("should not detect cycle")
|
||||||
|
});
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
reactor.inner.dependencies.borrow().get(&observer),
|
reactor.inner.dependencies.borrow().get(&observer),
|
||||||
@@ -457,7 +469,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cycle_detection_panics_with_structured_error() {
|
fn cycle_detection_panics_with_expected() {
|
||||||
let reactor = Reactor::new();
|
let reactor = Reactor::new();
|
||||||
let a = reactor.allocate_node();
|
let a = reactor.allocate_node();
|
||||||
let b = reactor.allocate_node();
|
let b = reactor.allocate_node();
|
||||||
@@ -465,15 +477,26 @@ mod tests {
|
|||||||
let panic = catch_unwind(AssertUnwindSafe(|| {
|
let panic = catch_unwind(AssertUnwindSafe(|| {
|
||||||
reactor.run_in_context(a, || {
|
reactor.run_in_context(a, || {
|
||||||
reactor.observe(b);
|
reactor.observe(b);
|
||||||
reactor.run_in_context(b, || reactor.observe(a));
|
reactor.run_in_context(b, || {
|
||||||
|
reactor.observe(a);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}))
|
}))
|
||||||
.expect_err("cycle should panic");
|
.expect_err("cycle should panic");
|
||||||
|
|
||||||
let error = panic
|
let Some(cycle_error) = panic.downcast_ref::<String>() else {
|
||||||
.downcast::<ReactCycleError>()
|
panic!("panic should be a string");
|
||||||
.expect("panic payload should be ReactCycleError");
|
};
|
||||||
assert_eq!(error.cycle(), &[a, b, a]);
|
|
||||||
|
assert!(
|
||||||
|
cycle_error.contains("reactive cycle detected"),
|
||||||
|
"panic should indicate cycle detected"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
cycle_error.contains("1 -> 2 -> 1"),
|
||||||
|
"panic should include cycle path"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
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 alloc::boxed::Box;
|
||||||
use std::rc::Rc;
|
use alloc::rc::Rc;
|
||||||
|
use core::cell::{Cell, RefCell};
|
||||||
|
|
||||||
use crate::reactor::ObserverHook;
|
use crate::reactor::ObserverHook;
|
||||||
use crate::{NodeId, Reactor, current, trace_targets};
|
use crate::{NodeId, Reactor, current, trace_targets};
|
||||||
@@ -428,9 +429,13 @@ mod tests {
|
|||||||
.expect_err("mutually recursive thunks should panic");
|
.expect_err("mutually recursive thunks should panic");
|
||||||
|
|
||||||
let error = panic
|
let error = panic
|
||||||
.downcast::<crate::ReactCycleError>()
|
.downcast_ref::<String>()
|
||||||
.expect("panic payload should be ReactCycleError");
|
.expect("panic should be a string");
|
||||||
assert_eq!(error.cycle().len(), 3);
|
|
||||||
|
assert!(
|
||||||
|
error.contains("reactive cycle detected"),
|
||||||
|
"panic should indicate cycle detected"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
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" }
|
||||||
785
lib/reactivity_parsing/examples/json_reactive_parser.rs
Normal file
785
lib/reactivity_parsing/examples/json_reactive_parser.rs
Normal file
@@ -0,0 +1,785 @@
|
|||||||
|
use std::env;
|
||||||
|
use std::fmt;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use ruin_reactivity::{cell_in, effect_in};
|
||||||
|
use ruin_reactivity_parsing::{
|
||||||
|
Anchor, AnchorBias, EditDelta, ReactiveRope, RopeEdit, TextPoint, TextRange,
|
||||||
|
};
|
||||||
|
use ruin_runtime::{async_main, fs, stdio};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct ParseSnapshot {
|
||||||
|
text: String,
|
||||||
|
root_anchor: Anchor,
|
||||||
|
root: AstNode,
|
||||||
|
reused_nodes: usize,
|
||||||
|
total_nodes: usize,
|
||||||
|
diagnostics: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct AstNode {
|
||||||
|
id: u64,
|
||||||
|
kind: JsonKind,
|
||||||
|
local_range: TextRange,
|
||||||
|
children: Vec<AstNode>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
enum JsonKind {
|
||||||
|
Object,
|
||||||
|
Array,
|
||||||
|
Pair,
|
||||||
|
String,
|
||||||
|
Number,
|
||||||
|
True,
|
||||||
|
False,
|
||||||
|
Null,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for JsonKind {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
let label = match self {
|
||||||
|
Self::Object => "object",
|
||||||
|
Self::Array => "array",
|
||||||
|
Self::Pair => "pair",
|
||||||
|
Self::String => "string",
|
||||||
|
Self::Number => "number",
|
||||||
|
Self::True => "true",
|
||||||
|
Self::False => "false",
|
||||||
|
Self::Null => "null",
|
||||||
|
};
|
||||||
|
write!(f, "{label}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct ParsedNode {
|
||||||
|
kind: JsonKind,
|
||||||
|
range: TextRange,
|
||||||
|
children: Vec<ParsedNode>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct JsonParser {
|
||||||
|
next_id: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Cursor<'a> {
|
||||||
|
text: &'a str,
|
||||||
|
offset: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
enum Command {
|
||||||
|
Insert {
|
||||||
|
offset: usize,
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
Replace {
|
||||||
|
start: usize,
|
||||||
|
end: usize,
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
ReplaceAll {
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
Delete {
|
||||||
|
start: usize,
|
||||||
|
end: usize,
|
||||||
|
},
|
||||||
|
Print,
|
||||||
|
Help,
|
||||||
|
Quit,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_main]
|
||||||
|
async fn main() {
|
||||||
|
let path = env::args()
|
||||||
|
.nth(1)
|
||||||
|
.expect("usage: cargo run -p ruin-reactivity-parsing --example json_reactive_parser -- <path/to/file.json>");
|
||||||
|
let initial_text = fs::read_to_string(&path)
|
||||||
|
.await
|
||||||
|
.expect("json file should be readable");
|
||||||
|
|
||||||
|
let reactor = ruin_reactivity::Reactor::new();
|
||||||
|
let rope = ReactiveRope::from_text(&reactor, &initial_text);
|
||||||
|
let parser = Rc::new(std::cell::RefCell::new(JsonParser::default()));
|
||||||
|
|
||||||
|
let initial_snapshot = parser
|
||||||
|
.borrow_mut()
|
||||||
|
.parse_initial(&rope)
|
||||||
|
.expect("initial JSON should parse");
|
||||||
|
let parse_state = cell_in(&reactor, initial_snapshot);
|
||||||
|
let last_delta = cell_in(&reactor, None::<EditDelta>);
|
||||||
|
|
||||||
|
let _parser_subscription = {
|
||||||
|
let rope = rope.clone();
|
||||||
|
let parser = Rc::clone(&parser);
|
||||||
|
let parse_state = parse_state.clone();
|
||||||
|
let last_delta = last_delta.clone();
|
||||||
|
rope.edit_events().subscribe(move |delta| {
|
||||||
|
let previous = parse_state.get();
|
||||||
|
match parser.borrow_mut().parse_incremental(&rope, &previous, delta) {
|
||||||
|
Ok(next) => {
|
||||||
|
last_delta.replace(Some(delta.clone()));
|
||||||
|
parse_state.replace(next);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("parse error after edit: {error}");
|
||||||
|
eprintln!(
|
||||||
|
"note: current text length is {} bytes; use `replace_all <text>` to replace the full document without manual offsets",
|
||||||
|
rope.len_bytes()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
effect_in(&reactor, {
|
||||||
|
let rope = rope.clone();
|
||||||
|
let parse_state = parse_state.clone();
|
||||||
|
let last_delta = last_delta.clone();
|
||||||
|
move || {
|
||||||
|
parse_state.with(|snapshot| {
|
||||||
|
print_snapshot(snapshot, &rope, last_delta.with(Clone::clone).as_ref())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.leak();
|
||||||
|
|
||||||
|
println!("Commands:");
|
||||||
|
println!(" insert <byte_offset> <text>");
|
||||||
|
println!(" replace <start> <end> <text>");
|
||||||
|
println!(" replace_all <text>");
|
||||||
|
println!(" delete <start> <end>");
|
||||||
|
println!(" print");
|
||||||
|
println!(" help");
|
||||||
|
println!(" quit");
|
||||||
|
|
||||||
|
let mut input = stdio::stdin().expect("stdin should be available");
|
||||||
|
while let Some(line) = input.read_line().await.expect("stdin read should succeed") {
|
||||||
|
let trimmed = line.trim_end();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match parse_command(trimmed) {
|
||||||
|
Ok(Command::Insert { offset, text }) => {
|
||||||
|
let _ = rope.edit(RopeEdit::insert(offset, text));
|
||||||
|
}
|
||||||
|
Ok(Command::Replace { start, end, text }) => {
|
||||||
|
let _ = rope.edit(RopeEdit::replace(TextRange::new(start, end), text));
|
||||||
|
}
|
||||||
|
Ok(Command::ReplaceAll { text }) => {
|
||||||
|
let _ = rope.edit(RopeEdit::replace(TextRange::new(0, rope.len_bytes()), text));
|
||||||
|
}
|
||||||
|
Ok(Command::Delete { start, end }) => {
|
||||||
|
let _ = rope.edit(RopeEdit::delete(TextRange::new(start, end)));
|
||||||
|
}
|
||||||
|
Ok(Command::Print) => {
|
||||||
|
let snapshot = parse_state.get();
|
||||||
|
print_snapshot(&snapshot, &rope, None);
|
||||||
|
}
|
||||||
|
Ok(Command::Help) => {
|
||||||
|
println!("Commands:");
|
||||||
|
println!(" insert <byte_offset> <text>");
|
||||||
|
println!(" replace <start> <end> <text>");
|
||||||
|
println!(" replace_all <text>");
|
||||||
|
println!(" delete <start> <end>");
|
||||||
|
println!(" print");
|
||||||
|
println!(" help");
|
||||||
|
println!(" quit");
|
||||||
|
}
|
||||||
|
Ok(Command::Quit) => break,
|
||||||
|
Err(error) => eprintln!("{error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_command(input: &str) -> Result<Command, String> {
|
||||||
|
if input == "quit" {
|
||||||
|
return Ok(Command::Quit);
|
||||||
|
}
|
||||||
|
if input == "print" {
|
||||||
|
return Ok(Command::Print);
|
||||||
|
}
|
||||||
|
if input == "help" {
|
||||||
|
return Ok(Command::Help);
|
||||||
|
}
|
||||||
|
if let Some(rest) = input.strip_prefix("insert ") {
|
||||||
|
let (offset, text) = rest
|
||||||
|
.split_once(' ')
|
||||||
|
.ok_or_else(|| "insert expects: insert <offset> <text>".to_string())?;
|
||||||
|
return Ok(Command::Insert {
|
||||||
|
offset: offset
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| "invalid insert offset".to_string())?,
|
||||||
|
text: text.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(rest) = input.strip_prefix("replace ") {
|
||||||
|
let mut parts = rest.splitn(3, ' ');
|
||||||
|
let start = parts
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| "replace expects: replace <start> <end> <text>".to_string())?;
|
||||||
|
let end = parts
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| "replace expects: replace <start> <end> <text>".to_string())?;
|
||||||
|
let text = parts
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| "replace expects: replace <start> <end> <text>".to_string())?;
|
||||||
|
return Ok(Command::Replace {
|
||||||
|
start: start
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| "invalid replace start".to_string())?,
|
||||||
|
end: end.parse().map_err(|_| "invalid replace end".to_string())?,
|
||||||
|
text: text.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(text) = input.strip_prefix("replace_all ") {
|
||||||
|
return Ok(Command::ReplaceAll {
|
||||||
|
text: text.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(rest) = input.strip_prefix("delete ") {
|
||||||
|
let (start, end) = rest
|
||||||
|
.split_once(' ')
|
||||||
|
.ok_or_else(|| "delete expects: delete <start> <end>".to_string())?;
|
||||||
|
return Ok(Command::Delete {
|
||||||
|
start: start
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| "invalid delete start".to_string())?,
|
||||||
|
end: end.parse().map_err(|_| "invalid delete end".to_string())?,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err("unknown command; type `help`".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JsonParser {
|
||||||
|
fn parse_initial(&mut self, rope: &ReactiveRope) -> Result<ParseSnapshot, String> {
|
||||||
|
let text = read_rope_text(rope);
|
||||||
|
let parsed = parse_json(&text)?;
|
||||||
|
let root = self.materialize_fresh(&parsed, 0);
|
||||||
|
Ok(ParseSnapshot {
|
||||||
|
text,
|
||||||
|
root_anchor: Anchor::new(0, AnchorBias::Left),
|
||||||
|
total_nodes: count_nodes(&root),
|
||||||
|
reused_nodes: 0,
|
||||||
|
diagnostics: Vec::new(),
|
||||||
|
root,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_incremental(
|
||||||
|
&mut self,
|
||||||
|
rope: &ReactiveRope,
|
||||||
|
previous: &ParseSnapshot,
|
||||||
|
delta: &EditDelta,
|
||||||
|
) -> Result<ParseSnapshot, String> {
|
||||||
|
let text = read_rope_text(rope);
|
||||||
|
let parsed = parse_json(&text)?;
|
||||||
|
let mut root_anchor = previous.root_anchor;
|
||||||
|
root_anchor.map_through(delta);
|
||||||
|
let mut reused = 0usize;
|
||||||
|
let root = self.reuse_node(
|
||||||
|
&parsed,
|
||||||
|
Some(&previous.root),
|
||||||
|
previous.root_anchor.byte,
|
||||||
|
root_anchor.byte,
|
||||||
|
&previous.text,
|
||||||
|
&text,
|
||||||
|
delta,
|
||||||
|
&mut reused,
|
||||||
|
);
|
||||||
|
Ok(ParseSnapshot {
|
||||||
|
text,
|
||||||
|
root_anchor,
|
||||||
|
total_nodes: count_nodes(&root),
|
||||||
|
reused_nodes: reused,
|
||||||
|
diagnostics: Vec::new(),
|
||||||
|
root,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn materialize_fresh(&mut self, parsed: &ParsedNode, parent_start: usize) -> AstNode {
|
||||||
|
AstNode {
|
||||||
|
id: self.alloc_id(),
|
||||||
|
kind: parsed.kind,
|
||||||
|
local_range: localize(parsed.range, parent_start),
|
||||||
|
children: parsed
|
||||||
|
.children
|
||||||
|
.iter()
|
||||||
|
.map(|child| self.materialize_fresh(child, parsed.range.start))
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reuse_node(
|
||||||
|
&mut self,
|
||||||
|
parsed: &ParsedNode,
|
||||||
|
old: Option<&AstNode>,
|
||||||
|
old_parent_start: usize,
|
||||||
|
new_parent_start: usize,
|
||||||
|
old_text: &str,
|
||||||
|
new_text: &str,
|
||||||
|
delta: &EditDelta,
|
||||||
|
reused: &mut usize,
|
||||||
|
) -> AstNode {
|
||||||
|
let parsed_local = localize(parsed.range, new_parent_start);
|
||||||
|
if let Some(old) = old
|
||||||
|
&& old.kind == parsed.kind
|
||||||
|
&& map_range(absolute_range(old, old_parent_start), delta) == parsed.range
|
||||||
|
&& old_text.get(
|
||||||
|
absolute_range(old, old_parent_start).start
|
||||||
|
..absolute_range(old, old_parent_start).end,
|
||||||
|
) == new_text.get(parsed.range.start..parsed.range.end)
|
||||||
|
{
|
||||||
|
*reused += count_nodes(old);
|
||||||
|
return AstNode {
|
||||||
|
id: old.id,
|
||||||
|
kind: old.kind,
|
||||||
|
local_range: parsed_local,
|
||||||
|
children: old.children.clone(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let children = parsed
|
||||||
|
.children
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, child)| {
|
||||||
|
let previous_child = old.and_then(|node| node.children.get(index));
|
||||||
|
let next_old_parent_start = old
|
||||||
|
.map(|node| absolute_range(node, old_parent_start).start)
|
||||||
|
.unwrap_or(old_parent_start);
|
||||||
|
self.reuse_node(
|
||||||
|
child,
|
||||||
|
previous_child,
|
||||||
|
next_old_parent_start,
|
||||||
|
parsed.range.start,
|
||||||
|
old_text,
|
||||||
|
new_text,
|
||||||
|
delta,
|
||||||
|
reused,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
AstNode {
|
||||||
|
id: self.alloc_id(),
|
||||||
|
kind: parsed.kind,
|
||||||
|
local_range: parsed_local,
|
||||||
|
children,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn alloc_id(&mut self) -> u64 {
|
||||||
|
self.next_id = self.next_id.wrapping_add(1);
|
||||||
|
self.next_id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_rope_text(rope: &ReactiveRope) -> String {
|
||||||
|
let mut cursor = rope.cursor(0);
|
||||||
|
let mut text = String::new();
|
||||||
|
while let Some(chunk) = cursor.next_chunk() {
|
||||||
|
text.push_str(chunk.text());
|
||||||
|
}
|
||||||
|
text
|
||||||
|
}
|
||||||
|
|
||||||
|
fn localize(range: TextRange, parent_start: usize) -> TextRange {
|
||||||
|
TextRange::new(
|
||||||
|
range.start.saturating_sub(parent_start),
|
||||||
|
range.end.saturating_sub(parent_start),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn absolute_range(node: &AstNode, parent_start: usize) -> TextRange {
|
||||||
|
TextRange::new(
|
||||||
|
parent_start + node.local_range.start,
|
||||||
|
parent_start + node.local_range.end,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_range(range: TextRange, delta: &EditDelta) -> TextRange {
|
||||||
|
if range.end <= delta.range.start {
|
||||||
|
return range;
|
||||||
|
}
|
||||||
|
if range.start >= delta.range.end {
|
||||||
|
let shift = delta.net_byte_delta();
|
||||||
|
return TextRange::new(
|
||||||
|
range.start.saturating_add_signed(shift),
|
||||||
|
range.end.saturating_add_signed(shift),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
TextRange::new(usize::MAX, usize::MAX)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_json(text: &str) -> Result<ParsedNode, String> {
|
||||||
|
let mut cursor = Cursor { text, offset: 0 };
|
||||||
|
cursor.skip_ws();
|
||||||
|
let node = cursor.parse_value()?;
|
||||||
|
cursor.skip_ws();
|
||||||
|
if !cursor.is_eof() {
|
||||||
|
return Err(format!(
|
||||||
|
"unexpected trailing data at byte {}",
|
||||||
|
cursor.offset
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Cursor<'a> {
|
||||||
|
fn is_eof(&self) -> bool {
|
||||||
|
self.offset >= self.text.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn skip_ws(&mut self) {
|
||||||
|
while let Some(ch) = self.peek_char() {
|
||||||
|
if ch.is_ascii_whitespace() {
|
||||||
|
self.offset += ch.len_utf8();
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn peek_char(&self) -> Option<char> {
|
||||||
|
self.text[self.offset..].chars().next()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bump_char(&mut self) -> Option<char> {
|
||||||
|
let ch = self.peek_char()?;
|
||||||
|
self.offset += ch.len_utf8();
|
||||||
|
Some(ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expect_char(&mut self, expected: char) -> Result<(), String> {
|
||||||
|
match self.bump_char() {
|
||||||
|
Some(ch) if ch == expected => Ok(()),
|
||||||
|
Some(ch) => Err(format!(
|
||||||
|
"expected `{expected}` at byte {}, found `{ch}`",
|
||||||
|
self.offset.saturating_sub(ch.len_utf8())
|
||||||
|
)),
|
||||||
|
None => Err(format!("expected `{expected}` at end of file")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_value(&mut self) -> Result<ParsedNode, String> {
|
||||||
|
self.skip_ws();
|
||||||
|
match self.peek_char() {
|
||||||
|
Some('{') => self.parse_object(),
|
||||||
|
Some('[') => self.parse_array(),
|
||||||
|
Some('"') => self.parse_string(),
|
||||||
|
Some('t') => self.parse_literal("true", JsonKind::True),
|
||||||
|
Some('f') => self.parse_literal("false", JsonKind::False),
|
||||||
|
Some('n') => self.parse_literal("null", JsonKind::Null),
|
||||||
|
Some('-' | '0'..='9') => self.parse_number(),
|
||||||
|
Some(other) => Err(format!("unexpected `{other}` at byte {}", self.offset)),
|
||||||
|
None => Err("unexpected end of JSON input".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_object(&mut self) -> Result<ParsedNode, String> {
|
||||||
|
let start = self.offset;
|
||||||
|
self.expect_char('{')?;
|
||||||
|
self.skip_ws();
|
||||||
|
let mut children = Vec::new();
|
||||||
|
if self.peek_char() == Some('}') {
|
||||||
|
self.bump_char();
|
||||||
|
return Ok(ParsedNode {
|
||||||
|
kind: JsonKind::Object,
|
||||||
|
range: TextRange::new(start, self.offset),
|
||||||
|
children,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loop {
|
||||||
|
self.skip_ws();
|
||||||
|
let key = self.parse_string()?;
|
||||||
|
self.skip_ws();
|
||||||
|
self.expect_char(':')?;
|
||||||
|
self.skip_ws();
|
||||||
|
let value = self.parse_value()?;
|
||||||
|
let pair = ParsedNode {
|
||||||
|
kind: JsonKind::Pair,
|
||||||
|
range: TextRange::new(key.range.start, value.range.end),
|
||||||
|
children: vec![key, value],
|
||||||
|
};
|
||||||
|
children.push(pair);
|
||||||
|
self.skip_ws();
|
||||||
|
match self.peek_char() {
|
||||||
|
Some(',') => {
|
||||||
|
self.bump_char();
|
||||||
|
}
|
||||||
|
Some('}') => {
|
||||||
|
self.bump_char();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Some(other) => {
|
||||||
|
return Err(format!(
|
||||||
|
"expected `,` or `}}` at byte {}, found `{other}`",
|
||||||
|
self.offset
|
||||||
|
));
|
||||||
|
}
|
||||||
|
None => return Err("unterminated object".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ParsedNode {
|
||||||
|
kind: JsonKind::Object,
|
||||||
|
range: TextRange::new(start, self.offset),
|
||||||
|
children,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_array(&mut self) -> Result<ParsedNode, String> {
|
||||||
|
let start = self.offset;
|
||||||
|
self.expect_char('[')?;
|
||||||
|
self.skip_ws();
|
||||||
|
let mut children = Vec::new();
|
||||||
|
if self.peek_char() == Some(']') {
|
||||||
|
self.bump_char();
|
||||||
|
return Ok(ParsedNode {
|
||||||
|
kind: JsonKind::Array,
|
||||||
|
range: TextRange::new(start, self.offset),
|
||||||
|
children,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let value = self.parse_value()?;
|
||||||
|
children.push(value);
|
||||||
|
self.skip_ws();
|
||||||
|
match self.peek_char() {
|
||||||
|
Some(',') => {
|
||||||
|
self.bump_char();
|
||||||
|
self.skip_ws();
|
||||||
|
}
|
||||||
|
Some(']') => {
|
||||||
|
self.bump_char();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Some(other) => {
|
||||||
|
return Err(format!(
|
||||||
|
"expected `,` or `]` at byte {}, found `{other}`",
|
||||||
|
self.offset
|
||||||
|
));
|
||||||
|
}
|
||||||
|
None => return Err("unterminated array".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ParsedNode {
|
||||||
|
kind: JsonKind::Array,
|
||||||
|
range: TextRange::new(start, self.offset),
|
||||||
|
children,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_string(&mut self) -> Result<ParsedNode, String> {
|
||||||
|
let start = self.offset;
|
||||||
|
self.expect_char('"')?;
|
||||||
|
loop {
|
||||||
|
match self.bump_char() {
|
||||||
|
Some('"') => {
|
||||||
|
return Ok(ParsedNode {
|
||||||
|
kind: JsonKind::String,
|
||||||
|
range: TextRange::new(start, self.offset),
|
||||||
|
children: Vec::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Some('\\') => match self.bump_char() {
|
||||||
|
Some('"') | Some('\\') | Some('/') | Some('b') | Some('f') | Some('n')
|
||||||
|
| Some('r') | Some('t') => {}
|
||||||
|
Some('u') => {
|
||||||
|
for _ in 0..4 {
|
||||||
|
match self.bump_char() {
|
||||||
|
Some(ch) if ch.is_ascii_hexdigit() => {}
|
||||||
|
_ => {
|
||||||
|
return Err(format!(
|
||||||
|
"invalid unicode escape at byte {}",
|
||||||
|
self.offset
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => return Err(format!("invalid escape sequence at byte {}", self.offset)),
|
||||||
|
},
|
||||||
|
Some(ch) if ch >= '\u{20}' => {}
|
||||||
|
Some(_) => {
|
||||||
|
return Err(format!(
|
||||||
|
"control character in string at byte {}",
|
||||||
|
self.offset
|
||||||
|
));
|
||||||
|
}
|
||||||
|
None => return Err("unterminated string".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_number(&mut self) -> Result<ParsedNode, String> {
|
||||||
|
let start = self.offset;
|
||||||
|
if self.peek_char() == Some('-') {
|
||||||
|
self.bump_char();
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.peek_char() {
|
||||||
|
Some('0') => {
|
||||||
|
self.bump_char();
|
||||||
|
}
|
||||||
|
Some('1'..='9') => {
|
||||||
|
self.bump_char();
|
||||||
|
while matches!(self.peek_char(), Some('0'..='9')) {
|
||||||
|
self.bump_char();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => return Err(format!("invalid number at byte {}", self.offset)),
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.peek_char() == Some('.') {
|
||||||
|
self.bump_char();
|
||||||
|
let mut digits = 0usize;
|
||||||
|
while matches!(self.peek_char(), Some('0'..='9')) {
|
||||||
|
digits += 1;
|
||||||
|
self.bump_char();
|
||||||
|
}
|
||||||
|
if digits == 0 {
|
||||||
|
return Err(format!("expected fraction digits at byte {}", self.offset));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches!(self.peek_char(), Some('e' | 'E')) {
|
||||||
|
self.bump_char();
|
||||||
|
if matches!(self.peek_char(), Some('+' | '-')) {
|
||||||
|
self.bump_char();
|
||||||
|
}
|
||||||
|
let mut digits = 0usize;
|
||||||
|
while matches!(self.peek_char(), Some('0'..='9')) {
|
||||||
|
digits += 1;
|
||||||
|
self.bump_char();
|
||||||
|
}
|
||||||
|
if digits == 0 {
|
||||||
|
return Err(format!("expected exponent digits at byte {}", self.offset));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ParsedNode {
|
||||||
|
kind: JsonKind::Number,
|
||||||
|
range: TextRange::new(start, self.offset),
|
||||||
|
children: Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_literal(&mut self, literal: &str, kind: JsonKind) -> Result<ParsedNode, String> {
|
||||||
|
let start = self.offset;
|
||||||
|
if !self.text[self.offset..].starts_with(literal) {
|
||||||
|
return Err(format!("expected `{literal}` at byte {}", self.offset));
|
||||||
|
}
|
||||||
|
self.offset += literal.len();
|
||||||
|
Ok(ParsedNode {
|
||||||
|
kind,
|
||||||
|
range: TextRange::new(start, self.offset),
|
||||||
|
children: Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_snapshot(snapshot: &ParseSnapshot, rope: &ReactiveRope, delta: Option<&EditDelta>) {
|
||||||
|
let metrics = rope.snapshot_metrics();
|
||||||
|
println!();
|
||||||
|
println!("Document revision {}", metrics.revision);
|
||||||
|
println!(
|
||||||
|
"Text bytes={}, leaves={}, nodes={}, reused_on_last_edit={}",
|
||||||
|
metrics.text.bytes, metrics.leaves, snapshot.total_nodes, snapshot.reused_nodes
|
||||||
|
);
|
||||||
|
if let Some(delta) = delta {
|
||||||
|
println!(
|
||||||
|
"Last edit: [{}..{}) -> {:?}",
|
||||||
|
delta.range.start, delta.range.end, delta.inserted_text
|
||||||
|
);
|
||||||
|
let focus = delta.range.start.min(snapshot.text.len().saturating_sub(1));
|
||||||
|
let path = path_for_offset(&snapshot.root, snapshot.root_anchor.byte, focus);
|
||||||
|
println!("Changed path:");
|
||||||
|
for (node, range) in path {
|
||||||
|
let start = rope.point_at(range.start);
|
||||||
|
let end = rope.point_at(range.end);
|
||||||
|
println!(
|
||||||
|
" id={} kind={} span={}",
|
||||||
|
node.id,
|
||||||
|
node.kind,
|
||||||
|
format_span(start, end)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !snapshot.diagnostics.is_empty() {
|
||||||
|
println!("Diagnostics:");
|
||||||
|
for diagnostic in &snapshot.diagnostics {
|
||||||
|
println!(" - {diagnostic}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!("Root id={} kind={}", snapshot.root.id, snapshot.root.kind);
|
||||||
|
println!("Parse tree:");
|
||||||
|
print_tree(&snapshot.root, snapshot.root_anchor.byte, rope, 0);
|
||||||
|
println!("Current text:");
|
||||||
|
println!("{}", snapshot.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_tree(node: &AstNode, parent_start: usize, rope: &ReactiveRope, depth: usize) {
|
||||||
|
let range = absolute_range(node, parent_start);
|
||||||
|
let start = rope.point_at(range.start);
|
||||||
|
let end = rope.point_at(range.end);
|
||||||
|
let indent = " ".repeat(depth);
|
||||||
|
println!(
|
||||||
|
"{indent}- id={} kind={} span={}",
|
||||||
|
node.id,
|
||||||
|
node.kind,
|
||||||
|
format_span(start, end)
|
||||||
|
);
|
||||||
|
for child in &node.children {
|
||||||
|
print_tree(child, range.start, rope, depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path_for_offset(root: &AstNode, root_start: usize, offset: usize) -> Vec<(&AstNode, TextRange)> {
|
||||||
|
fn walk<'a>(
|
||||||
|
node: &'a AstNode,
|
||||||
|
parent_start: usize,
|
||||||
|
offset: usize,
|
||||||
|
out: &mut Vec<(&'a AstNode, TextRange)>,
|
||||||
|
) -> bool {
|
||||||
|
let range = absolute_range(node, parent_start);
|
||||||
|
if offset < range.start || offset >= range.end {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
out.push((node, range));
|
||||||
|
for child in &node.children {
|
||||||
|
if walk(child, range.start, offset, out) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut path = Vec::new();
|
||||||
|
let _ = walk(root, root_start, offset, &mut path);
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_span(start: TextPoint, end: TextPoint) -> String {
|
||||||
|
format!(
|
||||||
|
"{}:{}..{}:{}",
|
||||||
|
start.line + 1,
|
||||||
|
start.column + 1,
|
||||||
|
end.line + 1,
|
||||||
|
end.column + 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn count_nodes(node: &AstNode) -> usize {
|
||||||
|
1 + node.children.iter().map(count_nodes).sum::<usize>()
|
||||||
|
}
|
||||||
223
lib/reactivity_parsing/examples/tokentree_reactive_parser.rs
Normal file
223
lib/reactivity_parsing/examples/tokentree_reactive_parser.rs
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
use std::env;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use ruin_reactivity::effect_in;
|
||||||
|
use ruin_reactivity_parsing::{
|
||||||
|
ReactiveRope, ReactiveTokenTree, RopeEdit, TextRange, TokenTreeNode, TokenTreeParse,
|
||||||
|
TokenTreeUpdate,
|
||||||
|
};
|
||||||
|
use ruin_runtime::{async_main, fs, stdio};
|
||||||
|
|
||||||
|
enum Command {
|
||||||
|
Insert {
|
||||||
|
offset: usize,
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
Replace {
|
||||||
|
start: usize,
|
||||||
|
end: usize,
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
ReplaceAll {
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
Delete {
|
||||||
|
start: usize,
|
||||||
|
end: usize,
|
||||||
|
},
|
||||||
|
Print,
|
||||||
|
Help,
|
||||||
|
Quit,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_main]
|
||||||
|
async fn main() {
|
||||||
|
let path = env::args()
|
||||||
|
.nth(1)
|
||||||
|
.expect("usage: cargo run -p ruin-reactivity-parsing --example tokentree_reactive_parser -- <path/to/file>");
|
||||||
|
let initial_text = fs::read_to_string(&path)
|
||||||
|
.await
|
||||||
|
.expect("source file should be readable");
|
||||||
|
|
||||||
|
let reactor = ruin_reactivity::Reactor::new();
|
||||||
|
let rope = ReactiveRope::from_text(&reactor, &initial_text);
|
||||||
|
let parser = Rc::new(ReactiveTokenTree::new(&rope));
|
||||||
|
|
||||||
|
effect_in(&reactor, {
|
||||||
|
let parser = Rc::clone(&parser);
|
||||||
|
let rope = rope.clone();
|
||||||
|
move || {
|
||||||
|
parser.with_snapshot(|snapshot| {
|
||||||
|
let last_update = parser.last_update();
|
||||||
|
print_snapshot(snapshot, &rope, last_update.as_ref());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.leak();
|
||||||
|
|
||||||
|
println!("Commands:");
|
||||||
|
println!(" insert <byte_offset> <text>");
|
||||||
|
println!(" replace <start> <end> <text>");
|
||||||
|
println!(" replace_all <text>");
|
||||||
|
println!(" delete <start> <end>");
|
||||||
|
println!(" print");
|
||||||
|
println!(" help");
|
||||||
|
println!(" quit");
|
||||||
|
|
||||||
|
let mut input = stdio::stdin().expect("stdin should be available");
|
||||||
|
while let Some(line) = input.read_line().await.expect("stdin read should succeed") {
|
||||||
|
let trimmed = line.trim_end();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match parse_command(trimmed) {
|
||||||
|
Ok(Command::Insert { offset, text }) => {
|
||||||
|
let _ = rope.edit(RopeEdit::insert(offset, text));
|
||||||
|
}
|
||||||
|
Ok(Command::Replace { start, end, text }) => {
|
||||||
|
let _ = rope.edit(RopeEdit::replace(TextRange::new(start, end), text));
|
||||||
|
}
|
||||||
|
Ok(Command::ReplaceAll { text }) => {
|
||||||
|
let _ = rope.edit(RopeEdit::replace(TextRange::new(0, rope.len_bytes()), text));
|
||||||
|
}
|
||||||
|
Ok(Command::Delete { start, end }) => {
|
||||||
|
let _ = rope.edit(RopeEdit::delete(TextRange::new(start, end)));
|
||||||
|
}
|
||||||
|
Ok(Command::Print) => {
|
||||||
|
let snapshot = parser.snapshot();
|
||||||
|
let last_update = parser.last_update();
|
||||||
|
print_snapshot(&snapshot, &rope, last_update.as_ref());
|
||||||
|
}
|
||||||
|
Ok(Command::Help) => {
|
||||||
|
println!("Commands:");
|
||||||
|
println!(" insert <byte_offset> <text>");
|
||||||
|
println!(" replace <start> <end> <text>");
|
||||||
|
println!(" replace_all <text>");
|
||||||
|
println!(" delete <start> <end>");
|
||||||
|
println!(" print");
|
||||||
|
println!(" help");
|
||||||
|
println!(" quit");
|
||||||
|
}
|
||||||
|
Ok(Command::Quit) => break,
|
||||||
|
Err(error) => eprintln!("{error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_command(input: &str) -> Result<Command, String> {
|
||||||
|
if input == "quit" {
|
||||||
|
return Ok(Command::Quit);
|
||||||
|
}
|
||||||
|
if input == "print" {
|
||||||
|
return Ok(Command::Print);
|
||||||
|
}
|
||||||
|
if input == "help" {
|
||||||
|
return Ok(Command::Help);
|
||||||
|
}
|
||||||
|
if let Some(rest) = input.strip_prefix("insert ") {
|
||||||
|
let (offset, text) = rest
|
||||||
|
.split_once(' ')
|
||||||
|
.ok_or_else(|| "insert expects: insert <offset> <text>".to_string())?;
|
||||||
|
return Ok(Command::Insert {
|
||||||
|
offset: offset
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| "invalid insert offset".to_string())?,
|
||||||
|
text: text.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(rest) = input.strip_prefix("replace ") {
|
||||||
|
let mut parts = rest.splitn(3, ' ');
|
||||||
|
let start = parts
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| "replace expects: replace <start> <end> <text>".to_string())?;
|
||||||
|
let end = parts
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| "replace expects: replace <start> <end> <text>".to_string())?;
|
||||||
|
let text = parts
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| "replace expects: replace <start> <end> <text>".to_string())?;
|
||||||
|
return Ok(Command::Replace {
|
||||||
|
start: start
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| "invalid replace start".to_string())?,
|
||||||
|
end: end.parse().map_err(|_| "invalid replace end".to_string())?,
|
||||||
|
text: text.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(text) = input.strip_prefix("replace_all ") {
|
||||||
|
return Ok(Command::ReplaceAll {
|
||||||
|
text: text.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(rest) = input.strip_prefix("delete ") {
|
||||||
|
let (start, end) = rest
|
||||||
|
.split_once(' ')
|
||||||
|
.ok_or_else(|| "delete expects: delete <start> <end>".to_string())?;
|
||||||
|
return Ok(Command::Delete {
|
||||||
|
start: start
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| "invalid delete start".to_string())?,
|
||||||
|
end: end.parse().map_err(|_| "invalid delete end".to_string())?,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err("unknown command; type `help`".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_snapshot(
|
||||||
|
snapshot: &TokenTreeParse,
|
||||||
|
rope: &ReactiveRope,
|
||||||
|
update: Option<&TokenTreeUpdate>,
|
||||||
|
) {
|
||||||
|
let metrics = rope.snapshot_metrics();
|
||||||
|
println!();
|
||||||
|
println!("Document revision {}", metrics.revision);
|
||||||
|
println!(
|
||||||
|
"Text bytes={}, leaves={}, nodes={}, reused_on_last_edit={}",
|
||||||
|
metrics.text.bytes, metrics.leaves, snapshot.stats.total_nodes, snapshot.stats.reused_nodes
|
||||||
|
);
|
||||||
|
if let Some(update) = update {
|
||||||
|
println!(
|
||||||
|
"Last edit: [{}..{}) -> {:?}",
|
||||||
|
update.delta.range.start, update.delta.range.end, update.delta.inserted_text
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"Diagnostics changed={}, trivia changed={}",
|
||||||
|
update.diagnostics_changed, update.trivia_changed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!("Parse tree:");
|
||||||
|
print_tree(snapshot, rope, &snapshot.root, 0);
|
||||||
|
if !snapshot.diagnostics.is_empty() {
|
||||||
|
println!("Diagnostics:");
|
||||||
|
for diagnostic in &snapshot.diagnostics {
|
||||||
|
println!(
|
||||||
|
" {:?} [{}..{}): {}",
|
||||||
|
diagnostic.kind, diagnostic.range.start, diagnostic.range.end, diagnostic.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !snapshot.trivia.comments().is_empty() {
|
||||||
|
println!("Comments:");
|
||||||
|
for comment in snapshot.trivia.comments() {
|
||||||
|
println!(
|
||||||
|
" {:?} [{}..{}): {}",
|
||||||
|
comment.kind, comment.range.start, comment.range.end, comment.text
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_tree(snapshot: &TokenTreeParse, rope: &ReactiveRope, node: &TokenTreeNode, depth: usize) {
|
||||||
|
let indent = " ".repeat(depth);
|
||||||
|
let (start, end) = snapshot
|
||||||
|
.absolute_span(rope, node.id)
|
||||||
|
.expect("node span should be available");
|
||||||
|
println!(
|
||||||
|
"{}- id={} kind={} span=({}:{})..({}:{})",
|
||||||
|
indent, node.id, node.kind, start.line, start.column, end.line, end.column
|
||||||
|
);
|
||||||
|
for child in &node.children {
|
||||||
|
print_tree(snapshot, rope, child, depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
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, ParseTree, ParserCheckpoint,
|
||||||
|
SyntaxFragment,
|
||||||
|
};
|
||||||
|
pub use rope::{
|
||||||
|
DEFAULT_LEAF_TARGET_BYTES, ReactiveRope, RopeBuilder, RopeChunk, RopeCursor,
|
||||||
|
RopeSnapshotMetrics,
|
||||||
|
};
|
||||||
|
pub use tokentree::{
|
||||||
|
DelimiterKind, NumberBase, NumberLiteral, NumberSuffix, ReactiveTokenTree, StringDelimiter,
|
||||||
|
StringFragment, TokenTreeDiagnostic, TokenTreeDiagnosticKind, TokenTreeNode, TokenTreeNodeKind,
|
||||||
|
TokenTreeParse, TokenTreeParser, TokenTreeStats, TokenTreeUpdate, TriviaComment, TriviaKind,
|
||||||
|
TriviaStore,
|
||||||
|
};
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
170
lib/reactivity_parsing/src/parse.rs
Normal file
170
lib/reactivity_parsing/src/parse.rs
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
use core::fmt;
|
||||||
|
|
||||||
|
use crate::{RopeCursor, TextRange};
|
||||||
|
|
||||||
|
pub trait ParserCheckpoint: Clone + Eq + fmt::Debug + 'static {}
|
||||||
|
|
||||||
|
impl<T> ParserCheckpoint for T where T: Clone + Eq + fmt::Debug + 'static {}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct SyntaxFragment<C, T, D> {
|
||||||
|
pub range: TextRange,
|
||||||
|
pub start_checkpoint: C,
|
||||||
|
pub end_checkpoint: C,
|
||||||
|
pub tree: T,
|
||||||
|
pub diagnostics: Vec<D>,
|
||||||
|
pub grammar_revision: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct ParseTree<T> {
|
||||||
|
pub root: T,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct ParseInvalidation<C> {
|
||||||
|
pub resume_at: usize,
|
||||||
|
pub restart_checkpoint: C,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct ParseResult<C, T, D> {
|
||||||
|
pub tree: ParseTree<T>,
|
||||||
|
pub fragments: Vec<SyntaxFragment<C, T, D>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct ParseError {
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ParseError {
|
||||||
|
pub fn new(message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait IncrementalParser {
|
||||||
|
type Checkpoint: ParserCheckpoint;
|
||||||
|
type Tree: Clone + fmt::Debug + 'static;
|
||||||
|
type Diagnostic: Clone + fmt::Debug + 'static;
|
||||||
|
|
||||||
|
fn grammar_revision(&self) -> u64;
|
||||||
|
|
||||||
|
fn initial_checkpoint(&self) -> Self::Checkpoint;
|
||||||
|
|
||||||
|
fn invalidate(
|
||||||
|
&self,
|
||||||
|
delta_range: TextRange,
|
||||||
|
previous_fragments: &[SyntaxFragment<Self::Checkpoint, Self::Tree, Self::Diagnostic>],
|
||||||
|
) -> ParseInvalidation<Self::Checkpoint>;
|
||||||
|
|
||||||
|
fn parse(
|
||||||
|
&self,
|
||||||
|
cursor: RopeCursor,
|
||||||
|
fragments: &[SyntaxFragment<Self::Checkpoint, Self::Tree, Self::Diagnostic>],
|
||||||
|
invalidation: ParseInvalidation<Self::Checkpoint>,
|
||||||
|
) -> Result<ParseResult<Self::Checkpoint, Self::Tree, Self::Diagnostic>, ParseError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::cell::Cell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use ruin_reactivity::Reactor;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
IncrementalParser, ParseError, ParseInvalidation, ParseResult, ParseTree, SyntaxFragment,
|
||||||
|
};
|
||||||
|
use crate::{ReactiveRope, RopeCursor, TextRange};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct DummyParser {
|
||||||
|
parse_calls: Rc<Cell<usize>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IncrementalParser for DummyParser {
|
||||||
|
type Checkpoint = u8;
|
||||||
|
type Tree = String;
|
||||||
|
type Diagnostic = String;
|
||||||
|
|
||||||
|
fn grammar_revision(&self) -> u64 {
|
||||||
|
7
|
||||||
|
}
|
||||||
|
|
||||||
|
fn initial_checkpoint(&self) -> Self::Checkpoint {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalidate(
|
||||||
|
&self,
|
||||||
|
delta_range: TextRange,
|
||||||
|
_previous_fragments: &[SyntaxFragment<
|
||||||
|
Self::Checkpoint,
|
||||||
|
Self::Tree,
|
||||||
|
Self::Diagnostic,
|
||||||
|
>],
|
||||||
|
) -> ParseInvalidation<Self::Checkpoint> {
|
||||||
|
ParseInvalidation {
|
||||||
|
resume_at: delta_range.start.saturating_sub(1),
|
||||||
|
restart_checkpoint: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse(
|
||||||
|
&self,
|
||||||
|
mut cursor: RopeCursor,
|
||||||
|
_fragments: &[SyntaxFragment<Self::Checkpoint, Self::Tree, Self::Diagnostic>],
|
||||||
|
invalidation: ParseInvalidation<Self::Checkpoint>,
|
||||||
|
) -> Result<ParseResult<Self::Checkpoint, Self::Tree, Self::Diagnostic>, ParseError>
|
||||||
|
{
|
||||||
|
self.parse_calls.set(self.parse_calls.get() + 1);
|
||||||
|
let text = cursor.read_bytes(usize::MAX);
|
||||||
|
Ok(ParseResult {
|
||||||
|
tree: ParseTree { root: text.clone() },
|
||||||
|
fragments: vec![SyntaxFragment {
|
||||||
|
range: TextRange::new(
|
||||||
|
invalidation.resume_at,
|
||||||
|
invalidation.resume_at + text.len(),
|
||||||
|
),
|
||||||
|
start_checkpoint: invalidation.restart_checkpoint,
|
||||||
|
end_checkpoint: 2,
|
||||||
|
tree: text,
|
||||||
|
diagnostics: Vec::new(),
|
||||||
|
grammar_revision: self.grammar_revision(),
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_error_constructor_sets_message() {
|
||||||
|
let error = ParseError::new("bad");
|
||||||
|
assert_eq!(error.message, "bad");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parser_trait_round_trip_produces_fragments() {
|
||||||
|
let reactor = Reactor::new();
|
||||||
|
let rope = ReactiveRope::from_text(&reactor, "{\"a\":1}");
|
||||||
|
let parser = DummyParser {
|
||||||
|
parse_calls: Rc::new(Cell::new(0)),
|
||||||
|
};
|
||||||
|
|
||||||
|
let invalidation = parser.invalidate(TextRange::new(3, 4), &[]);
|
||||||
|
assert_eq!(invalidation.resume_at, 2);
|
||||||
|
assert_eq!(invalidation.restart_checkpoint, 1);
|
||||||
|
|
||||||
|
let result = parser
|
||||||
|
.parse(rope.cursor(0), &[], invalidation)
|
||||||
|
.expect("dummy parse should succeed");
|
||||||
|
|
||||||
|
assert_eq!(result.tree.root, "{\"a\":1}");
|
||||||
|
assert_eq!(result.fragments.len(), 1);
|
||||||
|
assert_eq!(result.fragments[0].grammar_revision, 7);
|
||||||
|
assert_eq!(parser.parse_calls.get(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
732
lib/reactivity_parsing/src/rope.rs
Normal file
732
lib/reactivity_parsing/src/rope.rs
Normal file
@@ -0,0 +1,732 @@
|
|||||||
|
use std::cell::{Cell, RefCell};
|
||||||
|
use std::cmp::min;
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use ruin_reactivity::{Event, Reactor, Source, event_in, source_in};
|
||||||
|
|
||||||
|
use crate::delta::{EditDelta, RopeEdit, TextRange};
|
||||||
|
use crate::metrics::{TextMetrics, TextPoint};
|
||||||
|
|
||||||
|
pub const DEFAULT_LEAF_TARGET_BYTES: usize = 1024;
|
||||||
|
const DEFAULT_LEAF_MAX_BYTES: usize = DEFAULT_LEAF_TARGET_BYTES * 2;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ReactiveRope {
|
||||||
|
inner: Rc<RopeInner>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RopeBuilder {
|
||||||
|
reactor: Reactor,
|
||||||
|
leaf_target_bytes: usize,
|
||||||
|
chunks: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct RopeChunk {
|
||||||
|
text: Rc<str>,
|
||||||
|
pub start: TextPoint,
|
||||||
|
pub metrics: TextMetrics,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct RopeCursor {
|
||||||
|
rope: ReactiveRope,
|
||||||
|
next_offset: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct RopeSnapshotMetrics {
|
||||||
|
pub leaves: usize,
|
||||||
|
pub text: TextMetrics,
|
||||||
|
pub revision: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RopeInner {
|
||||||
|
reactor: Reactor,
|
||||||
|
structure: Source,
|
||||||
|
revision: Cell<u64>,
|
||||||
|
leaf_target_bytes: usize,
|
||||||
|
leaves: RefCell<Vec<Rc<Leaf>>>,
|
||||||
|
edit_events: Event<EditDelta>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Leaf {
|
||||||
|
source: Source,
|
||||||
|
text: RefCell<Rc<str>>,
|
||||||
|
metrics: Cell<TextMetrics>,
|
||||||
|
tail_chars: Cell<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RopeBuilder {
|
||||||
|
pub fn new(reactor: &Reactor) -> Self {
|
||||||
|
Self {
|
||||||
|
reactor: reactor.clone(),
|
||||||
|
leaf_target_bytes: DEFAULT_LEAF_TARGET_BYTES,
|
||||||
|
chunks: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn leaf_target_bytes(mut self, bytes: usize) -> Self {
|
||||||
|
self.leaf_target_bytes = bytes.max(32);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push(mut self, text: impl Into<String>) -> Self {
|
||||||
|
self.chunks.push(text.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build(self) -> ReactiveRope {
|
||||||
|
ReactiveRope::from_chunks(self.reactor, self.leaf_target_bytes, self.chunks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReactiveRope {
|
||||||
|
pub fn new(reactor: &Reactor) -> Self {
|
||||||
|
RopeBuilder::new(reactor).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_text(reactor: &Reactor, text: impl AsRef<str>) -> Self {
|
||||||
|
RopeBuilder::new(reactor).push(text.as_ref()).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_chunks(reactor: Reactor, leaf_target_bytes: usize, chunks: Vec<String>) -> Self {
|
||||||
|
let structure = source_in(&reactor);
|
||||||
|
let edit_events = event_in(&reactor);
|
||||||
|
let mut leaves = Vec::new();
|
||||||
|
for chunk in chunks {
|
||||||
|
Self::append_chunk_into(&reactor, leaf_target_bytes, &mut leaves, &chunk);
|
||||||
|
}
|
||||||
|
if leaves.is_empty() {
|
||||||
|
leaves.push(Rc::new(Leaf::new(&reactor, String::new())));
|
||||||
|
}
|
||||||
|
|
||||||
|
Self {
|
||||||
|
inner: Rc::new(RopeInner {
|
||||||
|
reactor,
|
||||||
|
structure,
|
||||||
|
revision: Cell::new(0),
|
||||||
|
leaf_target_bytes,
|
||||||
|
leaves: RefCell::new(leaves),
|
||||||
|
edit_events,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn edit_events(&self) -> Event<EditDelta> {
|
||||||
|
self.inner.edit_events.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reactor(&self) -> Reactor {
|
||||||
|
self.inner.reactor.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn snapshot_metrics(&self) -> RopeSnapshotMetrics {
|
||||||
|
self.inner.structure.observe();
|
||||||
|
RopeSnapshotMetrics {
|
||||||
|
leaves: self.inner.leaves.borrow().len(),
|
||||||
|
text: self.total_metrics(),
|
||||||
|
revision: self.inner.revision.get(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn len_bytes(&self) -> usize {
|
||||||
|
self.total_metrics().bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.len_bytes() == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_string(&self) -> String {
|
||||||
|
self.slice(TextRange::new(0, self.len_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn point_at(&self, offset: usize) -> TextPoint {
|
||||||
|
self.inner.structure.observe();
|
||||||
|
let offset = offset.min(self.len_bytes());
|
||||||
|
let mut point = TextPoint::origin();
|
||||||
|
let leaves = self.inner.leaves.borrow();
|
||||||
|
let mut remaining = offset;
|
||||||
|
for leaf in &*leaves {
|
||||||
|
let bytes = leaf.metrics.get().bytes;
|
||||||
|
if remaining > bytes {
|
||||||
|
remaining -= bytes;
|
||||||
|
point.byte += bytes;
|
||||||
|
point.char_offset += leaf.metrics.get().chars;
|
||||||
|
point.utf16_offset += leaf.metrics.get().utf16;
|
||||||
|
point.line += leaf.metrics.get().line_breaks;
|
||||||
|
if leaf.metrics.get().line_breaks == 0 {
|
||||||
|
point.column += leaf.tail_chars.get();
|
||||||
|
} else {
|
||||||
|
point.column = leaf.tail_chars.get();
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let text = leaf.read();
|
||||||
|
point = point.advance(&text[..remaining]);
|
||||||
|
return point;
|
||||||
|
}
|
||||||
|
point
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn slice(&self, range: TextRange) -> String {
|
||||||
|
self.inner.structure.observe();
|
||||||
|
self.assert_char_boundary(range.start);
|
||||||
|
self.assert_char_boundary(range.end);
|
||||||
|
assert!(range.start <= range.end, "invalid text range");
|
||||||
|
assert!(range.end <= self.len_bytes(), "range out of bounds");
|
||||||
|
|
||||||
|
let mut out = String::with_capacity(range.len());
|
||||||
|
let mut cursor = 0usize;
|
||||||
|
let leaves = self.inner.leaves.borrow();
|
||||||
|
for leaf in &*leaves {
|
||||||
|
let metrics = leaf.metrics.get();
|
||||||
|
let end = cursor + metrics.bytes;
|
||||||
|
if end <= range.start {
|
||||||
|
cursor = end;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if cursor >= range.end {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let text = leaf.read();
|
||||||
|
let start_in_leaf = range.start.saturating_sub(cursor);
|
||||||
|
let end_in_leaf = min(metrics.bytes, range.end - cursor);
|
||||||
|
out.push_str(&text[start_in_leaf..end_in_leaf]);
|
||||||
|
cursor = end;
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn chunk_at(&self, offset: usize) -> Option<RopeChunk> {
|
||||||
|
self.inner.structure.observe();
|
||||||
|
if offset >= self.len_bytes() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut start = TextPoint::origin();
|
||||||
|
let leaves = self.inner.leaves.borrow();
|
||||||
|
let mut cursor = 0usize;
|
||||||
|
for leaf in &*leaves {
|
||||||
|
let metrics = leaf.metrics.get();
|
||||||
|
let end = cursor + metrics.bytes;
|
||||||
|
if offset < end {
|
||||||
|
let text = leaf.read();
|
||||||
|
leaf.source.observe();
|
||||||
|
return Some(RopeChunk {
|
||||||
|
text,
|
||||||
|
start,
|
||||||
|
metrics,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
start.byte += metrics.bytes;
|
||||||
|
start.char_offset += metrics.chars;
|
||||||
|
start.utf16_offset += metrics.utf16;
|
||||||
|
start.line += metrics.line_breaks;
|
||||||
|
if metrics.line_breaks == 0 {
|
||||||
|
start.column += leaf.tail_chars.get();
|
||||||
|
} else {
|
||||||
|
start.column = leaf.tail_chars.get();
|
||||||
|
}
|
||||||
|
cursor = end;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cursor(&self, offset: usize) -> RopeCursor {
|
||||||
|
RopeCursor {
|
||||||
|
rope: self.clone(),
|
||||||
|
next_offset: offset.min(self.len_bytes()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn edit(&self, edit: RopeEdit) -> EditDelta {
|
||||||
|
let delta = self.apply_edit(edit);
|
||||||
|
self.inner.edit_events.emit(delta.clone());
|
||||||
|
delta
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn edit_batch(&self, edits: impl IntoIterator<Item = RopeEdit>) -> Vec<EditDelta> {
|
||||||
|
let mut deltas = Vec::new();
|
||||||
|
for edit in edits {
|
||||||
|
let delta = self.apply_edit(edit);
|
||||||
|
self.inner.edit_events.emit(delta.clone());
|
||||||
|
deltas.push(delta);
|
||||||
|
}
|
||||||
|
deltas
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_edit(&self, edit: RopeEdit) -> EditDelta {
|
||||||
|
assert!(edit.range.start <= edit.range.end, "invalid edit range");
|
||||||
|
assert!(
|
||||||
|
edit.range.end <= self.len_bytes(),
|
||||||
|
"edit range out of bounds"
|
||||||
|
);
|
||||||
|
self.assert_char_boundary(edit.range.start);
|
||||||
|
self.assert_char_boundary(edit.range.end);
|
||||||
|
assert!(
|
||||||
|
edit.insert.is_char_boundary(edit.insert.len()),
|
||||||
|
"replacement text must be valid UTF-8"
|
||||||
|
);
|
||||||
|
|
||||||
|
let old_start = self.point_at(edit.range.start);
|
||||||
|
let old_end = self.point_at(edit.range.end);
|
||||||
|
let removed_text = self.slice(edit.range);
|
||||||
|
let removed = TextMetrics::from_text(&removed_text);
|
||||||
|
let inserted = TextMetrics::from_text(&edit.insert);
|
||||||
|
|
||||||
|
let mut leaves = self.inner.leaves.borrow_mut();
|
||||||
|
let mut cursor = 0usize;
|
||||||
|
let mut first_index = None;
|
||||||
|
let mut last_index = None;
|
||||||
|
let mut prefix = String::new();
|
||||||
|
let mut suffix = String::new();
|
||||||
|
|
||||||
|
for (index, leaf) in leaves.iter().enumerate() {
|
||||||
|
let text = leaf.read();
|
||||||
|
let end = cursor + text.len();
|
||||||
|
if first_index.is_none() && edit.range.start <= end {
|
||||||
|
first_index = Some(index);
|
||||||
|
let split = edit.range.start.saturating_sub(cursor);
|
||||||
|
prefix.push_str(&text[..split]);
|
||||||
|
}
|
||||||
|
if first_index.is_some() && edit.range.end <= end {
|
||||||
|
last_index = Some(index);
|
||||||
|
let split = edit.range.end.saturating_sub(cursor);
|
||||||
|
suffix.push_str(&text[split..]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
cursor = end;
|
||||||
|
}
|
||||||
|
|
||||||
|
let first_index = first_index.unwrap_or_else(|| leaves.len().saturating_sub(1));
|
||||||
|
let last_index = last_index.unwrap_or(first_index);
|
||||||
|
|
||||||
|
let mut replacement = prefix;
|
||||||
|
replacement.push_str(&edit.insert);
|
||||||
|
replacement.push_str(&suffix);
|
||||||
|
|
||||||
|
let structure_changed =
|
||||||
|
last_index > first_index || replacement.len() > DEFAULT_LEAF_MAX_BYTES;
|
||||||
|
if !structure_changed {
|
||||||
|
if let Some(leaf) = leaves.get(first_index) {
|
||||||
|
leaf.set_text(replacement);
|
||||||
|
leaf.source.trigger();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let mut replacement_leaves = Vec::new();
|
||||||
|
Self::append_chunk_into(
|
||||||
|
&self.inner.reactor,
|
||||||
|
self.inner.leaf_target_bytes,
|
||||||
|
&mut replacement_leaves,
|
||||||
|
&replacement,
|
||||||
|
);
|
||||||
|
if replacement_leaves.is_empty() {
|
||||||
|
replacement_leaves.push(Rc::new(Leaf::new(&self.inner.reactor, String::new())));
|
||||||
|
}
|
||||||
|
|
||||||
|
let removed_span = leaves.splice(first_index..=last_index, replacement_leaves);
|
||||||
|
let mut touched = BTreeSet::new();
|
||||||
|
for leaf in removed_span {
|
||||||
|
touched.insert(leaf.source.id());
|
||||||
|
}
|
||||||
|
for leaf in leaves.iter().skip(first_index) {
|
||||||
|
touched.insert(leaf.source.id());
|
||||||
|
}
|
||||||
|
for leaf in &*leaves {
|
||||||
|
if touched.contains(&leaf.source.id()) {
|
||||||
|
leaf.source.trigger();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.inner.structure.trigger();
|
||||||
|
}
|
||||||
|
|
||||||
|
self.inner
|
||||||
|
.revision
|
||||||
|
.set(self.inner.revision.get().wrapping_add(1));
|
||||||
|
|
||||||
|
let new_end = old_start.advance(&edit.insert);
|
||||||
|
drop(leaves);
|
||||||
|
|
||||||
|
EditDelta {
|
||||||
|
range: edit.range,
|
||||||
|
removed_text,
|
||||||
|
inserted_text: edit.insert,
|
||||||
|
removed,
|
||||||
|
inserted,
|
||||||
|
start: old_start,
|
||||||
|
old_end,
|
||||||
|
new_end,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn total_metrics(&self) -> TextMetrics {
|
||||||
|
self.inner
|
||||||
|
.leaves
|
||||||
|
.borrow()
|
||||||
|
.iter()
|
||||||
|
.fold(TextMetrics::default(), |sum, leaf| sum + leaf.metrics.get())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_chunk_into(
|
||||||
|
reactor: &Reactor,
|
||||||
|
leaf_target_bytes: usize,
|
||||||
|
out: &mut Vec<Rc<Leaf>>,
|
||||||
|
text: &str,
|
||||||
|
) {
|
||||||
|
if text.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let max_bytes = (leaf_target_bytes.max(32)) * 2;
|
||||||
|
let mut start = 0usize;
|
||||||
|
while start < text.len() {
|
||||||
|
let mut end = min(text.len(), start + max_bytes);
|
||||||
|
while end > start && !text.is_char_boundary(end) {
|
||||||
|
end -= 1;
|
||||||
|
}
|
||||||
|
if end == start {
|
||||||
|
end = text.len();
|
||||||
|
}
|
||||||
|
out.push(Rc::new(Leaf::new(reactor, text[start..end].to_string())));
|
||||||
|
start = end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_char_boundary(&self, offset: usize) {
|
||||||
|
if offset == self.len_bytes() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let leaves = self.inner.leaves.borrow();
|
||||||
|
let mut cursor = 0usize;
|
||||||
|
for leaf in &*leaves {
|
||||||
|
let bytes = leaf.metrics.get().bytes;
|
||||||
|
let end = cursor + bytes;
|
||||||
|
if offset < end {
|
||||||
|
let text = leaf.read();
|
||||||
|
assert!(
|
||||||
|
text.is_char_boundary(offset - cursor),
|
||||||
|
"offset must be a char boundary"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cursor = end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RopeChunk {
|
||||||
|
pub fn text(&self) -> &str {
|
||||||
|
&self.text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RopeCursor {
|
||||||
|
pub fn offset(&self) -> usize {
|
||||||
|
self.next_offset
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn peek_chunk(&self) -> Option<RopeChunk> {
|
||||||
|
let chunk = self.rope.chunk_at(self.next_offset)?;
|
||||||
|
let local = self.next_offset.saturating_sub(chunk.start.byte);
|
||||||
|
if local == 0 {
|
||||||
|
return Some(chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
let partial = &chunk.text()[local..];
|
||||||
|
Some(RopeChunk {
|
||||||
|
text: Rc::from(partial),
|
||||||
|
start: self.rope.point_at(self.next_offset),
|
||||||
|
metrics: TextMetrics::from_text(partial),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next_chunk(&mut self) -> Option<RopeChunk> {
|
||||||
|
let chunk = self.peek_chunk()?;
|
||||||
|
let end = chunk.start.byte + chunk.metrics.bytes;
|
||||||
|
self.next_offset = end;
|
||||||
|
Some(chunk)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_bytes(&mut self, len: usize) -> String {
|
||||||
|
let start = self.next_offset;
|
||||||
|
let end = min(self.rope.len_bytes(), start + len);
|
||||||
|
let slice = self.rope.slice(TextRange::new(start, end));
|
||||||
|
self.next_offset = end;
|
||||||
|
slice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Leaf {
|
||||||
|
fn new(reactor: &Reactor, text: String) -> Self {
|
||||||
|
let text: Rc<str> = Rc::from(text);
|
||||||
|
let metrics = TextMetrics::from_text(&text);
|
||||||
|
let tail_chars = tail_chars(&text);
|
||||||
|
Self {
|
||||||
|
source: source_in(reactor),
|
||||||
|
text: RefCell::new(text),
|
||||||
|
metrics: Cell::new(metrics),
|
||||||
|
tail_chars: Cell::new(tail_chars),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read(&self) -> Rc<str> {
|
||||||
|
self.source.observe();
|
||||||
|
self.text.borrow().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_text(&self, text: String) {
|
||||||
|
let text: Rc<str> = Rc::from(text);
|
||||||
|
self.metrics.set(TextMetrics::from_text(&text));
|
||||||
|
self.tail_chars.set(tail_chars(&text));
|
||||||
|
*self.text.borrow_mut() = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tail_chars(text: &str) -> usize {
|
||||||
|
match text.rfind('\n') {
|
||||||
|
Some(index) => text[index + 1..].chars().count(),
|
||||||
|
None => text.chars().count(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::cell::Cell as Counter;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use ruin_reactivity::{Reactor, thunk_in};
|
||||||
|
|
||||||
|
use crate::{Anchor, AnchorBias, ReactiveRope, RopeBuilder, RopeEdit, TextRange};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn edits_preserve_text_and_metrics() {
|
||||||
|
let reactor = Reactor::new();
|
||||||
|
let rope = ReactiveRope::from_text(&reactor, "hello\nworld");
|
||||||
|
|
||||||
|
let delta = rope.edit(RopeEdit::replace(TextRange::new(5, 6), ", "));
|
||||||
|
|
||||||
|
assert_eq!(rope.to_string(), "hello, world");
|
||||||
|
assert_eq!(delta.removed_text, "\n");
|
||||||
|
assert_eq!(delta.inserted_text, ", ");
|
||||||
|
assert_eq!(rope.snapshot_metrics().text.bytes, "hello, world".len());
|
||||||
|
assert_eq!(rope.snapshot_metrics().text.line_breaks, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cursor_walks_tokens_across_leaf_boundaries() {
|
||||||
|
let reactor = Reactor::new();
|
||||||
|
let rope = RopeBuilder::new(&reactor)
|
||||||
|
.leaf_target_bytes(4)
|
||||||
|
.push("alpha")
|
||||||
|
.push("beta")
|
||||||
|
.build();
|
||||||
|
let mut cursor = rope.cursor(3);
|
||||||
|
|
||||||
|
assert_eq!(cursor.read_bytes(4), "habe");
|
||||||
|
let rest = cursor.next_chunk().expect("remaining chunk");
|
||||||
|
assert_eq!(rest.text(), "ta");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anchors_map_through_insertions() {
|
||||||
|
let reactor = Reactor::new();
|
||||||
|
let rope = ReactiveRope::from_text(&reactor, "abcdef");
|
||||||
|
let mut left = Anchor::new(3, AnchorBias::Left);
|
||||||
|
let mut right = Anchor::new(3, AnchorBias::Right);
|
||||||
|
|
||||||
|
let delta = rope.edit(RopeEdit::insert(3, "XYZ"));
|
||||||
|
left.map_through(&delta);
|
||||||
|
right.map_through(&delta);
|
||||||
|
|
||||||
|
assert_eq!(left.byte, 3);
|
||||||
|
assert_eq!(right.byte, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disjoint_leaf_reads_do_not_recompute_on_local_edit() {
|
||||||
|
let reactor = Reactor::new();
|
||||||
|
let rope = RopeBuilder::new(&reactor)
|
||||||
|
.leaf_target_bytes(64)
|
||||||
|
.push("a".repeat(80))
|
||||||
|
.push("b".repeat(80))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let first_runs = Rc::new(Counter::new(0usize));
|
||||||
|
let second_runs = Rc::new(Counter::new(0usize));
|
||||||
|
|
||||||
|
let first = thunk_in(&reactor, {
|
||||||
|
let rope = rope.clone();
|
||||||
|
let first_runs = Rc::clone(&first_runs);
|
||||||
|
move || {
|
||||||
|
first_runs.set(first_runs.get() + 1);
|
||||||
|
rope.slice(TextRange::new(0, 10))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let second = thunk_in(&reactor, {
|
||||||
|
let rope = rope.clone();
|
||||||
|
let second_runs = Rc::clone(&second_runs);
|
||||||
|
move || {
|
||||||
|
second_runs.set(second_runs.get() + 1);
|
||||||
|
rope.slice(TextRange::new(90, 100))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(first.get(), "aaaaaaaaaa");
|
||||||
|
assert_eq!(second.get(), "bbbbbbbbbb");
|
||||||
|
rope.edit(RopeEdit::replace(TextRange::new(2, 4), "zz"));
|
||||||
|
|
||||||
|
assert_eq!(first.get(), "aazzaaaaaa");
|
||||||
|
assert_eq!(second.get(), "bbbbbbbbbb");
|
||||||
|
assert_eq!(first_runs.get(), 2);
|
||||||
|
assert_eq!(second_runs.get(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn point_at_tracks_multibyte_offsets() {
|
||||||
|
let reactor = Reactor::new();
|
||||||
|
let rope = ReactiveRope::from_text(&reactor, "a\né🙂");
|
||||||
|
|
||||||
|
assert_eq!(rope.point_at(0).byte, 0);
|
||||||
|
assert_eq!(rope.point_at(2).line, 1);
|
||||||
|
assert_eq!(rope.point_at(4).char_offset, 3);
|
||||||
|
assert_eq!(rope.point_at("a\né🙂".len()).utf16_offset, 5);
|
||||||
|
assert_eq!(rope.point_at("a\né".len()).column, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn point_at_tracks_column_across_leaves() {
|
||||||
|
let reactor = Reactor::new();
|
||||||
|
let rope = RopeBuilder::new(&reactor)
|
||||||
|
.leaf_target_bytes(4)
|
||||||
|
.push("ab\n")
|
||||||
|
.push("cde")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let point = rope.point_at("ab\ncd".len());
|
||||||
|
assert_eq!(point.line, 1);
|
||||||
|
assert_eq!(point.column, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chunk_at_and_peek_chunk_respect_boundaries() {
|
||||||
|
let reactor = Reactor::new();
|
||||||
|
let rope = RopeBuilder::new(&reactor)
|
||||||
|
.leaf_target_bytes(4)
|
||||||
|
.push("abcd")
|
||||||
|
.push("efgh")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let first = rope.chunk_at(0).expect("chunk at start");
|
||||||
|
assert_eq!(first.text(), "abcd");
|
||||||
|
|
||||||
|
let second = rope.chunk_at(4).expect("chunk at split");
|
||||||
|
assert_eq!(second.text(), "efgh");
|
||||||
|
assert!(rope.chunk_at(8).is_none());
|
||||||
|
|
||||||
|
let mut cursor = rope.cursor(2);
|
||||||
|
let partial = cursor.peek_chunk().expect("partial chunk");
|
||||||
|
assert_eq!(partial.text(), "cd");
|
||||||
|
let advanced = cursor.next_chunk().expect("advanced chunk");
|
||||||
|
assert_eq!(advanced.text(), "cd");
|
||||||
|
assert_eq!(cursor.offset(), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cursor_stops_at_end_of_document() {
|
||||||
|
let reactor = Reactor::new();
|
||||||
|
let rope = RopeBuilder::new(&reactor)
|
||||||
|
.leaf_target_bytes(4)
|
||||||
|
.push("abcd")
|
||||||
|
.push("ef")
|
||||||
|
.build();
|
||||||
|
let mut cursor = rope.cursor(0);
|
||||||
|
let mut chunks = Vec::new();
|
||||||
|
while let Some(chunk) = cursor.next_chunk() {
|
||||||
|
chunks.push(chunk.text().to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(chunks, vec!["abcd".to_string(), "ef".to_string()]);
|
||||||
|
assert!(cursor.next_chunk().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_edits_return_deltas_and_increment_revision() {
|
||||||
|
let reactor = Reactor::new();
|
||||||
|
let rope = ReactiveRope::from_text(&reactor, "abcdef");
|
||||||
|
let before = rope.snapshot_metrics().revision;
|
||||||
|
|
||||||
|
let deltas = rope.edit_batch([
|
||||||
|
RopeEdit::replace(TextRange::new(1, 3), "ZZ"),
|
||||||
|
RopeEdit::delete(TextRange::new(4, 6)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert_eq!(deltas.len(), 2);
|
||||||
|
assert_eq!(rope.to_string(), "aZZd");
|
||||||
|
assert_eq!(rope.snapshot_metrics().revision, before + 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_to_empty_preserves_valid_empty_rope() {
|
||||||
|
let reactor = Reactor::new();
|
||||||
|
let rope = ReactiveRope::from_text(&reactor, "hello");
|
||||||
|
|
||||||
|
let delta = rope.edit(RopeEdit::delete(TextRange::new(0, 5)));
|
||||||
|
|
||||||
|
assert_eq!(delta.removed_text, "hello");
|
||||||
|
assert!(rope.is_empty());
|
||||||
|
assert_eq!(rope.to_string(), "");
|
||||||
|
assert_eq!(rope.snapshot_metrics().leaves, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn structure_changing_edit_updates_leaf_count() {
|
||||||
|
let reactor = Reactor::new();
|
||||||
|
let rope = RopeBuilder::new(&reactor)
|
||||||
|
.leaf_target_bytes(8)
|
||||||
|
.push("abcdefgh")
|
||||||
|
.push("ijklmnop")
|
||||||
|
.build();
|
||||||
|
let before = rope.snapshot_metrics();
|
||||||
|
|
||||||
|
rope.edit(RopeEdit::insert(8, "QRSTUVWXYZ0123456789"));
|
||||||
|
let after = rope.snapshot_metrics();
|
||||||
|
|
||||||
|
assert!(after.leaves >= before.leaves);
|
||||||
|
assert!(after.text.bytes > before.text.bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_char_boundary_panics() {
|
||||||
|
let reactor = Reactor::new();
|
||||||
|
let rope = ReactiveRope::from_text(&reactor, "é");
|
||||||
|
|
||||||
|
let panic = catch_unwind(AssertUnwindSafe(|| {
|
||||||
|
let _ = rope.slice(TextRange::new(1, 2));
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert!(panic.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn edit_events_emit_immediately() {
|
||||||
|
let reactor = Reactor::new();
|
||||||
|
let rope = ReactiveRope::from_text(&reactor, "abc");
|
||||||
|
let seen = Rc::new(RefCell::new(Vec::new()));
|
||||||
|
let _sub = rope.edit_events().subscribe({
|
||||||
|
let seen = Rc::clone(&seen);
|
||||||
|
move |delta| {
|
||||||
|
seen.borrow_mut()
|
||||||
|
.push((delta.range, delta.inserted_text.clone()))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
rope.edit(RopeEdit::insert(1, "Z"));
|
||||||
|
|
||||||
|
assert_eq!(&*seen.borrow(), &[(TextRange::new(1, 1), "Z".to_string())]);
|
||||||
|
}
|
||||||
|
}
|
||||||
2233
lib/reactivity_parsing/src/tokentree.rs
Normal file
2233
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};
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
#[ruin_runtime::async_main]
|
#[ruin_runtime::async_main]
|
||||||
@@ -18,6 +19,8 @@ async fn main() -> ruin_app::Result<()> {
|
|||||||
Window::new()
|
Window::new()
|
||||||
.title(title)
|
.title(title)
|
||||||
.app_id("dev.ruin.counter")
|
.app_id("dev.ruin.counter")
|
||||||
|
.base_color(Color::rgba(255, 255, 255, 80))
|
||||||
|
.min_size(320.0, 240.0)
|
||||||
.size(960.0, 640.0),
|
.size(960.0, 640.0),
|
||||||
)
|
)
|
||||||
.mount(view! {
|
.mount(view! {
|
||||||
@@ -40,9 +43,15 @@ fn CounterApp(title: &'static str) -> impl IntoView {
|
|||||||
move || format!("{title} ({})", count.get())
|
move || format!("{title} ({})", count.get())
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let text_style = PartialTextStyle {
|
||||||
|
color: Some(Color::BLACK),
|
||||||
|
weight: Some(FontWeight::Bold),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
column(gap = 16.0, padding = 24.0) {
|
column(gap = 16.0, padding = 24.0, align_items = AlignItems::Center, text_style = text_style) {
|
||||||
text(role = TextRole::Heading(1), size = 32.0, weight = FontWeight::Semibold) {
|
text(role = TextRole::Heading(1), size = 32.0, weight = FontWeight::Semibold, color = Color::rgba(0, 0, 0, 255)) {
|
||||||
title
|
title
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,12 +60,14 @@ fn CounterApp(title: &'static str) -> impl IntoView {
|
|||||||
block(
|
block(
|
||||||
padding = 16.0,
|
padding = 16.0,
|
||||||
gap = 8.0,
|
gap = 8.0,
|
||||||
background = surfaces::raised(),
|
align_self = AlignItems::Stretch,
|
||||||
|
background = Color::rgba(255, 255, 255, 200),
|
||||||
border_radius = 12.0,
|
border_radius = 12.0,
|
||||||
border = Border::new(2.0, Color::rgb(0, 0, 0))
|
border = Border::new(2.0, Color::rgba(0, 0, 0, 120)),
|
||||||
|
shadow = BoxShadow::new(Point::new(2.0, 2.0), 8.0, 2.0, Color::rgba(0, 0, 0, 60), BoxShadowKind::Outer),
|
||||||
) {
|
) {
|
||||||
text(size = 18.0) { "count = "; count.clone() }
|
text(size = 18.0, color = Color::rgb(0, 0, 0)) { "count = "; count.clone() }
|
||||||
text(color = colors::muted()) { "double = "; doubled.clone() }
|
text(color = Color::rgba(0, 0, 0, 200)) { "double = "; doubled.clone() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,8 +76,9 @@ fn CounterApp(title: &'static str) -> impl IntoView {
|
|||||||
#[component]
|
#[component]
|
||||||
fn CounterActions(count: Signal<i32>) -> impl IntoView {
|
fn CounterActions(count: Signal<i32>) -> impl IntoView {
|
||||||
view! {
|
view! {
|
||||||
row(gap = 8.0) {
|
row(gap = 16.0) {
|
||||||
button(
|
button(
|
||||||
|
background = Color::rgba(255, 255, 255, 90),
|
||||||
on_press = {
|
on_press = {
|
||||||
let count = count.clone();
|
let count = count.clone();
|
||||||
move |_| {
|
move |_| {
|
||||||
@@ -74,10 +86,11 @@ fn CounterActions(count: Signal<i32>) -> impl IntoView {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
"-1"
|
text(selectable = false, pointer_events = false) { "-1" }
|
||||||
}
|
}
|
||||||
|
|
||||||
button(
|
button(
|
||||||
|
background = Color::rgba(255, 255, 255, 90),
|
||||||
on_press = {
|
on_press = {
|
||||||
let count = count.clone();
|
let count = count.clone();
|
||||||
move |_| {
|
move |_| {
|
||||||
@@ -85,10 +98,11 @@ fn CounterActions(count: Signal<i32>) -> impl IntoView {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
"Reset"
|
text(selectable = false, pointer_events = false) { "Reset" }
|
||||||
}
|
}
|
||||||
|
|
||||||
button(
|
button(
|
||||||
|
background = Color::rgba(255, 255, 255, 90),
|
||||||
on_press = {
|
on_press = {
|
||||||
let count = count.clone();
|
let count = count.clone();
|
||||||
move |_| {
|
move |_| {
|
||||||
@@ -96,7 +110,7 @@ fn CounterActions(count: Signal<i32>) -> impl IntoView {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
"+1"
|
text(selectable = false, pointer_events = false) { "+1" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
621
lib/ruin_app/src/app.rs
Normal file
621
lib/ruin_app/src/app.rs
Normal file
@@ -0,0 +1,621 @@
|
|||||||
|
use std::cell::Cell as StdCell;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::iter;
|
||||||
|
use std::rc::Rc;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use ruin_reactivity::{current as current_reactor, effect};
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
use ruin_runtime::{queue_future, spawn_worker};
|
||||||
|
use ruin_ui::{
|
||||||
|
Color, Element, InteractionTree, KeyboardEvent, LayoutCache, LayoutSnapshot, PlatformEvent,
|
||||||
|
PointerButton, PointerEvent, PointerEventKind, PointerRouter, RoutedPointerEvent,
|
||||||
|
RoutedPointerEventKind, TextSystem, UiSize, WindowController, WindowSpec, WindowUpdate,
|
||||||
|
layout_snapshot_with_cache,
|
||||||
|
};
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
use ruin_ui_platform_macos::{run_macos_app_event_loop, start_macos_ui};
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
use ruin_ui_platform_wayland::start_wayland_ui;
|
||||||
|
|
||||||
|
use crate::Result;
|
||||||
|
use crate::context::{
|
||||||
|
InteractionSnapshot, RenderState, render_with_context, render_with_context_and_interaction,
|
||||||
|
};
|
||||||
|
use crate::input::{
|
||||||
|
EventBindings, InputState, ShortcutBinding, TextSelection, TextSelectionDrag,
|
||||||
|
TextSelectionState, apply_text_selection_overlay, focused_element_for_pointer,
|
||||||
|
sync_primary_selection,
|
||||||
|
};
|
||||||
|
use crate::view::View;
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn start_platform_ui() -> ruin_ui::UiRuntime {
|
||||||
|
start_wayland_ui()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn start_platform_ui() -> ruin_ui::UiRuntime {
|
||||||
|
start_macos_ui()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Window {
|
||||||
|
pub(crate) spec: WindowSpec,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Window {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
spec: WindowSpec::new("RUIN App"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn title(mut self, title: impl Into<String>) -> Self {
|
||||||
|
self.spec.title = title.into();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn app_id(mut self, app_id: impl Into<String>) -> Self {
|
||||||
|
self.spec = self.spec.app_id(app_id);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn size(mut self, width: f32, height: f32) -> Self {
|
||||||
|
self.spec = self.spec.requested_inner_size(UiSize::new(width, height));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn min_size(mut self, min_width: f32, min_height: f32) -> Self {
|
||||||
|
self.spec = self.spec.min_inner_size(UiSize::new(min_width, min_height));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn base_color(mut self, color: Color) -> Self {
|
||||||
|
self.spec = self.spec.base_color(color);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resizable(mut self, resizable: bool) -> Self {
|
||||||
|
self.spec.resizable = resizable;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Window {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct App {
|
||||||
|
window: Window,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
window: Window::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn window(mut self, window: Window) -> Self {
|
||||||
|
self.window = window;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mount<M: Mountable>(self, root: M) -> MountedApp<M> {
|
||||||
|
MountedApp {
|
||||||
|
window: self.window,
|
||||||
|
root,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for App {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait Mountable: 'static {
|
||||||
|
fn render(&self) -> View;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait Component: 'static {
|
||||||
|
type Builder;
|
||||||
|
|
||||||
|
fn builder() -> Self::Builder;
|
||||||
|
fn render(&self) -> View;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait ContextKey: 'static {
|
||||||
|
type Value: Clone + 'static;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Component> Mountable for T {
|
||||||
|
fn render(&self) -> View {
|
||||||
|
Component::render(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Mountable for View {
|
||||||
|
fn render(&self) -> View {
|
||||||
|
self.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Mountable for Element {
|
||||||
|
fn render(&self) -> View {
|
||||||
|
View::from_element(self.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct MountedApp<M: Mountable> {
|
||||||
|
pub(crate) window: Window,
|
||||||
|
pub(crate) root: M,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<M: Mountable> MountedApp<M> {
|
||||||
|
async fn run_with_ui_runtime(
|
||||||
|
app_window: Window,
|
||||||
|
root: M,
|
||||||
|
mut ui: ruin_ui::UiRuntime,
|
||||||
|
) -> Result<()> {
|
||||||
|
let root = Rc::new(root);
|
||||||
|
let render_state = Rc::new(RenderState::default());
|
||||||
|
let window = ui.create_window(app_window.spec.clone())?;
|
||||||
|
let initial_viewport = app_window
|
||||||
|
.spec
|
||||||
|
.requested_inner_size
|
||||||
|
.unwrap_or(UiSize::new(960.0, 640.0));
|
||||||
|
|
||||||
|
let viewport = ruin_reactivity::cell(initial_viewport);
|
||||||
|
let configure_serial = ruin_reactivity::cell(0_u64);
|
||||||
|
let scene_version = StdCell::new(0_u64);
|
||||||
|
let text_system = Rc::new(RefCell::new(TextSystem::new()));
|
||||||
|
let layout_cache = Rc::new(RefCell::new(LayoutCache::new()));
|
||||||
|
let interaction_tree = Rc::new(RefCell::new(None::<InteractionTree>));
|
||||||
|
let bindings = Rc::new(RefCell::new(EventBindings::default()));
|
||||||
|
let shortcuts = Rc::new(RefCell::new(Vec::<ShortcutBinding>::new()));
|
||||||
|
let current_title = Rc::new(RefCell::new(None::<String>));
|
||||||
|
let last_min_size = Rc::new(RefCell::new(None::<UiSize>));
|
||||||
|
let last_fixed_size = Rc::new(RefCell::new(None::<UiSize>));
|
||||||
|
let explicit_min_size = app_window.spec.min_inner_size;
|
||||||
|
let explicit_max_size = app_window.spec.max_inner_size;
|
||||||
|
let window_resizable = app_window.spec.resizable;
|
||||||
|
let mut input_state = InputState::new();
|
||||||
|
let mut pointer_router = PointerRouter::new();
|
||||||
|
|
||||||
|
let _scene_effect = effect({
|
||||||
|
let window = window.clone();
|
||||||
|
let viewport = viewport.clone();
|
||||||
|
let configure_serial = configure_serial.clone();
|
||||||
|
let text_system = Rc::clone(&text_system);
|
||||||
|
let layout_cache = Rc::clone(&layout_cache);
|
||||||
|
let interaction_tree = Rc::clone(&interaction_tree);
|
||||||
|
let bindings = Rc::clone(&bindings);
|
||||||
|
let shortcuts = Rc::clone(&shortcuts);
|
||||||
|
let current_title = Rc::clone(¤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 _ = configure_serial.get();
|
||||||
|
let version = scene_version.get().wrapping_add(1);
|
||||||
|
scene_version.set(version);
|
||||||
|
let _ = text_selection.version.get();
|
||||||
|
let _ = interaction_version.get();
|
||||||
|
|
||||||
|
let t_effect = Instant::now();
|
||||||
|
let interaction_snapshot = Rc::new(InteractionSnapshot {
|
||||||
|
hovered: hovered_elements.get(),
|
||||||
|
pressed: pressed_elements.get(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let render_output = render_with_context_and_interaction(
|
||||||
|
Rc::clone(&render_state),
|
||||||
|
interaction_snapshot,
|
||||||
|
|| root.render(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if render_output.side_effects.window_title != *current_title.borrow() {
|
||||||
|
if let Some(title) = &render_output.side_effects.window_title {
|
||||||
|
window
|
||||||
|
.update(WindowUpdate::new().title(title.clone()))
|
||||||
|
.expect("window should remain alive while the app is running");
|
||||||
|
}
|
||||||
|
*current_title.borrow_mut() = render_output.side_effects.window_title.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
let render_us = t_effect.elapsed().as_micros();
|
||||||
|
let t_layout = Instant::now();
|
||||||
|
let LayoutSnapshot {
|
||||||
|
mut scene,
|
||||||
|
interaction_tree: next_interaction_tree,
|
||||||
|
root_min_size,
|
||||||
|
} = layout_snapshot_with_cache(
|
||||||
|
version,
|
||||||
|
viewport,
|
||||||
|
render_output.view.element(),
|
||||||
|
&mut text_system.borrow_mut(),
|
||||||
|
&mut layout_cache.borrow_mut(),
|
||||||
|
);
|
||||||
|
let layout_us = t_layout.elapsed().as_micros();
|
||||||
|
|
||||||
|
let desired_min = UiSize::new(
|
||||||
|
root_min_size
|
||||||
|
.width
|
||||||
|
.max(explicit_min_size.map_or(0.0, |s| s.width)),
|
||||||
|
root_min_size
|
||||||
|
.height
|
||||||
|
.max(explicit_min_size.map_or(0.0, |s| s.height)),
|
||||||
|
);
|
||||||
|
if window_resizable {
|
||||||
|
let current_min = *last_min_size.borrow();
|
||||||
|
if current_min != Some(desired_min) {
|
||||||
|
let _ =
|
||||||
|
window.update(WindowUpdate::new().min_inner_size(Some(desired_min)));
|
||||||
|
*last_min_size.borrow_mut() = Some(desired_min);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let desired_fixed = UiSize::new(
|
||||||
|
desired_min
|
||||||
|
.width
|
||||||
|
.min(explicit_max_size.map_or(f32::INFINITY, |s| s.width)),
|
||||||
|
desired_min
|
||||||
|
.height
|
||||||
|
.min(explicit_max_size.map_or(f32::INFINITY, |s| s.height)),
|
||||||
|
);
|
||||||
|
let current_fixed = *last_fixed_size.borrow();
|
||||||
|
if current_fixed != Some(desired_fixed) {
|
||||||
|
let _ = window.update(
|
||||||
|
WindowUpdate::new()
|
||||||
|
.requested_inner_size(Some(desired_fixed))
|
||||||
|
.min_inner_size(Some(desired_fixed))
|
||||||
|
.max_inner_size(Some(desired_fixed)),
|
||||||
|
);
|
||||||
|
*last_fixed_size.borrow_mut() = Some(desired_fixed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let effect_us = t_effect.elapsed().as_micros();
|
||||||
|
|
||||||
|
tracing::debug!(
|
||||||
|
target: "ruin_app::resize",
|
||||||
|
version,
|
||||||
|
width = viewport.width,
|
||||||
|
height = viewport.height,
|
||||||
|
render_us,
|
||||||
|
layout_us,
|
||||||
|
effect_us,
|
||||||
|
"scene effect complete, sending ReplaceScene"
|
||||||
|
);
|
||||||
|
|
||||||
|
apply_text_selection_overlay(&mut scene, *text_selection.selection.borrow());
|
||||||
|
|
||||||
|
*interaction_tree.borrow_mut() = Some(next_interaction_tree);
|
||||||
|
*bindings.borrow_mut() = render_output.view.bindings;
|
||||||
|
*shortcuts.borrow_mut() = render_output.side_effects.shortcuts.clone();
|
||||||
|
window
|
||||||
|
.replace_scene(scene)
|
||||||
|
.expect("window should remain alive while the app is running");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let Some(event) = ui.next_event().await else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut should_flush_reactive_work = false;
|
||||||
|
for event in iter::once(event).chain(ui.take_pending_events()) {
|
||||||
|
match event {
|
||||||
|
PlatformEvent::Configured {
|
||||||
|
window_id,
|
||||||
|
configuration,
|
||||||
|
} if window_id == window.id() => {
|
||||||
|
tracing::debug!(
|
||||||
|
target: "ruin_app::resize",
|
||||||
|
width = configuration.actual_inner_size.width,
|
||||||
|
height = configuration.actual_inner_size.height,
|
||||||
|
"app received Configured, queuing layout effect"
|
||||||
|
);
|
||||||
|
let _ = viewport.set(configuration.actual_inner_size);
|
||||||
|
configure_serial.update(|value| *value = value.wrapping_add(1));
|
||||||
|
should_flush_reactive_work = true;
|
||||||
|
}
|
||||||
|
PlatformEvent::Pointer { window_id, event } if window_id == window.id() => {
|
||||||
|
Self::handle_pointer_event(
|
||||||
|
&window,
|
||||||
|
&interaction_tree,
|
||||||
|
&bindings,
|
||||||
|
&mut pointer_router,
|
||||||
|
&mut input_state,
|
||||||
|
event,
|
||||||
|
)?;
|
||||||
|
should_flush_reactive_work = true;
|
||||||
|
}
|
||||||
|
PlatformEvent::Keyboard { window_id, event } if window_id == window.id() => {
|
||||||
|
Self::handle_keyboard_event(
|
||||||
|
&interaction_tree,
|
||||||
|
&bindings,
|
||||||
|
&shortcuts,
|
||||||
|
&input_state,
|
||||||
|
event,
|
||||||
|
)?;
|
||||||
|
should_flush_reactive_work = true;
|
||||||
|
}
|
||||||
|
PlatformEvent::CloseRequested { window_id } if window_id == window.id() => {
|
||||||
|
tracing::debug!(
|
||||||
|
target: "ruin_app::lifecycle",
|
||||||
|
?window_id,
|
||||||
|
"app received CloseRequested, issuing open(false)"
|
||||||
|
);
|
||||||
|
let _ = window.update(WindowUpdate::new().open(false));
|
||||||
|
}
|
||||||
|
PlatformEvent::Closed { window_id } if window_id == window.id() => {
|
||||||
|
tracing::debug!(
|
||||||
|
target: "ruin_app::lifecycle",
|
||||||
|
?window_id,
|
||||||
|
"app received Closed, shutting down ui runtime"
|
||||||
|
);
|
||||||
|
ui.shutdown()?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if should_flush_reactive_work {
|
||||||
|
current_reactor().flush_now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.shutdown()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<M: Mountable> MountedApp<M> {
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
pub async fn run(self) -> Result<()> {
|
||||||
|
let ui = start_platform_ui();
|
||||||
|
Self::run_with_ui_runtime(self.window, self.root, ui).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
pub async fn run(self) -> Result<()>
|
||||||
|
where
|
||||||
|
M: Send + 'static,
|
||||||
|
{
|
||||||
|
let ui = start_platform_ui();
|
||||||
|
let (done_tx, done_rx) = std::sync::mpsc::channel::<std::result::Result<(), String>>();
|
||||||
|
let window = self.window;
|
||||||
|
let root = self.root;
|
||||||
|
let _worker = spawn_worker(
|
||||||
|
move || {
|
||||||
|
queue_future(async move {
|
||||||
|
let result = Self::run_with_ui_runtime(window, root, ui)
|
||||||
|
.await
|
||||||
|
.map_err(|err| err.to_string());
|
||||||
|
let _ = done_tx.send(result);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|| {},
|
||||||
|
);
|
||||||
|
|
||||||
|
run_macos_app_event_loop();
|
||||||
|
|
||||||
|
match done_rx.recv() {
|
||||||
|
Ok(Ok(())) => Ok(()),
|
||||||
|
Ok(Err(err)) => Err(err.into()),
|
||||||
|
Err(_) => Err("ui worker terminated before completion".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn handle_pointer_event(
|
||||||
|
window: &WindowController,
|
||||||
|
interaction_tree: &RefCell<Option<InteractionTree>>,
|
||||||
|
bindings: &RefCell<EventBindings>,
|
||||||
|
pointer_router: &mut PointerRouter,
|
||||||
|
input_state: &mut InputState,
|
||||||
|
event: PointerEvent,
|
||||||
|
) -> Result<()> {
|
||||||
|
if matches!(
|
||||||
|
event.kind,
|
||||||
|
PointerEventKind::Down {
|
||||||
|
button: PointerButton::Primary | PointerButton::Middle,
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
let interaction_tree = interaction_tree.borrow();
|
||||||
|
if let Some(interaction_tree) = interaction_tree.as_ref() {
|
||||||
|
input_state.focused_element = focused_element_for_pointer(interaction_tree, &event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let routed = {
|
||||||
|
let interaction_tree = interaction_tree.borrow();
|
||||||
|
let Some(interaction_tree) = interaction_tree.as_ref() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
pointer_router.route(interaction_tree, event)
|
||||||
|
};
|
||||||
|
|
||||||
|
{
|
||||||
|
let interaction_tree = interaction_tree.borrow();
|
||||||
|
let Some(interaction_tree) = interaction_tree.as_ref() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let hovered_targets = pointer_router.hovered_targets();
|
||||||
|
for routed_event in &routed {
|
||||||
|
bindings
|
||||||
|
.borrow()
|
||||||
|
.dispatch(routed_event, interaction_tree, hovered_targets);
|
||||||
|
Self::handle_text_selection_event(
|
||||||
|
window,
|
||||||
|
interaction_tree,
|
||||||
|
routed_event,
|
||||||
|
&input_state.text_selection,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let next_cursor = pointer_router
|
||||||
|
.hovered_targets()
|
||||||
|
.last()
|
||||||
|
.map(|target| target.cursor)
|
||||||
|
.unwrap_or(ruin_ui::CursorIcon::Default);
|
||||||
|
let hovered_ids = Self::interaction_ids(pointer_router.hovered_targets());
|
||||||
|
let pressed_ids = Self::interaction_ids(pointer_router.pressed_targets());
|
||||||
|
if next_cursor != input_state.current_cursor {
|
||||||
|
input_state.current_cursor = next_cursor;
|
||||||
|
window.set_cursor_icon(next_cursor)?;
|
||||||
|
}
|
||||||
|
if Self::update_interaction_state(input_state, hovered_ids, pressed_ids) {
|
||||||
|
input_state.interaction_version.update(|value| *value += 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn handle_keyboard_event(
|
||||||
|
interaction_tree: &RefCell<Option<InteractionTree>>,
|
||||||
|
bindings: &RefCell<EventBindings>,
|
||||||
|
shortcuts: &RefCell<Vec<ShortcutBinding>>,
|
||||||
|
input_state: &InputState,
|
||||||
|
event: KeyboardEvent,
|
||||||
|
) -> Result<()> {
|
||||||
|
let interaction_tree = interaction_tree.borrow();
|
||||||
|
let Some(interaction_tree) = interaction_tree.as_ref() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let shortcut_bindings = shortcuts.borrow().clone();
|
||||||
|
let mut consumed = false;
|
||||||
|
for shortcut in shortcut_bindings {
|
||||||
|
if shortcut.matches(&event, input_state.focused_element, interaction_tree) {
|
||||||
|
shortcut.trigger(interaction_tree);
|
||||||
|
consumed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if consumed {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
bindings
|
||||||
|
.borrow()
|
||||||
|
.dispatch_key(input_state.focused_element, &event, interaction_tree);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_text_selection_event(
|
||||||
|
window: &WindowController,
|
||||||
|
interaction_tree: &InteractionTree,
|
||||||
|
event: &RoutedPointerEvent,
|
||||||
|
text_selection: &TextSelectionState,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut selection_changed = false;
|
||||||
|
|
||||||
|
match event.kind {
|
||||||
|
RoutedPointerEventKind::Down {
|
||||||
|
button: PointerButton::Primary,
|
||||||
|
} => {
|
||||||
|
if let Some(hit) = interaction_tree.text_hit_test(event.position)
|
||||||
|
&& let Some(element_id) = hit.target.element_id
|
||||||
|
{
|
||||||
|
let next = Some(TextSelection {
|
||||||
|
element_id,
|
||||||
|
anchor: hit.byte_offset,
|
||||||
|
focus: hit.byte_offset,
|
||||||
|
});
|
||||||
|
if *text_selection.selection.borrow() != next {
|
||||||
|
*text_selection.selection.borrow_mut() = next;
|
||||||
|
selection_changed = true;
|
||||||
|
}
|
||||||
|
*text_selection.drag.borrow_mut() = Some(TextSelectionDrag {
|
||||||
|
element_id,
|
||||||
|
anchor: hit.byte_offset,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
selection_changed = text_selection.selection.borrow_mut().take().is_some();
|
||||||
|
let _ = text_selection.drag.borrow_mut().take();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RoutedPointerEventKind::Move => {
|
||||||
|
let Some(drag) = *text_selection.drag.borrow() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let Some(text) = interaction_tree.text_for_element(drag.element_id) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let next = Some(TextSelection {
|
||||||
|
element_id: drag.element_id,
|
||||||
|
anchor: drag.anchor,
|
||||||
|
focus: text.byte_offset_for_position(event.position),
|
||||||
|
});
|
||||||
|
if *text_selection.selection.borrow() != next {
|
||||||
|
*text_selection.selection.borrow_mut() = next;
|
||||||
|
selection_changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RoutedPointerEventKind::Up {
|
||||||
|
button: PointerButton::Primary,
|
||||||
|
} => {
|
||||||
|
if text_selection.drag.borrow_mut().take().is_some() {
|
||||||
|
selection_changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if selection_changed {
|
||||||
|
text_selection.version.update(|value| *value += 1);
|
||||||
|
sync_primary_selection(window, interaction_tree, *text_selection.selection.borrow())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn interaction_ids(targets: &[ruin_ui::HitTarget]) -> Vec<ruin_ui::ElementId> {
|
||||||
|
targets
|
||||||
|
.iter()
|
||||||
|
.filter_map(|target| target.element_id)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_interaction_state(
|
||||||
|
input_state: &mut InputState,
|
||||||
|
hovered_ids: Vec<ruin_ui::ElementId>,
|
||||||
|
pressed_ids: Vec<ruin_ui::ElementId>,
|
||||||
|
) -> bool {
|
||||||
|
let previous_hovered = input_state.hovered_elements.get();
|
||||||
|
let previous_pressed = input_state.pressed_elements.get();
|
||||||
|
let hovered_changed = input_state
|
||||||
|
.hovered_elements
|
||||||
|
.set(hovered_ids.clone())
|
||||||
|
.is_some();
|
||||||
|
let pressed_changed = input_state
|
||||||
|
.pressed_elements
|
||||||
|
.set(pressed_ids.clone())
|
||||||
|
.is_some();
|
||||||
|
if hovered_changed || pressed_changed {
|
||||||
|
tracing::trace!(
|
||||||
|
target: "ruin_app::interaction",
|
||||||
|
hovered_before = ?previous_hovered,
|
||||||
|
hovered_after = ?hovered_ids,
|
||||||
|
pressed_before = ?previous_pressed,
|
||||||
|
pressed_after = ?pressed_ids,
|
||||||
|
"interaction state changed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
hovered_changed || pressed_changed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn __render_mountable_for_test<M: Mountable>(mountable: &M) -> View {
|
||||||
|
render_with_context(Rc::new(RenderState::default()), || mountable.render()).view
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
201
lib/ruin_app/src/context.rs
Normal file
201
lib/ruin_app/src/context.rs
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
use std::any::{Any, TypeId};
|
||||||
|
use std::cell::{Cell as StdCell, RefCell};
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use ruin_ui::ElementId;
|
||||||
|
|
||||||
|
use crate::ContextKey;
|
||||||
|
use crate::view::View;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct ContextEntry {
|
||||||
|
pub(crate) key: TypeId,
|
||||||
|
pub(crate) value: Rc<dyn Any>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ContextEntry {
|
||||||
|
pub(crate) fn new<C: ContextKey>(value: C::Value) -> Self {
|
||||||
|
Self {
|
||||||
|
key: TypeId::of::<C>(),
|
||||||
|
value: Rc::new(value),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(crate) struct RenderState {
|
||||||
|
pub(crate) hooks: RefCell<Vec<Box<dyn Any>>>,
|
||||||
|
pub(crate) element_ids: RefCell<Vec<ElementId>>,
|
||||||
|
pub(crate) next_element_id: StdCell<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub(crate) struct InteractionSnapshot {
|
||||||
|
pub(crate) hovered: Vec<ElementId>,
|
||||||
|
pub(crate) pressed: Vec<ElementId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub(crate) struct RenderSideEffects {
|
||||||
|
pub(crate) window_title: Option<String>,
|
||||||
|
pub(crate) shortcuts: Vec<crate::input::ShortcutBinding>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct RenderContext {
|
||||||
|
pub(crate) state: Rc<RenderState>,
|
||||||
|
pub(crate) hook_index: Rc<StdCell<usize>>,
|
||||||
|
pub(crate) element_index: Rc<StdCell<usize>>,
|
||||||
|
pub(crate) side_effects: Rc<RefCell<RenderSideEffects>>,
|
||||||
|
pub(crate) context_entries: Rc<Vec<ContextEntry>>,
|
||||||
|
pub(crate) interaction: Rc<InteractionSnapshot>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderContext {
|
||||||
|
pub(crate) fn with_context_entries(&self, context_entries: Rc<Vec<ContextEntry>>) -> Self {
|
||||||
|
Self {
|
||||||
|
state: Rc::clone(&self.state),
|
||||||
|
hook_index: Rc::clone(&self.hook_index),
|
||||||
|
element_index: Rc::clone(&self.element_index),
|
||||||
|
side_effects: Rc::clone(&self.side_effects),
|
||||||
|
context_entries,
|
||||||
|
interaction: Rc::clone(&self.interaction),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct RenderOutput {
|
||||||
|
pub(crate) view: View,
|
||||||
|
pub(crate) side_effects: RenderSideEffects,
|
||||||
|
}
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
pub(crate) static CURRENT_RENDER_CONTEXT: RefCell<Option<RenderContext>> = const { RefCell::new(None) };
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn render_with_context(
|
||||||
|
state: Rc<RenderState>,
|
||||||
|
render: impl FnOnce() -> View,
|
||||||
|
) -> RenderOutput {
|
||||||
|
render_with_context_and_interaction(state, Rc::new(InteractionSnapshot::default()), render)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn render_with_context_and_interaction(
|
||||||
|
state: Rc<RenderState>,
|
||||||
|
interaction: Rc<InteractionSnapshot>,
|
||||||
|
render: impl FnOnce() -> View,
|
||||||
|
) -> RenderOutput {
|
||||||
|
let context = RenderContext {
|
||||||
|
state,
|
||||||
|
hook_index: Rc::new(StdCell::new(0)),
|
||||||
|
element_index: Rc::new(StdCell::new(0)),
|
||||||
|
side_effects: Rc::new(RefCell::new(RenderSideEffects::default())),
|
||||||
|
context_entries: Rc::new(Vec::new()),
|
||||||
|
interaction,
|
||||||
|
};
|
||||||
|
|
||||||
|
let view = with_render_context(context.clone(), render);
|
||||||
|
let side_effects = context.side_effects.borrow().clone();
|
||||||
|
RenderOutput { view, side_effects }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn with_render_context(context: RenderContext, render: impl FnOnce() -> View) -> View {
|
||||||
|
CURRENT_RENDER_CONTEXT.with(|slot| {
|
||||||
|
let previous = slot.replace(Some(context.clone()));
|
||||||
|
|
||||||
|
struct Guard<'a> {
|
||||||
|
slot: &'a RefCell<Option<RenderContext>>,
|
||||||
|
previous: Option<RenderContext>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Guard<'_> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = self.slot.replace(self.previous.take());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _guard = Guard { slot, previous };
|
||||||
|
render()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn with_render_context_state<R>(f: impl FnOnce(&RenderContext) -> R) -> R {
|
||||||
|
CURRENT_RENDER_CONTEXT.with(|slot| {
|
||||||
|
let context = slot
|
||||||
|
.borrow()
|
||||||
|
.clone()
|
||||||
|
.expect("ruin_app hooks can only run while rendering a mounted component");
|
||||||
|
f(&context)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn with_hook_slot<T: 'static, R>(
|
||||||
|
init: impl FnOnce() -> T,
|
||||||
|
f: impl FnOnce(&mut T) -> R,
|
||||||
|
) -> R {
|
||||||
|
with_render_context_state(|context| {
|
||||||
|
let index = context.hook_index.get();
|
||||||
|
context.hook_index.set(index + 1);
|
||||||
|
|
||||||
|
let mut hooks = context.state.hooks.borrow_mut();
|
||||||
|
if hooks.len() == index {
|
||||||
|
hooks.push(Box::new(init()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let slot = hooks[index]
|
||||||
|
.downcast_mut::<T>()
|
||||||
|
.expect("ruin_app hook call order changed between renders");
|
||||||
|
f(slot)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn allocate_element_id() -> ElementId {
|
||||||
|
with_render_context_state(|context| {
|
||||||
|
let index = context.element_index.get();
|
||||||
|
context.element_index.set(index + 1);
|
||||||
|
|
||||||
|
let mut element_ids = context.state.element_ids.borrow_mut();
|
||||||
|
if element_ids.len() == index {
|
||||||
|
let next = context.state.next_element_id.get().wrapping_add(1);
|
||||||
|
context.state.next_element_id.set(next);
|
||||||
|
element_ids.push(ElementId::new(next));
|
||||||
|
}
|
||||||
|
element_ids[index]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct MemoSlot<T> {
|
||||||
|
pub(crate) compute: Rc<RefCell<Box<dyn Fn() -> T>>>,
|
||||||
|
pub(crate) handle: crate::hooks::Memo<T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct ResourceSlot<T, E> {
|
||||||
|
pub(crate) resource: crate::hooks::Resource<T, E>,
|
||||||
|
pub(crate) _effect: ruin_reactivity::EffectHandle,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: 'static> MemoSlot<T> {
|
||||||
|
pub(crate) fn new(compute: Rc<RefCell<Box<dyn Fn() -> T>>>) -> Self {
|
||||||
|
let handle = crate::hooks::Memo {
|
||||||
|
compute: Rc::new({
|
||||||
|
let compute = Rc::clone(&compute);
|
||||||
|
move || {
|
||||||
|
let compute = compute.borrow();
|
||||||
|
(compute.as_ref())()
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
Self { compute, handle }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn replace_compute(&mut self, compute: Rc<RefCell<Box<dyn Fn() -> T>>>) {
|
||||||
|
self.compute = Rc::clone(&compute);
|
||||||
|
self.handle.compute = Rc::new({
|
||||||
|
let compute = Rc::clone(&compute);
|
||||||
|
move || {
|
||||||
|
let compute = compute.borrow();
|
||||||
|
(compute.as_ref())()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
419
lib/ruin_app/src/hooks.rs
Normal file
419
lib/ruin_app/src/hooks.rs
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
use std::any::TypeId;
|
||||||
|
use std::any::type_name;
|
||||||
|
use std::cell::Cell as StdCell;
|
||||||
|
use std::future::Future;
|
||||||
|
use std::marker::PhantomData;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use ruin_ui::{ElementId, InteractionTree, KeyboardEvent, KeyboardEventKind, KeyboardKey};
|
||||||
|
|
||||||
|
use crate::ContextKey;
|
||||||
|
use crate::context::{
|
||||||
|
ContextEntry, MemoSlot, ResourceSlot, with_hook_slot, with_render_context,
|
||||||
|
with_render_context_state,
|
||||||
|
};
|
||||||
|
use crate::input::ShortcutBinding;
|
||||||
|
|
||||||
|
pub struct SignalInner<T> {
|
||||||
|
pub(crate) cell: ruin_reactivity::Cell<T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Signal<T> {
|
||||||
|
pub(crate) inner: Rc<SignalInner<T>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Clone for Signal<T> {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
Self {
|
||||||
|
inner: Rc::clone(&self.inner),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: 'static> Signal<T> {
|
||||||
|
pub(crate) fn new(initial: T) -> Self {
|
||||||
|
Self {
|
||||||
|
inner: Rc::new(SignalInner {
|
||||||
|
cell: ruin_reactivity::cell(initial),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with<R>(&self, f: impl FnOnce(&T) -> R) -> R {
|
||||||
|
self.inner.cell.with(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn replace(&self, value: T) -> T {
|
||||||
|
self.inner.cell.replace(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update<R>(&self, f: impl FnOnce(&mut T) -> R) -> R {
|
||||||
|
self.inner.cell.update(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Clone + 'static> Signal<T> {
|
||||||
|
pub fn get(&self) -> T {
|
||||||
|
self.inner.cell.get()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: PartialEq + 'static> Signal<T> {
|
||||||
|
pub fn set(&self, value: T) -> Option<T> {
|
||||||
|
self.inner.cell.set(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Memo<T> {
|
||||||
|
pub(crate) compute: Rc<dyn Fn() -> T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Clone for Memo<T> {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
Self {
|
||||||
|
compute: Rc::clone(&self.compute),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Memo<T> {
|
||||||
|
pub fn with<R>(&self, f: impl FnOnce(&T) -> R) -> R {
|
||||||
|
let value = (self.compute)();
|
||||||
|
f(&value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Clone> Memo<T> {
|
||||||
|
pub fn get(&self) -> T {
|
||||||
|
(self.compute)()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn use_signal<T: 'static>(initial: impl FnOnce() -> T) -> Signal<T> {
|
||||||
|
with_hook_slot(|| Signal::new(initial()), |signal| signal.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn use_context<C: ContextKey>() -> C::Value {
|
||||||
|
with_render_context_state(|context| {
|
||||||
|
context
|
||||||
|
.context_entries
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find_map(|entry| {
|
||||||
|
(entry.key == TypeId::of::<C>())
|
||||||
|
.then(|| entry.value.downcast_ref::<C::Value>())
|
||||||
|
.flatten()
|
||||||
|
.cloned()
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
panic!(
|
||||||
|
"missing context provider for {} while rendering",
|
||||||
|
type_name::<C>()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn provide<C: ContextKey>(
|
||||||
|
value: C::Value,
|
||||||
|
render: impl FnOnce() -> crate::view::View,
|
||||||
|
) -> crate::view::View {
|
||||||
|
with_render_context_state(|context| {
|
||||||
|
let mut context_entries = (*context.context_entries).clone();
|
||||||
|
context_entries.push(ContextEntry::new::<C>(value));
|
||||||
|
with_render_context(
|
||||||
|
context.with_context_entries(Rc::new(context_entries)),
|
||||||
|
render,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn use_memo<T: 'static>(compute: impl Fn() -> T + 'static) -> Memo<T> {
|
||||||
|
use std::cell::RefCell;
|
||||||
|
let compute: Rc<RefCell<Box<dyn Fn() -> T>>> =
|
||||||
|
Rc::new(RefCell::new(Box::new(compute) as Box<dyn Fn() -> T>));
|
||||||
|
with_hook_slot(
|
||||||
|
{
|
||||||
|
let compute = Rc::clone(&compute);
|
||||||
|
move || MemoSlot::new(compute)
|
||||||
|
},
|
||||||
|
|slot: &mut MemoSlot<T>| {
|
||||||
|
slot.replace_compute(Rc::clone(&compute));
|
||||||
|
slot.handle.clone()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn use_effect(effect: impl Fn() + 'static) {
|
||||||
|
with_hook_slot(|| ruin_reactivity::effect(effect), |_| ());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn use_window_title(compute: impl FnOnce() -> String) {
|
||||||
|
with_render_context_state(|context| {
|
||||||
|
context.side_effects.borrow_mut().window_title = Some(compute());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum Key {
|
||||||
|
Character(char),
|
||||||
|
Enter,
|
||||||
|
Backspace,
|
||||||
|
Escape,
|
||||||
|
Delete,
|
||||||
|
ArrowLeft,
|
||||||
|
ArrowRight,
|
||||||
|
ArrowUp,
|
||||||
|
ArrowDown,
|
||||||
|
Home,
|
||||||
|
End,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Key {
|
||||||
|
pub(crate) fn matches(&self, event: &KeyboardEvent) -> bool {
|
||||||
|
match (self, &event.key) {
|
||||||
|
(Self::Character(expected), KeyboardKey::Character(actual)) => actual
|
||||||
|
.chars()
|
||||||
|
.next()
|
||||||
|
.is_some_and(|actual| actual.eq_ignore_ascii_case(expected)),
|
||||||
|
(Self::Enter, KeyboardKey::Enter) => true,
|
||||||
|
(Self::Backspace, KeyboardKey::Backspace) => true,
|
||||||
|
(Self::Escape, KeyboardKey::Escape) => true,
|
||||||
|
(Self::Delete, KeyboardKey::Delete) => true,
|
||||||
|
(Self::ArrowLeft, KeyboardKey::ArrowLeft) => true,
|
||||||
|
(Self::ArrowRight, KeyboardKey::ArrowRight) => true,
|
||||||
|
(Self::ArrowUp, KeyboardKey::ArrowUp) => true,
|
||||||
|
(Self::ArrowDown, KeyboardKey::ArrowDown) => true,
|
||||||
|
(Self::Home, KeyboardKey::Home) => true,
|
||||||
|
(Self::End, KeyboardKey::End) => true,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct Shortcut {
|
||||||
|
pub(crate) key: Key,
|
||||||
|
pub(crate) control: bool,
|
||||||
|
pub(crate) shift: bool,
|
||||||
|
pub(crate) alt: bool,
|
||||||
|
pub(crate) super_key: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Shortcut {
|
||||||
|
pub fn new(key: Key) -> Self {
|
||||||
|
Self {
|
||||||
|
key,
|
||||||
|
control: false,
|
||||||
|
shift: false,
|
||||||
|
alt: false,
|
||||||
|
super_key: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_ctrl(mut self) -> Self {
|
||||||
|
self.control = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_shift(mut self) -> Self {
|
||||||
|
self.shift = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn matches(&self, event: &KeyboardEvent) -> bool {
|
||||||
|
if event.kind != KeyboardEventKind::Pressed {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if !self.key.matches(event) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if event.modifiers.control != self.control {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if event.modifiers.alt != self.alt {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if event.modifiers.super_key != self.super_key {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// For character shortcuts the produced character already encodes the shift
|
||||||
|
// state, so don't additionally require that Shift is (un)held.
|
||||||
|
if !matches!(self.key, Key::Character(_)) && event.modifiers.shift != self.shift {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct FocusScope {
|
||||||
|
pub(crate) element_id: Signal<Option<ElementId>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub struct InteractionState {
|
||||||
|
pub hovered: bool,
|
||||||
|
pub pressed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub enum ShortcutScope {
|
||||||
|
Application,
|
||||||
|
FocusedWithin(FocusScope),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ShortcutScope {
|
||||||
|
pub(crate) fn matches(
|
||||||
|
&self,
|
||||||
|
focused_element: Option<ElementId>,
|
||||||
|
interaction_tree: &InteractionTree,
|
||||||
|
) -> bool {
|
||||||
|
match self {
|
||||||
|
Self::Application => true,
|
||||||
|
Self::FocusedWithin(scope) => {
|
||||||
|
let Some(scope_element) = scope.element_id.get() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some(focused_element) = focused_element else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
crate::input::element_contains_element(
|
||||||
|
interaction_tree,
|
||||||
|
scope_element,
|
||||||
|
focused_element,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct WidgetRef<T> {
|
||||||
|
pub(crate) element_id: Signal<Option<ElementId>>,
|
||||||
|
pub(crate) _marker: PhantomData<fn() -> T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Clone for WidgetRef<T> {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
Self {
|
||||||
|
element_id: self.element_id.clone(),
|
||||||
|
_marker: PhantomData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> WidgetRef<T> {
|
||||||
|
pub(crate) fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
element_id: Signal::new(None),
|
||||||
|
_marker: PhantomData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn element_id(&self) -> Option<ElementId> {
|
||||||
|
self.element_id.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn focus_scope(&self) -> FocusScope {
|
||||||
|
FocusScope {
|
||||||
|
element_id: self.element_id.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ScrollBoxWidget;
|
||||||
|
pub struct BlockWidget;
|
||||||
|
|
||||||
|
pub fn use_widget_ref<T: 'static>() -> WidgetRef<T> {
|
||||||
|
with_hook_slot(WidgetRef::new, |widget_ref: &mut WidgetRef<T>| {
|
||||||
|
widget_ref.clone()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn use_interaction_state<T: 'static>(widget_ref: WidgetRef<T>) -> InteractionState {
|
||||||
|
with_render_context_state(|context| {
|
||||||
|
let Some(element_id) = widget_ref.element_id() else {
|
||||||
|
return InteractionState::default();
|
||||||
|
};
|
||||||
|
InteractionState {
|
||||||
|
hovered: context.interaction.hovered.contains(&element_id),
|
||||||
|
pressed: context.interaction.pressed.contains(&element_id),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn use_shortcut(shortcut: Shortcut, scope: ShortcutScope, action: impl Fn() + 'static) {
|
||||||
|
use_shortcut_with_context(shortcut, scope, move |_| action());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn use_shortcut_with_context(
|
||||||
|
shortcut: Shortcut,
|
||||||
|
scope: ShortcutScope,
|
||||||
|
action: impl Fn(&InteractionTree) + 'static,
|
||||||
|
) {
|
||||||
|
with_render_context_state(|context| {
|
||||||
|
context
|
||||||
|
.side_effects
|
||||||
|
.borrow_mut()
|
||||||
|
.shortcuts
|
||||||
|
.push(ShortcutBinding {
|
||||||
|
shortcut,
|
||||||
|
scope,
|
||||||
|
action: Rc::new(action),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Resource<T, E> {
|
||||||
|
pub(crate) state: Signal<ResourceState<T, E>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Clone + 'static, E: Clone + 'static> Resource<T, E> {
|
||||||
|
pub fn read(&self) -> ResourceState<T, E> {
|
||||||
|
self.state.get()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub enum ResourceState<T, E> {
|
||||||
|
Pending,
|
||||||
|
Ready(std::result::Result<T, E>),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn use_resource<T, E, Fut, F>(factory: F) -> Resource<T, E>
|
||||||
|
where
|
||||||
|
T: Clone + 'static,
|
||||||
|
E: Clone + 'static,
|
||||||
|
Fut: Future<Output = std::result::Result<T, E>> + 'static,
|
||||||
|
F: Fn() -> Fut + 'static,
|
||||||
|
{
|
||||||
|
with_hook_slot(
|
||||||
|
|| {
|
||||||
|
let resource = Resource {
|
||||||
|
state: Signal::new(ResourceState::Pending),
|
||||||
|
};
|
||||||
|
let generation = Rc::new(StdCell::new(0_u64));
|
||||||
|
let _effect = ruin_reactivity::effect({
|
||||||
|
let resource = resource.clone();
|
||||||
|
let generation = Rc::clone(&generation);
|
||||||
|
move || {
|
||||||
|
let next_generation = generation.get().wrapping_add(1);
|
||||||
|
generation.set(next_generation);
|
||||||
|
let _ = resource.state.replace(ResourceState::Pending);
|
||||||
|
let resource = resource.clone();
|
||||||
|
let future = factory();
|
||||||
|
let generation = Rc::clone(&generation);
|
||||||
|
std::mem::drop(ruin_runtime::queue_future(async move {
|
||||||
|
let result = future.await;
|
||||||
|
if generation.get() == next_generation {
|
||||||
|
let _ = resource.state.replace(ResourceState::Ready(result));
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ResourceSlot { resource, _effect }
|
||||||
|
},
|
||||||
|
|slot: &mut ResourceSlot<T, E>| slot.resource.clone(),
|
||||||
|
)
|
||||||
|
}
|
||||||
357
lib/ruin_app/src/input.rs
Normal file
357
lib/ruin_app/src/input.rs
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
use std::cell::RefCell;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use ruin_ui::{
|
||||||
|
CursorIcon, DisplayItem, ElementId, HitTarget, InteractionTree, KeyboardEvent, PointerButton,
|
||||||
|
PointerEvent, Quad, RoutedPointerEvent, RoutedPointerEventKind, WindowController,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::hooks::{Shortcut, ShortcutScope, Signal};
|
||||||
|
use crate::view::View;
|
||||||
|
|
||||||
|
pub(crate) type PressHandler = Rc<dyn Fn(&RoutedPointerEvent) + 'static>;
|
||||||
|
pub(crate) type ScrollHandler = Rc<dyn Fn(&RoutedPointerEvent, &InteractionTree) + 'static>;
|
||||||
|
pub(crate) type KeyHandler = Rc<dyn Fn(&KeyboardEvent, &InteractionTree) -> bool + 'static>;
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub(crate) struct EventBindings {
|
||||||
|
pub(crate) on_press: HashMap<ElementId, PressHandler>,
|
||||||
|
pub(crate) on_scroll: HashMap<ElementId, ScrollHandler>,
|
||||||
|
pub(crate) on_key: HashMap<ElementId, KeyHandler>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventBindings {
|
||||||
|
pub(crate) fn extend(&mut self, other: EventBindings) {
|
||||||
|
self.on_press.extend(other.on_press);
|
||||||
|
self.on_scroll.extend(other.on_scroll);
|
||||||
|
self.on_key.extend(other.on_key);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn dispatch(
|
||||||
|
&self,
|
||||||
|
event: &RoutedPointerEvent,
|
||||||
|
interaction_tree: &InteractionTree,
|
||||||
|
hovered_targets: &[HitTarget],
|
||||||
|
) {
|
||||||
|
match event.kind {
|
||||||
|
RoutedPointerEventKind::Up {
|
||||||
|
button: PointerButton::Primary,
|
||||||
|
} => {
|
||||||
|
if let Some(element_id) = event.target.element_id
|
||||||
|
&& let Some(handler) = self.on_press.get(&element_id)
|
||||||
|
{
|
||||||
|
handler(event);
|
||||||
|
}
|
||||||
|
if let Some(handler) = scroll_handler_for_event(
|
||||||
|
&self.on_scroll,
|
||||||
|
event.target.element_id,
|
||||||
|
hovered_targets,
|
||||||
|
) {
|
||||||
|
handler(event, interaction_tree);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RoutedPointerEventKind::Down {
|
||||||
|
button: PointerButton::Primary,
|
||||||
|
}
|
||||||
|
| RoutedPointerEventKind::Move
|
||||||
|
| RoutedPointerEventKind::Scroll { .. }
|
||||||
|
| RoutedPointerEventKind::Leave => {
|
||||||
|
if let Some(handler) = scroll_handler_for_event(
|
||||||
|
&self.on_scroll,
|
||||||
|
event.target.element_id,
|
||||||
|
hovered_targets,
|
||||||
|
) {
|
||||||
|
handler(event, interaction_tree);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn dispatch_key(
|
||||||
|
&self,
|
||||||
|
focused_element: Option<ElementId>,
|
||||||
|
event: &KeyboardEvent,
|
||||||
|
interaction_tree: &InteractionTree,
|
||||||
|
) {
|
||||||
|
let Some(handler) = key_handler_for_focus(&self.on_key, focused_element, interaction_tree)
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let _ = handler(event, interaction_tree);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq)]
|
||||||
|
pub(crate) struct ScrollbarDrag {
|
||||||
|
pub(crate) start_pointer_y: f32,
|
||||||
|
pub(crate) start_offset_y: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq)]
|
||||||
|
pub(crate) struct TextSelection {
|
||||||
|
pub(crate) element_id: ElementId,
|
||||||
|
pub(crate) anchor: usize,
|
||||||
|
pub(crate) focus: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq)]
|
||||||
|
pub(crate) struct TextSelectionDrag {
|
||||||
|
pub(crate) element_id: ElementId,
|
||||||
|
pub(crate) anchor: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct TextSelectionState {
|
||||||
|
pub(crate) selection: RefCell<Option<TextSelection>>,
|
||||||
|
pub(crate) drag: RefCell<Option<TextSelectionDrag>>,
|
||||||
|
pub(crate) version: Signal<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextSelectionState {
|
||||||
|
pub(crate) fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
selection: RefCell::new(None),
|
||||||
|
drag: RefCell::new(None),
|
||||||
|
version: Signal::new(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct InputState {
|
||||||
|
pub(crate) current_cursor: CursorIcon,
|
||||||
|
pub(crate) focused_element: Option<ElementId>,
|
||||||
|
pub(crate) text_selection: Rc<TextSelectionState>,
|
||||||
|
pub(crate) hovered_elements: Signal<Vec<ElementId>>,
|
||||||
|
pub(crate) pressed_elements: Signal<Vec<ElementId>>,
|
||||||
|
pub(crate) interaction_version: Signal<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InputState {
|
||||||
|
pub(crate) fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
current_cursor: CursorIcon::Default,
|
||||||
|
focused_element: None,
|
||||||
|
text_selection: Rc::new(TextSelectionState::new()),
|
||||||
|
hovered_elements: Signal::new(Vec::new()),
|
||||||
|
pressed_elements: Signal::new(Vec::new()),
|
||||||
|
interaction_version: Signal::new(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct ShortcutBinding {
|
||||||
|
pub(crate) shortcut: Shortcut,
|
||||||
|
pub(crate) scope: ShortcutScope,
|
||||||
|
pub(crate) action: Rc<dyn Fn(&InteractionTree)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ShortcutBinding {
|
||||||
|
pub(crate) fn matches(
|
||||||
|
&self,
|
||||||
|
event: &KeyboardEvent,
|
||||||
|
focused_element: Option<ElementId>,
|
||||||
|
interaction_tree: &InteractionTree,
|
||||||
|
) -> bool {
|
||||||
|
self.shortcut.matches(event) && self.scope.matches(focused_element, interaction_tree)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn trigger(&self, interaction_tree: &InteractionTree) {
|
||||||
|
(self.action)(interaction_tree);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn clamp_scroll_offset(offset_y: f32, max_offset_y: f32) -> f32 {
|
||||||
|
offset_y.clamp(0.0, max_offset_y.max(0.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn apply_text_selection_overlay(
|
||||||
|
scene: &mut ruin_ui::SceneSnapshot,
|
||||||
|
selection: Option<TextSelection>,
|
||||||
|
) {
|
||||||
|
let Some(selection) = selection else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut next_items = Vec::with_capacity(scene.items.len());
|
||||||
|
for item in scene.items.drain(..) {
|
||||||
|
match item {
|
||||||
|
DisplayItem::Text(mut text) if text.element_id == Some(selection.element_id) => {
|
||||||
|
for rect in text.selection_rects(selection.anchor, selection.focus) {
|
||||||
|
next_items.push(DisplayItem::Quad(Quad::new(
|
||||||
|
rect,
|
||||||
|
text.selection_style.highlight_color,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
text.apply_selected_text_color(selection.anchor, selection.focus);
|
||||||
|
next_items.push(DisplayItem::Text(text));
|
||||||
|
}
|
||||||
|
other => next_items.push(other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scene.items = next_items;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn sync_primary_selection(
|
||||||
|
window: &WindowController,
|
||||||
|
interaction_tree: &InteractionTree,
|
||||||
|
selection: Option<TextSelection>,
|
||||||
|
) -> crate::Result<()> {
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
{
|
||||||
|
let _ = (window, interaction_tree, selection);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
let Some(selection) = selection else {
|
||||||
|
window.set_primary_selection_text(String::new())?;
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(text) = interaction_tree.text_for_element(selection.element_id) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let copied = text
|
||||||
|
.selected_text(selection.anchor, selection.focus)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_owned();
|
||||||
|
window.set_primary_selection_text(copied)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn scroll_handler_for_event<'a>(
|
||||||
|
handlers: &'a HashMap<ElementId, ScrollHandler>,
|
||||||
|
direct_target: Option<ElementId>,
|
||||||
|
hovered_targets: &[HitTarget],
|
||||||
|
) -> Option<&'a ScrollHandler> {
|
||||||
|
if let Some(element_id) = direct_target
|
||||||
|
&& let Some(handler) = handlers.get(&element_id)
|
||||||
|
{
|
||||||
|
return Some(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
hovered_targets
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.filter_map(|target| target.element_id)
|
||||||
|
.find_map(|element_id| handlers.get(&element_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn focused_element_for_pointer(
|
||||||
|
interaction_tree: &InteractionTree,
|
||||||
|
event: &PointerEvent,
|
||||||
|
) -> Option<ElementId> {
|
||||||
|
let hit_path = interaction_tree.hit_path(event.position);
|
||||||
|
hit_path
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find_map(|target| target.focusable.then_some(target.element_id).flatten())
|
||||||
|
.or_else(|| hit_path.iter().rev().find_map(|target| target.element_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn element_contains_element(
|
||||||
|
interaction_tree: &InteractionTree,
|
||||||
|
ancestor: ElementId,
|
||||||
|
descendant: ElementId,
|
||||||
|
) -> bool {
|
||||||
|
fn contains_descendant(node: &ruin_ui::LayoutNode, descendant: ElementId) -> bool {
|
||||||
|
if node.element_id == Some(descendant) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
node.children
|
||||||
|
.iter()
|
||||||
|
.any(|child| contains_descendant(child, descendant))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ancestor_contains(
|
||||||
|
node: &ruin_ui::LayoutNode,
|
||||||
|
ancestor: ElementId,
|
||||||
|
descendant: ElementId,
|
||||||
|
) -> bool {
|
||||||
|
if node.element_id == Some(ancestor) {
|
||||||
|
return contains_descendant(node, descendant);
|
||||||
|
}
|
||||||
|
node.children
|
||||||
|
.iter()
|
||||||
|
.any(|child| ancestor_contains(child, ancestor, descendant))
|
||||||
|
}
|
||||||
|
|
||||||
|
ancestor_contains(&interaction_tree.root, ancestor, descendant)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn key_handler_for_focus<'a>(
|
||||||
|
handlers: &'a HashMap<ElementId, KeyHandler>,
|
||||||
|
focused_element: Option<ElementId>,
|
||||||
|
interaction_tree: &InteractionTree,
|
||||||
|
) -> Option<&'a KeyHandler> {
|
||||||
|
let focused_element = focused_element?;
|
||||||
|
focused_ancestor_chain(&interaction_tree.root, focused_element)?
|
||||||
|
.into_iter()
|
||||||
|
.find_map(|element_id| handlers.get(&element_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn focused_ancestor_chain(
|
||||||
|
node: &ruin_ui::LayoutNode,
|
||||||
|
focused_element: ElementId,
|
||||||
|
) -> Option<Vec<ElementId>> {
|
||||||
|
let mut chain = Vec::new();
|
||||||
|
if build_focused_ancestor_chain(node, focused_element, &mut chain) {
|
||||||
|
Some(chain)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn build_focused_ancestor_chain(
|
||||||
|
node: &ruin_ui::LayoutNode,
|
||||||
|
focused_element: ElementId,
|
||||||
|
chain: &mut Vec<ElementId>,
|
||||||
|
) -> bool {
|
||||||
|
if node.element_id == Some(focused_element) {
|
||||||
|
if let Some(element_id) = node.element_id {
|
||||||
|
chain.push(element_id);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
for child in &node.children {
|
||||||
|
if build_focused_ancestor_chain(child, focused_element, chain) {
|
||||||
|
if let Some(element_id) = node.element_id {
|
||||||
|
chain.push(element_id);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
// View helper methods for attaching handlers — used by primitives
|
||||||
|
impl View {
|
||||||
|
pub(crate) fn with_press_handler(
|
||||||
|
mut self,
|
||||||
|
element_id: ElementId,
|
||||||
|
handler: PressHandler,
|
||||||
|
) -> Self {
|
||||||
|
self.bindings.on_press.insert(element_id, handler);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn with_scroll_handler(
|
||||||
|
mut self,
|
||||||
|
element_id: ElementId,
|
||||||
|
handler: ScrollHandler,
|
||||||
|
) -> Self {
|
||||||
|
self.bindings.on_scroll.insert(element_id, handler);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn with_key_handler(mut self, element_id: ElementId, handler: KeyHandler) -> Self {
|
||||||
|
self.bindings.on_key.insert(element_id, handler);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
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::surfaces;
|
||||||
|
use crate::text_style::{pop_text_style, push_text_style};
|
||||||
|
use crate::view::{Children, View};
|
||||||
|
|
||||||
|
pub struct ButtonBuilder {
|
||||||
|
pub(crate) props: ContainerProps,
|
||||||
|
pub(crate) on_press: Option<PressHandler>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn button() -> ButtonBuilder {
|
||||||
|
ButtonBuilder {
|
||||||
|
props: ContainerProps::new(
|
||||||
|
Element::column()
|
||||||
|
.padding(Edges::symmetric(14.0, 10.0))
|
||||||
|
.background(surfaces::interactive())
|
||||||
|
.corner_radius(10.0)
|
||||||
|
.cursor(CursorIcon::Pointer)
|
||||||
|
.focusable(true),
|
||||||
|
),
|
||||||
|
on_press: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ButtonBuilder {
|
||||||
|
impl_props_methods!();
|
||||||
|
|
||||||
|
pub fn shadow(mut self, shadow: impl crate::converters::IntoShadow) -> Self {
|
||||||
|
self.props = self.props.shadow(shadow);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn on_press(mut self, handler: impl Fn(&RoutedPointerEvent) + 'static) -> Self {
|
||||||
|
self.on_press = Some(Rc::new(handler));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn text_style(mut self, style: crate::text_style::PartialTextStyle) -> Self {
|
||||||
|
self.props = self.props.text_style(style);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn children(mut self, children: impl Children) -> View {
|
||||||
|
let id = allocate_element_id();
|
||||||
|
self.props.element = self.props.element.id(id);
|
||||||
|
if let Some(widget_ref) = &self.props.widget_ref {
|
||||||
|
let _ = widget_ref.set(Some(id));
|
||||||
|
}
|
||||||
|
let had_style = self.props.partial_text_style.is_some();
|
||||||
|
if let Some(style) = self.props.partial_text_style.take() {
|
||||||
|
push_text_style(style);
|
||||||
|
}
|
||||||
|
let children_views = children.into_views();
|
||||||
|
if had_style {
|
||||||
|
pop_text_style();
|
||||||
|
}
|
||||||
|
let view = View::from_container(self.props.element, children_views);
|
||||||
|
match self.on_press {
|
||||||
|
Some(handler) => view.with_press_handler(id, handler),
|
||||||
|
None => view,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
63
lib/ruin_app/src/text_style.rs
Normal file
63
lib/ruin_app/src/text_style.rs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
use std::cell::RefCell;
|
||||||
|
|
||||||
|
use ruin_ui::{Color, TextFontFamily, TextWrap};
|
||||||
|
|
||||||
|
use crate::primitives::FontWeight;
|
||||||
|
|
||||||
|
/// Partially-specified text style for context-based inheritance.
|
||||||
|
///
|
||||||
|
/// Set on a container via `.text_style(...)`. All descendant `text()` nodes inherit
|
||||||
|
/// any fields that are `Some`, falling back to their own defaults for fields that are `None`.
|
||||||
|
/// Inner (closer) ancestors take precedence over outer ancestors.
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct PartialTextStyle {
|
||||||
|
pub size: Option<f32>,
|
||||||
|
pub color: Option<Color>,
|
||||||
|
pub weight: Option<FontWeight>,
|
||||||
|
pub font_family: Option<TextFontFamily>,
|
||||||
|
pub wrap: Option<TextWrap>,
|
||||||
|
pub line_height: Option<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PartialTextStyle {
|
||||||
|
/// Merge `other` on top of `self` — `other`'s `Some` fields win.
|
||||||
|
fn merge_over(self, other: &PartialTextStyle) -> Self {
|
||||||
|
Self {
|
||||||
|
size: other.size.or(self.size),
|
||||||
|
color: other.color.or(self.color),
|
||||||
|
weight: other.weight.or(self.weight),
|
||||||
|
font_family: other.font_family.clone().or(self.font_family),
|
||||||
|
wrap: other.wrap.or(self.wrap),
|
||||||
|
line_height: other.line_height.or(self.line_height),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
static TEXT_STYLE_STACK: RefCell<Vec<PartialTextStyle>> = const { RefCell::new(Vec::new()) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push a partial style onto the context stack. Must be paired with `pop_text_style`.
|
||||||
|
pub(crate) fn push_text_style(style: PartialTextStyle) {
|
||||||
|
TEXT_STYLE_STACK.with(|stack| stack.borrow_mut().push(style));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pop the top partial style from the context stack.
|
||||||
|
pub(crate) fn pop_text_style() {
|
||||||
|
TEXT_STYLE_STACK.with(|stack| {
|
||||||
|
stack.borrow_mut().pop();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the effective `PartialTextStyle` at the current render position by merging
|
||||||
|
/// all layers from outermost to innermost (innermost wins).
|
||||||
|
pub(crate) fn current_text_style() -> PartialTextStyle {
|
||||||
|
TEXT_STYLE_STACK.with(|stack| {
|
||||||
|
stack
|
||||||
|
.borrow()
|
||||||
|
.iter()
|
||||||
|
.fold(PartialTextStyle::default(), |acc, layer| {
|
||||||
|
acc.merge_over(layer)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
238
lib/ruin_app/src/view.rs
Normal file
238
lib/ruin_app/src/view.rs
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
use ruin_ui::Element;
|
||||||
|
|
||||||
|
use crate::Component;
|
||||||
|
use crate::input::EventBindings;
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct View {
|
||||||
|
pub(crate) element: Element,
|
||||||
|
pub(crate) bindings: EventBindings,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl View {
|
||||||
|
pub fn from_element(element: Element) -> Self {
|
||||||
|
Self {
|
||||||
|
element,
|
||||||
|
bindings: EventBindings::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn element(&self) -> &Element {
|
||||||
|
&self.element
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_container(element: Element, children: Vec<View>) -> Self {
|
||||||
|
let mut composed = Self::from_element(element);
|
||||||
|
let mut element = composed.element;
|
||||||
|
|
||||||
|
for child in children {
|
||||||
|
element = element.child(child.element);
|
||||||
|
composed.bindings.extend(child.bindings);
|
||||||
|
}
|
||||||
|
|
||||||
|
composed.element = element;
|
||||||
|
composed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Element> for View {
|
||||||
|
fn from(element: Element) -> Self {
|
||||||
|
Self::from_element(element)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait IntoView {
|
||||||
|
fn into_view(self) -> View;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoView for View {
|
||||||
|
fn into_view(self) -> View {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoView for Element {
|
||||||
|
fn into_view(self) -> View {
|
||||||
|
View::from_element(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Component> IntoView for T {
|
||||||
|
fn into_view(self) -> View {
|
||||||
|
self.render()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait Children {
|
||||||
|
fn into_views(self) -> Vec<View>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct ChildViews(pub(crate) Vec<View>);
|
||||||
|
|
||||||
|
impl ChildViews {
|
||||||
|
pub fn from_children(children: impl Children) -> Self {
|
||||||
|
Self(children.into_views())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_vec(self) -> Vec<View> {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Children for () {
|
||||||
|
fn into_views(self) -> Vec<View> {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: IntoView> Children for T {
|
||||||
|
fn into_views(self) -> Vec<View> {
|
||||||
|
vec![self.into_view()]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: IntoView> Children for Vec<T> {
|
||||||
|
fn into_views(self) -> Vec<View> {
|
||||||
|
self.into_iter().map(IntoView::into_view).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Children for ChildViews {
|
||||||
|
fn into_views(self) -> Vec<View> {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wraps a lazy closure so it can be used as container children.
|
||||||
|
///
|
||||||
|
/// The closure is called exactly once, inside the builder's `.children()` method,
|
||||||
|
/// after any `PartialTextStyle` context has been pushed. This enables text style
|
||||||
|
/// inheritance from ancestor containers.
|
||||||
|
///
|
||||||
|
/// The `view!` macro automatically generates `LazyChildren(|| vec![...])` for
|
||||||
|
/// primitive container nodes so that style context propagates correctly.
|
||||||
|
pub struct LazyChildren<F: FnOnce() -> Vec<View>>(pub F);
|
||||||
|
|
||||||
|
impl<F: FnOnce() -> Vec<View>> Children for LazyChildren<F> {
|
||||||
|
fn into_views(self) -> Vec<View> {
|
||||||
|
(self.0)()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! impl_children_tuple {
|
||||||
|
($($name:ident),+ $(,)?) => {
|
||||||
|
#[allow(non_camel_case_types)]
|
||||||
|
impl<$($name: IntoView),+> Children for ($($name,)+) {
|
||||||
|
fn into_views(self) -> Vec<View> {
|
||||||
|
let ($($name,)+) = self;
|
||||||
|
vec![$($name.into_view(),)+]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
impl_children_tuple!(a, b);
|
||||||
|
impl_children_tuple!(a, b, c);
|
||||||
|
impl_children_tuple!(a, b, c, d);
|
||||||
|
impl_children_tuple!(a, b, c, d, e);
|
||||||
|
impl_children_tuple!(a, b, c, d, e, f);
|
||||||
|
impl_children_tuple!(a, b, c, d, e, f, g);
|
||||||
|
impl_children_tuple!(a, b, c, d, e, f, g, h);
|
||||||
|
|
||||||
|
pub trait TextChildren {
|
||||||
|
fn into_text(self) -> String;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct TextValue(pub(crate) String);
|
||||||
|
|
||||||
|
impl TextValue {
|
||||||
|
pub fn from_text(children: impl TextChildren) -> Self {
|
||||||
|
Self(children.into_text())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_string(self) -> String {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextChildren for TextValue {
|
||||||
|
fn into_text(self) -> String {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextChildren for &'static str {
|
||||||
|
fn into_text(self) -> String {
|
||||||
|
self.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextChildren for String {
|
||||||
|
fn into_text(self) -> String {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextChildren for i32 {
|
||||||
|
fn into_text(self) -> String {
|
||||||
|
self.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextChildren for i64 {
|
||||||
|
fn into_text(self) -> String {
|
||||||
|
self.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextChildren for usize {
|
||||||
|
fn into_text(self) -> String {
|
||||||
|
self.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextChildren for u64 {
|
||||||
|
fn into_text(self) -> String {
|
||||||
|
self.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextChildren for f32 {
|
||||||
|
fn into_text(self) -> String {
|
||||||
|
self.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: std::fmt::Display + 'static> TextChildren for crate::hooks::Signal<T> {
|
||||||
|
fn into_text(self) -> String {
|
||||||
|
self.with(|value| value.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: std::fmt::Display + 'static> TextChildren for crate::hooks::Memo<T> {
|
||||||
|
fn into_text(self) -> String {
|
||||||
|
self.with(|value| value.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! impl_text_children_tuple {
|
||||||
|
($($name:ident),+ $(,)?) => {
|
||||||
|
#[allow(non_camel_case_types)]
|
||||||
|
impl<$($name: TextChildren),+> TextChildren for ($($name,)+) {
|
||||||
|
fn into_text(self) -> String {
|
||||||
|
let ($($name,)+) = self;
|
||||||
|
let mut text = String::new();
|
||||||
|
$(text.push_str(&$name.into_text());)+
|
||||||
|
text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
impl_text_children_tuple!(a, b);
|
||||||
|
impl_text_children_tuple!(a, b, c);
|
||||||
|
impl_text_children_tuple!(a, b, c, d);
|
||||||
|
impl_text_children_tuple!(a, b, c, d, e);
|
||||||
|
impl_text_children_tuple!(a, b, c, d, e, f);
|
||||||
@@ -681,23 +681,49 @@ fn expand_node(node: &Node) -> proc_macro2::TokenStream {
|
|||||||
let value = &property.value;
|
let value = &property.value;
|
||||||
quote! { .#name(#value) }
|
quote! { .#name(#value) }
|
||||||
});
|
});
|
||||||
let children = expand_children(&node.children);
|
|
||||||
|
|
||||||
if is_component_path(path) {
|
if is_component_path(path) {
|
||||||
|
// Component nodes: eager children so #[component] builder impls keep working.
|
||||||
|
let children = expand_children(&node.children);
|
||||||
quote! {
|
quote! {
|
||||||
#path::builder()
|
#path::builder()
|
||||||
#(#prop_calls)*
|
#(#prop_calls)*
|
||||||
.children(#children)
|
.children(#children)
|
||||||
}
|
}
|
||||||
} else {
|
} else if is_text_path(path) {
|
||||||
|
// text(): children are TextChildren (string/value), not Views — keep eager.
|
||||||
|
let children = expand_children(&node.children);
|
||||||
quote! {
|
quote! {
|
||||||
::ruin_app::#path()
|
::ruin_app::#path()
|
||||||
#(#prop_calls)*
|
#(#prop_calls)*
|
||||||
.children(#children)
|
.children(#children)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Primitive container nodes (column, row, block, button, scroll_box, …):
|
||||||
|
// Wrap children in a LazyChildren closure so that PartialTextStyle context is
|
||||||
|
// pushed BEFORE children run, enabling correct text style inheritance.
|
||||||
|
//
|
||||||
|
// Each child is spread via Children::into_views(), which handles:
|
||||||
|
// - Child::Node results (View) via impl<T: IntoView> Children for T
|
||||||
|
// - &str literals via Children for &str
|
||||||
|
// - ChildViews (slot values) via Children for ChildViews (flattens)
|
||||||
|
let items = node.children.iter().map(expand_child);
|
||||||
|
quote! {
|
||||||
|
::ruin_app::#path()
|
||||||
|
#(#prop_calls)*
|
||||||
|
.children(::ruin_app::LazyChildren(|| {
|
||||||
|
let mut __v: ::std::vec::Vec<::ruin_app::View> = ::std::vec::Vec::new();
|
||||||
|
#(__v.extend(::ruin_app::Children::into_views(#items));)*
|
||||||
|
__v
|
||||||
|
}))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_text_path(path: &Path) -> bool {
|
||||||
|
path.segments.len() == 1 && path.segments[0].ident == "text"
|
||||||
|
}
|
||||||
|
|
||||||
fn expand_provider_node(node: &Node) -> proc_macro2::TokenStream {
|
fn expand_provider_node(node: &Node) -> proc_macro2::TokenStream {
|
||||||
let path = &node.path;
|
let path = &node.path;
|
||||||
let value = match node.props.as_slice() {
|
let value = match node.props.as_slice() {
|
||||||
|
|||||||
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
|
//! The public surface intentionally mirrors `std::fs` where that shape makes sense, while using
|
||||||
//! async methods for operations that may block the caller.
|
//! async methods for operations that may block the caller.
|
||||||
|
|
||||||
|
use alloc::sync::Arc;
|
||||||
|
|
||||||
use std::ffi::OsStr;
|
use std::ffi::OsStr;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::os::fd::{AsRawFd, OwnedFd};
|
use std::os::fd::{AsRawFd, OwnedFd};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use crate::op::fs::{
|
use crate::op::fs::{
|
||||||
FileType as RawFileType, FsOp, MetadataTarget, OpenOptions as OpOpenOptions,
|
FileType as RawFileType, FsOp, MetadataTarget, OpenOptions as OpOpenOptions,
|
||||||
|
|||||||
@@ -34,7 +34,10 @@ pub(crate) mod trace_targets {
|
|||||||
pub const DRIVER: &str = "ruin_runtime::driver";
|
pub const DRIVER: &str = "ruin_runtime::driver";
|
||||||
pub const RUNTIME: &str = "ruin_runtime::runtime";
|
pub const RUNTIME: &str = "ruin_runtime::runtime";
|
||||||
pub const SCHEDULER: &str = "ruin_runtime::scheduler";
|
pub const SCHEDULER: &str = "ruin_runtime::scheduler";
|
||||||
|
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
pub const TIMER: &str = "ruin_runtime::timer";
|
pub const TIMER: &str = "ruin_runtime::timer";
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
pub const ASYNC: &str = "ruin_runtime::async";
|
pub const ASYNC: &str = "ruin_runtime::async";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +49,7 @@ pub mod net;
|
|||||||
pub mod op;
|
pub mod op;
|
||||||
#[doc(hidden)]
|
#[doc(hidden)]
|
||||||
pub mod platform;
|
pub mod platform;
|
||||||
|
pub mod stdio;
|
||||||
#[doc(hidden)]
|
#[doc(hidden)]
|
||||||
pub mod sys;
|
pub mod sys;
|
||||||
pub mod time;
|
pub mod time;
|
||||||
|
|||||||
@@ -3,14 +3,15 @@
|
|||||||
//! The public surface follows the general shape of `std::net`, but uses async methods for socket
|
//! The public surface follows the general shape of `std::net`, but uses async methods for socket
|
||||||
//! operations that would otherwise block the caller.
|
//! operations that would otherwise block the caller.
|
||||||
|
|
||||||
use std::future::Future;
|
use core::future::Future;
|
||||||
|
use core::pin::Pin;
|
||||||
|
use core::task::{Context, Poll};
|
||||||
|
use core::time::Duration;
|
||||||
|
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::net::{Shutdown, SocketAddr, ToSocketAddrs};
|
use std::net::{Shutdown, SocketAddr, ToSocketAddrs};
|
||||||
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
|
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
|
||||||
use std::pin::Pin;
|
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::task::{Context, Poll};
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use hyper::rt::{Read as HyperRead, ReadBufCursor, Write as HyperWrite};
|
use hyper::rt::{Read as HyperRead, ReadBufCursor, Write as HyperWrite};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
//! Public runtime driver primitives.
|
//! RUIN Runtime Driver for Linux x86_64.
|
||||||
|
//!
|
||||||
|
//!
|
||||||
|
|
||||||
use std::cell::Cell;
|
use std::cell::Cell;
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
@@ -88,12 +90,21 @@ pub struct ReadyEvents {
|
|||||||
|
|
||||||
/// Low-level Linux runtime driver backed by `io_uring`.
|
/// Low-level Linux runtime driver backed by `io_uring`.
|
||||||
pub struct Driver {
|
pub struct Driver {
|
||||||
|
/// The `io_uring` instance driving this runtime thread.
|
||||||
ring: IoUring,
|
ring: IoUring,
|
||||||
|
/// Shared notifier that other threads can use to wake this runtime thread.
|
||||||
notifier: Arc<NotifierInner>,
|
notifier: Arc<NotifierInner>,
|
||||||
|
/// Next sequence number for generated completion tokens.
|
||||||
next_token: Cell<u64>,
|
next_token: Cell<u64>,
|
||||||
|
/// The token of the currently active timer, if any timer is armed.
|
||||||
active_timer_token: Cell<Option<u64>>,
|
active_timer_token: Cell<Option<u64>>,
|
||||||
|
/// Accumulated count of pending wake notifications that have not yet been triggered.
|
||||||
pending_wakes: Cell<u64>,
|
pending_wakes: Cell<u64>,
|
||||||
|
/// Accumulated count of pending timer expirations that have not yet been triggered.
|
||||||
pending_timers: Cell<u64>,
|
pending_timers: Cell<u64>,
|
||||||
|
/// Map of active completion tokens to associated handlers. When a CQE is received with a token in this map, the
|
||||||
|
/// corresponding handler will be invoked with the CQE and removed from the map. This is the core mechanism by which
|
||||||
|
/// async operations are tracked and dispatched to their continuations.
|
||||||
completions: RefCell<HashMap<u64, CompletionHandler>>,
|
completions: RefCell<HashMap<u64, CompletionHandler>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ pub struct TimeoutHandle {
|
|||||||
_local: Rc<()>,
|
_local: Rc<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl TimeoutHandle {
|
||||||
|
pub fn clear(&self) {
|
||||||
|
clear_timeout(self);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
/// Handle returned by [`set_interval`].
|
/// Handle returned by [`set_interval`].
|
||||||
pub struct IntervalHandle {
|
pub struct IntervalHandle {
|
||||||
@@ -58,6 +64,12 @@ pub struct IntervalHandle {
|
|||||||
_local: Rc<()>,
|
_local: Rc<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl IntervalHandle {
|
||||||
|
pub fn clear(&self) {
|
||||||
|
clear_interval(self);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Handle returned by [`queue_future`].
|
/// Handle returned by [`queue_future`].
|
||||||
///
|
///
|
||||||
/// Awaiting a join handle yields the output of the queued future.
|
/// Awaiting a join handle yields the output of the queued future.
|
||||||
@@ -68,9 +80,10 @@ pub struct JoinHandle<T> {
|
|||||||
/// Future returned by [`yield_now`].
|
/// Future returned by [`yield_now`].
|
||||||
///
|
///
|
||||||
/// Awaiting this future will immediately yield control back to the runtime scheduler, allowing other queued microtasks
|
/// Awaiting this future will immediately yield control back to the runtime scheduler, allowing other queued microtasks
|
||||||
/// to run before the current task continues executing. Note that continuation of futures runs as a microtask, so this
|
/// to run before the current task continues executing. Note that continuations of futures run as microtasks, so this
|
||||||
/// can only yield to other microtasks and not to macrotasks (driver events such as file or network I/O, timers, or
|
/// can only yield to other microtasks and not to macrotasks (driver events such as file or network I/O, timers, or
|
||||||
/// channel messages).
|
/// channel messages). To yield to macrotasks, you must allow the flow of execution to return to the runtime event loop
|
||||||
|
/// and flush the full microtask queue, for example by awaiting a timer.
|
||||||
pub struct YieldNow {
|
pub struct YieldNow {
|
||||||
yielded: bool,
|
yielded: bool,
|
||||||
}
|
}
|
||||||
@@ -213,17 +226,19 @@ where
|
|||||||
);
|
);
|
||||||
|
|
||||||
if delay.is_zero() {
|
if delay.is_zero() {
|
||||||
|
unimplemented!("Zero-delay intervals are not yet implemented.")
|
||||||
|
// TODO: vibeshit got this completely wrong
|
||||||
// Zero-delay intervals never touch the timer heap or arm an io_uring
|
// Zero-delay intervals never touch the timer heap or arm an io_uring
|
||||||
// timer (a past-deadline kernel timer would fire on every event loop
|
// timer (a past-deadline kernel timer would fire on every event loop
|
||||||
// iteration, spinning the runtime at 100 % CPU). Instead the callback
|
// iteration, spinning the runtime at 100 % CPU). Instead the callback
|
||||||
// is stored in `immediate_intervals` and self-schedules as a macrotask
|
// is stored in `immediate_intervals` and self-schedules as a macrotask
|
||||||
// each turn, mirroring JS `setInterval(f, 0)` semantics.
|
// each turn, mirroring JS `setInterval(f, 0)` semantics.
|
||||||
let callback = Rc::new(RefCell::new(Box::new(callback) as Box<dyn FnMut()>));
|
// let callback = Rc::new(RefCell::new(Box::new(callback) as Box<dyn FnMut()>));
|
||||||
current_thread()
|
// current_thread()
|
||||||
.immediate_intervals
|
// .immediate_intervals
|
||||||
.borrow_mut()
|
// .borrow_mut()
|
||||||
.insert(id, Rc::clone(&callback));
|
// .insert(id, Rc::clone(&callback));
|
||||||
schedule_immediate_interval(owner, id);
|
// schedule_immediate_interval(owner, id);
|
||||||
} else {
|
} else {
|
||||||
let deadline = deadline_from_now(delay);
|
let deadline = deadline_from_now(delay);
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
@@ -660,6 +675,8 @@ impl Future for YieldNow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ImmediateIntervals = RefCell<HashMap<usize, Rc<RefCell<Box<dyn FnMut()>>>>>;
|
||||||
|
|
||||||
struct ThreadState {
|
struct ThreadState {
|
||||||
driver: Driver,
|
driver: Driver,
|
||||||
shared: Arc<ThreadShared>,
|
shared: Arc<ThreadShared>,
|
||||||
@@ -669,7 +686,7 @@ struct ThreadState {
|
|||||||
timers: RefCell<TimerHeap>,
|
timers: RefCell<TimerHeap>,
|
||||||
/// Zero-delay intervals bypasses the timer heap entirely. Each entry
|
/// Zero-delay intervals bypasses the timer heap entirely. Each entry
|
||||||
/// re-enqueues itself as a macrotask on every turn.
|
/// re-enqueues itself as a macrotask on every turn.
|
||||||
immediate_intervals: RefCell<HashMap<usize, IntervalCallback>>,
|
immediate_intervals: ImmediateIntervals,
|
||||||
next_timer_id: Cell<usize>,
|
next_timer_id: Cell<usize>,
|
||||||
children: RefCell<Vec<ChildWorker>>,
|
children: RefCell<Vec<ChildWorker>>,
|
||||||
}
|
}
|
||||||
@@ -1251,31 +1268,32 @@ fn clear_timer(owner: *const ThreadState, id: usize) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enqueues one macrotask turn for a zero-delay interval.
|
// TODO: vibeshit got this completely wrong
|
||||||
///
|
// /// Enqueues one macrotask turn for a zero-delay interval.
|
||||||
/// The macrotask checks whether the interval is still live (not cleared), fires
|
// ///
|
||||||
/// the callback, and re-enqueues itself for the next turn.
|
// /// The macrotask checks whether the interval is still live (not cleared), fires
|
||||||
fn schedule_immediate_interval(owner: *const ThreadState, id: usize) {
|
// /// the callback, and re-enqueues itself for the next turn.
|
||||||
push_local_macrotask(Box::new(move || {
|
// fn schedule_immediate_interval(owner: *const ThreadState, id: usize) {
|
||||||
let callback = current_thread()
|
// push_local_macrotask(Box::new(move || {
|
||||||
.immediate_intervals
|
// let callback = current_thread()
|
||||||
.borrow()
|
// .immediate_intervals
|
||||||
.get(&id)
|
// .borrow()
|
||||||
.map(Rc::clone);
|
// .get(&id)
|
||||||
let Some(callback) = callback else {
|
// .map(Rc::clone);
|
||||||
return; // interval was cleared before this turn ran
|
// let Some(callback) = callback else {
|
||||||
};
|
// return; // interval was cleared before this turn ran
|
||||||
(callback.borrow_mut())();
|
// };
|
||||||
// Re-enqueue for the next turn if still live.
|
// (callback.borrow_mut())();
|
||||||
if current_thread()
|
// // Re-enqueue for the next turn if still live.
|
||||||
.immediate_intervals
|
// if current_thread()
|
||||||
.borrow()
|
// .immediate_intervals
|
||||||
.contains_key(&id)
|
// .borrow()
|
||||||
{
|
// .contains_key(&id)
|
||||||
schedule_immediate_interval(owner, id);
|
// {
|
||||||
}
|
// schedule_immediate_interval(owner, id);
|
||||||
}));
|
// }
|
||||||
}
|
// }));
|
||||||
|
// }
|
||||||
|
|
||||||
fn dispatch_expired_timers() {
|
fn dispatch_expired_timers() {
|
||||||
let now = deadline_from_now(Duration::ZERO);
|
let now = deadline_from_now(Duration::ZERO);
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ pub struct Driver {
|
|||||||
pending_wakes: Cell<u64>,
|
pending_wakes: Cell<u64>,
|
||||||
pending_timers: Cell<u64>,
|
pending_timers: Cell<u64>,
|
||||||
next_fd_token: Cell<u64>,
|
next_fd_token: Cell<u64>,
|
||||||
fd_waiters: RefCell<HashMap<FdKey, Vec<FdWaiter>>>,
|
fd_waiters: RefCell<HashMap<FdKey, FdWaiter>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a new driver and its paired [`ThreadNotifier`].
|
/// Creates a new driver and its paired [`ThreadNotifier`].
|
||||||
@@ -241,17 +241,31 @@ impl Driver {
|
|||||||
completion: FdCompletion,
|
completion: FdCompletion,
|
||||||
) -> io::Result<FdReadinessToken> {
|
) -> io::Result<FdReadinessToken> {
|
||||||
let key = FdKey { fd, interest };
|
let key = FdKey { fd, interest };
|
||||||
let should_register = !self.fd_waiters.borrow().contains_key(&key);
|
let removed_stale_waiter = {
|
||||||
if should_register {
|
let mut waiters = self.fd_waiters.borrow_mut();
|
||||||
self.update_fd_interest(key, libc::EV_ADD | libc::EV_ENABLE)?;
|
match waiters.get(&key) {
|
||||||
|
Some(waiter) if !waiter.completion.is_interested() => {
|
||||||
|
waiters.remove(&key);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Some(_) => {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::AlreadyExists,
|
||||||
|
"fd readiness already has a waiter for this interest",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if removed_stale_waiter {
|
||||||
|
let _ = self.update_fd_interest(key, libc::EV_DELETE);
|
||||||
}
|
}
|
||||||
|
|
||||||
let token = self.allocate_fd_token();
|
let token = self.allocate_fd_token();
|
||||||
|
self.update_fd_interest(key, libc::EV_ADD | libc::EV_ENABLE | libc::EV_ONESHOT)?;
|
||||||
self.fd_waiters
|
self.fd_waiters
|
||||||
.borrow_mut()
|
.borrow_mut()
|
||||||
.entry(key)
|
.insert(key, FdWaiter { token, completion });
|
||||||
.or_default()
|
|
||||||
.push(FdWaiter { token, completion });
|
|
||||||
Ok(token)
|
Ok(token)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,12 +273,9 @@ impl Driver {
|
|||||||
let mut empty_key = None;
|
let mut empty_key = None;
|
||||||
{
|
{
|
||||||
let mut waiters = self.fd_waiters.borrow_mut();
|
let mut waiters = self.fd_waiters.borrow_mut();
|
||||||
for (key, entries) in waiters.iter_mut() {
|
for (key, entry) in waiters.iter() {
|
||||||
if let Some(index) = entries.iter().position(|entry| entry.token == token) {
|
if entry.token == token {
|
||||||
entries.swap_remove(index);
|
empty_key = Some(*key);
|
||||||
if entries.is_empty() {
|
|
||||||
empty_key = Some(*key);
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -372,19 +383,13 @@ impl Driver {
|
|||||||
|
|
||||||
fn complete_fd_waiters(&self, fd: RawFd, interest: FdInterest, event: &libc::kevent) {
|
fn complete_fd_waiters(&self, fd: RawFd, interest: FdInterest, event: &libc::kevent) {
|
||||||
let key = FdKey { fd, interest };
|
let key = FdKey { fd, interest };
|
||||||
let waiters = self.fd_waiters.borrow_mut().remove(&key);
|
let waiter = self.fd_waiters.borrow_mut().remove(&key);
|
||||||
let Some(waiters) = waiters else {
|
let Some(waiter) = waiter else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let _ = self.update_fd_interest(key, libc::EV_DELETE);
|
let result = fd_event_result(event, interest);
|
||||||
let result = fd_event_result(event);
|
waiter.completion.complete(result);
|
||||||
for waiter in waiters {
|
|
||||||
waiter.completion.complete(match &result {
|
|
||||||
Ok(()) => Ok(()),
|
|
||||||
Err(error) => Err(io::Error::new(error.kind(), error.to_string())),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -436,9 +441,14 @@ fn interest_from_filter(filter: i16) -> Option<FdInterest> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fd_event_result(event: &libc::kevent) -> io::Result<()> {
|
fn fd_event_result(event: &libc::kevent, interest: FdInterest) -> io::Result<()> {
|
||||||
if event.flags & libc::EV_ERROR != 0 && event.data != 0 {
|
if event.flags & libc::EV_ERROR != 0 && event.data != 0 {
|
||||||
Err(io::Error::from_raw_os_error(event.data as i32))
|
Err(io::Error::from_raw_os_error(event.data as i32))
|
||||||
|
} else if event.flags & libc::EV_EOF != 0 && interest == FdInterest::Writable {
|
||||||
|
Err(io::Error::new(
|
||||||
|
io::ErrorKind::BrokenPipe,
|
||||||
|
"fd write side reached EOF",
|
||||||
|
))
|
||||||
} else {
|
} else {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,12 @@ pub struct IntervalHandle {
|
|||||||
_local: Rc<()>,
|
_local: Rc<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl IntervalHandle {
|
||||||
|
pub fn clear(&self) {
|
||||||
|
clear_interval(self);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Handle returned by [`queue_future`].
|
/// Handle returned by [`queue_future`].
|
||||||
///
|
///
|
||||||
/// Awaiting a join handle yields the output of the queued future.
|
/// Awaiting a join handle yields the output of the queued future.
|
||||||
|
|||||||
198
lib/runtime/src/stdio.rs
Normal file
198
lib/runtime/src/stdio.rs
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
//! Async standard-input helpers.
|
||||||
|
|
||||||
|
use std::io;
|
||||||
|
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
use crate::op::completion::completion_for_current_thread;
|
||||||
|
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||||
|
use crate::platform::linux_x86_64::runtime::with_current_driver;
|
||||||
|
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||||
|
use crate::platform::linux_x86_64::uring::{IORING_OP_READ, IoUringCqe, IoUringSqe};
|
||||||
|
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||||
|
use std::cell::Cell;
|
||||||
|
|
||||||
|
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||||
|
thread_local! {
|
||||||
|
static STDIN_URING_SUPPORTED: Cell<Option<bool>> = const { Cell::new(None) };
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||||
|
const FILE_CURSOR: u64 = u64::MAX;
|
||||||
|
const READ_CHUNK_BYTES: usize = 1024;
|
||||||
|
|
||||||
|
/// Async line-oriented stdin reader.
|
||||||
|
///
|
||||||
|
/// The reader is `io_uring`-first. When the active stdin fd rejects `IORING_OP_READ`, the module
|
||||||
|
/// falls back to a helper-thread blocking read on the same duplicated fd.
|
||||||
|
pub struct Stdin {
|
||||||
|
fd: OwnedFd,
|
||||||
|
buffer: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens an async stdin reader.
|
||||||
|
pub fn stdin() -> io::Result<Stdin> {
|
||||||
|
let raw = cvt(unsafe { libc::fcntl(libc::STDIN_FILENO, libc::F_DUPFD_CLOEXEC, 0) })?;
|
||||||
|
Ok(Stdin {
|
||||||
|
fd: unsafe { OwnedFd::from_raw_fd(raw) },
|
||||||
|
buffer: Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Stdin {
|
||||||
|
/// Reads a single UTF-8 line, including the trailing newline when present.
|
||||||
|
///
|
||||||
|
/// Returns `Ok(None)` on EOF.
|
||||||
|
pub async fn read_line(&mut self) -> io::Result<Option<String>> {
|
||||||
|
loop {
|
||||||
|
if let Some(index) = self.buffer.iter().position(|byte| *byte == b'\n') {
|
||||||
|
let line = self.buffer.drain(..=index).collect::<Vec<_>>();
|
||||||
|
return decode_line(line).map(Some);
|
||||||
|
}
|
||||||
|
|
||||||
|
let chunk = self.read_chunk().await?;
|
||||||
|
if chunk.is_empty() {
|
||||||
|
if self.buffer.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let line = std::mem::take(&mut self.buffer);
|
||||||
|
return decode_line(line).map(Some);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.buffer.extend_from_slice(&chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_chunk(&self) -> io::Result<Vec<u8>> {
|
||||||
|
let fd = self.fd.as_raw_fd();
|
||||||
|
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||||
|
{
|
||||||
|
let support = STDIN_URING_SUPPORTED.with(Cell::get);
|
||||||
|
if support != Some(false) {
|
||||||
|
match submit_uring_read(fd, READ_CHUNK_BYTES).await {
|
||||||
|
Ok(bytes) => {
|
||||||
|
STDIN_URING_SUPPORTED.with(|state| state.set(Some(true)));
|
||||||
|
return Ok(bytes);
|
||||||
|
}
|
||||||
|
Err(error) if should_fallback_to_offload(&error) => {
|
||||||
|
STDIN_URING_SUPPORTED.with(|state| state.set(Some(false)));
|
||||||
|
}
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
offload(move || {
|
||||||
|
let mut buffer = vec![0; READ_CHUNK_BYTES];
|
||||||
|
let read = blocking_read(fd, &mut buffer)?;
|
||||||
|
if read == 0 {
|
||||||
|
buffer.clear();
|
||||||
|
} else {
|
||||||
|
buffer.truncate(read);
|
||||||
|
}
|
||||||
|
Ok(buffer)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn offload<T: Send + 'static>(
|
||||||
|
task: impl FnOnce() -> io::Result<T> + Send + 'static,
|
||||||
|
) -> io::Result<T> {
|
||||||
|
let (future, handle) = completion_for_current_thread::<io::Result<T>>();
|
||||||
|
thread::Builder::new()
|
||||||
|
.name("ruin-runtime-stdio-offload".into())
|
||||||
|
.spawn(move || handle.complete(task()))
|
||||||
|
.map_err(io::Error::other)?;
|
||||||
|
future.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||||
|
async fn submit_uring_read(fd: RawFd, len: usize) -> io::Result<Vec<u8>> {
|
||||||
|
let mut buffer = vec![0; len];
|
||||||
|
let ptr = buffer.as_mut_ptr();
|
||||||
|
let capacity = buffer.len();
|
||||||
|
submit_uring(
|
||||||
|
move |sqe| {
|
||||||
|
sqe.opcode = IORING_OP_READ;
|
||||||
|
sqe.fd = fd;
|
||||||
|
sqe.addr = ptr as u64;
|
||||||
|
sqe.len = capacity as u32;
|
||||||
|
sqe.off = FILE_CURSOR;
|
||||||
|
},
|
||||||
|
move |cqe| {
|
||||||
|
let read = cqe_to_result(cqe)? as usize;
|
||||||
|
buffer.truncate(read);
|
||||||
|
Ok(buffer)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||||
|
async fn submit_uring<T: Send + 'static, M>(
|
||||||
|
fill: impl FnOnce(&mut IoUringSqe),
|
||||||
|
map: M,
|
||||||
|
) -> io::Result<T>
|
||||||
|
where
|
||||||
|
M: FnOnce(IoUringCqe) -> io::Result<T> + Send + 'static,
|
||||||
|
{
|
||||||
|
let (future, handle) = completion_for_current_thread::<io::Result<T>>();
|
||||||
|
let callback_handle = handle.clone();
|
||||||
|
let token = with_current_driver(|driver| {
|
||||||
|
driver.submit_operation(fill, move |cqe| {
|
||||||
|
callback_handle.complete(map(cqe));
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
|
||||||
|
handle.set_cancel(move || {
|
||||||
|
let _ = with_current_driver(|driver| driver.cancel_operation(token));
|
||||||
|
});
|
||||||
|
|
||||||
|
future.await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn blocking_read(fd: RawFd, buffer: &mut [u8]) -> io::Result<usize> {
|
||||||
|
loop {
|
||||||
|
let read =
|
||||||
|
unsafe { libc::read(fd, buffer.as_mut_ptr().cast::<libc::c_void>(), buffer.len()) };
|
||||||
|
if read >= 0 {
|
||||||
|
return Ok(read as usize);
|
||||||
|
}
|
||||||
|
|
||||||
|
let error = io::Error::last_os_error();
|
||||||
|
if error.kind() == io::ErrorKind::Interrupted {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_line(bytes: Vec<u8>) -> io::Result<String> {
|
||||||
|
String::from_utf8(bytes).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||||
|
fn cqe_to_result(cqe: IoUringCqe) -> io::Result<i32> {
|
||||||
|
if cqe.res < 0 {
|
||||||
|
Err(io::Error::from_raw_os_error(-cqe.res))
|
||||||
|
} else {
|
||||||
|
Ok(cqe.res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||||
|
fn should_fallback_to_offload(error: &io::Error) -> bool {
|
||||||
|
matches!(
|
||||||
|
error.raw_os_error(),
|
||||||
|
Some(libc::EINVAL | libc::ENOSYS | libc::EOPNOTSUPP)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cvt(value: libc::c_int) -> io::Result<libc::c_int> {
|
||||||
|
if value == -1 {
|
||||||
|
Err(io::Error::last_os_error())
|
||||||
|
} else {
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ use crate::platform::current::runtime::{ThreadHandle, current_thread_handle};
|
|||||||
type BlockingTask = Box<dyn FnOnce() + Send + 'static>;
|
type BlockingTask = Box<dyn FnOnce() + Send + 'static>;
|
||||||
|
|
||||||
static BLOCKING_POOL: OnceLock<io::Result<BlockingPool>> = OnceLock::new();
|
static BLOCKING_POOL: OnceLock<io::Result<BlockingPool>> = OnceLock::new();
|
||||||
|
const BLOCKING_QUEUE_CAPACITY: usize = 1024;
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub enum ExecutionPath {
|
pub enum ExecutionPath {
|
||||||
@@ -367,16 +368,20 @@ async fn offload<T: Send + 'static>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct BlockingPool {
|
struct BlockingPool {
|
||||||
sender: mpsc::Sender<BlockingTask>,
|
sender: mpsc::SyncSender<BlockingTask>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BlockingPool {
|
impl BlockingPool {
|
||||||
fn spawn(&self, task: BlockingTask) -> io::Result<()> {
|
fn spawn(&self, task: BlockingTask) -> io::Result<()> {
|
||||||
self.sender.send(task).map_err(|_| {
|
self.sender.try_send(task).map_err(|error| match error {
|
||||||
io::Error::new(
|
mpsc::TrySendError::Full(_) => io::Error::new(
|
||||||
|
io::ErrorKind::WouldBlock,
|
||||||
|
"filesystem blocking worker queue is full",
|
||||||
|
),
|
||||||
|
mpsc::TrySendError::Disconnected(_) => io::Error::new(
|
||||||
io::ErrorKind::BrokenPipe,
|
io::ErrorKind::BrokenPipe,
|
||||||
"filesystem blocking worker pool has stopped",
|
"filesystem blocking worker pool has stopped",
|
||||||
)
|
),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -393,7 +398,7 @@ fn blocking_pool() -> io::Result<&'static BlockingPool> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn create_blocking_pool() -> io::Result<BlockingPool> {
|
fn create_blocking_pool() -> io::Result<BlockingPool> {
|
||||||
let (sender, receiver) = mpsc::channel::<BlockingTask>();
|
let (sender, receiver) = mpsc::sync_channel::<BlockingTask>(BLOCKING_QUEUE_CAPACITY);
|
||||||
let receiver = Arc::new(Mutex::new(receiver));
|
let receiver = Arc::new(Mutex::new(receiver));
|
||||||
let worker_count = std::thread::available_parallelism()
|
let worker_count = std::thread::available_parallelism()
|
||||||
.map(usize::from)
|
.map(usize::from)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use std::net::{
|
|||||||
};
|
};
|
||||||
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
|
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
|
use std::sync::{Arc, Mutex, OnceLock, mpsc};
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -16,10 +17,14 @@ use crate::op::completion::completion_for_current_thread;
|
|||||||
use crate::op::net::{AcceptedSocket, NetOp, ReceivedDatagram};
|
use crate::op::net::{AcceptedSocket, NetOp, ReceivedDatagram};
|
||||||
|
|
||||||
const DEFAULT_LISTENER_BACKLOG: i32 = 1024;
|
const DEFAULT_LISTENER_BACKLOG: i32 = 1024;
|
||||||
|
const DNS_QUEUE_CAPACITY: usize = 256;
|
||||||
|
|
||||||
type RecvFuture = Pin<Box<dyn Future<Output = io::Result<Vec<u8>>> + 'static>>;
|
type RecvFuture = Pin<Box<dyn Future<Output = io::Result<Vec<u8>>> + 'static>>;
|
||||||
type SendFuture = Pin<Box<dyn Future<Output = io::Result<usize>> + 'static>>;
|
type SendFuture = Pin<Box<dyn Future<Output = io::Result<usize>> + 'static>>;
|
||||||
type ShutdownFuture = Pin<Box<dyn Future<Output = io::Result<()>> + 'static>>;
|
type ShutdownFuture = Pin<Box<dyn Future<Output = io::Result<()>> + 'static>>;
|
||||||
|
type BlockingTask = Box<dyn FnOnce() + Send + 'static>;
|
||||||
|
|
||||||
|
static DNS_POOL: OnceLock<io::Result<BlockingPool>> = OnceLock::new();
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub enum ExecutionPath {
|
pub enum ExecutionPath {
|
||||||
@@ -391,16 +396,83 @@ async fn offload<T: Send + 'static>(
|
|||||||
work: impl FnOnce() -> io::Result<T> + Send + 'static,
|
work: impl FnOnce() -> io::Result<T> + Send + 'static,
|
||||||
) -> io::Result<T> {
|
) -> io::Result<T> {
|
||||||
let (future, handle) = completion_for_current_thread::<io::Result<T>>();
|
let (future, handle) = completion_for_current_thread::<io::Result<T>>();
|
||||||
let handle_for_thread = handle.clone();
|
if let Err(error) = dns_pool().and_then(|pool| {
|
||||||
if let Err(error) = thread::Builder::new()
|
pool.spawn(Box::new({
|
||||||
.name("ruin-runtime-net-offload".into())
|
let handle = handle.clone();
|
||||||
.spawn(move || handle_for_thread.complete(work()))
|
move || handle.complete(work())
|
||||||
{
|
}))
|
||||||
handle.complete(Err(io::Error::other(error)));
|
}) {
|
||||||
|
handle.complete(Err(error));
|
||||||
}
|
}
|
||||||
future.await
|
future.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct BlockingPool {
|
||||||
|
sender: mpsc::SyncSender<BlockingTask>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BlockingPool {
|
||||||
|
fn spawn(&self, task: BlockingTask) -> io::Result<()> {
|
||||||
|
self.sender.try_send(task).map_err(|error| match error {
|
||||||
|
mpsc::TrySendError::Full(_) => io::Error::new(
|
||||||
|
io::ErrorKind::WouldBlock,
|
||||||
|
"DNS resolver blocking worker queue is full",
|
||||||
|
),
|
||||||
|
mpsc::TrySendError::Disconnected(_) => io::Error::new(
|
||||||
|
io::ErrorKind::BrokenPipe,
|
||||||
|
"DNS resolver blocking worker pool has stopped",
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dns_pool() -> io::Result<&'static BlockingPool> {
|
||||||
|
match DNS_POOL.get_or_init(create_dns_pool) {
|
||||||
|
Ok(pool) => Ok(pool),
|
||||||
|
Err(error) => Err(io::Error::new(error.kind(), error.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_dns_pool() -> io::Result<BlockingPool> {
|
||||||
|
let (sender, receiver) = mpsc::sync_channel::<BlockingTask>(DNS_QUEUE_CAPACITY);
|
||||||
|
let receiver = Arc::new(Mutex::new(receiver));
|
||||||
|
let worker_count = std::thread::available_parallelism()
|
||||||
|
.map(usize::from)
|
||||||
|
.unwrap_or(2)
|
||||||
|
.clamp(2, 4);
|
||||||
|
let mut spawned = 0usize;
|
||||||
|
let mut last_error = None;
|
||||||
|
|
||||||
|
for index in 0..worker_count {
|
||||||
|
let receiver = Arc::clone(&receiver);
|
||||||
|
match thread::Builder::new()
|
||||||
|
.name(format!("ruin-runtime-dns-{index}"))
|
||||||
|
.spawn(move || {
|
||||||
|
loop {
|
||||||
|
let task = {
|
||||||
|
let receiver = receiver.lock().expect("DNS worker queue mutex poisoned");
|
||||||
|
receiver.recv()
|
||||||
|
};
|
||||||
|
match task {
|
||||||
|
Ok(task) => task(),
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Ok(_) => spawned += 1,
|
||||||
|
Err(error) => last_error = Some(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if spawned == 0 {
|
||||||
|
return Err(io::Error::other(last_error.expect(
|
||||||
|
"at least one DNS worker spawn should have been attempted",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(BlockingPool { sender })
|
||||||
|
}
|
||||||
|
|
||||||
async fn io_timeout<T>(
|
async fn io_timeout<T>(
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
future: impl Future<Output = io::Result<T>>,
|
future: impl Future<Output = io::Result<T>>,
|
||||||
@@ -531,7 +603,7 @@ impl RawSocketAddr {
|
|||||||
sin_family: libc::AF_INET as libc::sa_family_t,
|
sin_family: libc::AF_INET as libc::sa_family_t,
|
||||||
sin_port: addr.port().to_be(),
|
sin_port: addr.port().to_be(),
|
||||||
sin_addr: libc::in_addr {
|
sin_addr: libc::in_addr {
|
||||||
s_addr: u32::from_ne_bytes(addr.ip().octets()),
|
s_addr: u32::from_be_bytes(addr.ip().octets()).to_be(),
|
||||||
},
|
},
|
||||||
sin_zero: [0; 8],
|
sin_zero: [0; 8],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,14 +3,14 @@
|
|||||||
//! These helpers integrate with the runtime's timer queue and are designed to be used from
|
//! These helpers integrate with the runtime's timer queue and are designed to be used from
|
||||||
//! futures scheduled with [`crate::queue_future`] or one of the runtime entry macros.
|
//! futures scheduled with [`crate::queue_future`] or one of the runtime entry macros.
|
||||||
|
|
||||||
use std::cell::{Cell, RefCell};
|
use alloc::rc::Rc;
|
||||||
use std::fmt;
|
use core::cell::{Cell, RefCell};
|
||||||
use std::future::{Future, poll_fn};
|
use core::fmt;
|
||||||
use std::pin::Pin;
|
use core::future::{Future, poll_fn};
|
||||||
use std::rc::Rc;
|
use core::pin::Pin;
|
||||||
use std::task::Waker;
|
use core::task::Waker;
|
||||||
use std::task::{Context, Poll};
|
use core::task::{Context, Poll};
|
||||||
use std::time::Duration;
|
use core::time::Duration;
|
||||||
|
|
||||||
use crate::{clear_timeout, set_timeout};
|
use crate::{clear_timeout, set_timeout};
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ pub struct RoutedPointerEvent {
|
|||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct PointerRouter {
|
pub struct PointerRouter {
|
||||||
hovered: Vec<HitTarget>,
|
hovered: Vec<HitTarget>,
|
||||||
pressed: Option<HitTarget>,
|
pressed: Option<Vec<HitTarget>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PointerRouter {
|
impl PointerRouter {
|
||||||
@@ -71,7 +71,11 @@ impl PointerRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn pressed_target(&self) -> Option<&HitTarget> {
|
pub fn pressed_target(&self) -> Option<&HitTarget> {
|
||||||
self.pressed.as_ref()
|
self.pressed.as_ref().and_then(|targets| targets.last())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pressed_targets(&self) -> &[HitTarget] {
|
||||||
|
self.pressed.as_deref().unwrap_or(&[])
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn route(
|
pub fn route(
|
||||||
@@ -114,7 +118,12 @@ impl PointerRouter {
|
|||||||
|
|
||||||
match event.kind {
|
match event.kind {
|
||||||
PointerEventKind::Move => {
|
PointerEventKind::Move => {
|
||||||
if let Some(target) = self.pressed.clone().or(hit_target.clone()) {
|
if let Some(target) = self
|
||||||
|
.pressed
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|targets| targets.last().cloned())
|
||||||
|
.or(hit_target.clone())
|
||||||
|
{
|
||||||
routed.push(RoutedPointerEvent {
|
routed.push(RoutedPointerEvent {
|
||||||
kind: RoutedPointerEventKind::Move,
|
kind: RoutedPointerEventKind::Move,
|
||||||
target,
|
target,
|
||||||
@@ -125,7 +134,7 @@ impl PointerRouter {
|
|||||||
}
|
}
|
||||||
PointerEventKind::Down { button } => {
|
PointerEventKind::Down { button } => {
|
||||||
if let Some(target) = hit_target {
|
if let Some(target) = hit_target {
|
||||||
self.pressed = Some(target.clone());
|
self.pressed = Some(self.hovered.clone());
|
||||||
routed.push(RoutedPointerEvent {
|
routed.push(RoutedPointerEvent {
|
||||||
kind: RoutedPointerEventKind::Down { button },
|
kind: RoutedPointerEventKind::Down { button },
|
||||||
target,
|
target,
|
||||||
@@ -135,7 +144,12 @@ impl PointerRouter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
PointerEventKind::Up { button } => {
|
PointerEventKind::Up { button } => {
|
||||||
if let Some(target) = self.pressed.take().or(hit_target) {
|
if let Some(target) = self
|
||||||
|
.pressed
|
||||||
|
.take()
|
||||||
|
.and_then(|targets| targets.last().cloned())
|
||||||
|
.or(hit_target)
|
||||||
|
{
|
||||||
routed.push(RoutedPointerEvent {
|
routed.push(RoutedPointerEvent {
|
||||||
kind: RoutedPointerEventKind::Up { button },
|
kind: RoutedPointerEventKind::Up { button },
|
||||||
target,
|
target,
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ use crate::scene::{
|
|||||||
UiSize,
|
UiSize,
|
||||||
};
|
};
|
||||||
use crate::text::TextSystem;
|
use crate::text::TextSystem;
|
||||||
use crate::tree::{CursorIcon, Edges, Element, ElementId, FlexDirection, ImageNode, Style};
|
use crate::tree::{
|
||||||
|
AlignItems, CursorIcon, Edges, Element, ElementId, FlexDirection, ImageNode, Style,
|
||||||
|
};
|
||||||
|
|
||||||
pub fn layout_scene(version: u64, logical_size: UiSize, root: &Element) -> SceneSnapshot {
|
pub fn layout_scene(version: u64, logical_size: UiSize, root: &Element) -> SceneSnapshot {
|
||||||
let mut text_system = TextSystem::new();
|
let mut text_system = TextSystem::new();
|
||||||
@@ -27,6 +29,11 @@ pub fn layout_scene_with_text_system(
|
|||||||
pub struct LayoutSnapshot {
|
pub struct LayoutSnapshot {
|
||||||
pub scene: SceneSnapshot,
|
pub scene: SceneSnapshot,
|
||||||
pub interaction_tree: InteractionTree,
|
pub interaction_tree: InteractionTree,
|
||||||
|
/// Minimum size the root element needs in logical units. Accounts for
|
||||||
|
/// `min_width`/`min_height` constraints and the natural minimum of fixed-
|
||||||
|
/// size content. Scroll boxes contribute only their own explicit/min size
|
||||||
|
/// on the scroll axis, not their content.
|
||||||
|
pub root_min_size: UiSize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||||
@@ -242,11 +249,13 @@ pub fn layout_snapshot_with_cache(
|
|||||||
"layout snapshot perf"
|
"layout snapshot perf"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
let root_min_size = element_min_size(root);
|
||||||
LayoutSnapshot {
|
LayoutSnapshot {
|
||||||
scene,
|
scene,
|
||||||
interaction_tree: InteractionTree {
|
interaction_tree: InteractionTree {
|
||||||
root: interaction_root,
|
root: interaction_root,
|
||||||
},
|
},
|
||||||
|
root_min_size,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,6 +273,7 @@ fn layout_element(
|
|||||||
clip_rect: Option<Rect>,
|
clip_rect: Option<Rect>,
|
||||||
) -> LayoutNode {
|
) -> LayoutNode {
|
||||||
perf_stats.nodes += 1;
|
perf_stats.nodes += 1;
|
||||||
|
let rect = snap_rect(rect);
|
||||||
|
|
||||||
// Viewport culling: skip fully off-screen elements inside a scroll box.
|
// Viewport culling: skip fully off-screen elements inside a scroll box.
|
||||||
if let Some(clip) = clip_rect
|
if let Some(clip) = clip_rect
|
||||||
@@ -774,7 +784,11 @@ fn point_in_rounded_rect(point: crate::scene::Point, rect: Rect, radius: f32) ->
|
|||||||
|
|
||||||
#[derive(Clone, Copy, Debug)]
|
#[derive(Clone, Copy, Debug)]
|
||||||
struct MeasuredChild {
|
struct MeasuredChild {
|
||||||
|
/// Offered cross-axis size (full available_cross for stretch; explicit value otherwise).
|
||||||
cross: f32,
|
cross: f32,
|
||||||
|
/// Natural cross-axis size: set when parent uses non-stretch alignment, child has no
|
||||||
|
/// explicit cross size, and the child is not flex. `None` means use `cross`.
|
||||||
|
natural_cross: Option<f32>,
|
||||||
main: f32,
|
main: f32,
|
||||||
is_flex: bool,
|
is_flex: bool,
|
||||||
}
|
}
|
||||||
@@ -804,15 +818,29 @@ fn layout_container_children(
|
|||||||
let mut fixed_total = 0.0;
|
let mut fixed_total = 0.0;
|
||||||
let mut flex_total = 0.0;
|
let mut flex_total = 0.0;
|
||||||
for child in &element.children {
|
for child in &element.children {
|
||||||
|
let child_insets = content_insets(&child.style);
|
||||||
|
let main_inset = main_axis_padding(child_insets, element.style.direction);
|
||||||
|
let cross_inset = cross_axis_padding(child_insets, element.style.direction);
|
||||||
|
// min-size constraints (content-box) converted to outer.
|
||||||
|
let min_cross_outer = child_min_cross_size(child, element.style.direction)
|
||||||
|
.map(|c| (c + cross_inset).max(0.0))
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
let min_main_outer = child_min_main_size(child, element.style.direction)
|
||||||
|
.map(|m| (m + main_inset).max(0.0))
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
// style.width/height are content-box; add insets to get the outer (allocated) size.
|
||||||
|
// min-cross acts as floor; allow exceeding available_cross only when min demands it.
|
||||||
let cross = child_cross_size(child, element.style.direction)
|
let cross = child_cross_size(child, element.style.direction)
|
||||||
.unwrap_or(available_cross)
|
.map(|c| (c + cross_inset).max(0.0).max(min_cross_outer))
|
||||||
.clamp(0.0, available_cross);
|
.unwrap_or(available_cross.max(min_cross_outer))
|
||||||
let explicit_main =
|
.min(available_cross.max(min_cross_outer));
|
||||||
child_main_size(child, element.style.direction).map(|main| main.max(0.0));
|
let explicit_main = child_main_size(child, element.style.direction)
|
||||||
|
.map(|main| (main + main_inset).max(0.0).max(min_main_outer));
|
||||||
let is_flex = explicit_main.is_none() && child.style.flex_grow > 0.0;
|
let is_flex = explicit_main.is_none() && child.style.flex_grow > 0.0;
|
||||||
let measured_main = explicit_main.unwrap_or_else(|| {
|
let measured_main = explicit_main.unwrap_or_else(|| {
|
||||||
if is_flex {
|
if is_flex {
|
||||||
0.0
|
// Flex items get their share of remaining space later; record min floor now.
|
||||||
|
min_main_outer
|
||||||
} else {
|
} else {
|
||||||
perf_stats.intrinsic_calls += 1;
|
perf_stats.intrinsic_calls += 1;
|
||||||
if child.text_node().is_some() {
|
if child.text_node().is_some() {
|
||||||
@@ -833,9 +861,33 @@ fn layout_container_children(
|
|||||||
if let Some(intrinsic_started) = intrinsic_started {
|
if let Some(intrinsic_started) = intrinsic_started {
|
||||||
perf_stats.intrinsic_ms += intrinsic_started.elapsed().as_secs_f64() * 1_000.0;
|
perf_stats.intrinsic_ms += intrinsic_started.elapsed().as_secs_f64() * 1_000.0;
|
||||||
}
|
}
|
||||||
intrinsic
|
// Apply min-main floor to intrinsic result.
|
||||||
|
intrinsic.max(min_main_outer)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// For non-stretch alignment, measure the child's natural cross size so it is not
|
||||||
|
// forcibly stretched to fill the container's cross axis. Only done for non-flex
|
||||||
|
// children with no explicit cross-axis style, since flex main size is unknown here.
|
||||||
|
// align_self on the child overrides the parent's align_items for this child.
|
||||||
|
let effective_align = child.style.align_self.unwrap_or(element.style.align_items);
|
||||||
|
let natural_cross = if effective_align != AlignItems::Stretch
|
||||||
|
&& !is_flex
|
||||||
|
&& child_cross_size(child, element.style.direction).is_none()
|
||||||
|
{
|
||||||
|
let offered = match element.style.direction {
|
||||||
|
FlexDirection::Row => UiSize::new(measured_main, available_cross),
|
||||||
|
FlexDirection::Column => UiSize::new(available_cross, measured_main),
|
||||||
|
};
|
||||||
|
let natural = intrinsic_size(child, offered, text_system, perf_stats, layout_cache);
|
||||||
|
Some(
|
||||||
|
cross_axis_size(natural, element.style.direction)
|
||||||
|
.max(min_cross_outer)
|
||||||
|
.min(available_cross.max(min_cross_outer)),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
if is_flex {
|
if is_flex {
|
||||||
flex_total += child_flex_weight(child);
|
flex_total += child_flex_weight(child);
|
||||||
} else {
|
} else {
|
||||||
@@ -843,6 +895,7 @@ fn layout_container_children(
|
|||||||
}
|
}
|
||||||
measured_children.push(MeasuredChild {
|
measured_children.push(MeasuredChild {
|
||||||
cross,
|
cross,
|
||||||
|
natural_cross,
|
||||||
main: measured_main,
|
main: measured_main,
|
||||||
is_flex,
|
is_flex,
|
||||||
});
|
});
|
||||||
@@ -853,20 +906,39 @@ fn layout_container_children(
|
|||||||
let mut children = Vec::with_capacity(element.children.len());
|
let mut children = Vec::with_capacity(element.children.len());
|
||||||
for (index, (child, measured)) in element.children.iter().zip(measured_children).enumerate() {
|
for (index, (child, measured)) in element.children.iter().zip(measured_children).enumerate() {
|
||||||
let child_main = if measured.is_flex {
|
let child_main = if measured.is_flex {
|
||||||
if flex_total <= 0.0 {
|
let allocated = if flex_total <= 0.0 {
|
||||||
0.0
|
0.0
|
||||||
} else {
|
} else {
|
||||||
remaining_main * (child_flex_weight(child) / flex_total)
|
remaining_main * (child_flex_weight(child) / flex_total)
|
||||||
}
|
};
|
||||||
|
// Apply min-main floor to flex allocation.
|
||||||
|
let child_insets = content_insets(&child.style);
|
||||||
|
let main_inset = main_axis_padding(child_insets, element.style.direction);
|
||||||
|
let min_main_outer = child_min_main_size(child, element.style.direction)
|
||||||
|
.map(|m| (m + main_inset).max(0.0))
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
allocated.max(min_main_outer)
|
||||||
} else {
|
} else {
|
||||||
measured.main
|
measured.main
|
||||||
};
|
};
|
||||||
|
// For non-stretch alignment, use the child's natural cross size; otherwise stretch.
|
||||||
|
// align_self on the child overrides the parent's align_items for this child.
|
||||||
|
let effective_align = child.style.align_self.unwrap_or(element.style.align_items);
|
||||||
|
let actual_cross = measured.natural_cross.unwrap_or(measured.cross);
|
||||||
|
let cross_origin = {
|
||||||
|
let base = cross_axis_origin(content, element.style.direction);
|
||||||
|
match effective_align {
|
||||||
|
AlignItems::Stretch | AlignItems::Start => base,
|
||||||
|
AlignItems::Center => base + (available_cross - actual_cross) * 0.5,
|
||||||
|
AlignItems::End => base + available_cross - actual_cross,
|
||||||
|
}
|
||||||
|
};
|
||||||
let child_rect = child_rect(
|
let child_rect = child_rect(
|
||||||
content,
|
cross_origin,
|
||||||
element.style.direction,
|
element.style.direction,
|
||||||
cursor,
|
cursor,
|
||||||
child_main.max(0.0),
|
child_main.max(0.0),
|
||||||
measured.cross,
|
actual_cross,
|
||||||
);
|
);
|
||||||
children.push(layout_element(
|
children.push(layout_element(
|
||||||
child,
|
child,
|
||||||
@@ -1021,15 +1093,25 @@ fn layout_interaction_skeleton_children(
|
|||||||
let mut fixed_total = 0.0;
|
let mut fixed_total = 0.0;
|
||||||
let mut flex_total = 0.0;
|
let mut flex_total = 0.0;
|
||||||
for child in &element.children {
|
for child in &element.children {
|
||||||
|
let child_insets = content_insets(&child.style);
|
||||||
|
let main_inset = main_axis_padding(child_insets, element.style.direction);
|
||||||
|
let cross_inset = cross_axis_padding(child_insets, element.style.direction);
|
||||||
|
let min_cross_outer = child_min_cross_size(child, element.style.direction)
|
||||||
|
.map(|c| (c + cross_inset).max(0.0))
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
let min_main_outer = child_min_main_size(child, element.style.direction)
|
||||||
|
.map(|m| (m + main_inset).max(0.0))
|
||||||
|
.unwrap_or(0.0);
|
||||||
let cross = child_cross_size(child, element.style.direction)
|
let cross = child_cross_size(child, element.style.direction)
|
||||||
.unwrap_or(available_cross)
|
.map(|c| (c + cross_inset).max(0.0).max(min_cross_outer))
|
||||||
.clamp(0.0, available_cross);
|
.unwrap_or(available_cross.max(min_cross_outer))
|
||||||
let explicit_main =
|
.min(available_cross.max(min_cross_outer));
|
||||||
child_main_size(child, element.style.direction).map(|main| main.max(0.0));
|
let explicit_main = child_main_size(child, element.style.direction)
|
||||||
|
.map(|main| (main + main_inset).max(0.0).max(min_main_outer));
|
||||||
let is_flex = explicit_main.is_none() && child.style.flex_grow > 0.0;
|
let is_flex = explicit_main.is_none() && child.style.flex_grow > 0.0;
|
||||||
let measured_main = explicit_main.unwrap_or_else(|| {
|
let measured_main = explicit_main.unwrap_or_else(|| {
|
||||||
if is_flex {
|
if is_flex {
|
||||||
0.0
|
min_main_outer
|
||||||
} else {
|
} else {
|
||||||
intrinsic_main_size(
|
intrinsic_main_size(
|
||||||
child,
|
child,
|
||||||
@@ -1040,8 +1122,27 @@ fn layout_interaction_skeleton_children(
|
|||||||
perf_stats,
|
perf_stats,
|
||||||
layout_cache,
|
layout_cache,
|
||||||
)
|
)
|
||||||
|
.max(min_main_outer)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
let effective_align = child.style.align_self.unwrap_or(element.style.align_items);
|
||||||
|
let natural_cross = if effective_align != AlignItems::Stretch
|
||||||
|
&& !is_flex
|
||||||
|
&& child_cross_size(child, element.style.direction).is_none()
|
||||||
|
{
|
||||||
|
let offered = match element.style.direction {
|
||||||
|
FlexDirection::Row => UiSize::new(measured_main, available_cross),
|
||||||
|
FlexDirection::Column => UiSize::new(available_cross, measured_main),
|
||||||
|
};
|
||||||
|
let natural = intrinsic_size(child, offered, text_system, perf_stats, layout_cache);
|
||||||
|
Some(
|
||||||
|
cross_axis_size(natural, element.style.direction)
|
||||||
|
.max(min_cross_outer)
|
||||||
|
.min(available_cross.max(min_cross_outer)),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
if is_flex {
|
if is_flex {
|
||||||
flex_total += child_flex_weight(child);
|
flex_total += child_flex_weight(child);
|
||||||
} else {
|
} else {
|
||||||
@@ -1049,6 +1150,7 @@ fn layout_interaction_skeleton_children(
|
|||||||
}
|
}
|
||||||
measured_children.push(MeasuredChild {
|
measured_children.push(MeasuredChild {
|
||||||
cross,
|
cross,
|
||||||
|
natural_cross,
|
||||||
main: measured_main,
|
main: measured_main,
|
||||||
is_flex,
|
is_flex,
|
||||||
});
|
});
|
||||||
@@ -1059,20 +1161,36 @@ fn layout_interaction_skeleton_children(
|
|||||||
let mut children = Vec::with_capacity(element.children.len());
|
let mut children = Vec::with_capacity(element.children.len());
|
||||||
for (index, (child, measured)) in element.children.iter().zip(measured_children).enumerate() {
|
for (index, (child, measured)) in element.children.iter().zip(measured_children).enumerate() {
|
||||||
let child_main = if measured.is_flex {
|
let child_main = if measured.is_flex {
|
||||||
if flex_total <= 0.0 {
|
let allocated = if flex_total <= 0.0 {
|
||||||
0.0
|
0.0
|
||||||
} else {
|
} else {
|
||||||
remaining_main * (child_flex_weight(child) / flex_total)
|
remaining_main * (child_flex_weight(child) / flex_total)
|
||||||
}
|
};
|
||||||
|
let child_insets = content_insets(&child.style);
|
||||||
|
let main_inset = main_axis_padding(child_insets, element.style.direction);
|
||||||
|
let min_main_outer = child_min_main_size(child, element.style.direction)
|
||||||
|
.map(|m| (m + main_inset).max(0.0))
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
allocated.max(min_main_outer)
|
||||||
} else {
|
} else {
|
||||||
measured.main
|
measured.main
|
||||||
};
|
};
|
||||||
|
let effective_align = child.style.align_self.unwrap_or(element.style.align_items);
|
||||||
|
let actual_cross = measured.natural_cross.unwrap_or(measured.cross);
|
||||||
|
let cross_origin = {
|
||||||
|
let base = cross_axis_origin(content, element.style.direction);
|
||||||
|
match effective_align {
|
||||||
|
AlignItems::Stretch | AlignItems::Start => base,
|
||||||
|
AlignItems::Center => base + (available_cross - actual_cross) * 0.5,
|
||||||
|
AlignItems::End => base + available_cross - actual_cross,
|
||||||
|
}
|
||||||
|
};
|
||||||
let child_rect = child_rect(
|
let child_rect = child_rect(
|
||||||
content,
|
cross_origin,
|
||||||
element.style.direction,
|
element.style.direction,
|
||||||
cursor,
|
cursor,
|
||||||
child_main.max(0.0),
|
child_main.max(0.0),
|
||||||
measured.cross,
|
actual_cross,
|
||||||
);
|
);
|
||||||
children.push(layout_interaction_skeleton(
|
children.push(layout_interaction_skeleton(
|
||||||
child,
|
child,
|
||||||
@@ -1097,13 +1215,22 @@ fn intrinsic_main_size(
|
|||||||
layout_cache: &mut LayoutCache,
|
layout_cache: &mut LayoutCache,
|
||||||
) -> f32 {
|
) -> f32 {
|
||||||
if let Some(text) = child.text_node() {
|
if let Some(text) = child.text_node() {
|
||||||
|
// cross_size and available_main are outer sizes; subtract insets to get content dimensions
|
||||||
|
// for text measurement so wrapping is constrained to the actual content area.
|
||||||
|
let child_insets = content_insets(&child.style);
|
||||||
let constraints = match direction {
|
let constraints = match direction {
|
||||||
FlexDirection::Row => (Some(available_main.max(0.0)), Some(cross_size.max(0.0))),
|
FlexDirection::Row => (
|
||||||
FlexDirection::Column => (Some(cross_size.max(0.0)), None),
|
Some((available_main - main_axis_padding(child_insets, direction)).max(0.0)),
|
||||||
|
Some((cross_size - cross_axis_padding(child_insets, direction)).max(0.0)),
|
||||||
|
),
|
||||||
|
FlexDirection::Column => (
|
||||||
|
Some((cross_size - cross_axis_padding(child_insets, direction)).max(0.0)),
|
||||||
|
None,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
let content =
|
let content =
|
||||||
text_system.measure_spans(&text.spans, &text.style, constraints.0, constraints.1);
|
text_system.measure_spans(&text.spans, &text.style, constraints.0, constraints.1);
|
||||||
let padding = main_axis_padding(content_insets(&child.style), direction);
|
let padding = main_axis_padding(child_insets, direction);
|
||||||
return main_axis_size(content, direction) + padding;
|
return main_axis_size(content, direction) + padding;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1140,19 +1267,29 @@ fn intrinsic_container_content_size(
|
|||||||
let mut height = gap_total;
|
let mut height = gap_total;
|
||||||
for child in &element.children {
|
for child in &element.children {
|
||||||
let skip_main = child.style.flex_grow > 0.0 && child.style.height.is_none();
|
let skip_main = child.style.flex_grow > 0.0 && child.style.height.is_none();
|
||||||
|
let child_insets = content_insets(&child.style);
|
||||||
|
// Offer outer size: content-box style.w/h + insets, or parent's content width.
|
||||||
|
let offered_w = child
|
||||||
|
.style
|
||||||
|
.width
|
||||||
|
.map(|w| w + horizontal_insets(child_insets))
|
||||||
|
.unwrap_or(content_size.width);
|
||||||
|
let offered_h = child
|
||||||
|
.style
|
||||||
|
.height
|
||||||
|
.map(|h| h + vertical_insets(child_insets))
|
||||||
|
.unwrap_or(content_size.height);
|
||||||
let child_size = intrinsic_size(
|
let child_size = intrinsic_size(
|
||||||
child,
|
child,
|
||||||
UiSize::new(
|
UiSize::new(offered_w, offered_h),
|
||||||
child.style.width.unwrap_or(content_size.width),
|
|
||||||
child.style.height.unwrap_or(content_size.height),
|
|
||||||
),
|
|
||||||
text_system,
|
text_system,
|
||||||
perf_stats,
|
perf_stats,
|
||||||
layout_cache,
|
layout_cache,
|
||||||
);
|
);
|
||||||
width = width.max(child.style.width.unwrap_or(child_size.width));
|
// child_size is always outer; accumulate directly.
|
||||||
|
width = width.max(child_size.width);
|
||||||
if !skip_main {
|
if !skip_main {
|
||||||
height += child.style.height.unwrap_or(child_size.height);
|
height += child_size.height;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
UiSize::new(width, height)
|
UiSize::new(width, height)
|
||||||
@@ -1170,17 +1307,26 @@ fn intrinsic_container_content_size(
|
|||||||
child_main_sizes.push(None);
|
child_main_sizes.push(None);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
let child_insets = content_insets(&child.style);
|
||||||
|
let offered_w = child
|
||||||
|
.style
|
||||||
|
.width
|
||||||
|
.map(|w| w + horizontal_insets(child_insets))
|
||||||
|
.unwrap_or(content_size.width);
|
||||||
|
let offered_h = child
|
||||||
|
.style
|
||||||
|
.height
|
||||||
|
.map(|h| h + vertical_insets(child_insets))
|
||||||
|
.unwrap_or(content_size.height);
|
||||||
let child_size = intrinsic_size(
|
let child_size = intrinsic_size(
|
||||||
child,
|
child,
|
||||||
UiSize::new(
|
UiSize::new(offered_w, offered_h),
|
||||||
child.style.width.unwrap_or(content_size.width),
|
|
||||||
child.style.height.unwrap_or(content_size.height),
|
|
||||||
),
|
|
||||||
text_system,
|
text_system,
|
||||||
perf_stats,
|
perf_stats,
|
||||||
layout_cache,
|
layout_cache,
|
||||||
);
|
);
|
||||||
let child_main = child.style.width.unwrap_or(child_size.width);
|
// child_size.width is outer; use directly as the fixed main contribution.
|
||||||
|
let child_main = child_size.width;
|
||||||
fixed_main += child_main;
|
fixed_main += child_main;
|
||||||
child_main_sizes.push(Some(child_main));
|
child_main_sizes.push(Some(child_main));
|
||||||
}
|
}
|
||||||
@@ -1194,12 +1340,15 @@ fn intrinsic_container_content_size(
|
|||||||
remaining_main * (child_flex_weight(child) / flex_total)
|
remaining_main * (child_flex_weight(child) / flex_total)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
let child_insets = content_insets(&child.style);
|
||||||
|
let offered_h = child
|
||||||
|
.style
|
||||||
|
.height
|
||||||
|
.map(|h| h + vertical_insets(child_insets))
|
||||||
|
.unwrap_or(content_size.height);
|
||||||
let child_size = intrinsic_size(
|
let child_size = intrinsic_size(
|
||||||
child,
|
child,
|
||||||
UiSize::new(
|
UiSize::new(child_main, offered_h),
|
||||||
child_main,
|
|
||||||
child.style.height.unwrap_or(content_size.height),
|
|
||||||
),
|
|
||||||
text_system,
|
text_system,
|
||||||
perf_stats,
|
perf_stats,
|
||||||
layout_cache,
|
layout_cache,
|
||||||
@@ -1207,7 +1356,7 @@ fn intrinsic_container_content_size(
|
|||||||
if !skip_main {
|
if !skip_main {
|
||||||
width += child_main;
|
width += child_main;
|
||||||
}
|
}
|
||||||
height = height.max(child.style.height.unwrap_or(child_size.height));
|
height = height.max(child_size.height);
|
||||||
}
|
}
|
||||||
UiSize::new(width, height)
|
UiSize::new(width, height)
|
||||||
}
|
}
|
||||||
@@ -1255,30 +1404,51 @@ fn intrinsic_size_inner(
|
|||||||
perf_stats: &mut LayoutPerfStats,
|
perf_stats: &mut LayoutPerfStats,
|
||||||
layout_cache: &mut LayoutCache,
|
layout_cache: &mut LayoutCache,
|
||||||
) -> UiSize {
|
) -> UiSize {
|
||||||
|
// style.width/height are content-box (content dimensions).
|
||||||
|
// available_size is the outer size the parent is offering.
|
||||||
|
// All returned sizes are outer (content + insets).
|
||||||
|
|
||||||
|
let min_outer_w = element
|
||||||
|
.style
|
||||||
|
.min_width
|
||||||
|
.map(|mw| mw + horizontal_insets(insets))
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
let min_outer_h = element
|
||||||
|
.style
|
||||||
|
.min_height
|
||||||
|
.map(|mh| mh + vertical_insets(insets))
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
|
||||||
if let Some(text) = element.text_node() {
|
if let Some(text) = element.text_node() {
|
||||||
|
// Measure at content dimensions: use explicit if set, else available minus insets.
|
||||||
|
let content_w = element
|
||||||
|
.style
|
||||||
|
.width
|
||||||
|
.unwrap_or_else(|| (available_size.width - horizontal_insets(insets)).max(0.0));
|
||||||
|
let content_h = element
|
||||||
|
.style
|
||||||
|
.height
|
||||||
|
.unwrap_or_else(|| (available_size.height - vertical_insets(insets)).max(0.0));
|
||||||
let measured = text_system.measure_spans(
|
let measured = text_system.measure_spans(
|
||||||
&text.spans,
|
&text.spans,
|
||||||
&text.style,
|
&text.style,
|
||||||
Some(available_size.width.max(0.0)),
|
Some(content_w.max(0.0)),
|
||||||
Some(available_size.height.max(0.0)),
|
Some(content_h.max(0.0)),
|
||||||
);
|
);
|
||||||
|
// Return outer: explicit (content) + insets, or measured + insets for auto.
|
||||||
return UiSize::new(
|
return UiSize::new(
|
||||||
element
|
(element.style.width.unwrap_or(measured.width) + horizontal_insets(insets))
|
||||||
.style
|
.max(min_outer_w),
|
||||||
.width
|
(element.style.height.unwrap_or(measured.height) + vertical_insets(insets))
|
||||||
.unwrap_or(measured.width + horizontal_insets(insets)),
|
.max(min_outer_h),
|
||||||
element
|
|
||||||
.style
|
|
||||||
.height
|
|
||||||
.unwrap_or(measured.height + vertical_insets(insets)),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(image) = element.image_node() {
|
if let Some(image) = element.image_node() {
|
||||||
let resolved = resolve_image_element_size(element, image.resource.size());
|
let resolved = resolve_image_element_size(element, image.resource.size());
|
||||||
return UiSize::new(
|
return UiSize::new(
|
||||||
resolved.width + horizontal_insets(insets),
|
(resolved.width + horizontal_insets(insets)).max(min_outer_w),
|
||||||
resolved.height + vertical_insets(insets),
|
(resolved.height + vertical_insets(insets)).max(min_outer_h),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1287,10 +1457,13 @@ fn intrinsic_size_inner(
|
|||||||
let scroll_gutter = element
|
let scroll_gutter = element
|
||||||
.scroll_box_node()
|
.scroll_box_node()
|
||||||
.map_or(0.0, |scroll_box| scroll_box.scrollbar.gutter_width.max(0.0));
|
.map_or(0.0, |scroll_box| scroll_box.scrollbar.gutter_width.max(0.0));
|
||||||
let content_width =
|
// Content dimensions: explicit = already content; auto = available_outer - insets.
|
||||||
explicit_width.unwrap_or(available_size.width).max(0.0) - horizontal_insets(insets);
|
let content_width = explicit_width
|
||||||
let content_height =
|
.map(|w| w.max(0.0))
|
||||||
explicit_height.unwrap_or(available_size.height).max(0.0) - vertical_insets(insets);
|
.unwrap_or_else(|| (available_size.width - horizontal_insets(insets)).max(0.0));
|
||||||
|
let content_height = explicit_height
|
||||||
|
.map(|h| h.max(0.0))
|
||||||
|
.unwrap_or_else(|| (available_size.height - vertical_insets(insets)).max(0.0));
|
||||||
let content_size = UiSize::new(
|
let content_size = UiSize::new(
|
||||||
(content_width - scroll_gutter).max(0.0),
|
(content_width - scroll_gutter).max(0.0),
|
||||||
content_height.max(0.0),
|
content_height.max(0.0),
|
||||||
@@ -1298,8 +1471,14 @@ fn intrinsic_size_inner(
|
|||||||
|
|
||||||
if element.children.is_empty() {
|
if element.children.is_empty() {
|
||||||
return UiSize::new(
|
return UiSize::new(
|
||||||
explicit_width.unwrap_or(horizontal_insets(insets) + scroll_gutter),
|
(explicit_width
|
||||||
explicit_height.unwrap_or(vertical_insets(insets)),
|
.map(|w| w + horizontal_insets(insets) + scroll_gutter)
|
||||||
|
.unwrap_or(horizontal_insets(insets) + scroll_gutter))
|
||||||
|
.max(min_outer_w),
|
||||||
|
(explicit_height
|
||||||
|
.map(|h| h + vertical_insets(insets))
|
||||||
|
.unwrap_or(vertical_insets(insets)))
|
||||||
|
.max(min_outer_h),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1312,9 +1491,159 @@ fn intrinsic_size_inner(
|
|||||||
);
|
);
|
||||||
|
|
||||||
UiSize::new(
|
UiSize::new(
|
||||||
explicit_width
|
(explicit_width
|
||||||
.unwrap_or(intrinsic_content.width + horizontal_insets(insets) + scroll_gutter),
|
.map(|w| w + horizontal_insets(insets) + scroll_gutter)
|
||||||
explicit_height.unwrap_or(intrinsic_content.height + vertical_insets(insets)),
|
.unwrap_or(intrinsic_content.width + horizontal_insets(insets) + scroll_gutter))
|
||||||
|
.max(min_outer_w),
|
||||||
|
(explicit_height
|
||||||
|
.map(|h| h + vertical_insets(insets))
|
||||||
|
.unwrap_or(intrinsic_content.height + vertical_insets(insets)))
|
||||||
|
.max(min_outer_h),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computes the minimum logical size that the element's content requires.
|
||||||
|
///
|
||||||
|
/// Unlike [`intrinsic_size`], this function is designed for window minimum-size
|
||||||
|
/// propagation. The key difference is scroll boxes: a vertically-scrolling box
|
||||||
|
/// does not propagate its content height to the window minimum — only its own
|
||||||
|
/// explicit `height` or `min_height` contributes. Flex children without an
|
||||||
|
/// explicit size or `min_*` constraint contribute 0 on the main axis (they
|
||||||
|
/// can shrink). Text nodes with no explicit size contribute 0 (they wrap).
|
||||||
|
///
|
||||||
|
/// The result is in the same coordinate space as [`intrinsic_size`] (outer /
|
||||||
|
/// including padding and border insets).
|
||||||
|
pub fn element_min_size(element: &Element) -> UiSize {
|
||||||
|
let insets = content_insets(&element.style);
|
||||||
|
let min_outer_w = element
|
||||||
|
.style
|
||||||
|
.min_width
|
||||||
|
.map(|mw| (mw + horizontal_insets(insets)).max(0.0))
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
let min_outer_h = element
|
||||||
|
.style
|
||||||
|
.min_height
|
||||||
|
.map(|mh| (mh + vertical_insets(insets)).max(0.0))
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
|
||||||
|
// Text: can wrap to any width; only explicit size or min_* constrain the window.
|
||||||
|
if element.text_node().is_some() {
|
||||||
|
let w = element
|
||||||
|
.style
|
||||||
|
.width
|
||||||
|
.map(|w| w + horizontal_insets(insets))
|
||||||
|
.unwrap_or(0.0)
|
||||||
|
.max(min_outer_w);
|
||||||
|
let h = element
|
||||||
|
.style
|
||||||
|
.height
|
||||||
|
.map(|h| h + vertical_insets(insets))
|
||||||
|
.unwrap_or(0.0)
|
||||||
|
.max(min_outer_h);
|
||||||
|
return UiSize::new(w, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image: natural size counts as a minimum.
|
||||||
|
if let Some(image) = element.image_node() {
|
||||||
|
let resolved = resolve_image_element_size(element, image.resource.size());
|
||||||
|
return UiSize::new(
|
||||||
|
(resolved.width + horizontal_insets(insets)).max(min_outer_w),
|
||||||
|
(resolved.height + vertical_insets(insets)).max(min_outer_h),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let explicit_w = element.style.width;
|
||||||
|
let explicit_h = element.style.height;
|
||||||
|
|
||||||
|
// Scroll box: content scrolls on the vertical axis; use only explicit/min height.
|
||||||
|
// Width still accumulates child widths (no horizontal scroll yet).
|
||||||
|
if element.scroll_box_node().is_some() {
|
||||||
|
let scroll_gutter = element
|
||||||
|
.scroll_box_node()
|
||||||
|
.map_or(0.0, |sb| sb.scrollbar.gutter_width.max(0.0));
|
||||||
|
let w = explicit_w
|
||||||
|
.map(|w| w + horizontal_insets(insets) + scroll_gutter)
|
||||||
|
.unwrap_or(horizontal_insets(insets) + scroll_gutter)
|
||||||
|
.max(min_outer_w);
|
||||||
|
let h = explicit_h
|
||||||
|
.map(|h| h + vertical_insets(insets))
|
||||||
|
.unwrap_or(vertical_insets(insets))
|
||||||
|
.max(min_outer_h);
|
||||||
|
return UiSize::new(w, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular container: sum/max children's minimums.
|
||||||
|
if element.children.is_empty() {
|
||||||
|
return UiSize::new(
|
||||||
|
explicit_w
|
||||||
|
.map(|w| w + horizontal_insets(insets))
|
||||||
|
.unwrap_or(horizontal_insets(insets))
|
||||||
|
.max(min_outer_w),
|
||||||
|
explicit_h
|
||||||
|
.map(|h| h + vertical_insets(insets))
|
||||||
|
.unwrap_or(vertical_insets(insets))
|
||||||
|
.max(min_outer_h),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let gap_total = element.style.gap * element.children.len().saturating_sub(1) as f32;
|
||||||
|
let children_min = match element.style.direction {
|
||||||
|
FlexDirection::Column => {
|
||||||
|
let mut width: f32 = 0.0;
|
||||||
|
let mut height = gap_total;
|
||||||
|
for child in &element.children {
|
||||||
|
let child_min = element_min_size(child);
|
||||||
|
// Flex children with no explicit height contribute their min_height only;
|
||||||
|
// if that is also unset they contribute 0 (they can shrink freely).
|
||||||
|
let skip_main = child.style.flex_grow > 0.0 && child.style.height.is_none();
|
||||||
|
width = width.max(child_min.width);
|
||||||
|
if !skip_main {
|
||||||
|
height += child_min.height;
|
||||||
|
} else {
|
||||||
|
// Still count the min_height floor even for flex children.
|
||||||
|
let child_insets = content_insets(&child.style);
|
||||||
|
let min_h = child
|
||||||
|
.style
|
||||||
|
.min_height
|
||||||
|
.map(|mh| mh + vertical_insets(child_insets))
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
height += min_h;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
UiSize::new(width, height)
|
||||||
|
}
|
||||||
|
FlexDirection::Row => {
|
||||||
|
let mut width = gap_total;
|
||||||
|
let mut height: f32 = 0.0;
|
||||||
|
for child in &element.children {
|
||||||
|
let child_min = element_min_size(child);
|
||||||
|
let skip_main = child.style.flex_grow > 0.0 && child.style.width.is_none();
|
||||||
|
if !skip_main {
|
||||||
|
width += child_min.width;
|
||||||
|
} else {
|
||||||
|
let child_insets = content_insets(&child.style);
|
||||||
|
let min_w = child
|
||||||
|
.style
|
||||||
|
.min_width
|
||||||
|
.map(|mw| mw + horizontal_insets(child_insets))
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
width += min_w;
|
||||||
|
}
|
||||||
|
height = height.max(child_min.height);
|
||||||
|
}
|
||||||
|
UiSize::new(width, height)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
UiSize::new(
|
||||||
|
explicit_w
|
||||||
|
.map(|w| w + horizontal_insets(insets))
|
||||||
|
.unwrap_or(children_min.width + horizontal_insets(insets))
|
||||||
|
.max(min_outer_w),
|
||||||
|
explicit_h
|
||||||
|
.map(|h| h + vertical_insets(insets))
|
||||||
|
.unwrap_or(children_min.height + vertical_insets(insets))
|
||||||
|
.max(min_outer_h),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1758,6 +2087,20 @@ fn child_cross_size(child: &Element, direction: FlexDirection) -> Option<f32> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn child_min_main_size(child: &Element, direction: FlexDirection) -> Option<f32> {
|
||||||
|
match direction {
|
||||||
|
FlexDirection::Row => child.style.min_width,
|
||||||
|
FlexDirection::Column => child.style.min_height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn child_min_cross_size(child: &Element, direction: FlexDirection) -> Option<f32> {
|
||||||
|
match direction {
|
||||||
|
FlexDirection::Row => child.style.min_height,
|
||||||
|
FlexDirection::Column => child.style.min_width,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn child_flex_weight(child: &Element) -> f32 {
|
fn child_flex_weight(child: &Element) -> f32 {
|
||||||
if child.style.flex_grow > 0.0 {
|
if child.style.flex_grow > 0.0 {
|
||||||
child.style.flex_grow
|
child.style.flex_grow
|
||||||
@@ -1773,6 +2116,26 @@ fn main_axis_padding(edges: Edges, direction: FlexDirection) -> f32 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn cross_axis_padding(edges: Edges, direction: FlexDirection) -> f32 {
|
||||||
|
match direction {
|
||||||
|
FlexDirection::Row => edges.top + edges.bottom,
|
||||||
|
FlexDirection::Column => edges.left + edges.right,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snap a rect to the integer pixel grid.
|
||||||
|
///
|
||||||
|
/// Origin and far edge are each rounded independently so that adjacent
|
||||||
|
/// elements (where one's far edge equals the other's near edge) always
|
||||||
|
/// butt flush against each other with no gap or overlap.
|
||||||
|
fn snap_rect(r: Rect) -> Rect {
|
||||||
|
let x0 = r.origin.x.round();
|
||||||
|
let y0 = r.origin.y.round();
|
||||||
|
let x1 = (r.origin.x + r.size.width).round();
|
||||||
|
let y1 = (r.origin.y + r.size.height).round();
|
||||||
|
Rect::new(x0, y0, (x1 - x0).max(0.0), (y1 - y0).max(0.0))
|
||||||
|
}
|
||||||
|
|
||||||
fn main_axis_size(size: UiSize, direction: FlexDirection) -> f32 {
|
fn main_axis_size(size: UiSize, direction: FlexDirection) -> f32 {
|
||||||
match direction {
|
match direction {
|
||||||
FlexDirection::Row => size.width,
|
FlexDirection::Row => size.width,
|
||||||
@@ -1794,16 +2157,23 @@ fn main_axis_origin(rect: Rect, direction: FlexDirection) -> f32 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn cross_axis_origin(content: Rect, direction: FlexDirection) -> f32 {
|
||||||
|
match direction {
|
||||||
|
FlexDirection::Row => content.origin.y,
|
||||||
|
FlexDirection::Column => content.origin.x,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn child_rect(
|
fn child_rect(
|
||||||
content: Rect,
|
cross_origin: f32,
|
||||||
direction: FlexDirection,
|
direction: FlexDirection,
|
||||||
main_origin: f32,
|
main_origin: f32,
|
||||||
main_size: f32,
|
main_size: f32,
|
||||||
cross_size: f32,
|
cross_size: f32,
|
||||||
) -> Rect {
|
) -> Rect {
|
||||||
match direction {
|
match direction {
|
||||||
FlexDirection::Row => Rect::new(main_origin, content.origin.y, main_size, cross_size),
|
FlexDirection::Row => Rect::new(main_origin, cross_origin, main_size, cross_size),
|
||||||
FlexDirection::Column => Rect::new(content.origin.x, main_origin, cross_size, main_size),
|
FlexDirection::Column => Rect::new(cross_origin, main_origin, cross_size, main_size),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//! sibling crates.
|
//! sibling crates.
|
||||||
|
|
||||||
pub(crate) mod trace_targets {
|
pub(crate) mod trace_targets {
|
||||||
|
#![allow(dead_code)]
|
||||||
pub const PLATFORM: &str = "ruin_ui::platform";
|
pub const PLATFORM: &str = "ruin_ui::platform";
|
||||||
pub const SCENE: &str = "ruin_ui::scene";
|
pub const SCENE: &str = "ruin_ui::scene";
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
@@ -31,7 +32,8 @@ pub use interaction::{
|
|||||||
pub use keyboard::{KeyboardEvent, KeyboardEventKind, KeyboardKey, KeyboardModifiers};
|
pub use keyboard::{KeyboardEvent, KeyboardEventKind, KeyboardKey, KeyboardModifiers};
|
||||||
pub use layout::{
|
pub use layout::{
|
||||||
HitTarget, InteractionTree, LayoutCache, LayoutNode, LayoutPath, LayoutSnapshot, ScrollMetrics,
|
HitTarget, InteractionTree, LayoutCache, LayoutNode, LayoutPath, LayoutSnapshot, ScrollMetrics,
|
||||||
TextHitTarget, layout_snapshot, layout_snapshot_with_cache, layout_snapshot_with_text_system,
|
TextHitTarget, element_min_size, layout_snapshot, layout_snapshot_with_cache,
|
||||||
|
layout_snapshot_with_text_system,
|
||||||
};
|
};
|
||||||
pub use layout::{layout_scene, layout_scene_with_text_system};
|
pub use layout::{layout_scene, layout_scene_with_text_system};
|
||||||
pub use platform::{
|
pub use platform::{
|
||||||
@@ -49,8 +51,8 @@ pub use text::{
|
|||||||
TextStyle, TextSystem, TextWrap,
|
TextStyle, TextSystem, TextWrap,
|
||||||
};
|
};
|
||||||
pub use tree::{
|
pub use tree::{
|
||||||
Border, BoxShadow, BoxShadowKind, CornerRadius, CursorIcon, Edges, Element, ElementId,
|
AlignItems, Border, BoxShadow, BoxShadowKind, CornerRadius, CursorIcon, Edges, Element,
|
||||||
FlexDirection, ScrollbarStyle, Style,
|
ElementId, FlexDirection, ScrollbarStyle, Style,
|
||||||
};
|
};
|
||||||
pub use window::{
|
pub use window::{
|
||||||
DecorationMode, WindowConfigured, WindowId, WindowLifecycle, WindowSpec, WindowUpdate,
|
DecorationMode, WindowConfigured, WindowId, WindowLifecycle, WindowSpec, WindowUpdate,
|
||||||
|
|||||||
@@ -80,6 +80,9 @@ impl Color {
|
|||||||
/// safe to reserve as a sentinel. No user-facing API sets this value directly.
|
/// safe to reserve as a sentinel. No user-facing API sets this value directly.
|
||||||
pub const SENTINEL: Self = Self::rgba(0, 0, 0, 0);
|
pub const SENTINEL: Self = Self::rgba(0, 0, 0, 0);
|
||||||
|
|
||||||
|
pub const BLACK: Self = Self::rgb(0, 0, 0);
|
||||||
|
pub const WHITE: Self = Self::rgb(255, 255, 255);
|
||||||
|
|
||||||
pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self {
|
pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self {
|
||||||
Self { r, g, b, a }
|
Self { r, g, b, a }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,15 @@ pub enum FlexDirection {
|
|||||||
Column,
|
Column,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub enum AlignItems {
|
||||||
|
#[default]
|
||||||
|
Stretch,
|
||||||
|
Start,
|
||||||
|
Center,
|
||||||
|
End,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||||
pub enum CursorIcon {
|
pub enum CursorIcon {
|
||||||
Default,
|
Default,
|
||||||
@@ -180,8 +189,13 @@ impl Default for ScrollbarStyle {
|
|||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub struct Style {
|
pub struct Style {
|
||||||
pub direction: FlexDirection,
|
pub direction: FlexDirection,
|
||||||
|
pub align_items: AlignItems,
|
||||||
|
/// Per-element override of the parent container's `align_items`. `None` means inherit.
|
||||||
|
pub align_self: Option<AlignItems>,
|
||||||
pub width: Option<f32>,
|
pub width: Option<f32>,
|
||||||
pub height: Option<f32>,
|
pub height: Option<f32>,
|
||||||
|
pub min_width: Option<f32>,
|
||||||
|
pub min_height: Option<f32>,
|
||||||
pub flex_grow: f32,
|
pub flex_grow: f32,
|
||||||
pub gap: f32,
|
pub gap: f32,
|
||||||
pub padding: Edges,
|
pub padding: Edges,
|
||||||
@@ -198,8 +212,12 @@ impl Default for Style {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
direction: FlexDirection::Column,
|
direction: FlexDirection::Column,
|
||||||
|
align_items: AlignItems::Stretch,
|
||||||
|
align_self: None,
|
||||||
width: None,
|
width: None,
|
||||||
height: None,
|
height: None,
|
||||||
|
min_width: None,
|
||||||
|
min_height: None,
|
||||||
flex_grow: 0.0,
|
flex_grow: 0.0,
|
||||||
gap: 0.0,
|
gap: 0.0,
|
||||||
padding: Edges::ZERO,
|
padding: Edges::ZERO,
|
||||||
@@ -332,6 +350,20 @@ impl Element {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn min_width(mut self, min_width: f32) -> Self {
|
||||||
|
self.style.min_width = Some(min_width.max(0.0));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn min_height(mut self, min_height: f32) -> Self {
|
||||||
|
self.style.min_height = Some(min_height.max(0.0));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn min_size(self, min_width: f32, min_height: f32) -> Self {
|
||||||
|
self.min_width(min_width).min_height(min_height)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn flex(mut self, flex_grow: f32) -> Self {
|
pub fn flex(mut self, flex_grow: f32) -> Self {
|
||||||
self.style.flex_grow = flex_grow.max(0.0);
|
self.style.flex_grow = flex_grow.max(0.0);
|
||||||
self
|
self
|
||||||
@@ -382,6 +414,16 @@ impl Element {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn align_items(mut self, align: AlignItems) -> Self {
|
||||||
|
self.style.align_items = align;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn align_self(mut self, align: AlignItems) -> Self {
|
||||||
|
self.style.align_self = Some(align);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn cursor(mut self, cursor: CursorIcon) -> Self {
|
pub fn cursor(mut self, cursor: CursorIcon) -> Self {
|
||||||
self.style.cursor = Some(cursor);
|
self.style.cursor = Some(cursor);
|
||||||
self
|
self
|
||||||
@@ -527,11 +569,21 @@ impl Hash for ScrollbarStyle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Hash for AlignItems {
|
||||||
|
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||||
|
(*self as u8).hash(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Hash for Style {
|
impl Hash for Style {
|
||||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||||
self.direction.hash(state);
|
self.direction.hash(state);
|
||||||
|
self.align_items.hash(state);
|
||||||
|
self.align_self.hash(state);
|
||||||
self.width.map(f32::to_bits).hash(state);
|
self.width.map(f32::to_bits).hash(state);
|
||||||
self.height.map(f32::to_bits).hash(state);
|
self.height.map(f32::to_bits).hash(state);
|
||||||
|
self.min_width.map(f32::to_bits).hash(state);
|
||||||
|
self.min_height.map(f32::to_bits).hash(state);
|
||||||
self.flex_grow.to_bits().hash(state);
|
self.flex_grow.to_bits().hash(state);
|
||||||
self.gap.to_bits().hash(state);
|
self.gap.to_bits().hash(state);
|
||||||
self.padding.hash(state);
|
self.padding.hash(state);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! Common window model types.
|
//! Common window model types.
|
||||||
|
|
||||||
use crate::scene::UiSize;
|
use crate::scene::{Color, UiSize};
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||||
pub struct WindowId(u64);
|
pub struct WindowId(u64);
|
||||||
@@ -34,6 +34,11 @@ pub enum WindowLifecycle {
|
|||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
pub struct WindowConfigured {
|
pub struct WindowConfigured {
|
||||||
|
/// Current logical content size and platform presentation scale.
|
||||||
|
///
|
||||||
|
/// Backends should emit a new configuration when either the logical size or
|
||||||
|
/// scale changes. This lets hosts relayout for size changes while renderers
|
||||||
|
/// can update drawable/text scale when a window moves between displays.
|
||||||
pub actual_inner_size: UiSize,
|
pub actual_inner_size: UiSize,
|
||||||
pub scale_factor: f32,
|
pub scale_factor: f32,
|
||||||
pub visible: bool,
|
pub visible: bool,
|
||||||
@@ -55,6 +60,7 @@ pub struct WindowSpec {
|
|||||||
pub maximized: bool,
|
pub maximized: bool,
|
||||||
pub fullscreen: bool,
|
pub fullscreen: bool,
|
||||||
pub resizable: bool,
|
pub resizable: bool,
|
||||||
|
pub base_color: Color,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WindowSpec {
|
impl WindowSpec {
|
||||||
@@ -72,9 +78,15 @@ impl WindowSpec {
|
|||||||
maximized: false,
|
maximized: false,
|
||||||
fullscreen: false,
|
fullscreen: false,
|
||||||
resizable: true,
|
resizable: true,
|
||||||
|
base_color: Color::rgb(0x08, 0x0C, 0x14),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn base_color(mut self, color: Color) -> Self {
|
||||||
|
self.base_color = color;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn key(mut self, key: impl Into<String>) -> Self {
|
pub fn key(mut self, key: impl Into<String>) -> Self {
|
||||||
self.key = Some(key.into());
|
self.key = Some(key.into());
|
||||||
self
|
self
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -44,6 +44,9 @@ use wayland_protocols::wp::primary_selection::zv1::client::{
|
|||||||
zwp_primary_selection_device_manager_v1, zwp_primary_selection_device_v1,
|
zwp_primary_selection_device_manager_v1, zwp_primary_selection_device_v1,
|
||||||
zwp_primary_selection_offer_v1, zwp_primary_selection_source_v1,
|
zwp_primary_selection_offer_v1, zwp_primary_selection_source_v1,
|
||||||
};
|
};
|
||||||
|
use wayland_protocols::xdg::decoration::zv1::client::{
|
||||||
|
zxdg_decoration_manager_v1, zxdg_toplevel_decoration_v1,
|
||||||
|
};
|
||||||
use wayland_protocols::xdg::shell::client::{xdg_surface, xdg_toplevel, xdg_wm_base};
|
use wayland_protocols::xdg::shell::client::{xdg_surface, xdg_toplevel, xdg_wm_base};
|
||||||
use xkbcommon::xkb;
|
use xkbcommon::xkb;
|
||||||
|
|
||||||
@@ -145,6 +148,7 @@ enum InternalBackendEvent {
|
|||||||
|
|
||||||
struct State {
|
struct State {
|
||||||
running: bool,
|
running: bool,
|
||||||
|
fixed_size_request: Option<(u32, u32)>,
|
||||||
_connection: Connection,
|
_connection: Connection,
|
||||||
_compositor: wl_compositor::WlCompositor,
|
_compositor: wl_compositor::WlCompositor,
|
||||||
_surface: wl_surface::WlSurface,
|
_surface: wl_surface::WlSurface,
|
||||||
@@ -156,6 +160,8 @@ struct State {
|
|||||||
clipboard_device: Option<wl_data_device::WlDataDevice>,
|
clipboard_device: Option<wl_data_device::WlDataDevice>,
|
||||||
cursor_shape_manager: Option<wp_cursor_shape_manager_v1::WpCursorShapeManagerV1>,
|
cursor_shape_manager: Option<wp_cursor_shape_manager_v1::WpCursorShapeManagerV1>,
|
||||||
cursor_shape_device: Option<wp_cursor_shape_device_v1::WpCursorShapeDeviceV1>,
|
cursor_shape_device: Option<wp_cursor_shape_device_v1::WpCursorShapeDeviceV1>,
|
||||||
|
/// Server-side decoration handle. Kept alive so the compositor maintains decorations.
|
||||||
|
_decoration: Option<zxdg_toplevel_decoration_v1::ZxdgToplevelDecorationV1>,
|
||||||
primary_selection_manager:
|
primary_selection_manager:
|
||||||
Option<zwp_primary_selection_device_manager_v1::ZwpPrimarySelectionDeviceManagerV1>,
|
Option<zwp_primary_selection_device_manager_v1::ZwpPrimarySelectionDeviceManagerV1>,
|
||||||
primary_selection_device: Option<zwp_primary_selection_device_v1::ZwpPrimarySelectionDeviceV1>,
|
primary_selection_device: Option<zwp_primary_selection_device_v1::ZwpPrimarySelectionDeviceV1>,
|
||||||
@@ -227,6 +233,21 @@ impl State {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn fixed_size_request_for_spec(spec: &WindowSpec) -> Option<(u32, u32)> {
|
||||||
|
if spec.resizable {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let size = spec
|
||||||
|
.requested_inner_size
|
||||||
|
.or(spec.min_inner_size)
|
||||||
|
.or(spec.max_inner_size)
|
||||||
|
.unwrap_or_else(|| UiSize::new(800.0, 500.0));
|
||||||
|
Some((
|
||||||
|
size.width.max(1.0).round() as u32,
|
||||||
|
size.height.max(1.0).round() as u32,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
fn wayland_cursor_shape(icon: CursorIcon) -> wp_cursor_shape_device_v1::Shape {
|
fn wayland_cursor_shape(icon: CursorIcon) -> wp_cursor_shape_device_v1::Shape {
|
||||||
match icon {
|
match icon {
|
||||||
CursorIcon::Default => wp_cursor_shape_device_v1::Shape::Default,
|
CursorIcon::Default => wp_cursor_shape_device_v1::Shape::Default,
|
||||||
@@ -318,6 +339,8 @@ impl WaylandWindow {
|
|||||||
manager.get_device(&seat, &qh, ())
|
manager.get_device(&seat, &qh, ())
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
let decoration_manager: Option<zxdg_decoration_manager_v1::ZxdgDecorationManagerV1> =
|
||||||
|
globals.bind(&qh, 1..=1, ()).ok();
|
||||||
let surface = compositor.create_surface(&qh, ());
|
let surface = compositor.create_surface(&qh, ());
|
||||||
let xdg_surface = wm_base.get_xdg_surface(&surface, &qh, ());
|
let xdg_surface = wm_base.get_xdg_surface(&surface, &qh, ());
|
||||||
let toplevel = xdg_surface.get_toplevel(&qh, ());
|
let toplevel = xdg_surface.get_toplevel(&qh, ());
|
||||||
@@ -325,6 +348,11 @@ impl WaylandWindow {
|
|||||||
if let Some(app_id) = spec.app_id.as_ref() {
|
if let Some(app_id) = spec.app_id.as_ref() {
|
||||||
toplevel.set_app_id(app_id.clone());
|
toplevel.set_app_id(app_id.clone());
|
||||||
}
|
}
|
||||||
|
let decoration = decoration_manager.as_ref().map(|dm| {
|
||||||
|
let deco = dm.get_toplevel_decoration(&toplevel, &qh, ());
|
||||||
|
deco.set_mode(zxdg_toplevel_decoration_v1::Mode::ServerSide);
|
||||||
|
deco
|
||||||
|
});
|
||||||
|
|
||||||
apply_size_constraints(&toplevel, &spec);
|
apply_size_constraints(&toplevel, &spec);
|
||||||
if spec.maximized {
|
if spec.maximized {
|
||||||
@@ -352,6 +380,7 @@ impl WaylandWindow {
|
|||||||
surface_target,
|
surface_target,
|
||||||
state: State {
|
state: State {
|
||||||
running: true,
|
running: true,
|
||||||
|
fixed_size_request: fixed_size_request_for_spec(&spec),
|
||||||
_connection: connection,
|
_connection: connection,
|
||||||
_compositor: compositor,
|
_compositor: compositor,
|
||||||
_surface: surface,
|
_surface: surface,
|
||||||
@@ -395,6 +424,7 @@ impl WaylandWindow {
|
|||||||
last_selection_serial: None,
|
last_selection_serial: None,
|
||||||
last_pointer_enter_serial: None,
|
last_pointer_enter_serial: None,
|
||||||
cursor_icon: CursorIcon::Default,
|
cursor_icon: CursorIcon::Default,
|
||||||
|
_decoration: decoration,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -684,6 +714,7 @@ impl WaylandWindow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn apply_spec(&mut self, spec: &WindowSpec) -> Result<(), Box<dyn Error>> {
|
pub fn apply_spec(&mut self, spec: &WindowSpec) -> Result<(), Box<dyn Error>> {
|
||||||
|
self.state.fixed_size_request = fixed_size_request_for_spec(spec);
|
||||||
self.state._toplevel.set_title(spec.title.clone());
|
self.state._toplevel.set_title(spec.title.clone());
|
||||||
if let Some(app_id) = spec.app_id.as_ref() {
|
if let Some(app_id) = spec.app_id.as_ref() {
|
||||||
self.state._toplevel.set_app_id(app_id.clone());
|
self.state._toplevel.set_app_id(app_id.clone());
|
||||||
@@ -719,6 +750,14 @@ impl WaylandWindow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some((width, height)) = self.state.pending_size.take() {
|
if let Some((width, height)) = self.state.pending_size.take() {
|
||||||
|
trace!(
|
||||||
|
target: "ruin_ui_platform_wayland::resize",
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
previous_width = self.state.current_size.0,
|
||||||
|
previous_height = self.state.current_size.1,
|
||||||
|
"prepare_frame observed pending size change"
|
||||||
|
);
|
||||||
self.state.current_size = (width, height);
|
self.state.current_size = (width, height);
|
||||||
self.state.needs_redraw = false;
|
self.state.needs_redraw = false;
|
||||||
return Some(FrameRequest {
|
return Some(FrameRequest {
|
||||||
@@ -729,6 +768,12 @@ impl WaylandWindow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if self.state.needs_redraw {
|
if self.state.needs_redraw {
|
||||||
|
trace!(
|
||||||
|
target: "ruin_ui_platform_wayland::resize",
|
||||||
|
width = self.state.current_size.0,
|
||||||
|
height = self.state.current_size.1,
|
||||||
|
"prepare_frame servicing redraw without size change"
|
||||||
|
);
|
||||||
self.state.needs_redraw = false;
|
self.state.needs_redraw = false;
|
||||||
return Some(FrameRequest {
|
return Some(FrameRequest {
|
||||||
width: self.state.current_size.0,
|
width: self.state.current_size.0,
|
||||||
@@ -1083,6 +1128,7 @@ fn spawn_window_worker(
|
|||||||
spec.requested_inner_size
|
spec.requested_inner_size
|
||||||
.unwrap_or_else(|| UiSize::new(800.0, 500.0))
|
.unwrap_or_else(|| UiSize::new(800.0, 500.0))
|
||||||
.height as u32,
|
.height as u32,
|
||||||
|
spec.base_color,
|
||||||
) {
|
) {
|
||||||
Ok(renderer) => renderer,
|
Ok(renderer) => renderer,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
@@ -1359,6 +1405,19 @@ fn spawn_window_worker(
|
|||||||
state_ref
|
state_ref
|
||||||
.pending_viewport_since
|
.pending_viewport_since
|
||||||
.get_or_insert_with(Instant::now);
|
.get_or_insert_with(Instant::now);
|
||||||
|
// Deadlock prevention: if the app responded to the
|
||||||
|
// configure we sent (scene matches in_flight) but the
|
||||||
|
// viewport has since moved on, clear the in_flight
|
||||||
|
// marker so we can immediately send the next configure.
|
||||||
|
// Without this, viewport_request_in_flight stays set
|
||||||
|
// to the old size forever and the app is never told
|
||||||
|
// about the new viewport.
|
||||||
|
if state_ref.viewport_request_in_flight
|
||||||
|
== Some(scene.logical_size)
|
||||||
|
{
|
||||||
|
state_ref.viewport_request_in_flight = None;
|
||||||
|
maybe_request_pending_viewport(&mut state_ref);
|
||||||
|
}
|
||||||
state_ref.window.request_redraw();
|
state_ref.window.request_redraw();
|
||||||
} else if !state_ref.window.presentation_ready() {
|
} else if !state_ref.window.presentation_ready() {
|
||||||
// Correct scene is ready but the compositor
|
// Correct scene is ready but the compositor
|
||||||
@@ -1589,6 +1648,8 @@ delegate_noop!(
|
|||||||
State: ignore
|
State: ignore
|
||||||
zwp_primary_selection_device_manager_v1::ZwpPrimarySelectionDeviceManagerV1
|
zwp_primary_selection_device_manager_v1::ZwpPrimarySelectionDeviceManagerV1
|
||||||
);
|
);
|
||||||
|
delegate_noop!(State: ignore zxdg_decoration_manager_v1::ZxdgDecorationManagerV1);
|
||||||
|
delegate_noop!(State: ignore zxdg_toplevel_decoration_v1::ZxdgToplevelDecorationV1);
|
||||||
impl Dispatch<wl_seat::WlSeat, ()> for State {
|
impl Dispatch<wl_seat::WlSeat, ()> for State {
|
||||||
fn event(
|
fn event(
|
||||||
state: &mut Self,
|
state: &mut Self,
|
||||||
@@ -2079,6 +2140,14 @@ impl Dispatch<xdg_surface::XdgSurface, ()> for State {
|
|||||||
if let xdg_surface::Event::Configure { serial } = event {
|
if let xdg_surface::Event::Configure { serial } = event {
|
||||||
xdg_surface.ack_configure(serial);
|
xdg_surface.ack_configure(serial);
|
||||||
state.configured = true;
|
state.configured = true;
|
||||||
|
trace!(
|
||||||
|
target: "ruin_ui_platform_wayland::resize",
|
||||||
|
serial,
|
||||||
|
current_width = state.current_size.0,
|
||||||
|
current_height = state.current_size.1,
|
||||||
|
has_pending_size = state.pending_size.is_some(),
|
||||||
|
"received xdg_surface configure"
|
||||||
|
);
|
||||||
state.request_redraw();
|
state.request_redraw();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2131,7 +2200,37 @@ impl Dispatch<xdg_toplevel::XdgToplevel, ()> for State {
|
|||||||
height,
|
height,
|
||||||
"received Wayland toplevel configure"
|
"received Wayland toplevel configure"
|
||||||
);
|
);
|
||||||
state.pending_size = Some((width, height));
|
let next_size = (width, height);
|
||||||
|
if let Some(expected_size) = state.fixed_size_request {
|
||||||
|
if next_size != expected_size {
|
||||||
|
trace!(
|
||||||
|
target: "ruin_ui_platform_wayland::resize",
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
expected_width = expected_size.0,
|
||||||
|
expected_height = expected_size.1,
|
||||||
|
"ignoring stale toplevel configure for fixed-size window"
|
||||||
|
);
|
||||||
|
state.request_redraw();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let size_changed =
|
||||||
|
next_size != state.current_size && state.pending_size != Some(next_size);
|
||||||
|
trace!(
|
||||||
|
target: "ruin_ui_platform_wayland::resize",
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
current_width = state.current_size.0,
|
||||||
|
current_height = state.current_size.1,
|
||||||
|
pending_width = state.pending_size.map(|size| size.0),
|
||||||
|
pending_height = state.pending_size.map(|size| size.1),
|
||||||
|
size_changed,
|
||||||
|
"processed Wayland toplevel configure"
|
||||||
|
);
|
||||||
|
if size_changed {
|
||||||
|
state.pending_size = Some(next_size);
|
||||||
|
}
|
||||||
state.request_redraw();
|
state.request_redraw();
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|||||||
@@ -333,6 +333,7 @@ pub struct WgpuSceneRenderer {
|
|||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
glyph_atlas: GlyphAtlas,
|
glyph_atlas: GlyphAtlas,
|
||||||
scale_factor: f32,
|
scale_factor: f32,
|
||||||
|
base_color: Color,
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_TEXT_CACHE_ENTRIES: usize = 64;
|
const MAX_TEXT_CACHE_ENTRIES: usize = 64;
|
||||||
@@ -346,6 +347,7 @@ impl WgpuSceneRenderer {
|
|||||||
target: impl HasDisplayHandle + HasWindowHandle + Send + Sync + 'static,
|
target: impl HasDisplayHandle + HasWindowHandle + Send + Sync + 'static,
|
||||||
width: u32,
|
width: u32,
|
||||||
height: u32,
|
height: u32,
|
||||||
|
base_color: Color,
|
||||||
) -> Result<Self, Box<dyn Error>> {
|
) -> Result<Self, Box<dyn Error>> {
|
||||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||||
let surface = instance.create_surface(target)?;
|
let surface = instance.create_surface(target)?;
|
||||||
@@ -364,14 +366,26 @@ impl WgpuSceneRenderer {
|
|||||||
.copied()
|
.copied()
|
||||||
.find(wgpu::TextureFormat::is_srgb)
|
.find(wgpu::TextureFormat::is_srgb)
|
||||||
.unwrap_or(caps.formats[0]);
|
.unwrap_or(caps.formats[0]);
|
||||||
|
let alpha_mode = if base_color.a < 255
|
||||||
|
&& caps
|
||||||
|
.alpha_modes
|
||||||
|
.contains(&wgpu::CompositeAlphaMode::PreMultiplied)
|
||||||
|
{
|
||||||
|
wgpu::CompositeAlphaMode::PreMultiplied
|
||||||
|
} else {
|
||||||
|
caps.alpha_modes[0]
|
||||||
|
};
|
||||||
let config = wgpu::SurfaceConfiguration {
|
let config = wgpu::SurfaceConfiguration {
|
||||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||||
format,
|
format,
|
||||||
width: width.max(1),
|
width: width.max(1),
|
||||||
height: height.max(1),
|
height: height.max(1),
|
||||||
|
// RUIN prioritizes immediate resize/input feedback over frame pacing
|
||||||
|
// here. Keep latency to one frame and let callers opt into a more
|
||||||
|
// power-conservative policy once presentation settings are exposed.
|
||||||
present_mode: wgpu::PresentMode::AutoNoVsync,
|
present_mode: wgpu::PresentMode::AutoNoVsync,
|
||||||
desired_maximum_frame_latency: 1,
|
desired_maximum_frame_latency: 1,
|
||||||
alpha_mode: caps.alpha_modes[0],
|
alpha_mode,
|
||||||
view_formats: vec![],
|
view_formats: vec![],
|
||||||
};
|
};
|
||||||
surface.configure(&device, &config);
|
surface.configure(&device, &config);
|
||||||
@@ -418,7 +432,7 @@ fn sd_round_rect(point: vec2<f32>, rect: vec4<f32>, radius: f32) -> f32 {
|
|||||||
|
|
||||||
fn rounded_rect_alpha(point: vec2<f32>, rect: vec4<f32>, radius: f32) -> f32 {
|
fn rounded_rect_alpha(point: vec2<f32>, rect: vec4<f32>, radius: f32) -> f32 {
|
||||||
let distance = sd_round_rect(point, rect, radius);
|
let distance = sd_round_rect(point, rect, radius);
|
||||||
return 1.0 - smoothstep(0.0, 1.0, distance);
|
return 1.0 - smoothstep(-0.5, 0.5, distance);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn blurred_round_rect_alpha(point: vec2<f32>, rect: vec4<f32>, radius: f32, blur: f32) -> f32 {
|
fn blurred_round_rect_alpha(point: vec2<f32>, rect: vec4<f32>, radius: f32, blur: f32) -> f32 {
|
||||||
@@ -556,7 +570,7 @@ fn sd_round_rect(point: vec2<f32>, rect: vec4<f32>, radius: f32) -> f32 {
|
|||||||
|
|
||||||
fn rounded_rect_alpha(point: vec2<f32>, rect: vec4<f32>, radius: f32) -> f32 {
|
fn rounded_rect_alpha(point: vec2<f32>, rect: vec4<f32>, radius: f32) -> f32 {
|
||||||
let distance = sd_round_rect(point, rect, radius);
|
let distance = sd_round_rect(point, rect, radius);
|
||||||
return 1.0 - smoothstep(0.0, 1.0, distance);
|
return 1.0 - smoothstep(-0.5, 0.5, distance);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_clip_alpha(point: vec2<f32>, clip_rect: vec4<f32>, rounded_clip_rect: vec4<f32>, clip_params: vec2<f32>) -> f32 {
|
fn apply_clip_alpha(point: vec2<f32>, clip_rect: vec4<f32>, rounded_clip_rect: vec4<f32>, clip_params: vec2<f32>) -> f32 {
|
||||||
@@ -714,6 +728,7 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
|
|||||||
image_cache_order: VecDeque::new(),
|
image_cache_order: VecDeque::new(),
|
||||||
glyph_atlas,
|
glyph_atlas,
|
||||||
scale_factor: 1.0,
|
scale_factor: 1.0,
|
||||||
|
base_color,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -795,9 +810,7 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
DisplayItem::Text(text) => {
|
DisplayItem::Text(text) => {
|
||||||
if (self.scale_factor - 1.0).abs() <= f32::EPSILON
|
if text.glyphs.iter().all(|glyph| glyph.cache_key.is_some()) {
|
||||||
&& text.glyphs.iter().all(|glyph| glyph.cache_key.is_some())
|
|
||||||
{
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some(uploaded) =
|
if let Some(uploaded) =
|
||||||
@@ -823,11 +836,16 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
|
|||||||
depth_slice: None,
|
depth_slice: None,
|
||||||
resolve_target: None,
|
resolve_target: None,
|
||||||
ops: wgpu::Operations {
|
ops: wgpu::Operations {
|
||||||
load: wgpu::LoadOp::Clear(wgpu::Color {
|
load: wgpu::LoadOp::Clear({
|
||||||
r: 0.03,
|
let a = self.base_color.a as f64 / 255.0;
|
||||||
g: 0.04,
|
// Premultiplied: multiply RGB by alpha so the compositor
|
||||||
b: 0.08,
|
// can blend correctly when alpha_mode is PreMultiplied.
|
||||||
a: 1.0,
|
wgpu::Color {
|
||||||
|
r: (self.base_color.r as f64 / 255.0) * a,
|
||||||
|
g: (self.base_color.g as f64 / 255.0) * a,
|
||||||
|
b: (self.base_color.b as f64 / 255.0) * a,
|
||||||
|
a,
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
store: wgpu::StoreOp::Store,
|
store: wgpu::StoreOp::Store,
|
||||||
},
|
},
|
||||||
@@ -1070,9 +1088,6 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
|
|||||||
logical_size: UiSize,
|
logical_size: UiSize,
|
||||||
perf: &mut AtlasTextPerfStats,
|
perf: &mut AtlasTextPerfStats,
|
||||||
) -> Option<UploadedAtlasText> {
|
) -> Option<UploadedAtlasText> {
|
||||||
if (self.scale_factor - 1.0).abs() > f32::EPSILON {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let mut vertices = Vec::new();
|
let mut vertices = Vec::new();
|
||||||
let mut clip_stack = Vec::new();
|
let mut clip_stack = Vec::new();
|
||||||
let mut active_clip = ActiveClip::default();
|
let mut active_clip = ActiveClip::default();
|
||||||
@@ -1104,7 +1119,8 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
|
|||||||
if active_clip.rect_active && logical_clip_rect.is_none() {
|
if active_clip.rect_active && logical_clip_rect.is_none() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let clip_rect = logical_clip_rect.map(rect_to_pixel_rect);
|
let clip_rect = logical_clip_rect
|
||||||
|
.map(|rect| scale_rect_to_pixel_rect(rect, self.scale_factor));
|
||||||
|
|
||||||
// Only iterate glyphs whose lines fall within the clip rect.
|
// Only iterate glyphs whose lines fall within the clip rect.
|
||||||
// For large text nodes (e.g. full Cargo.lock) this reduces
|
// For large text nodes (e.g. full Cargo.lock) this reduces
|
||||||
@@ -1115,7 +1131,10 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
|
|||||||
perf.glyphs_clip_culled += (all_count - visible.len()) as u32;
|
perf.glyphs_clip_culled += (all_count - visible.len()) as u32;
|
||||||
|
|
||||||
for glyph in visible {
|
for glyph in visible {
|
||||||
let Some(cache_key) = glyph.cache_key else {
|
let Some(cache_key) = glyph
|
||||||
|
.cache_key
|
||||||
|
.map(|cache_key| scale_glyph_cache_key(cache_key, self.scale_factor))
|
||||||
|
else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let resolved_color = resolve_glyph_color(glyph.color, text.default_color);
|
let resolved_color = resolve_glyph_color(glyph.color, text.default_color);
|
||||||
@@ -1124,9 +1143,12 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Glyph positions are LOCAL; add text.origin for absolute screen coords.
|
// Atlas glyphs are rasterized in device pixels while scene geometry remains
|
||||||
let abs_x = (text.origin.x + glyph.position.x).round() as i32;
|
// logical. Build and clip in device space, then convert back for vertices.
|
||||||
let abs_y = (text.origin.y + glyph.position.y).round() as i32;
|
let abs_x =
|
||||||
|
((text.origin.x + glyph.position.x) * self.scale_factor).round() as i32;
|
||||||
|
let abs_y =
|
||||||
|
((text.origin.y + glyph.position.y) * self.scale_factor).round() as i32;
|
||||||
let glyph_rect = PixelRect {
|
let glyph_rect = PixelRect {
|
||||||
left: abs_x + atlas_glyph.placement_left,
|
left: abs_x + atlas_glyph.placement_left,
|
||||||
top: abs_y - atlas_glyph.placement_top,
|
top: abs_y - atlas_glyph.placement_top,
|
||||||
@@ -1142,6 +1164,7 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
|
|||||||
atlas_glyph.atlas_rect,
|
atlas_glyph.atlas_rect,
|
||||||
clip_rect,
|
clip_rect,
|
||||||
logical_size,
|
logical_size,
|
||||||
|
self.scale_factor,
|
||||||
resolved_color,
|
resolved_color,
|
||||||
active_clip,
|
active_clip,
|
||||||
);
|
);
|
||||||
@@ -1872,6 +1895,7 @@ fn push_glyph_vertices(
|
|||||||
atlas_rect: AtlasRect,
|
atlas_rect: AtlasRect,
|
||||||
clip_rect: Option<PixelRect>,
|
clip_rect: Option<PixelRect>,
|
||||||
logical_size: UiSize,
|
logical_size: UiSize,
|
||||||
|
scale_factor: f32,
|
||||||
color: Color,
|
color: Color,
|
||||||
clip: ActiveClip,
|
clip: ActiveClip,
|
||||||
) {
|
) {
|
||||||
@@ -1879,10 +1903,15 @@ fn push_glyph_vertices(
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let left = to_ndc_x(dest_rect.left as f32, logical_size.width.max(1.0));
|
let scale_factor = scale_factor.max(1.0);
|
||||||
let right = to_ndc_x(dest_rect.right as f32, logical_size.width.max(1.0));
|
let logical_left = dest_rect.left as f32 / scale_factor;
|
||||||
let top = to_ndc_y(dest_rect.top as f32, logical_size.height.max(1.0));
|
let logical_top = dest_rect.top as f32 / scale_factor;
|
||||||
let bottom = to_ndc_y(dest_rect.bottom as f32, logical_size.height.max(1.0));
|
let logical_right = dest_rect.right as f32 / scale_factor;
|
||||||
|
let logical_bottom = dest_rect.bottom as f32 / scale_factor;
|
||||||
|
let left = to_ndc_x(logical_left, logical_size.width.max(1.0));
|
||||||
|
let right = to_ndc_x(logical_right, logical_size.width.max(1.0));
|
||||||
|
let top = to_ndc_y(logical_top, logical_size.height.max(1.0));
|
||||||
|
let bottom = to_ndc_y(logical_bottom, logical_size.height.max(1.0));
|
||||||
|
|
||||||
let color = color_to_f32(color);
|
let color = color_to_f32(color);
|
||||||
let clip_rect = clip_rect_array(clip);
|
let clip_rect = clip_rect_array(clip);
|
||||||
@@ -1890,7 +1919,7 @@ fn push_glyph_vertices(
|
|||||||
vertices.extend_from_slice(&[
|
vertices.extend_from_slice(&[
|
||||||
TextVertex {
|
TextVertex {
|
||||||
position: [left, top],
|
position: [left, top],
|
||||||
world_position: [dest_rect.left as f32, dest_rect.top as f32],
|
world_position: [logical_left, logical_top],
|
||||||
uv: [uv_rect.0, uv_rect.1],
|
uv: [uv_rect.0, uv_rect.1],
|
||||||
color,
|
color,
|
||||||
clip_rect,
|
clip_rect,
|
||||||
@@ -1899,7 +1928,7 @@ fn push_glyph_vertices(
|
|||||||
},
|
},
|
||||||
TextVertex {
|
TextVertex {
|
||||||
position: [left, bottom],
|
position: [left, bottom],
|
||||||
world_position: [dest_rect.left as f32, dest_rect.bottom as f32],
|
world_position: [logical_left, logical_bottom],
|
||||||
uv: [uv_rect.0, uv_rect.3],
|
uv: [uv_rect.0, uv_rect.3],
|
||||||
color,
|
color,
|
||||||
clip_rect,
|
clip_rect,
|
||||||
@@ -1908,7 +1937,7 @@ fn push_glyph_vertices(
|
|||||||
},
|
},
|
||||||
TextVertex {
|
TextVertex {
|
||||||
position: [right, top],
|
position: [right, top],
|
||||||
world_position: [dest_rect.right as f32, dest_rect.top as f32],
|
world_position: [logical_right, logical_top],
|
||||||
uv: [uv_rect.2, uv_rect.1],
|
uv: [uv_rect.2, uv_rect.1],
|
||||||
color,
|
color,
|
||||||
clip_rect,
|
clip_rect,
|
||||||
@@ -1917,7 +1946,7 @@ fn push_glyph_vertices(
|
|||||||
},
|
},
|
||||||
TextVertex {
|
TextVertex {
|
||||||
position: [right, top],
|
position: [right, top],
|
||||||
world_position: [dest_rect.right as f32, dest_rect.top as f32],
|
world_position: [logical_right, logical_top],
|
||||||
uv: [uv_rect.2, uv_rect.1],
|
uv: [uv_rect.2, uv_rect.1],
|
||||||
color,
|
color,
|
||||||
clip_rect,
|
clip_rect,
|
||||||
@@ -1926,7 +1955,7 @@ fn push_glyph_vertices(
|
|||||||
},
|
},
|
||||||
TextVertex {
|
TextVertex {
|
||||||
position: [left, bottom],
|
position: [left, bottom],
|
||||||
world_position: [dest_rect.left as f32, dest_rect.bottom as f32],
|
world_position: [logical_left, logical_bottom],
|
||||||
uv: [uv_rect.0, uv_rect.3],
|
uv: [uv_rect.0, uv_rect.3],
|
||||||
color,
|
color,
|
||||||
clip_rect,
|
clip_rect,
|
||||||
@@ -1935,7 +1964,7 @@ fn push_glyph_vertices(
|
|||||||
},
|
},
|
||||||
TextVertex {
|
TextVertex {
|
||||||
position: [right, bottom],
|
position: [right, bottom],
|
||||||
world_position: [dest_rect.right as f32, dest_rect.bottom as f32],
|
world_position: [logical_right, logical_bottom],
|
||||||
uv: [uv_rect.2, uv_rect.3],
|
uv: [uv_rect.2, uv_rect.3],
|
||||||
color,
|
color,
|
||||||
clip_rect,
|
clip_rect,
|
||||||
@@ -2484,12 +2513,22 @@ fn pixel_rect_tuple(rect: PixelRect) -> (i32, i32, i32, i32) {
|
|||||||
(rect.left, rect.top, rect.right, rect.bottom)
|
(rect.left, rect.top, rect.right, rect.bottom)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn scale_glyph_cache_key(cache_key: CacheKey, scale_factor: f32) -> CacheKey {
|
||||||
|
CacheKey {
|
||||||
|
font_size_bits: (f32::from_bits(cache_key.font_size_bits) * scale_factor.max(1.0))
|
||||||
|
.to_bits(),
|
||||||
|
..cache_key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
ActiveClip, blend_rgba, build_text_vertices, build_vertices, clip_rect_array,
|
ActiveClip, AtlasRect, PixelRect, blend_rgba, build_text_vertices, build_vertices,
|
||||||
push_clip_state, rounded_clip_arrays, text_texture_key,
|
clip_rect_array, push_clip_state, push_glyph_vertices, rounded_clip_arrays,
|
||||||
|
scale_glyph_cache_key, text_texture_key,
|
||||||
};
|
};
|
||||||
|
use cosmic_text::{CacheKey, CacheKeyFlags, SubpixelBin, fontdb};
|
||||||
use ruin_ui::{ClipRegion, Color, Point, PreparedText, Rect, SceneSnapshot, UiSize};
|
use ruin_ui::{ClipRegion, Color, Point, PreparedText, Rect, SceneSnapshot, UiSize};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2568,6 +2607,58 @@ mod tests {
|
|||||||
assert_ne!(first, different_pixel);
|
assert_ne!(first, different_pixel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scale_glyph_cache_key_scales_font_size_only() {
|
||||||
|
let key = CacheKey {
|
||||||
|
font_id: fontdb::ID::dummy(),
|
||||||
|
glyph_id: 42,
|
||||||
|
font_size_bits: 13.0_f32.to_bits(),
|
||||||
|
x_bin: SubpixelBin::One,
|
||||||
|
y_bin: SubpixelBin::Two,
|
||||||
|
font_weight: fontdb::Weight::BOLD,
|
||||||
|
flags: CacheKeyFlags::FAKE_ITALIC,
|
||||||
|
};
|
||||||
|
|
||||||
|
let scaled = scale_glyph_cache_key(key, 2.0);
|
||||||
|
|
||||||
|
assert_eq!(f32::from_bits(scaled.font_size_bits), 26.0);
|
||||||
|
assert_eq!(scaled.font_id, key.font_id);
|
||||||
|
assert_eq!(scaled.glyph_id, key.glyph_id);
|
||||||
|
assert_eq!(scaled.x_bin, key.x_bin);
|
||||||
|
assert_eq!(scaled.y_bin, key.y_bin);
|
||||||
|
assert_eq!(scaled.font_weight, key.font_weight);
|
||||||
|
assert_eq!(scaled.flags, key.flags);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scaled_atlas_vertices_convert_device_pixels_to_logical_space() {
|
||||||
|
let mut vertices = Vec::new();
|
||||||
|
push_glyph_vertices(
|
||||||
|
&mut vertices,
|
||||||
|
PixelRect {
|
||||||
|
left: 20,
|
||||||
|
top: 10,
|
||||||
|
right: 40,
|
||||||
|
bottom: 30,
|
||||||
|
},
|
||||||
|
AtlasRect {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
UiSize::new(100.0, 100.0),
|
||||||
|
2.0,
|
||||||
|
Color::rgb(0xFF, 0xFF, 0xFF),
|
||||||
|
ActiveClip::default(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(vertices.len(), 6);
|
||||||
|
assert_eq!(vertices[0].world_position, [10.0, 5.0]);
|
||||||
|
assert_eq!(vertices[5].world_position, [20.0, 15.0]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn nested_clip_with_empty_intersection_stays_clipped() {
|
fn nested_clip_with_empty_intersection_stays_clipped() {
|
||||||
let mut clip_stack = Vec::new();
|
let mut clip_stack = Vec::new();
|
||||||
|
|||||||
Reference in New Issue
Block a user