Lots of owork on a calculator example, transparency, etc.
This commit is contained in:
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"
|
||||||
@@ -1793,6 +1846,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"
|
||||||
@@ -1831,6 +1892,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",
|
||||||
@@ -2257,6 +2319,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]]
|
||||||
@@ -2266,12 +2340,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]]
|
||||||
@@ -2336,6 +2413,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"
|
||||||
|
|||||||
14
README.md
14
README.md
@@ -0,0 +1,14 @@
|
|||||||
|
# RUIN - Rust Native Application Framework
|
||||||
|
|
||||||
|
A layered architecture for fast, native, cross-platform applications.
|
||||||
|
|
||||||
|
RUIN is a modern asynchronous runtime for interactive Rust applications on personal computers. It has a simple
|
||||||
|
threading and scheduling model, fast asynchronous I/O (io_uring on Linux, kqueue on macOS, and IOCP on windows),
|
||||||
|
and several libraries to enable building modern, reactive applications.
|
||||||
|
|
||||||
|
## Runtime
|
||||||
|
|
||||||
|
RUIN's core runtime is provided in [`ruin-runtime`](./lib/runtime/).
|
||||||
|
|
||||||
|
The runtime is non-workstealing with no threadpool. When a RUIN application starts, it runs a simple event loop on the
|
||||||
|
main thread.
|
||||||
15
examples/calculator/Cargo.toml
Normal file
15
examples/calculator/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[package]
|
||||||
|
name = "calculator"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "calculator"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ruin_app = { path = "../../lib/ruin_app" }
|
||||||
|
ruin_ui = { path = "../../lib/ui" }
|
||||||
|
ruin-runtime = { path = "../../lib/runtime" }
|
||||||
|
meval = "0.2"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
375
examples/calculator/src/components.rs
Normal file
375
examples/calculator/src/components.rs
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
use ruin_app::prelude::*;
|
||||||
|
use ruin_ui::{BoxShadow, BoxShadowKind, Point, TextFontFamily};
|
||||||
|
|
||||||
|
use crate::state::{CalcMode, CalcState};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Display panel
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Display(state: CalcState) -> impl IntoView {
|
||||||
|
let expression = state.expression.clone();
|
||||||
|
let is_error = state.is_error.clone();
|
||||||
|
let history = state.history.clone();
|
||||||
|
|
||||||
|
let result_color = use_memo({
|
||||||
|
let is_error = is_error.clone();
|
||||||
|
move || {
|
||||||
|
if is_error.get() {
|
||||||
|
Color::rgba(200, 30, 30, 245)
|
||||||
|
} else {
|
||||||
|
Color::rgba(20, 140, 20, 245)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let result_text = use_memo({
|
||||||
|
let result = state.result.clone();
|
||||||
|
move || {
|
||||||
|
let r = result.get();
|
||||||
|
if r.is_empty() { " ".to_string() } else { format!("= {r}") }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
view! {
|
||||||
|
block(
|
||||||
|
padding = 12.0,
|
||||||
|
gap = 8.0,
|
||||||
|
// min_height (content) sized to cover scroll_box + expression + result lines
|
||||||
|
// so the window auto-sizes correctly despite text min-size being 0.
|
||||||
|
// Content: scroll_box(100) + expr_text(31) + result_text(20) + 2×gap(16) = 167
|
||||||
|
min_height = 167.0,
|
||||||
|
align_self = AlignItems::Stretch,
|
||||||
|
align_items = AlignItems::End,
|
||||||
|
background = Color::rgba(255, 255, 255, 200),
|
||||||
|
border_radius = 12.0,
|
||||||
|
border = (1.0, Color::rgba(0, 0, 0, 60)),
|
||||||
|
shadow = BoxShadow::new(Point::new(2.0, 2.0), 8.0, 2.0, Color::rgba(0, 0, 0, 40), BoxShadowKind::Outer),
|
||||||
|
) {
|
||||||
|
block(flex = 1.0, min_height = 0.0) {}
|
||||||
|
|
||||||
|
// History (oldest at top, newest at bottom, scrollable)
|
||||||
|
scroll_box(
|
||||||
|
height = 100.0,
|
||||||
|
offset_y = state.history_scroll.clone(),
|
||||||
|
align_self = AlignItems::Stretch,
|
||||||
|
) {
|
||||||
|
column(padding = 4.0, gap = 2.0, align_items = AlignItems::End) {
|
||||||
|
history.with(|entries| {
|
||||||
|
entries
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.map(|e| {
|
||||||
|
let expr = e.expression.clone();
|
||||||
|
let res = e.result.clone();
|
||||||
|
let s = state.clone();
|
||||||
|
let res_for_press = res.clone();
|
||||||
|
view! {
|
||||||
|
button(
|
||||||
|
align_self = AlignItems::End,
|
||||||
|
padding = 4.0,
|
||||||
|
background = Color::rgba(0, 0, 0, 0),
|
||||||
|
border_radius = 4.0,
|
||||||
|
on_press = move |_| s.use_history_result(res_for_press.clone()),
|
||||||
|
) {
|
||||||
|
row(gap = 6.0, align_self = AlignItems::End) {
|
||||||
|
text(size = 12.0, color = Color::rgba(0, 0, 0, 200), font_family = TextFontFamily::Monospace, wrap = TextWrap::None, weight = FontWeight::Normal) {
|
||||||
|
expr.clone()
|
||||||
|
}
|
||||||
|
text(size = 12.0, color = Color::rgba(20, 130, 20, 230), font_family = TextFontFamily::Monospace, wrap = TextWrap::None, weight = FontWeight::Normal) {
|
||||||
|
format!("= {res}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current expression
|
||||||
|
text(
|
||||||
|
size = 26.0,
|
||||||
|
font_family = TextFontFamily::Monospace,
|
||||||
|
color = Color::rgba(0, 0, 0, 245),
|
||||||
|
wrap = TextWrap::None,
|
||||||
|
weight = FontWeight::Normal,
|
||||||
|
pointer_events = false,
|
||||||
|
) {
|
||||||
|
expression.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result / error line
|
||||||
|
if is_error.get() {
|
||||||
|
view! {
|
||||||
|
text(
|
||||||
|
size = 16.0,
|
||||||
|
font_family = TextFontFamily::Monospace,
|
||||||
|
color = result_color.get(),
|
||||||
|
wrap = TextWrap::None,
|
||||||
|
weight = FontWeight::Normal,
|
||||||
|
pointer_events = false,
|
||||||
|
) {
|
||||||
|
result_text.get()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
view! {
|
||||||
|
block(min_height = 0.0) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mode toggle
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn ModeToggle(state: CalcState) -> impl IntoView {
|
||||||
|
let mode = state.mode.clone();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
row(gap = 6.0, align_self = AlignItems::Stretch) {
|
||||||
|
toggle_button("Basic", mode.get() == CalcMode::Basic, {
|
||||||
|
let state = state.clone();
|
||||||
|
move || state.toggle_mode()
|
||||||
|
})
|
||||||
|
toggle_button("Scientific", mode.get() == CalcMode::Scientific, {
|
||||||
|
let state = state.clone();
|
||||||
|
move || state.toggle_mode()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Button helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn calc_button(label: String, bg: Color, on_press: impl Fn() + 'static) -> View {
|
||||||
|
let widget_ref = use_widget_ref::<BlockWidget>();
|
||||||
|
let interaction = use_interaction_state(widget_ref.clone());
|
||||||
|
|
||||||
|
button()
|
||||||
|
.widget_ref(widget_ref)
|
||||||
|
.flex(1.0)
|
||||||
|
.min_width(54.0)
|
||||||
|
// min_height = content height for 20px bold text (20 * 1.2 = 24).
|
||||||
|
// This matches the actual rendered line height so window sizing is accurate
|
||||||
|
// and the text is visually centered by the symmetric 8px top/bottom padding.
|
||||||
|
.min_height(24.0)
|
||||||
|
.padding(Edges::symmetric(8.0, 12.0))
|
||||||
|
.align_items(AlignItems::Center)
|
||||||
|
.background(interactive_background(bg, interaction))
|
||||||
|
.border_radius(8.0)
|
||||||
|
.border((0.5, interactive_border(interaction)))
|
||||||
|
.shadow(interactive_shadow(interaction))
|
||||||
|
.on_press(move |_| on_press())
|
||||||
|
.children(
|
||||||
|
text()
|
||||||
|
.size(20.0)
|
||||||
|
.weight(FontWeight::Bold)
|
||||||
|
.selectable(false)
|
||||||
|
.pointer_events(false)
|
||||||
|
.children(label),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn btn_row(buttons: Vec<View>) -> View {
|
||||||
|
row().gap(6.0).children(buttons)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn toggle_button(label: &'static str, active: bool, on_press: impl Fn() + 'static) -> View {
|
||||||
|
let widget_ref = use_widget_ref::<BlockWidget>();
|
||||||
|
let interaction = use_interaction_state(widget_ref.clone());
|
||||||
|
let base = if active {
|
||||||
|
Color::rgba(60, 100, 220, 180)
|
||||||
|
} else {
|
||||||
|
Color::rgba(255, 255, 255, 90)
|
||||||
|
};
|
||||||
|
|
||||||
|
button()
|
||||||
|
.widget_ref(widget_ref)
|
||||||
|
.flex(1.0)
|
||||||
|
.padding(8.0)
|
||||||
|
.min_height(17.0)
|
||||||
|
.align_items(AlignItems::Center)
|
||||||
|
.background(interactive_background(base, interaction))
|
||||||
|
.border_radius(8.0)
|
||||||
|
.border((0.5, interactive_border(interaction)))
|
||||||
|
.shadow(interactive_shadow(interaction))
|
||||||
|
.on_press(move |_| on_press())
|
||||||
|
.children(
|
||||||
|
text()
|
||||||
|
.size(14.0)
|
||||||
|
.weight(FontWeight::Bold)
|
||||||
|
.selectable(false)
|
||||||
|
.pointer_events(false)
|
||||||
|
.children(label),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn interactive_background(base: Color, interaction: InteractionState) -> Color {
|
||||||
|
if interaction.pressed {
|
||||||
|
mix(base, Color::BLACK, 0.22)
|
||||||
|
} else if interaction.hovered {
|
||||||
|
mix(base, Color::WHITE, 0.22)
|
||||||
|
} else {
|
||||||
|
base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn interactive_border(interaction: InteractionState) -> Color {
|
||||||
|
if interaction.pressed {
|
||||||
|
Color::rgba(0, 0, 0, 96)
|
||||||
|
} else if interaction.hovered {
|
||||||
|
Color::rgba(0, 0, 0, 72)
|
||||||
|
} else {
|
||||||
|
Color::rgba(0, 0, 0, 35)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn interactive_shadow(interaction: InteractionState) -> BoxShadow {
|
||||||
|
if interaction.pressed {
|
||||||
|
BoxShadow::new(
|
||||||
|
Point::new(0.0, 0.0),
|
||||||
|
2.0,
|
||||||
|
0.0,
|
||||||
|
Color::rgba(0, 0, 0, 36),
|
||||||
|
BoxShadowKind::Outer,
|
||||||
|
)
|
||||||
|
} else if interaction.hovered {
|
||||||
|
BoxShadow::new(
|
||||||
|
Point::new(0.0, 3.0),
|
||||||
|
14.0,
|
||||||
|
0.0,
|
||||||
|
Color::rgba(0, 0, 0, 42),
|
||||||
|
BoxShadowKind::Outer,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
BoxShadow::new(
|
||||||
|
Point::new(0.0, 1.0),
|
||||||
|
6.0,
|
||||||
|
0.0,
|
||||||
|
Color::rgba(0, 0, 0, 18),
|
||||||
|
BoxShadowKind::Outer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mix(base: Color, overlay: Color, amount: f32) -> Color {
|
||||||
|
let amount = amount.clamp(0.0, 1.0);
|
||||||
|
let blend = |from: u8, to: u8| -> u8 {
|
||||||
|
((from as f32) + ((to as f32) - (from as f32)) * amount).round() as u8
|
||||||
|
};
|
||||||
|
|
||||||
|
Color::rgba(
|
||||||
|
blend(base.r, overlay.r),
|
||||||
|
blend(base.g, overlay.g),
|
||||||
|
blend(base.b, overlay.b),
|
||||||
|
base.a,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Keypad (basic)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Keypad(state: CalcState) -> impl IntoView {
|
||||||
|
let bg_num = Color::rgba(255, 255, 255, 90);
|
||||||
|
let bg_op = Color::rgba(210, 230, 255, 140);
|
||||||
|
let bg_eq = Color::rgba(160, 230, 160, 160);
|
||||||
|
let bg_fn = Color::rgba(255, 200, 180, 140);
|
||||||
|
|
||||||
|
macro_rules! digit {
|
||||||
|
($ch:expr) => {{
|
||||||
|
let s = state.clone();
|
||||||
|
let t = $ch.to_string();
|
||||||
|
let label = t.clone();
|
||||||
|
calc_button(label, bg_num, move || s.input(&t))
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
macro_rules! op {
|
||||||
|
($ch:expr) => {{
|
||||||
|
let s = state.clone();
|
||||||
|
let t = $ch.to_string();
|
||||||
|
let label = t.clone();
|
||||||
|
calc_button(label, bg_op, move || s.input(&t))
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
|
let clear_btn = {
|
||||||
|
let s = state.clone();
|
||||||
|
calc_button("C".to_string(), bg_fn, move || s.clear())
|
||||||
|
};
|
||||||
|
let paren_btn = {
|
||||||
|
let s = state.clone();
|
||||||
|
calc_button("( )".to_string(), bg_num, move || s.input_paren())
|
||||||
|
};
|
||||||
|
let pct_btn = {
|
||||||
|
let s = state.clone();
|
||||||
|
calc_button("%".to_string(), bg_num, move || s.input("%"))
|
||||||
|
};
|
||||||
|
let bs_btn = {
|
||||||
|
let s = state.clone();
|
||||||
|
calc_button("⌫".to_string(), bg_fn, move || s.backspace())
|
||||||
|
};
|
||||||
|
let dot_btn = {
|
||||||
|
let s = state.clone();
|
||||||
|
calc_button(".".to_string(), bg_num, move || s.input("."))
|
||||||
|
};
|
||||||
|
let eq_btn = {
|
||||||
|
let s = state.clone();
|
||||||
|
calc_button("=".to_string(), bg_eq, move || s.evaluate())
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
column(gap = 6.0, align_self = AlignItems::Stretch) {
|
||||||
|
btn_row(vec![clear_btn, paren_btn, pct_btn, bs_btn])
|
||||||
|
btn_row(vec![digit!('7'), digit!('8'), digit!('9'), op!('÷')])
|
||||||
|
btn_row(vec![digit!('4'), digit!('5'), digit!('6'), op!('×')])
|
||||||
|
btn_row(vec![digit!('1'), digit!('2'), digit!('3'), op!('-')])
|
||||||
|
btn_row(vec![digit!('0'), dot_btn, eq_btn, op!('+')])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Scientific panel
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn ScientificPanel(state: CalcState) -> impl IntoView {
|
||||||
|
let bg = Color::rgba(230, 215, 255, 140);
|
||||||
|
|
||||||
|
macro_rules! sci {
|
||||||
|
($label:expr, $insert:expr) => {{
|
||||||
|
let s = state.clone();
|
||||||
|
let ins: &'static str = $insert;
|
||||||
|
calc_button($label.to_string(), bg, move || s.input(ins))
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
|
let sq_btn = {
|
||||||
|
let s = state.clone();
|
||||||
|
calc_button("x²".to_string(), bg, move || {
|
||||||
|
s.expression.update(|e| {
|
||||||
|
let wrapped = format!("({e})^2");
|
||||||
|
*e = wrapped;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
column(gap = 6.0, align_self = AlignItems::Stretch) {
|
||||||
|
btn_row(vec![sci!("sin(", "sin("), sci!("cos(", "cos("), sci!("tan(", "tan("), sci!("asin(", "asin(")])
|
||||||
|
btn_row(vec![sci!("log(", "log("), sci!("ln(", "ln("), sci!("√(", "√("), sq_btn])
|
||||||
|
btn_row(vec![sci!("xʸ", "^"), sci!("π", "π"), sci!("e", "e"), sci!("fact(", "fact(")])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
161
examples/calculator/src/main.rs
Normal file
161
examples/calculator/src/main.rs
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
mod components;
|
||||||
|
mod state;
|
||||||
|
|
||||||
|
use ruin_app::prelude::*;
|
||||||
|
use tracing_subscriber::layer::SubscriberExt;
|
||||||
|
use tracing_subscriber::util::SubscriberInitExt;
|
||||||
|
use tracing_subscriber::{EnvFilter, fmt};
|
||||||
|
|
||||||
|
use components::{Display, Keypad, ModeToggle, ScientificPanel};
|
||||||
|
use state::{CalcMode, CalcState};
|
||||||
|
|
||||||
|
#[ruin_runtime::async_main]
|
||||||
|
async fn main() -> ruin_app::Result<()> {
|
||||||
|
init_tracing();
|
||||||
|
|
||||||
|
App::new()
|
||||||
|
.window(
|
||||||
|
Window::new()
|
||||||
|
.title("Calculator")
|
||||||
|
.app_id("dev.ruin.calculator")
|
||||||
|
.base_color(Color::rgba(255, 255, 255, 80))
|
||||||
|
.resizable(false),
|
||||||
|
)
|
||||||
|
.mount(view! { CalculatorApp() {} })
|
||||||
|
.run()
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_tracing() {
|
||||||
|
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
||||||
|
EnvFilter::new(
|
||||||
|
"info,ruin_app::resize=trace,ruin_app::interaction=trace,\
|
||||||
|
ruin_ui_platform_wayland::resize=trace,ruin_ui_platform_wayland::perf=debug,\
|
||||||
|
ruin_ui_platform_wayland::event_bridge=trace",
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
let _ = tracing_subscriber::registry()
|
||||||
|
.with(fmt::layer().with_target(true).without_time())
|
||||||
|
.with(filter)
|
||||||
|
.try_init();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn CalculatorApp() -> impl IntoView {
|
||||||
|
let state = CalcState::new();
|
||||||
|
|
||||||
|
// ---- Keyboard shortcuts (application-scoped) ----
|
||||||
|
|
||||||
|
for ch in "0123456789.+-*/%()".chars() {
|
||||||
|
let s = state.clone();
|
||||||
|
let display = match ch {
|
||||||
|
'*' => "×",
|
||||||
|
'/' => "÷",
|
||||||
|
_ => "",
|
||||||
|
};
|
||||||
|
let text = if display.is_empty() {
|
||||||
|
ch.to_string()
|
||||||
|
} else {
|
||||||
|
display.to_string()
|
||||||
|
};
|
||||||
|
use_shortcut(Shortcut::new(Key::Character(ch)), ShortcutScope::Application, {
|
||||||
|
let text = text.clone();
|
||||||
|
move || s.input(&text)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// = key also evaluates
|
||||||
|
{
|
||||||
|
let s = state.clone();
|
||||||
|
use_shortcut(Shortcut::new(Key::Character('=')), ShortcutScope::Application, move || s.evaluate());
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let s = state.clone();
|
||||||
|
use_shortcut(Shortcut::new(Key::Enter), ShortcutScope::Application, move || s.evaluate());
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let s = state.clone();
|
||||||
|
use_shortcut(Shortcut::new(Key::Backspace), ShortcutScope::Application, move || s.backspace());
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let s = state.clone();
|
||||||
|
use_shortcut(Shortcut::new(Key::Escape), ShortcutScope::Application, move || s.clear());
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let s = state.clone();
|
||||||
|
use_shortcut(Shortcut::new(Key::Delete), ShortcutScope::Application, move || s.clear());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Layout ----
|
||||||
|
|
||||||
|
let mode = state.mode.clone();
|
||||||
|
|
||||||
|
let text_style = PartialTextStyle {
|
||||||
|
color: Some(Color::BLACK),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
if mode.get() == CalcMode::Scientific {
|
||||||
|
view! {
|
||||||
|
column(
|
||||||
|
gap = 10.0,
|
||||||
|
padding = 12.0,
|
||||||
|
align_items = AlignItems::Stretch,
|
||||||
|
text_style = text_style,
|
||||||
|
) {
|
||||||
|
Display(state = state.clone()) {}
|
||||||
|
ModeToggle(state = state.clone()) {}
|
||||||
|
ScientificPanel(state = state.clone()) {}
|
||||||
|
Keypad(state = state.clone()) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
view! {
|
||||||
|
column(
|
||||||
|
gap = 10.0,
|
||||||
|
padding = 12.0,
|
||||||
|
align_items = AlignItems::Stretch,
|
||||||
|
text_style = text_style,
|
||||||
|
) {
|
||||||
|
Display(state = state.clone()) {}
|
||||||
|
ModeToggle(state = state.clone()) {}
|
||||||
|
Keypad(state = state.clone()) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn StateProbe(slot: Rc<RefCell<Option<CalcState>>>) -> impl IntoView {
|
||||||
|
let state = CalcState::new();
|
||||||
|
*slot.borrow_mut() = Some(state);
|
||||||
|
block().children(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn successful_evaluation_replaces_expression_and_preserves_history() {
|
||||||
|
let slot = Rc::new(RefCell::new(None::<CalcState>));
|
||||||
|
let _ = ruin_app::__render_mountable_for_test(&view! {
|
||||||
|
StateProbe(slot = Rc::clone(&slot)) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
let state = slot.borrow().clone().expect("state should be captured");
|
||||||
|
let _ = state.expression.set("2+2".to_string());
|
||||||
|
state.evaluate();
|
||||||
|
|
||||||
|
assert_eq!(state.expression.get(), "4");
|
||||||
|
assert_eq!(state.result.get(), "");
|
||||||
|
assert_eq!(state.is_error.get(), false);
|
||||||
|
assert_eq!(state.history.get().len(), 1);
|
||||||
|
assert_eq!(state.history.get()[0].expression, "2+2");
|
||||||
|
assert_eq!(state.history.get()[0].result, "4");
|
||||||
|
}
|
||||||
|
}
|
||||||
198
examples/calculator/src/state.rs
Normal file
198
examples/calculator/src/state.rs
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
use ruin_app::prelude::*;
|
||||||
|
|
||||||
|
type EvalResult = std::result::Result<f64, String>;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub enum CalcMode {
|
||||||
|
#[default]
|
||||||
|
Basic,
|
||||||
|
Scientific,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct HistoryEntry {
|
||||||
|
pub expression: String,
|
||||||
|
pub result: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// State
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct CalcState {
|
||||||
|
/// The expression currently shown in the input display.
|
||||||
|
pub expression: Signal<String>,
|
||||||
|
/// The result of the last evaluation (or error message). Empty until first `=`.
|
||||||
|
pub result: Signal<String>,
|
||||||
|
/// Whether the last evaluation produced an error.
|
||||||
|
pub is_error: Signal<bool>,
|
||||||
|
/// Past evaluations, newest first.
|
||||||
|
pub history: Signal<Vec<HistoryEntry>>,
|
||||||
|
pub mode: Signal<CalcMode>,
|
||||||
|
/// When true, the next digit/operator input clears the expression first.
|
||||||
|
pub fresh: Signal<bool>,
|
||||||
|
/// Current scroll offset for the history scroll box.
|
||||||
|
pub history_scroll: Signal<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CalcState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
expression: use_signal(|| String::new()),
|
||||||
|
result: use_signal(|| String::new()),
|
||||||
|
is_error: use_signal(|| false),
|
||||||
|
history: use_signal(|| Vec::new()),
|
||||||
|
mode: use_signal(|| CalcMode::Basic),
|
||||||
|
fresh: use_signal(|| false),
|
||||||
|
history_scroll: use_signal(|| 10_000.0_f32),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append `text` to the expression. If `fresh` is set (just evaluated),
|
||||||
|
/// clear the expression first so digits don't append to the old result.
|
||||||
|
pub fn input(&self, text: &str) {
|
||||||
|
if self.fresh.get() {
|
||||||
|
// Starting fresh after a result: if it's an operator continue
|
||||||
|
// from the result, otherwise start a new expression.
|
||||||
|
let is_operator = matches!(text, "+" | "-" | "×" | "÷" | "^" | "%");
|
||||||
|
if !is_operator {
|
||||||
|
let _ = self.expression.set(String::new());
|
||||||
|
}
|
||||||
|
let _ = self.fresh.set(false);
|
||||||
|
}
|
||||||
|
self.expression.update(|e| e.push_str(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete the last character (or close-paren group) from the expression.
|
||||||
|
pub fn backspace(&self) {
|
||||||
|
if self.fresh.get() {
|
||||||
|
let _ = self.fresh.set(false);
|
||||||
|
let _ = self.expression.set(String::new());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.expression.update(|e| {
|
||||||
|
// Pop a full UTF-8 character
|
||||||
|
let new_len = e
|
||||||
|
.char_indices()
|
||||||
|
.next_back()
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
.unwrap_or(0);
|
||||||
|
e.truncate(new_len);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear the expression, result, and fresh flag.
|
||||||
|
pub fn clear(&self) {
|
||||||
|
let _ = self.expression.set(String::new());
|
||||||
|
let _ = self.result.set(String::new());
|
||||||
|
let _ = self.is_error.set(false);
|
||||||
|
let _ = self.fresh.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evaluate the current expression, update result, push to history.
|
||||||
|
pub fn evaluate(&self) {
|
||||||
|
let expr = self.expression.with(|e| e.clone());
|
||||||
|
if expr.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match evaluate(&expr) {
|
||||||
|
Ok(value) => {
|
||||||
|
let result_str = format_result(value);
|
||||||
|
let _ = self.expression.set(result_str.clone());
|
||||||
|
let _ = self.result.set(String::new());
|
||||||
|
let _ = self.is_error.set(false);
|
||||||
|
self.history.update(|h| {
|
||||||
|
h.insert(
|
||||||
|
0,
|
||||||
|
HistoryEntry {
|
||||||
|
expression: expr.clone(),
|
||||||
|
result: result_str,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
h.truncate(50);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(msg) => {
|
||||||
|
let _ = self.result.set(msg);
|
||||||
|
let _ = self.is_error.set(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = self.fresh.set(true);
|
||||||
|
let _ = self.history_scroll.set(10_000.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the expression to a previously computed result (e.g., when clicking a history entry).
|
||||||
|
pub fn use_history_result(&self, result: String) {
|
||||||
|
let _ = self.expression.set(result);
|
||||||
|
let _ = self.result.set(String::new());
|
||||||
|
let _ = self.is_error.set(false);
|
||||||
|
let _ = self.fresh.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_mode(&self) {
|
||||||
|
self.mode.update(|m| {
|
||||||
|
*m = match *m {
|
||||||
|
CalcMode::Basic => CalcMode::Scientific,
|
||||||
|
CalcMode::Scientific => CalcMode::Basic,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert the right parenthesis character based on current balance.
|
||||||
|
pub fn input_paren(&self) {
|
||||||
|
let open = self
|
||||||
|
.expression
|
||||||
|
.with(|e| e.chars().filter(|&c| c == '(').count());
|
||||||
|
let close = self
|
||||||
|
.expression
|
||||||
|
.with(|e| e.chars().filter(|&c| c == ')').count());
|
||||||
|
self.input(if open > close { ")" } else { "(" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Evaluation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Evaluate `raw_expr`, preprocessing display symbols to meval-compatible syntax.
|
||||||
|
pub fn evaluate(raw_expr: &str) -> EvalResult {
|
||||||
|
let expr = raw_expr
|
||||||
|
.replace('×', "*")
|
||||||
|
.replace('÷', "/")
|
||||||
|
.replace('π', "pi")
|
||||||
|
// √( is inserted by the scientific button; meval knows sqrt
|
||||||
|
.replace("√", "sqrt");
|
||||||
|
|
||||||
|
let mut ctx = meval::Context::new();
|
||||||
|
// Add factorial — only integers 0..=20 to avoid overflow
|
||||||
|
ctx.func("fact", |x: f64| {
|
||||||
|
if x < 0.0 || x.fract() != 0.0 || x > 20.0 {
|
||||||
|
return f64::NAN;
|
||||||
|
}
|
||||||
|
(1..=(x as u64)).product::<u64>() as f64
|
||||||
|
});
|
||||||
|
|
||||||
|
meval::eval_str_with_context(&expr, ctx).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format an f64 result: use integer display when possible, otherwise up to 10 sig figs.
|
||||||
|
fn format_result(value: f64) -> String {
|
||||||
|
if value.is_nan() {
|
||||||
|
return "Error".to_string();
|
||||||
|
}
|
||||||
|
if value.is_infinite() {
|
||||||
|
return if value > 0.0 { "∞".to_string() } else { "-∞".to_string() };
|
||||||
|
}
|
||||||
|
if value.fract() == 0.0 && value.abs() < 1e15 {
|
||||||
|
return format!("{}", value as i64);
|
||||||
|
}
|
||||||
|
// Up to 10 significant figures, strip trailing zeros
|
||||||
|
let s = format!("{:.10}", value);
|
||||||
|
let s = s.trim_end_matches('0').trim_end_matches('.');
|
||||||
|
s.to_string()
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
] }
|
||||||
|
|||||||
@@ -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,5 +1,6 @@
|
|||||||
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};
|
||||||
@@ -64,7 +65,7 @@ impl EffectHandle {
|
|||||||
/// recover an EffectHandle after calling this method, so be sure to call it on an EffectHandle that you don't need
|
/// 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.
|
/// to later dispose of.
|
||||||
pub fn leak(self) {
|
pub fn leak(self) {
|
||||||
std::mem::forget(self);
|
core::mem::forget(self);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Disposes the effect immediately.
|
/// Disposes the effect immediately.
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
use std::cell::{Cell, RefCell};
|
use alloc::boxed::Box;
|
||||||
use std::collections::HashMap;
|
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};
|
||||||
|
|
||||||
|
|||||||
@@ -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,8 +87,7 @@ impl Reactor {
|
|||||||
|
|
||||||
/// Returns the current thread's default reactor.
|
/// Returns the current thread's default reactor.
|
||||||
pub fn current() -> Self {
|
pub fn current() -> Self {
|
||||||
CURRENT_REACTOR.with(|slot| {
|
if let Some(inner) = CURRENT_REACTOR.borrow().upgrade() {
|
||||||
if let Some(inner) = slot.borrow().upgrade() {
|
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
tracing::trace!(
|
tracing::trace!(
|
||||||
target: trace_targets::GRAPH,
|
target: trace_targets::GRAPH,
|
||||||
@@ -97,14 +98,13 @@ impl Reactor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let reactor = Self::new();
|
let reactor = Self::new();
|
||||||
*slot.borrow_mut() = Rc::downgrade(&reactor.inner);
|
*CURRENT_REACTOR.borrow_mut() = Rc::downgrade(&reactor.inner);
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
target: trace_targets::GRAPH,
|
target: trace_targets::GRAPH,
|
||||||
event = "current_reactor_install",
|
event = "current_reactor_install",
|
||||||
"installed current thread default reactor"
|
"installed current thread default reactor"
|
||||||
);
|
);
|
||||||
reactor
|
reactor
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs `f` in the dependency-tracking scope of `observer`.
|
/// Runs `f` in the dependency-tracking scope of `observer`.
|
||||||
@@ -143,13 +143,9 @@ impl Reactor {
|
|||||||
f()
|
f()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Records that the currently executing observer depends on `observable`.
|
/// Attempts to record a dependency on `observable` for the currently running observer, returning an
|
||||||
///
|
/// error if doing so would create a dependency cycle.
|
||||||
/// # Panics
|
pub fn try_observe(&self, observable: NodeId) -> Result<(), ReactCycleError> {
|
||||||
///
|
|
||||||
/// Panics with [`ReactCycleError`] if `observable` is already being computed in the current
|
|
||||||
/// dependency stack.
|
|
||||||
pub fn observe(&self, observable: NodeId) {
|
|
||||||
if self
|
if self
|
||||||
.inner
|
.inner
|
||||||
.active_computations
|
.active_computations
|
||||||
@@ -170,12 +166,12 @@ impl Reactor {
|
|||||||
cycle_len = cycle.len(),
|
cycle_len = cycle.len(),
|
||||||
"reactive cycle detected"
|
"reactive cycle detected"
|
||||||
);
|
);
|
||||||
panic_any(ReactCycleError::new(cycle));
|
return Err(ReactCycleError::new(cycle));
|
||||||
}
|
}
|
||||||
|
|
||||||
let current = self.inner.stack.borrow().last().copied();
|
let current = self.inner.stack.borrow().last().copied();
|
||||||
let Some(observer) = current else {
|
let Some(observer) = current else {
|
||||||
return;
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
@@ -199,6 +195,18 @@ impl Reactor {
|
|||||||
.entry(observable)
|
.entry(observable)
|
||||||
.or_default()
|
.or_default()
|
||||||
.insert(observer);
|
.insert(observer);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records a dependency on `observable` for the currently running observer.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if recording the dependency would create a cycle in the reactive graph.
|
||||||
|
pub fn observe(&self, observable: NodeId) {
|
||||||
|
if let Err(e) = self.try_observe(observable) {
|
||||||
|
panic!("reactive cycle detected: {e}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Notifies dependents of `observable`.
|
/// Notifies dependents of `observable`.
|
||||||
@@ -344,11 +352,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>,
|
||||||
}
|
}
|
||||||
@@ -357,11 +365,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),
|
||||||
}
|
}
|
||||||
@@ -421,7 +429,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() {
|
||||||
@@ -436,7 +444,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),
|
||||||
@@ -449,7 +461,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();
|
||||||
@@ -457,15 +469,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" }
|
||||||
745
lib/reactivity_parsing/examples/json_reactive_parser.rs
Normal file
745
lib/reactivity_parsing/examples/json_reactive_parser.rs
Normal file
@@ -0,0 +1,745 @@
|
|||||||
|
use std::env;
|
||||||
|
use std::fmt;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use ruin_reactivity::{cell_in, effect_in};
|
||||||
|
use ruin_reactivity_parsing::{
|
||||||
|
Anchor, AnchorBias, EditDelta, ReactiveRope, RopeEdit, TextPoint, TextRange,
|
||||||
|
};
|
||||||
|
use ruin_runtime::{async_main, fs, stdio};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct ParseSnapshot {
|
||||||
|
text: String,
|
||||||
|
root_anchor: Anchor,
|
||||||
|
root: AstNode,
|
||||||
|
reused_nodes: usize,
|
||||||
|
total_nodes: usize,
|
||||||
|
diagnostics: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct AstNode {
|
||||||
|
id: u64,
|
||||||
|
kind: JsonKind,
|
||||||
|
local_range: TextRange,
|
||||||
|
children: Vec<AstNode>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
enum JsonKind {
|
||||||
|
Object,
|
||||||
|
Array,
|
||||||
|
Pair,
|
||||||
|
String,
|
||||||
|
Number,
|
||||||
|
True,
|
||||||
|
False,
|
||||||
|
Null,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for JsonKind {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
let label = match self {
|
||||||
|
Self::Object => "object",
|
||||||
|
Self::Array => "array",
|
||||||
|
Self::Pair => "pair",
|
||||||
|
Self::String => "string",
|
||||||
|
Self::Number => "number",
|
||||||
|
Self::True => "true",
|
||||||
|
Self::False => "false",
|
||||||
|
Self::Null => "null",
|
||||||
|
};
|
||||||
|
write!(f, "{label}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct ParsedNode {
|
||||||
|
kind: JsonKind,
|
||||||
|
range: TextRange,
|
||||||
|
children: Vec<ParsedNode>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct JsonParser {
|
||||||
|
next_id: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Cursor<'a> {
|
||||||
|
text: &'a str,
|
||||||
|
offset: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
enum Command {
|
||||||
|
Insert { offset: usize, text: String },
|
||||||
|
Replace { start: usize, end: usize, text: String },
|
||||||
|
ReplaceAll { text: String },
|
||||||
|
Delete { start: usize, end: usize },
|
||||||
|
Print,
|
||||||
|
Help,
|
||||||
|
Quit,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_main]
|
||||||
|
async fn main() {
|
||||||
|
let path = env::args()
|
||||||
|
.nth(1)
|
||||||
|
.expect("usage: cargo run -p ruin-reactivity-parsing --example json_reactive_parser -- <path/to/file.json>");
|
||||||
|
let initial_text = fs::read_to_string(&path)
|
||||||
|
.await
|
||||||
|
.expect("json file should be readable");
|
||||||
|
|
||||||
|
let reactor = ruin_reactivity::Reactor::new();
|
||||||
|
let rope = ReactiveRope::from_text(&reactor, &initial_text);
|
||||||
|
let parser = Rc::new(std::cell::RefCell::new(JsonParser::default()));
|
||||||
|
|
||||||
|
let initial_snapshot = parser
|
||||||
|
.borrow_mut()
|
||||||
|
.parse_initial(&rope)
|
||||||
|
.expect("initial JSON should parse");
|
||||||
|
let parse_state = cell_in(&reactor, initial_snapshot);
|
||||||
|
let last_delta = cell_in(&reactor, None::<EditDelta>);
|
||||||
|
|
||||||
|
let _parser_subscription = {
|
||||||
|
let rope = rope.clone();
|
||||||
|
let parser = Rc::clone(&parser);
|
||||||
|
let parse_state = parse_state.clone();
|
||||||
|
let last_delta = last_delta.clone();
|
||||||
|
rope.edit_events().subscribe(move |delta| {
|
||||||
|
let previous = parse_state.get();
|
||||||
|
match parser.borrow_mut().parse_incremental(&rope, &previous, delta) {
|
||||||
|
Ok(next) => {
|
||||||
|
last_delta.replace(Some(delta.clone()));
|
||||||
|
parse_state.replace(next);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("parse error after edit: {error}");
|
||||||
|
eprintln!(
|
||||||
|
"note: current text length is {} bytes; use `replace_all <text>` to replace the full document without manual offsets",
|
||||||
|
rope.len_bytes()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
effect_in(&reactor, {
|
||||||
|
let rope = rope.clone();
|
||||||
|
let parse_state = parse_state.clone();
|
||||||
|
let last_delta = last_delta.clone();
|
||||||
|
move || {
|
||||||
|
parse_state.with(|snapshot| print_snapshot(snapshot, &rope, last_delta.with(Clone::clone).as_ref()));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.leak();
|
||||||
|
|
||||||
|
println!("Commands:");
|
||||||
|
println!(" insert <byte_offset> <text>");
|
||||||
|
println!(" replace <start> <end> <text>");
|
||||||
|
println!(" replace_all <text>");
|
||||||
|
println!(" delete <start> <end>");
|
||||||
|
println!(" print");
|
||||||
|
println!(" help");
|
||||||
|
println!(" quit");
|
||||||
|
|
||||||
|
let mut input = stdio::stdin().expect("stdin should be available");
|
||||||
|
while let Some(line) = input.read_line().await.expect("stdin read should succeed") {
|
||||||
|
let trimmed = line.trim_end();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match parse_command(trimmed) {
|
||||||
|
Ok(Command::Insert { offset, text }) => {
|
||||||
|
let _ = rope.edit(RopeEdit::insert(offset, text));
|
||||||
|
}
|
||||||
|
Ok(Command::Replace { start, end, text }) => {
|
||||||
|
let _ = rope.edit(RopeEdit::replace(TextRange::new(start, end), text));
|
||||||
|
}
|
||||||
|
Ok(Command::ReplaceAll { text }) => {
|
||||||
|
let _ = rope.edit(RopeEdit::replace(TextRange::new(0, rope.len_bytes()), text));
|
||||||
|
}
|
||||||
|
Ok(Command::Delete { start, end }) => {
|
||||||
|
let _ = rope.edit(RopeEdit::delete(TextRange::new(start, end)));
|
||||||
|
}
|
||||||
|
Ok(Command::Print) => {
|
||||||
|
let snapshot = parse_state.get();
|
||||||
|
print_snapshot(&snapshot, &rope, None);
|
||||||
|
}
|
||||||
|
Ok(Command::Help) => {
|
||||||
|
println!("Commands:");
|
||||||
|
println!(" insert <byte_offset> <text>");
|
||||||
|
println!(" replace <start> <end> <text>");
|
||||||
|
println!(" replace_all <text>");
|
||||||
|
println!(" delete <start> <end>");
|
||||||
|
println!(" print");
|
||||||
|
println!(" help");
|
||||||
|
println!(" quit");
|
||||||
|
}
|
||||||
|
Ok(Command::Quit) => break,
|
||||||
|
Err(error) => eprintln!("{error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_command(input: &str) -> Result<Command, String> {
|
||||||
|
if input == "quit" {
|
||||||
|
return Ok(Command::Quit);
|
||||||
|
}
|
||||||
|
if input == "print" {
|
||||||
|
return Ok(Command::Print);
|
||||||
|
}
|
||||||
|
if input == "help" {
|
||||||
|
return Ok(Command::Help);
|
||||||
|
}
|
||||||
|
if let Some(rest) = input.strip_prefix("insert ") {
|
||||||
|
let (offset, text) = rest
|
||||||
|
.split_once(' ')
|
||||||
|
.ok_or_else(|| "insert expects: insert <offset> <text>".to_string())?;
|
||||||
|
return Ok(Command::Insert {
|
||||||
|
offset: offset.parse().map_err(|_| "invalid insert offset".to_string())?,
|
||||||
|
text: text.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(rest) = input.strip_prefix("replace ") {
|
||||||
|
let mut parts = rest.splitn(3, ' ');
|
||||||
|
let start = parts
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| "replace expects: replace <start> <end> <text>".to_string())?;
|
||||||
|
let end = parts
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| "replace expects: replace <start> <end> <text>".to_string())?;
|
||||||
|
let text = parts
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| "replace expects: replace <start> <end> <text>".to_string())?;
|
||||||
|
return Ok(Command::Replace {
|
||||||
|
start: start.parse().map_err(|_| "invalid replace start".to_string())?,
|
||||||
|
end: end.parse().map_err(|_| "invalid replace end".to_string())?,
|
||||||
|
text: text.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(text) = input.strip_prefix("replace_all ") {
|
||||||
|
return Ok(Command::ReplaceAll {
|
||||||
|
text: text.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(rest) = input.strip_prefix("delete ") {
|
||||||
|
let (start, end) = rest
|
||||||
|
.split_once(' ')
|
||||||
|
.ok_or_else(|| "delete expects: delete <start> <end>".to_string())?;
|
||||||
|
return Ok(Command::Delete {
|
||||||
|
start: start.parse().map_err(|_| "invalid delete start".to_string())?,
|
||||||
|
end: end.parse().map_err(|_| "invalid delete end".to_string())?,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err("unknown command; type `help`".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JsonParser {
|
||||||
|
fn parse_initial(&mut self, rope: &ReactiveRope) -> Result<ParseSnapshot, String> {
|
||||||
|
let text = read_rope_text(rope);
|
||||||
|
let parsed = parse_json(&text)?;
|
||||||
|
let root = self.materialize_fresh(&parsed, 0);
|
||||||
|
Ok(ParseSnapshot {
|
||||||
|
text,
|
||||||
|
root_anchor: Anchor::new(0, AnchorBias::Left),
|
||||||
|
total_nodes: count_nodes(&root),
|
||||||
|
reused_nodes: 0,
|
||||||
|
diagnostics: Vec::new(),
|
||||||
|
root,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_incremental(
|
||||||
|
&mut self,
|
||||||
|
rope: &ReactiveRope,
|
||||||
|
previous: &ParseSnapshot,
|
||||||
|
delta: &EditDelta,
|
||||||
|
) -> Result<ParseSnapshot, String> {
|
||||||
|
let text = read_rope_text(rope);
|
||||||
|
let parsed = parse_json(&text)?;
|
||||||
|
let mut root_anchor = previous.root_anchor;
|
||||||
|
root_anchor.map_through(delta);
|
||||||
|
let mut reused = 0usize;
|
||||||
|
let root = self.reuse_node(
|
||||||
|
&parsed,
|
||||||
|
Some(&previous.root),
|
||||||
|
previous.root_anchor.byte,
|
||||||
|
root_anchor.byte,
|
||||||
|
&previous.text,
|
||||||
|
&text,
|
||||||
|
delta,
|
||||||
|
&mut reused,
|
||||||
|
);
|
||||||
|
Ok(ParseSnapshot {
|
||||||
|
text,
|
||||||
|
root_anchor,
|
||||||
|
total_nodes: count_nodes(&root),
|
||||||
|
reused_nodes: reused,
|
||||||
|
diagnostics: Vec::new(),
|
||||||
|
root,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn materialize_fresh(&mut self, parsed: &ParsedNode, parent_start: usize) -> AstNode {
|
||||||
|
AstNode {
|
||||||
|
id: self.alloc_id(),
|
||||||
|
kind: parsed.kind,
|
||||||
|
local_range: localize(parsed.range, parent_start),
|
||||||
|
children: parsed
|
||||||
|
.children
|
||||||
|
.iter()
|
||||||
|
.map(|child| self.materialize_fresh(child, parsed.range.start))
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reuse_node(
|
||||||
|
&mut self,
|
||||||
|
parsed: &ParsedNode,
|
||||||
|
old: Option<&AstNode>,
|
||||||
|
old_parent_start: usize,
|
||||||
|
new_parent_start: usize,
|
||||||
|
old_text: &str,
|
||||||
|
new_text: &str,
|
||||||
|
delta: &EditDelta,
|
||||||
|
reused: &mut usize,
|
||||||
|
) -> AstNode {
|
||||||
|
let parsed_local = localize(parsed.range, new_parent_start);
|
||||||
|
if let Some(old) = old
|
||||||
|
&& old.kind == parsed.kind
|
||||||
|
&& map_range(absolute_range(old, old_parent_start), delta) == parsed.range
|
||||||
|
&& old_text
|
||||||
|
.get(absolute_range(old, old_parent_start).start..absolute_range(old, old_parent_start).end)
|
||||||
|
== new_text.get(parsed.range.start..parsed.range.end)
|
||||||
|
{
|
||||||
|
*reused += count_nodes(old);
|
||||||
|
return AstNode {
|
||||||
|
id: old.id,
|
||||||
|
kind: old.kind,
|
||||||
|
local_range: parsed_local,
|
||||||
|
children: old.children.clone(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let children = parsed
|
||||||
|
.children
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, child)| {
|
||||||
|
let previous_child = old.and_then(|node| node.children.get(index));
|
||||||
|
let next_old_parent_start = old
|
||||||
|
.map(|node| absolute_range(node, old_parent_start).start)
|
||||||
|
.unwrap_or(old_parent_start);
|
||||||
|
self.reuse_node(
|
||||||
|
child,
|
||||||
|
previous_child,
|
||||||
|
next_old_parent_start,
|
||||||
|
parsed.range.start,
|
||||||
|
old_text,
|
||||||
|
new_text,
|
||||||
|
delta,
|
||||||
|
reused,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
AstNode {
|
||||||
|
id: self.alloc_id(),
|
||||||
|
kind: parsed.kind,
|
||||||
|
local_range: parsed_local,
|
||||||
|
children,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn alloc_id(&mut self) -> u64 {
|
||||||
|
self.next_id = self.next_id.wrapping_add(1);
|
||||||
|
self.next_id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_rope_text(rope: &ReactiveRope) -> String {
|
||||||
|
let mut cursor = rope.cursor(0);
|
||||||
|
let mut text = String::new();
|
||||||
|
while let Some(chunk) = cursor.next_chunk() {
|
||||||
|
text.push_str(chunk.text());
|
||||||
|
}
|
||||||
|
text
|
||||||
|
}
|
||||||
|
|
||||||
|
fn localize(range: TextRange, parent_start: usize) -> TextRange {
|
||||||
|
TextRange::new(
|
||||||
|
range.start.saturating_sub(parent_start),
|
||||||
|
range.end.saturating_sub(parent_start),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn absolute_range(node: &AstNode, parent_start: usize) -> TextRange {
|
||||||
|
TextRange::new(
|
||||||
|
parent_start + node.local_range.start,
|
||||||
|
parent_start + node.local_range.end,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_range(range: TextRange, delta: &EditDelta) -> TextRange {
|
||||||
|
if range.end <= delta.range.start {
|
||||||
|
return range;
|
||||||
|
}
|
||||||
|
if range.start >= delta.range.end {
|
||||||
|
let shift = delta.net_byte_delta();
|
||||||
|
return TextRange::new(
|
||||||
|
range.start.saturating_add_signed(shift),
|
||||||
|
range.end.saturating_add_signed(shift),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
TextRange::new(usize::MAX, usize::MAX)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_json(text: &str) -> Result<ParsedNode, String> {
|
||||||
|
let mut cursor = Cursor { text, offset: 0 };
|
||||||
|
cursor.skip_ws();
|
||||||
|
let node = cursor.parse_value()?;
|
||||||
|
cursor.skip_ws();
|
||||||
|
if !cursor.is_eof() {
|
||||||
|
return Err(format!("unexpected trailing data at byte {}", cursor.offset));
|
||||||
|
}
|
||||||
|
Ok(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Cursor<'a> {
|
||||||
|
fn is_eof(&self) -> bool {
|
||||||
|
self.offset >= self.text.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn skip_ws(&mut self) {
|
||||||
|
while let Some(ch) = self.peek_char() {
|
||||||
|
if ch.is_ascii_whitespace() {
|
||||||
|
self.offset += ch.len_utf8();
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn peek_char(&self) -> Option<char> {
|
||||||
|
self.text[self.offset..].chars().next()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bump_char(&mut self) -> Option<char> {
|
||||||
|
let ch = self.peek_char()?;
|
||||||
|
self.offset += ch.len_utf8();
|
||||||
|
Some(ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expect_char(&mut self, expected: char) -> Result<(), String> {
|
||||||
|
match self.bump_char() {
|
||||||
|
Some(ch) if ch == expected => Ok(()),
|
||||||
|
Some(ch) => Err(format!(
|
||||||
|
"expected `{expected}` at byte {}, found `{ch}`",
|
||||||
|
self.offset.saturating_sub(ch.len_utf8())
|
||||||
|
)),
|
||||||
|
None => Err(format!("expected `{expected}` at end of file")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_value(&mut self) -> Result<ParsedNode, String> {
|
||||||
|
self.skip_ws();
|
||||||
|
match self.peek_char() {
|
||||||
|
Some('{') => self.parse_object(),
|
||||||
|
Some('[') => self.parse_array(),
|
||||||
|
Some('"') => self.parse_string(),
|
||||||
|
Some('t') => self.parse_literal("true", JsonKind::True),
|
||||||
|
Some('f') => self.parse_literal("false", JsonKind::False),
|
||||||
|
Some('n') => self.parse_literal("null", JsonKind::Null),
|
||||||
|
Some('-' | '0'..='9') => self.parse_number(),
|
||||||
|
Some(other) => Err(format!("unexpected `{other}` at byte {}", self.offset)),
|
||||||
|
None => Err("unexpected end of JSON input".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_object(&mut self) -> Result<ParsedNode, String> {
|
||||||
|
let start = self.offset;
|
||||||
|
self.expect_char('{')?;
|
||||||
|
self.skip_ws();
|
||||||
|
let mut children = Vec::new();
|
||||||
|
if self.peek_char() == Some('}') {
|
||||||
|
self.bump_char();
|
||||||
|
return Ok(ParsedNode {
|
||||||
|
kind: JsonKind::Object,
|
||||||
|
range: TextRange::new(start, self.offset),
|
||||||
|
children,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loop {
|
||||||
|
self.skip_ws();
|
||||||
|
let key = self.parse_string()?;
|
||||||
|
self.skip_ws();
|
||||||
|
self.expect_char(':')?;
|
||||||
|
self.skip_ws();
|
||||||
|
let value = self.parse_value()?;
|
||||||
|
let pair = ParsedNode {
|
||||||
|
kind: JsonKind::Pair,
|
||||||
|
range: TextRange::new(key.range.start, value.range.end),
|
||||||
|
children: vec![key, value],
|
||||||
|
};
|
||||||
|
children.push(pair);
|
||||||
|
self.skip_ws();
|
||||||
|
match self.peek_char() {
|
||||||
|
Some(',') => {
|
||||||
|
self.bump_char();
|
||||||
|
}
|
||||||
|
Some('}') => {
|
||||||
|
self.bump_char();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Some(other) => {
|
||||||
|
return Err(format!("expected `,` or `}}` at byte {}, found `{other}`", self.offset));
|
||||||
|
}
|
||||||
|
None => return Err("unterminated object".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ParsedNode {
|
||||||
|
kind: JsonKind::Object,
|
||||||
|
range: TextRange::new(start, self.offset),
|
||||||
|
children,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_array(&mut self) -> Result<ParsedNode, String> {
|
||||||
|
let start = self.offset;
|
||||||
|
self.expect_char('[')?;
|
||||||
|
self.skip_ws();
|
||||||
|
let mut children = Vec::new();
|
||||||
|
if self.peek_char() == Some(']') {
|
||||||
|
self.bump_char();
|
||||||
|
return Ok(ParsedNode {
|
||||||
|
kind: JsonKind::Array,
|
||||||
|
range: TextRange::new(start, self.offset),
|
||||||
|
children,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let value = self.parse_value()?;
|
||||||
|
children.push(value);
|
||||||
|
self.skip_ws();
|
||||||
|
match self.peek_char() {
|
||||||
|
Some(',') => {
|
||||||
|
self.bump_char();
|
||||||
|
self.skip_ws();
|
||||||
|
}
|
||||||
|
Some(']') => {
|
||||||
|
self.bump_char();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Some(other) => {
|
||||||
|
return Err(format!("expected `,` or `]` at byte {}, found `{other}`", self.offset));
|
||||||
|
}
|
||||||
|
None => return Err("unterminated array".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ParsedNode {
|
||||||
|
kind: JsonKind::Array,
|
||||||
|
range: TextRange::new(start, self.offset),
|
||||||
|
children,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_string(&mut self) -> Result<ParsedNode, String> {
|
||||||
|
let start = self.offset;
|
||||||
|
self.expect_char('"')?;
|
||||||
|
loop {
|
||||||
|
match self.bump_char() {
|
||||||
|
Some('"') => {
|
||||||
|
return Ok(ParsedNode {
|
||||||
|
kind: JsonKind::String,
|
||||||
|
range: TextRange::new(start, self.offset),
|
||||||
|
children: Vec::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Some('\\') => match self.bump_char() {
|
||||||
|
Some('"') | Some('\\') | Some('/') | Some('b') | Some('f') | Some('n')
|
||||||
|
| Some('r') | Some('t') => {}
|
||||||
|
Some('u') => {
|
||||||
|
for _ in 0..4 {
|
||||||
|
match self.bump_char() {
|
||||||
|
Some(ch) if ch.is_ascii_hexdigit() => {}
|
||||||
|
_ => return Err(format!("invalid unicode escape at byte {}", self.offset)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => return Err(format!("invalid escape sequence at byte {}", self.offset)),
|
||||||
|
},
|
||||||
|
Some(ch) if ch >= '\u{20}' => {}
|
||||||
|
Some(_) => return Err(format!("control character in string at byte {}", self.offset)),
|
||||||
|
None => return Err("unterminated string".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_number(&mut self) -> Result<ParsedNode, String> {
|
||||||
|
let start = self.offset;
|
||||||
|
if self.peek_char() == Some('-') {
|
||||||
|
self.bump_char();
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.peek_char() {
|
||||||
|
Some('0') => {
|
||||||
|
self.bump_char();
|
||||||
|
}
|
||||||
|
Some('1'..='9') => {
|
||||||
|
self.bump_char();
|
||||||
|
while matches!(self.peek_char(), Some('0'..='9')) {
|
||||||
|
self.bump_char();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => return Err(format!("invalid number at byte {}", self.offset)),
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.peek_char() == Some('.') {
|
||||||
|
self.bump_char();
|
||||||
|
let mut digits = 0usize;
|
||||||
|
while matches!(self.peek_char(), Some('0'..='9')) {
|
||||||
|
digits += 1;
|
||||||
|
self.bump_char();
|
||||||
|
}
|
||||||
|
if digits == 0 {
|
||||||
|
return Err(format!("expected fraction digits at byte {}", self.offset));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches!(self.peek_char(), Some('e' | 'E')) {
|
||||||
|
self.bump_char();
|
||||||
|
if matches!(self.peek_char(), Some('+' | '-')) {
|
||||||
|
self.bump_char();
|
||||||
|
}
|
||||||
|
let mut digits = 0usize;
|
||||||
|
while matches!(self.peek_char(), Some('0'..='9')) {
|
||||||
|
digits += 1;
|
||||||
|
self.bump_char();
|
||||||
|
}
|
||||||
|
if digits == 0 {
|
||||||
|
return Err(format!("expected exponent digits at byte {}", self.offset));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ParsedNode {
|
||||||
|
kind: JsonKind::Number,
|
||||||
|
range: TextRange::new(start, self.offset),
|
||||||
|
children: Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_literal(&mut self, literal: &str, kind: JsonKind) -> Result<ParsedNode, String> {
|
||||||
|
let start = self.offset;
|
||||||
|
if !self.text[self.offset..].starts_with(literal) {
|
||||||
|
return Err(format!("expected `{literal}` at byte {}", self.offset));
|
||||||
|
}
|
||||||
|
self.offset += literal.len();
|
||||||
|
Ok(ParsedNode {
|
||||||
|
kind,
|
||||||
|
range: TextRange::new(start, self.offset),
|
||||||
|
children: Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_snapshot(snapshot: &ParseSnapshot, rope: &ReactiveRope, delta: Option<&EditDelta>) {
|
||||||
|
let metrics = rope.snapshot_metrics();
|
||||||
|
println!();
|
||||||
|
println!("Document revision {}", metrics.revision);
|
||||||
|
println!(
|
||||||
|
"Text bytes={}, leaves={}, nodes={}, reused_on_last_edit={}",
|
||||||
|
metrics.text.bytes, metrics.leaves, snapshot.total_nodes, snapshot.reused_nodes
|
||||||
|
);
|
||||||
|
if let Some(delta) = delta {
|
||||||
|
println!(
|
||||||
|
"Last edit: [{}..{}) -> {:?}",
|
||||||
|
delta.range.start, delta.range.end, delta.inserted_text
|
||||||
|
);
|
||||||
|
let focus = delta.range.start.min(snapshot.text.len().saturating_sub(1));
|
||||||
|
let path = path_for_offset(&snapshot.root, snapshot.root_anchor.byte, focus);
|
||||||
|
println!("Changed path:");
|
||||||
|
for (node, range) in path {
|
||||||
|
let start = rope.point_at(range.start);
|
||||||
|
let end = rope.point_at(range.end);
|
||||||
|
println!(
|
||||||
|
" id={} kind={} span={}",
|
||||||
|
node.id,
|
||||||
|
node.kind,
|
||||||
|
format_span(start, end)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !snapshot.diagnostics.is_empty() {
|
||||||
|
println!("Diagnostics:");
|
||||||
|
for diagnostic in &snapshot.diagnostics {
|
||||||
|
println!(" - {diagnostic}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!("Root id={} kind={}", snapshot.root.id, snapshot.root.kind);
|
||||||
|
println!("Parse tree:");
|
||||||
|
print_tree(&snapshot.root, snapshot.root_anchor.byte, rope, 0);
|
||||||
|
println!("Current text:");
|
||||||
|
println!("{}", snapshot.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_tree(node: &AstNode, parent_start: usize, rope: &ReactiveRope, depth: usize) {
|
||||||
|
let range = absolute_range(node, parent_start);
|
||||||
|
let start = rope.point_at(range.start);
|
||||||
|
let end = rope.point_at(range.end);
|
||||||
|
let indent = " ".repeat(depth);
|
||||||
|
println!(
|
||||||
|
"{indent}- id={} kind={} span={}",
|
||||||
|
node.id,
|
||||||
|
node.kind,
|
||||||
|
format_span(start, end)
|
||||||
|
);
|
||||||
|
for child in &node.children {
|
||||||
|
print_tree(child, range.start, rope, depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path_for_offset(root: &AstNode, root_start: usize, offset: usize) -> Vec<(&AstNode, TextRange)> {
|
||||||
|
fn walk<'a>(
|
||||||
|
node: &'a AstNode,
|
||||||
|
parent_start: usize,
|
||||||
|
offset: usize,
|
||||||
|
out: &mut Vec<(&'a AstNode, TextRange)>,
|
||||||
|
) -> bool {
|
||||||
|
let range = absolute_range(node, parent_start);
|
||||||
|
if offset < range.start || offset >= range.end {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
out.push((node, range));
|
||||||
|
for child in &node.children {
|
||||||
|
if walk(child, range.start, offset, out) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut path = Vec::new();
|
||||||
|
let _ = walk(root, root_start, offset, &mut path);
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_span(start: TextPoint, end: TextPoint) -> String {
|
||||||
|
format!(
|
||||||
|
"{}:{}..{}:{}",
|
||||||
|
start.line + 1,
|
||||||
|
start.column + 1,
|
||||||
|
end.line + 1,
|
||||||
|
end.column + 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn count_nodes(node: &AstNode) -> usize {
|
||||||
|
1 + node.children.iter().map(count_nodes).sum::<usize>()
|
||||||
|
}
|
||||||
207
lib/reactivity_parsing/examples/tokentree_reactive_parser.rs
Normal file
207
lib/reactivity_parsing/examples/tokentree_reactive_parser.rs
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
use std::env;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use ruin_reactivity::effect_in;
|
||||||
|
use ruin_reactivity_parsing::{
|
||||||
|
ReactiveRope, ReactiveTokenTree, RopeEdit, TextRange, TokenTreeNode, TokenTreeParse,
|
||||||
|
TokenTreeUpdate,
|
||||||
|
};
|
||||||
|
use ruin_runtime::{async_main, fs, stdio};
|
||||||
|
|
||||||
|
enum Command {
|
||||||
|
Insert { offset: usize, text: String },
|
||||||
|
Replace { start: usize, end: usize, text: String },
|
||||||
|
ReplaceAll { text: String },
|
||||||
|
Delete { start: usize, end: usize },
|
||||||
|
Print,
|
||||||
|
Help,
|
||||||
|
Quit,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_main]
|
||||||
|
async fn main() {
|
||||||
|
let path = env::args()
|
||||||
|
.nth(1)
|
||||||
|
.expect("usage: cargo run -p ruin-reactivity-parsing --example tokentree_reactive_parser -- <path/to/file>");
|
||||||
|
let initial_text = fs::read_to_string(&path)
|
||||||
|
.await
|
||||||
|
.expect("source file should be readable");
|
||||||
|
|
||||||
|
let reactor = ruin_reactivity::Reactor::new();
|
||||||
|
let rope = ReactiveRope::from_text(&reactor, &initial_text);
|
||||||
|
let parser = Rc::new(ReactiveTokenTree::new(&rope));
|
||||||
|
|
||||||
|
effect_in(&reactor, {
|
||||||
|
let parser = Rc::clone(&parser);
|
||||||
|
let rope = rope.clone();
|
||||||
|
move || {
|
||||||
|
parser.with_snapshot(|snapshot| {
|
||||||
|
let last_update = parser.last_update();
|
||||||
|
print_snapshot(snapshot, &rope, last_update.as_ref());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.leak();
|
||||||
|
|
||||||
|
println!("Commands:");
|
||||||
|
println!(" insert <byte_offset> <text>");
|
||||||
|
println!(" replace <start> <end> <text>");
|
||||||
|
println!(" replace_all <text>");
|
||||||
|
println!(" delete <start> <end>");
|
||||||
|
println!(" print");
|
||||||
|
println!(" help");
|
||||||
|
println!(" quit");
|
||||||
|
|
||||||
|
let mut input = stdio::stdin().expect("stdin should be available");
|
||||||
|
while let Some(line) = input.read_line().await.expect("stdin read should succeed") {
|
||||||
|
let trimmed = line.trim_end();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match parse_command(trimmed) {
|
||||||
|
Ok(Command::Insert { offset, text }) => {
|
||||||
|
let _ = rope.edit(RopeEdit::insert(offset, text));
|
||||||
|
}
|
||||||
|
Ok(Command::Replace { start, end, text }) => {
|
||||||
|
let _ = rope.edit(RopeEdit::replace(TextRange::new(start, end), text));
|
||||||
|
}
|
||||||
|
Ok(Command::ReplaceAll { text }) => {
|
||||||
|
let _ = rope.edit(RopeEdit::replace(TextRange::new(0, rope.len_bytes()), text));
|
||||||
|
}
|
||||||
|
Ok(Command::Delete { start, end }) => {
|
||||||
|
let _ = rope.edit(RopeEdit::delete(TextRange::new(start, end)));
|
||||||
|
}
|
||||||
|
Ok(Command::Print) => {
|
||||||
|
let snapshot = parser.snapshot();
|
||||||
|
let last_update = parser.last_update();
|
||||||
|
print_snapshot(&snapshot, &rope, last_update.as_ref());
|
||||||
|
}
|
||||||
|
Ok(Command::Help) => {
|
||||||
|
println!("Commands:");
|
||||||
|
println!(" insert <byte_offset> <text>");
|
||||||
|
println!(" replace <start> <end> <text>");
|
||||||
|
println!(" replace_all <text>");
|
||||||
|
println!(" delete <start> <end>");
|
||||||
|
println!(" print");
|
||||||
|
println!(" help");
|
||||||
|
println!(" quit");
|
||||||
|
}
|
||||||
|
Ok(Command::Quit) => break,
|
||||||
|
Err(error) => eprintln!("{error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_command(input: &str) -> Result<Command, String> {
|
||||||
|
if input == "quit" {
|
||||||
|
return Ok(Command::Quit);
|
||||||
|
}
|
||||||
|
if input == "print" {
|
||||||
|
return Ok(Command::Print);
|
||||||
|
}
|
||||||
|
if input == "help" {
|
||||||
|
return Ok(Command::Help);
|
||||||
|
}
|
||||||
|
if let Some(rest) = input.strip_prefix("insert ") {
|
||||||
|
let (offset, text) = rest
|
||||||
|
.split_once(' ')
|
||||||
|
.ok_or_else(|| "insert expects: insert <offset> <text>".to_string())?;
|
||||||
|
return Ok(Command::Insert {
|
||||||
|
offset: offset.parse().map_err(|_| "invalid insert offset".to_string())?,
|
||||||
|
text: text.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(rest) = input.strip_prefix("replace ") {
|
||||||
|
let mut parts = rest.splitn(3, ' ');
|
||||||
|
let start = parts
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| "replace expects: replace <start> <end> <text>".to_string())?;
|
||||||
|
let end = parts
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| "replace expects: replace <start> <end> <text>".to_string())?;
|
||||||
|
let text = parts
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| "replace expects: replace <start> <end> <text>".to_string())?;
|
||||||
|
return Ok(Command::Replace {
|
||||||
|
start: start.parse().map_err(|_| "invalid replace start".to_string())?,
|
||||||
|
end: end.parse().map_err(|_| "invalid replace end".to_string())?,
|
||||||
|
text: text.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(text) = input.strip_prefix("replace_all ") {
|
||||||
|
return Ok(Command::ReplaceAll {
|
||||||
|
text: text.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(rest) = input.strip_prefix("delete ") {
|
||||||
|
let (start, end) = rest
|
||||||
|
.split_once(' ')
|
||||||
|
.ok_or_else(|| "delete expects: delete <start> <end>".to_string())?;
|
||||||
|
return Ok(Command::Delete {
|
||||||
|
start: start.parse().map_err(|_| "invalid delete start".to_string())?,
|
||||||
|
end: end.parse().map_err(|_| "invalid delete end".to_string())?,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err("unknown command; type `help`".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_snapshot(snapshot: &TokenTreeParse, rope: &ReactiveRope, update: Option<&TokenTreeUpdate>) {
|
||||||
|
let metrics = rope.snapshot_metrics();
|
||||||
|
println!();
|
||||||
|
println!("Document revision {}", metrics.revision);
|
||||||
|
println!(
|
||||||
|
"Text bytes={}, leaves={}, nodes={}, reused_on_last_edit={}",
|
||||||
|
metrics.text.bytes,
|
||||||
|
metrics.leaves,
|
||||||
|
snapshot.stats.total_nodes,
|
||||||
|
snapshot.stats.reused_nodes
|
||||||
|
);
|
||||||
|
if let Some(update) = update {
|
||||||
|
println!(
|
||||||
|
"Last edit: [{}..{}) -> {:?}",
|
||||||
|
update.delta.range.start,
|
||||||
|
update.delta.range.end,
|
||||||
|
update.delta.inserted_text
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"Diagnostics changed={}, trivia changed={}",
|
||||||
|
update.diagnostics_changed,
|
||||||
|
update.trivia_changed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!("Parse tree:");
|
||||||
|
print_tree(snapshot, rope, &snapshot.root, 0);
|
||||||
|
if !snapshot.diagnostics.is_empty() {
|
||||||
|
println!("Diagnostics:");
|
||||||
|
for diagnostic in &snapshot.diagnostics {
|
||||||
|
println!(
|
||||||
|
" {:?} [{}..{}): {}",
|
||||||
|
diagnostic.kind, diagnostic.range.start, diagnostic.range.end, diagnostic.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !snapshot.trivia.comments().is_empty() {
|
||||||
|
println!("Comments:");
|
||||||
|
for comment in snapshot.trivia.comments() {
|
||||||
|
println!(
|
||||||
|
" {:?} [{}..{}): {}",
|
||||||
|
comment.kind, comment.range.start, comment.range.end, comment.text
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_tree(snapshot: &TokenTreeParse, rope: &ReactiveRope, node: &TokenTreeNode, depth: usize) {
|
||||||
|
let indent = " ".repeat(depth);
|
||||||
|
let (start, end) = snapshot
|
||||||
|
.absolute_span(rope, node.id)
|
||||||
|
.expect("node span should be available");
|
||||||
|
println!(
|
||||||
|
"{}- id={} kind={} span=({}:{})..({}:{})",
|
||||||
|
indent, node.id, node.kind, start.line, start.column, end.line, end.column
|
||||||
|
);
|
||||||
|
for child in &node.children {
|
||||||
|
print_tree(snapshot, rope, child, depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
152
lib/reactivity_parsing/src/delta.rs
Normal file
152
lib/reactivity_parsing/src/delta.rs
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
use crate::metrics::{TextMetrics, TextPoint};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct TextRange {
|
||||||
|
pub start: usize,
|
||||||
|
pub end: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextRange {
|
||||||
|
pub const fn new(start: usize, end: usize) -> Self {
|
||||||
|
Self { start, end }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn len(self) -> usize {
|
||||||
|
self.end - self.start
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn is_empty(self) -> bool {
|
||||||
|
self.start == self.end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct RopeEdit {
|
||||||
|
pub range: TextRange,
|
||||||
|
pub insert: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RopeEdit {
|
||||||
|
pub fn insert(offset: usize, text: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
range: TextRange::new(offset, offset),
|
||||||
|
insert: text.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete(range: TextRange) -> Self {
|
||||||
|
Self {
|
||||||
|
range,
|
||||||
|
insert: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn replace(range: TextRange, text: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
range,
|
||||||
|
insert: text.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct EditDelta {
|
||||||
|
pub range: TextRange,
|
||||||
|
pub removed_text: String,
|
||||||
|
pub inserted_text: String,
|
||||||
|
pub removed: TextMetrics,
|
||||||
|
pub inserted: TextMetrics,
|
||||||
|
pub start: TextPoint,
|
||||||
|
pub old_end: TextPoint,
|
||||||
|
pub new_end: TextPoint,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EditDelta {
|
||||||
|
pub fn net_byte_delta(&self) -> isize {
|
||||||
|
self.inserted.bytes as isize - self.removed.bytes as isize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum AnchorBias {
|
||||||
|
Left,
|
||||||
|
Right,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct Anchor {
|
||||||
|
pub byte: usize,
|
||||||
|
pub bias: AnchorBias,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Anchor {
|
||||||
|
pub const fn new(byte: usize, bias: AnchorBias) -> Self {
|
||||||
|
Self { byte, bias }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn map_through(&mut self, delta: &EditDelta) {
|
||||||
|
if self.byte < delta.range.start {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.byte > delta.range.end {
|
||||||
|
self.byte = self.byte.saturating_add_signed(delta.net_byte_delta());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.byte = match self.bias {
|
||||||
|
AnchorBias::Left => delta.range.start,
|
||||||
|
AnchorBias::Right => delta.range.start + delta.inserted.bytes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{Anchor, AnchorBias, EditDelta, RopeEdit, TextRange};
|
||||||
|
use crate::{TextMetrics, TextPoint};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn constructors_build_expected_edits() {
|
||||||
|
assert_eq!(RopeEdit::insert(2, "x").range, TextRange::new(2, 2));
|
||||||
|
assert_eq!(RopeEdit::delete(TextRange::new(2, 4)).insert, "");
|
||||||
|
assert_eq!(RopeEdit::replace(TextRange::new(1, 3), "zz").insert, "zz");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_range_helpers_report_size() {
|
||||||
|
let range = TextRange::new(3, 7);
|
||||||
|
assert_eq!(range.len(), 4);
|
||||||
|
assert!(!range.is_empty());
|
||||||
|
assert!(TextRange::new(5, 5).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anchors_map_before_inside_and_after_edits() {
|
||||||
|
let delta = EditDelta {
|
||||||
|
range: TextRange::new(4, 7),
|
||||||
|
removed_text: "abc".into(),
|
||||||
|
inserted_text: "x".into(),
|
||||||
|
removed: TextMetrics::from_text("abc"),
|
||||||
|
inserted: TextMetrics::from_text("x"),
|
||||||
|
start: TextPoint::origin().advance("1234"),
|
||||||
|
old_end: TextPoint::origin().advance("1234abc"),
|
||||||
|
new_end: TextPoint::origin().advance("1234x"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut before = Anchor::new(2, AnchorBias::Left);
|
||||||
|
let mut inside_left = Anchor::new(5, AnchorBias::Left);
|
||||||
|
let mut inside_right = Anchor::new(5, AnchorBias::Right);
|
||||||
|
let mut after = Anchor::new(10, AnchorBias::Left);
|
||||||
|
|
||||||
|
before.map_through(&delta);
|
||||||
|
inside_left.map_through(&delta);
|
||||||
|
inside_right.map_through(&delta);
|
||||||
|
after.map_through(&delta);
|
||||||
|
|
||||||
|
assert_eq!(before.byte, 2);
|
||||||
|
assert_eq!(inside_left.byte, 4);
|
||||||
|
assert_eq!(inside_right.byte, 5);
|
||||||
|
assert_eq!(after.byte, 8);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
lib/reactivity_parsing/src/lib.rs
Normal file
24
lib/reactivity_parsing/src/lib.rs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
//! Reactive rope text storage and incremental parsing substrate for RUIN.
|
||||||
|
|
||||||
|
mod delta;
|
||||||
|
mod metrics;
|
||||||
|
mod parse;
|
||||||
|
mod rope;
|
||||||
|
mod tokentree;
|
||||||
|
|
||||||
|
pub use delta::{Anchor, AnchorBias, EditDelta, RopeEdit, TextRange};
|
||||||
|
pub use metrics::{TextMetrics, TextPoint};
|
||||||
|
pub use parse::{
|
||||||
|
IncrementalParser, ParseError, ParseInvalidation, ParseResult, ParserCheckpoint, ParseTree,
|
||||||
|
SyntaxFragment,
|
||||||
|
};
|
||||||
|
pub use rope::{
|
||||||
|
DEFAULT_LEAF_TARGET_BYTES, ReactiveRope, RopeBuilder, RopeChunk, RopeCursor,
|
||||||
|
RopeSnapshotMetrics,
|
||||||
|
};
|
||||||
|
pub use tokentree::{
|
||||||
|
DelimiterKind, NumberBase, NumberLiteral, NumberSuffix, StringDelimiter, StringFragment,
|
||||||
|
ReactiveTokenTree, TokenTreeDiagnostic, TokenTreeDiagnosticKind, TokenTreeNode,
|
||||||
|
TokenTreeNodeKind, TokenTreeParse, TokenTreeParser, TokenTreeStats, TokenTreeUpdate,
|
||||||
|
TriviaComment, TriviaKind, TriviaStore,
|
||||||
|
};
|
||||||
139
lib/reactivity_parsing/src/metrics.rs
Normal file
139
lib/reactivity_parsing/src/metrics.rs
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
use core::fmt;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub struct TextMetrics {
|
||||||
|
pub bytes: usize,
|
||||||
|
pub chars: usize,
|
||||||
|
pub utf16: usize,
|
||||||
|
pub line_breaks: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextMetrics {
|
||||||
|
pub fn from_text(text: &str) -> Self {
|
||||||
|
let mut metrics = Self::default();
|
||||||
|
for ch in text.chars() {
|
||||||
|
metrics.bytes += ch.len_utf8();
|
||||||
|
metrics.chars += 1;
|
||||||
|
metrics.utf16 += ch.len_utf16();
|
||||||
|
metrics.line_breaks += usize::from(ch == '\n');
|
||||||
|
}
|
||||||
|
metrics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl core::ops::Add for TextMetrics {
|
||||||
|
type Output = Self;
|
||||||
|
|
||||||
|
fn add(self, rhs: Self) -> Self::Output {
|
||||||
|
Self {
|
||||||
|
bytes: self.bytes + rhs.bytes,
|
||||||
|
chars: self.chars + rhs.chars,
|
||||||
|
utf16: self.utf16 + rhs.utf16,
|
||||||
|
line_breaks: self.line_breaks + rhs.line_breaks,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl core::ops::AddAssign for TextMetrics {
|
||||||
|
fn add_assign(&mut self, rhs: Self) {
|
||||||
|
*self = *self + rhs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl core::ops::Sub for TextMetrics {
|
||||||
|
type Output = Self;
|
||||||
|
|
||||||
|
fn sub(self, rhs: Self) -> Self::Output {
|
||||||
|
Self {
|
||||||
|
bytes: self.bytes - rhs.bytes,
|
||||||
|
chars: self.chars - rhs.chars,
|
||||||
|
utf16: self.utf16 - rhs.utf16,
|
||||||
|
line_breaks: self.line_breaks - rhs.line_breaks,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Default, Eq, PartialEq)]
|
||||||
|
pub struct TextPoint {
|
||||||
|
pub byte: usize,
|
||||||
|
pub char_offset: usize,
|
||||||
|
pub utf16_offset: usize,
|
||||||
|
pub line: usize,
|
||||||
|
pub column: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextPoint {
|
||||||
|
pub const fn origin() -> Self {
|
||||||
|
Self {
|
||||||
|
byte: 0,
|
||||||
|
char_offset: 0,
|
||||||
|
utf16_offset: 0,
|
||||||
|
line: 0,
|
||||||
|
column: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn advance(mut self, text: &str) -> Self {
|
||||||
|
self.byte += text.len();
|
||||||
|
for ch in text.chars() {
|
||||||
|
self.char_offset += 1;
|
||||||
|
self.utf16_offset += ch.len_utf16();
|
||||||
|
if ch == '\n' {
|
||||||
|
self.line += 1;
|
||||||
|
self.column = 0;
|
||||||
|
} else {
|
||||||
|
self.column += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for TextPoint {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_struct("TextPoint")
|
||||||
|
.field("byte", &self.byte)
|
||||||
|
.field("char_offset", &self.char_offset)
|
||||||
|
.field("utf16_offset", &self.utf16_offset)
|
||||||
|
.field("line", &self.line)
|
||||||
|
.field("column", &self.column)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{TextMetrics, TextPoint};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn metrics_count_utf8_utf16_and_lines() {
|
||||||
|
let metrics = TextMetrics::from_text("a\né🙂");
|
||||||
|
assert_eq!(metrics.bytes, "a\né🙂".len());
|
||||||
|
assert_eq!(metrics.chars, 4);
|
||||||
|
assert_eq!(metrics.utf16, 5);
|
||||||
|
assert_eq!(metrics.line_breaks, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn metrics_support_arithmetic() {
|
||||||
|
let left = TextMetrics::from_text("hello\n");
|
||||||
|
let right = TextMetrics::from_text("🙂");
|
||||||
|
let combined = left + right;
|
||||||
|
|
||||||
|
assert_eq!(combined.bytes, "hello\n🙂".len());
|
||||||
|
assert_eq!(combined.chars, 7);
|
||||||
|
assert_eq!(combined.utf16, 8);
|
||||||
|
assert_eq!(combined.line_breaks, 1);
|
||||||
|
assert_eq!(combined - right, left);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn point_advance_tracks_offsets() {
|
||||||
|
let point = TextPoint::origin().advance("ab\n🙂");
|
||||||
|
assert_eq!(point.byte, "ab\n🙂".len());
|
||||||
|
assert_eq!(point.char_offset, 4);
|
||||||
|
assert_eq!(point.utf16_offset, 5);
|
||||||
|
assert_eq!(point.line, 1);
|
||||||
|
assert_eq!(point.column, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
162
lib/reactivity_parsing/src/parse.rs
Normal file
162
lib/reactivity_parsing/src/parse.rs
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
use core::fmt;
|
||||||
|
|
||||||
|
use crate::{RopeCursor, TextRange};
|
||||||
|
|
||||||
|
pub trait ParserCheckpoint: Clone + Eq + fmt::Debug + 'static {}
|
||||||
|
|
||||||
|
impl<T> ParserCheckpoint for T where T: Clone + Eq + fmt::Debug + 'static {}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct SyntaxFragment<C, T, D> {
|
||||||
|
pub range: TextRange,
|
||||||
|
pub start_checkpoint: C,
|
||||||
|
pub end_checkpoint: C,
|
||||||
|
pub tree: T,
|
||||||
|
pub diagnostics: Vec<D>,
|
||||||
|
pub grammar_revision: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct ParseTree<T> {
|
||||||
|
pub root: T,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct ParseInvalidation<C> {
|
||||||
|
pub resume_at: usize,
|
||||||
|
pub restart_checkpoint: C,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct ParseResult<C, T, D> {
|
||||||
|
pub tree: ParseTree<T>,
|
||||||
|
pub fragments: Vec<SyntaxFragment<C, T, D>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct ParseError {
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ParseError {
|
||||||
|
pub fn new(message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait IncrementalParser {
|
||||||
|
type Checkpoint: ParserCheckpoint;
|
||||||
|
type Tree: Clone + fmt::Debug + 'static;
|
||||||
|
type Diagnostic: Clone + fmt::Debug + 'static;
|
||||||
|
|
||||||
|
fn grammar_revision(&self) -> u64;
|
||||||
|
|
||||||
|
fn initial_checkpoint(&self) -> Self::Checkpoint;
|
||||||
|
|
||||||
|
fn invalidate(
|
||||||
|
&self,
|
||||||
|
delta_range: TextRange,
|
||||||
|
previous_fragments: &[SyntaxFragment<Self::Checkpoint, Self::Tree, Self::Diagnostic>],
|
||||||
|
) -> ParseInvalidation<Self::Checkpoint>;
|
||||||
|
|
||||||
|
fn parse(
|
||||||
|
&self,
|
||||||
|
cursor: RopeCursor,
|
||||||
|
fragments: &[SyntaxFragment<Self::Checkpoint, Self::Tree, Self::Diagnostic>],
|
||||||
|
invalidation: ParseInvalidation<Self::Checkpoint>,
|
||||||
|
) -> Result<ParseResult<Self::Checkpoint, Self::Tree, Self::Diagnostic>, ParseError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::cell::Cell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use ruin_reactivity::Reactor;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
IncrementalParser, ParseError, ParseInvalidation, ParseResult, ParseTree, SyntaxFragment,
|
||||||
|
};
|
||||||
|
use crate::{ReactiveRope, RopeCursor, TextRange};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct DummyParser {
|
||||||
|
parse_calls: Rc<Cell<usize>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IncrementalParser for DummyParser {
|
||||||
|
type Checkpoint = u8;
|
||||||
|
type Tree = String;
|
||||||
|
type Diagnostic = String;
|
||||||
|
|
||||||
|
fn grammar_revision(&self) -> u64 {
|
||||||
|
7
|
||||||
|
}
|
||||||
|
|
||||||
|
fn initial_checkpoint(&self) -> Self::Checkpoint {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalidate(
|
||||||
|
&self,
|
||||||
|
delta_range: TextRange,
|
||||||
|
_previous_fragments: &[SyntaxFragment<Self::Checkpoint, Self::Tree, Self::Diagnostic>],
|
||||||
|
) -> ParseInvalidation<Self::Checkpoint> {
|
||||||
|
ParseInvalidation {
|
||||||
|
resume_at: delta_range.start.saturating_sub(1),
|
||||||
|
restart_checkpoint: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse(
|
||||||
|
&self,
|
||||||
|
mut cursor: RopeCursor,
|
||||||
|
_fragments: &[SyntaxFragment<Self::Checkpoint, Self::Tree, Self::Diagnostic>],
|
||||||
|
invalidation: ParseInvalidation<Self::Checkpoint>,
|
||||||
|
) -> Result<ParseResult<Self::Checkpoint, Self::Tree, Self::Diagnostic>, ParseError> {
|
||||||
|
self.parse_calls.set(self.parse_calls.get() + 1);
|
||||||
|
let text = cursor.read_bytes(usize::MAX);
|
||||||
|
Ok(ParseResult {
|
||||||
|
tree: ParseTree { root: text.clone() },
|
||||||
|
fragments: vec![SyntaxFragment {
|
||||||
|
range: TextRange::new(invalidation.resume_at, invalidation.resume_at + text.len()),
|
||||||
|
start_checkpoint: invalidation.restart_checkpoint,
|
||||||
|
end_checkpoint: 2,
|
||||||
|
tree: text,
|
||||||
|
diagnostics: Vec::new(),
|
||||||
|
grammar_revision: self.grammar_revision(),
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_error_constructor_sets_message() {
|
||||||
|
let error = ParseError::new("bad");
|
||||||
|
assert_eq!(error.message, "bad");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parser_trait_round_trip_produces_fragments() {
|
||||||
|
let reactor = Reactor::new();
|
||||||
|
let rope = ReactiveRope::from_text(&reactor, "{\"a\":1}");
|
||||||
|
let parser = DummyParser {
|
||||||
|
parse_calls: Rc::new(Cell::new(0)),
|
||||||
|
};
|
||||||
|
|
||||||
|
let invalidation = parser.invalidate(TextRange::new(3, 4), &[]);
|
||||||
|
assert_eq!(invalidation.resume_at, 2);
|
||||||
|
assert_eq!(invalidation.restart_checkpoint, 1);
|
||||||
|
|
||||||
|
let result = parser
|
||||||
|
.parse(rope.cursor(0), &[], invalidation)
|
||||||
|
.expect("dummy parse should succeed");
|
||||||
|
|
||||||
|
assert_eq!(result.tree.root, "{\"a\":1}");
|
||||||
|
assert_eq!(result.fragments.len(), 1);
|
||||||
|
assert_eq!(result.fragments[0].grammar_revision, 7);
|
||||||
|
assert_eq!(parser.parse_calls.get(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
720
lib/reactivity_parsing/src/rope.rs
Normal file
720
lib/reactivity_parsing/src/rope.rs
Normal file
@@ -0,0 +1,720 @@
|
|||||||
|
use std::cell::{Cell, RefCell};
|
||||||
|
use std::cmp::min;
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use ruin_reactivity::{Event, Reactor, Source, event_in, source_in};
|
||||||
|
|
||||||
|
use crate::delta::{EditDelta, RopeEdit, TextRange};
|
||||||
|
use crate::metrics::{TextMetrics, TextPoint};
|
||||||
|
|
||||||
|
pub const DEFAULT_LEAF_TARGET_BYTES: usize = 1024;
|
||||||
|
const DEFAULT_LEAF_MAX_BYTES: usize = DEFAULT_LEAF_TARGET_BYTES * 2;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ReactiveRope {
|
||||||
|
inner: Rc<RopeInner>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RopeBuilder {
|
||||||
|
reactor: Reactor,
|
||||||
|
leaf_target_bytes: usize,
|
||||||
|
chunks: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct RopeChunk {
|
||||||
|
text: Rc<str>,
|
||||||
|
pub start: TextPoint,
|
||||||
|
pub metrics: TextMetrics,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct RopeCursor {
|
||||||
|
rope: ReactiveRope,
|
||||||
|
next_offset: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct RopeSnapshotMetrics {
|
||||||
|
pub leaves: usize,
|
||||||
|
pub text: TextMetrics,
|
||||||
|
pub revision: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RopeInner {
|
||||||
|
reactor: Reactor,
|
||||||
|
structure: Source,
|
||||||
|
revision: Cell<u64>,
|
||||||
|
leaf_target_bytes: usize,
|
||||||
|
leaves: RefCell<Vec<Rc<Leaf>>>,
|
||||||
|
edit_events: Event<EditDelta>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Leaf {
|
||||||
|
source: Source,
|
||||||
|
text: RefCell<Rc<str>>,
|
||||||
|
metrics: Cell<TextMetrics>,
|
||||||
|
tail_chars: Cell<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RopeBuilder {
|
||||||
|
pub fn new(reactor: &Reactor) -> Self {
|
||||||
|
Self {
|
||||||
|
reactor: reactor.clone(),
|
||||||
|
leaf_target_bytes: DEFAULT_LEAF_TARGET_BYTES,
|
||||||
|
chunks: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn leaf_target_bytes(mut self, bytes: usize) -> Self {
|
||||||
|
self.leaf_target_bytes = bytes.max(32);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push(mut self, text: impl Into<String>) -> Self {
|
||||||
|
self.chunks.push(text.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build(self) -> ReactiveRope {
|
||||||
|
ReactiveRope::from_chunks(self.reactor, self.leaf_target_bytes, self.chunks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReactiveRope {
|
||||||
|
pub fn new(reactor: &Reactor) -> Self {
|
||||||
|
RopeBuilder::new(reactor).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_text(reactor: &Reactor, text: impl AsRef<str>) -> Self {
|
||||||
|
RopeBuilder::new(reactor).push(text.as_ref()).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_chunks(reactor: Reactor, leaf_target_bytes: usize, chunks: Vec<String>) -> Self {
|
||||||
|
let structure = source_in(&reactor);
|
||||||
|
let edit_events = event_in(&reactor);
|
||||||
|
let mut leaves = Vec::new();
|
||||||
|
for chunk in chunks {
|
||||||
|
Self::append_chunk_into(&reactor, leaf_target_bytes, &mut leaves, &chunk);
|
||||||
|
}
|
||||||
|
if leaves.is_empty() {
|
||||||
|
leaves.push(Rc::new(Leaf::new(&reactor, String::new())));
|
||||||
|
}
|
||||||
|
|
||||||
|
Self {
|
||||||
|
inner: Rc::new(RopeInner {
|
||||||
|
reactor,
|
||||||
|
structure,
|
||||||
|
revision: Cell::new(0),
|
||||||
|
leaf_target_bytes,
|
||||||
|
leaves: RefCell::new(leaves),
|
||||||
|
edit_events,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn edit_events(&self) -> Event<EditDelta> {
|
||||||
|
self.inner.edit_events.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reactor(&self) -> Reactor {
|
||||||
|
self.inner.reactor.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn snapshot_metrics(&self) -> RopeSnapshotMetrics {
|
||||||
|
self.inner.structure.observe();
|
||||||
|
RopeSnapshotMetrics {
|
||||||
|
leaves: self.inner.leaves.borrow().len(),
|
||||||
|
text: self.total_metrics(),
|
||||||
|
revision: self.inner.revision.get(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn len_bytes(&self) -> usize {
|
||||||
|
self.total_metrics().bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.len_bytes() == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_string(&self) -> String {
|
||||||
|
self.slice(TextRange::new(0, self.len_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn point_at(&self, offset: usize) -> TextPoint {
|
||||||
|
self.inner.structure.observe();
|
||||||
|
let offset = offset.min(self.len_bytes());
|
||||||
|
let mut point = TextPoint::origin();
|
||||||
|
let leaves = self.inner.leaves.borrow();
|
||||||
|
let mut remaining = offset;
|
||||||
|
for leaf in &*leaves {
|
||||||
|
let bytes = leaf.metrics.get().bytes;
|
||||||
|
if remaining > bytes {
|
||||||
|
remaining -= bytes;
|
||||||
|
point.byte += bytes;
|
||||||
|
point.char_offset += leaf.metrics.get().chars;
|
||||||
|
point.utf16_offset += leaf.metrics.get().utf16;
|
||||||
|
point.line += leaf.metrics.get().line_breaks;
|
||||||
|
if leaf.metrics.get().line_breaks == 0 {
|
||||||
|
point.column += leaf.tail_chars.get();
|
||||||
|
} else {
|
||||||
|
point.column = leaf.tail_chars.get();
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let text = leaf.read();
|
||||||
|
point = point.advance(&text[..remaining]);
|
||||||
|
return point;
|
||||||
|
}
|
||||||
|
point
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn slice(&self, range: TextRange) -> String {
|
||||||
|
self.inner.structure.observe();
|
||||||
|
self.assert_char_boundary(range.start);
|
||||||
|
self.assert_char_boundary(range.end);
|
||||||
|
assert!(range.start <= range.end, "invalid text range");
|
||||||
|
assert!(range.end <= self.len_bytes(), "range out of bounds");
|
||||||
|
|
||||||
|
let mut out = String::with_capacity(range.len());
|
||||||
|
let mut cursor = 0usize;
|
||||||
|
let leaves = self.inner.leaves.borrow();
|
||||||
|
for leaf in &*leaves {
|
||||||
|
let metrics = leaf.metrics.get();
|
||||||
|
let end = cursor + metrics.bytes;
|
||||||
|
if end <= range.start {
|
||||||
|
cursor = end;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if cursor >= range.end {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let text = leaf.read();
|
||||||
|
let start_in_leaf = range.start.saturating_sub(cursor);
|
||||||
|
let end_in_leaf = min(metrics.bytes, range.end - cursor);
|
||||||
|
out.push_str(&text[start_in_leaf..end_in_leaf]);
|
||||||
|
cursor = end;
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn chunk_at(&self, offset: usize) -> Option<RopeChunk> {
|
||||||
|
self.inner.structure.observe();
|
||||||
|
if offset >= self.len_bytes() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut start = TextPoint::origin();
|
||||||
|
let leaves = self.inner.leaves.borrow();
|
||||||
|
let mut cursor = 0usize;
|
||||||
|
for leaf in &*leaves {
|
||||||
|
let metrics = leaf.metrics.get();
|
||||||
|
let end = cursor + metrics.bytes;
|
||||||
|
if offset < end {
|
||||||
|
let text = leaf.read();
|
||||||
|
leaf.source.observe();
|
||||||
|
return Some(RopeChunk {
|
||||||
|
text,
|
||||||
|
start,
|
||||||
|
metrics,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
start.byte += metrics.bytes;
|
||||||
|
start.char_offset += metrics.chars;
|
||||||
|
start.utf16_offset += metrics.utf16;
|
||||||
|
start.line += metrics.line_breaks;
|
||||||
|
if metrics.line_breaks == 0 {
|
||||||
|
start.column += leaf.tail_chars.get();
|
||||||
|
} else {
|
||||||
|
start.column = leaf.tail_chars.get();
|
||||||
|
}
|
||||||
|
cursor = end;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cursor(&self, offset: usize) -> RopeCursor {
|
||||||
|
RopeCursor {
|
||||||
|
rope: self.clone(),
|
||||||
|
next_offset: offset.min(self.len_bytes()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn edit(&self, edit: RopeEdit) -> EditDelta {
|
||||||
|
let delta = self.apply_edit(edit);
|
||||||
|
self.inner.edit_events.emit(delta.clone());
|
||||||
|
delta
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn edit_batch(&self, edits: impl IntoIterator<Item = RopeEdit>) -> Vec<EditDelta> {
|
||||||
|
let mut deltas = Vec::new();
|
||||||
|
for edit in edits {
|
||||||
|
let delta = self.apply_edit(edit);
|
||||||
|
self.inner.edit_events.emit(delta.clone());
|
||||||
|
deltas.push(delta);
|
||||||
|
}
|
||||||
|
deltas
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_edit(&self, edit: RopeEdit) -> EditDelta {
|
||||||
|
assert!(edit.range.start <= edit.range.end, "invalid edit range");
|
||||||
|
assert!(edit.range.end <= self.len_bytes(), "edit range out of bounds");
|
||||||
|
self.assert_char_boundary(edit.range.start);
|
||||||
|
self.assert_char_boundary(edit.range.end);
|
||||||
|
assert!(
|
||||||
|
edit.insert.is_char_boundary(edit.insert.len()),
|
||||||
|
"replacement text must be valid UTF-8"
|
||||||
|
);
|
||||||
|
|
||||||
|
let old_start = self.point_at(edit.range.start);
|
||||||
|
let old_end = self.point_at(edit.range.end);
|
||||||
|
let removed_text = self.slice(edit.range);
|
||||||
|
let removed = TextMetrics::from_text(&removed_text);
|
||||||
|
let inserted = TextMetrics::from_text(&edit.insert);
|
||||||
|
|
||||||
|
let mut leaves = self.inner.leaves.borrow_mut();
|
||||||
|
let mut cursor = 0usize;
|
||||||
|
let mut first_index = None;
|
||||||
|
let mut last_index = None;
|
||||||
|
let mut prefix = String::new();
|
||||||
|
let mut suffix = String::new();
|
||||||
|
|
||||||
|
for (index, leaf) in leaves.iter().enumerate() {
|
||||||
|
let text = leaf.read();
|
||||||
|
let end = cursor + text.len();
|
||||||
|
if first_index.is_none() && edit.range.start <= end {
|
||||||
|
first_index = Some(index);
|
||||||
|
let split = edit.range.start.saturating_sub(cursor);
|
||||||
|
prefix.push_str(&text[..split]);
|
||||||
|
}
|
||||||
|
if first_index.is_some() && edit.range.end <= end {
|
||||||
|
last_index = Some(index);
|
||||||
|
let split = edit.range.end.saturating_sub(cursor);
|
||||||
|
suffix.push_str(&text[split..]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
cursor = end;
|
||||||
|
}
|
||||||
|
|
||||||
|
let first_index = first_index.unwrap_or_else(|| leaves.len().saturating_sub(1));
|
||||||
|
let last_index = last_index.unwrap_or(first_index);
|
||||||
|
|
||||||
|
let mut replacement = prefix;
|
||||||
|
replacement.push_str(&edit.insert);
|
||||||
|
replacement.push_str(&suffix);
|
||||||
|
|
||||||
|
let structure_changed = last_index > first_index || replacement.len() > DEFAULT_LEAF_MAX_BYTES;
|
||||||
|
if !structure_changed {
|
||||||
|
if let Some(leaf) = leaves.get(first_index) {
|
||||||
|
leaf.set_text(replacement);
|
||||||
|
leaf.source.trigger();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let mut replacement_leaves = Vec::new();
|
||||||
|
Self::append_chunk_into(
|
||||||
|
&self.inner.reactor,
|
||||||
|
self.inner.leaf_target_bytes,
|
||||||
|
&mut replacement_leaves,
|
||||||
|
&replacement,
|
||||||
|
);
|
||||||
|
if replacement_leaves.is_empty() {
|
||||||
|
replacement_leaves.push(Rc::new(Leaf::new(&self.inner.reactor, String::new())));
|
||||||
|
}
|
||||||
|
|
||||||
|
let removed_span = leaves.splice(first_index..=last_index, replacement_leaves);
|
||||||
|
let mut touched = BTreeSet::new();
|
||||||
|
for leaf in removed_span {
|
||||||
|
touched.insert(leaf.source.id());
|
||||||
|
}
|
||||||
|
for leaf in leaves.iter().skip(first_index) {
|
||||||
|
touched.insert(leaf.source.id());
|
||||||
|
}
|
||||||
|
for leaf in &*leaves {
|
||||||
|
if touched.contains(&leaf.source.id()) {
|
||||||
|
leaf.source.trigger();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.inner.structure.trigger();
|
||||||
|
}
|
||||||
|
|
||||||
|
self.inner.revision.set(self.inner.revision.get().wrapping_add(1));
|
||||||
|
|
||||||
|
let new_end = old_start.advance(&edit.insert);
|
||||||
|
drop(leaves);
|
||||||
|
|
||||||
|
EditDelta {
|
||||||
|
range: edit.range,
|
||||||
|
removed_text,
|
||||||
|
inserted_text: edit.insert,
|
||||||
|
removed,
|
||||||
|
inserted,
|
||||||
|
start: old_start,
|
||||||
|
old_end,
|
||||||
|
new_end,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn total_metrics(&self) -> TextMetrics {
|
||||||
|
self.inner
|
||||||
|
.leaves
|
||||||
|
.borrow()
|
||||||
|
.iter()
|
||||||
|
.fold(TextMetrics::default(), |sum, leaf| sum + leaf.metrics.get())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_chunk_into(
|
||||||
|
reactor: &Reactor,
|
||||||
|
leaf_target_bytes: usize,
|
||||||
|
out: &mut Vec<Rc<Leaf>>,
|
||||||
|
text: &str,
|
||||||
|
) {
|
||||||
|
if text.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let max_bytes = (leaf_target_bytes.max(32)) * 2;
|
||||||
|
let mut start = 0usize;
|
||||||
|
while start < text.len() {
|
||||||
|
let mut end = min(text.len(), start + max_bytes);
|
||||||
|
while end > start && !text.is_char_boundary(end) {
|
||||||
|
end -= 1;
|
||||||
|
}
|
||||||
|
if end == start {
|
||||||
|
end = text.len();
|
||||||
|
}
|
||||||
|
out.push(Rc::new(Leaf::new(reactor, text[start..end].to_string())));
|
||||||
|
start = end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_char_boundary(&self, offset: usize) {
|
||||||
|
if offset == self.len_bytes() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let leaves = self.inner.leaves.borrow();
|
||||||
|
let mut cursor = 0usize;
|
||||||
|
for leaf in &*leaves {
|
||||||
|
let bytes = leaf.metrics.get().bytes;
|
||||||
|
let end = cursor + bytes;
|
||||||
|
if offset < end {
|
||||||
|
let text = leaf.read();
|
||||||
|
assert!(text.is_char_boundary(offset - cursor), "offset must be a char boundary");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cursor = end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RopeChunk {
|
||||||
|
pub fn text(&self) -> &str {
|
||||||
|
&self.text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RopeCursor {
|
||||||
|
pub fn offset(&self) -> usize {
|
||||||
|
self.next_offset
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn peek_chunk(&self) -> Option<RopeChunk> {
|
||||||
|
let chunk = self.rope.chunk_at(self.next_offset)?;
|
||||||
|
let local = self.next_offset.saturating_sub(chunk.start.byte);
|
||||||
|
if local == 0 {
|
||||||
|
return Some(chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
let partial = &chunk.text()[local..];
|
||||||
|
Some(RopeChunk {
|
||||||
|
text: Rc::from(partial),
|
||||||
|
start: self.rope.point_at(self.next_offset),
|
||||||
|
metrics: TextMetrics::from_text(partial),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next_chunk(&mut self) -> Option<RopeChunk> {
|
||||||
|
let chunk = self.peek_chunk()?;
|
||||||
|
let end = chunk.start.byte + chunk.metrics.bytes;
|
||||||
|
self.next_offset = end;
|
||||||
|
Some(chunk)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_bytes(&mut self, len: usize) -> String {
|
||||||
|
let start = self.next_offset;
|
||||||
|
let end = min(self.rope.len_bytes(), start + len);
|
||||||
|
let slice = self.rope.slice(TextRange::new(start, end));
|
||||||
|
self.next_offset = end;
|
||||||
|
slice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Leaf {
|
||||||
|
fn new(reactor: &Reactor, text: String) -> Self {
|
||||||
|
let text: Rc<str> = Rc::from(text);
|
||||||
|
let metrics = TextMetrics::from_text(&text);
|
||||||
|
let tail_chars = tail_chars(&text);
|
||||||
|
Self {
|
||||||
|
source: source_in(reactor),
|
||||||
|
text: RefCell::new(text),
|
||||||
|
metrics: Cell::new(metrics),
|
||||||
|
tail_chars: Cell::new(tail_chars),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read(&self) -> Rc<str> {
|
||||||
|
self.source.observe();
|
||||||
|
self.text.borrow().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_text(&self, text: String) {
|
||||||
|
let text: Rc<str> = Rc::from(text);
|
||||||
|
self.metrics.set(TextMetrics::from_text(&text));
|
||||||
|
self.tail_chars.set(tail_chars(&text));
|
||||||
|
*self.text.borrow_mut() = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tail_chars(text: &str) -> usize {
|
||||||
|
match text.rfind('\n') {
|
||||||
|
Some(index) => text[index + 1..].chars().count(),
|
||||||
|
None => text.chars().count(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::cell::Cell as Counter;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use ruin_reactivity::{Reactor, thunk_in};
|
||||||
|
|
||||||
|
use crate::{Anchor, AnchorBias, ReactiveRope, RopeBuilder, RopeEdit, TextRange};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn edits_preserve_text_and_metrics() {
|
||||||
|
let reactor = Reactor::new();
|
||||||
|
let rope = ReactiveRope::from_text(&reactor, "hello\nworld");
|
||||||
|
|
||||||
|
let delta = rope.edit(RopeEdit::replace(TextRange::new(5, 6), ", "));
|
||||||
|
|
||||||
|
assert_eq!(rope.to_string(), "hello, world");
|
||||||
|
assert_eq!(delta.removed_text, "\n");
|
||||||
|
assert_eq!(delta.inserted_text, ", ");
|
||||||
|
assert_eq!(rope.snapshot_metrics().text.bytes, "hello, world".len());
|
||||||
|
assert_eq!(rope.snapshot_metrics().text.line_breaks, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cursor_walks_tokens_across_leaf_boundaries() {
|
||||||
|
let reactor = Reactor::new();
|
||||||
|
let rope = RopeBuilder::new(&reactor)
|
||||||
|
.leaf_target_bytes(4)
|
||||||
|
.push("alpha")
|
||||||
|
.push("beta")
|
||||||
|
.build();
|
||||||
|
let mut cursor = rope.cursor(3);
|
||||||
|
|
||||||
|
assert_eq!(cursor.read_bytes(4), "habe");
|
||||||
|
let rest = cursor.next_chunk().expect("remaining chunk");
|
||||||
|
assert_eq!(rest.text(), "ta");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anchors_map_through_insertions() {
|
||||||
|
let reactor = Reactor::new();
|
||||||
|
let rope = ReactiveRope::from_text(&reactor, "abcdef");
|
||||||
|
let mut left = Anchor::new(3, AnchorBias::Left);
|
||||||
|
let mut right = Anchor::new(3, AnchorBias::Right);
|
||||||
|
|
||||||
|
let delta = rope.edit(RopeEdit::insert(3, "XYZ"));
|
||||||
|
left.map_through(&delta);
|
||||||
|
right.map_through(&delta);
|
||||||
|
|
||||||
|
assert_eq!(left.byte, 3);
|
||||||
|
assert_eq!(right.byte, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disjoint_leaf_reads_do_not_recompute_on_local_edit() {
|
||||||
|
let reactor = Reactor::new();
|
||||||
|
let rope = RopeBuilder::new(&reactor)
|
||||||
|
.leaf_target_bytes(64)
|
||||||
|
.push("a".repeat(80))
|
||||||
|
.push("b".repeat(80))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let first_runs = Rc::new(Counter::new(0usize));
|
||||||
|
let second_runs = Rc::new(Counter::new(0usize));
|
||||||
|
|
||||||
|
let first = thunk_in(&reactor, {
|
||||||
|
let rope = rope.clone();
|
||||||
|
let first_runs = Rc::clone(&first_runs);
|
||||||
|
move || {
|
||||||
|
first_runs.set(first_runs.get() + 1);
|
||||||
|
rope.slice(TextRange::new(0, 10))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let second = thunk_in(&reactor, {
|
||||||
|
let rope = rope.clone();
|
||||||
|
let second_runs = Rc::clone(&second_runs);
|
||||||
|
move || {
|
||||||
|
second_runs.set(second_runs.get() + 1);
|
||||||
|
rope.slice(TextRange::new(90, 100))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(first.get(), "aaaaaaaaaa");
|
||||||
|
assert_eq!(second.get(), "bbbbbbbbbb");
|
||||||
|
rope.edit(RopeEdit::replace(TextRange::new(2, 4), "zz"));
|
||||||
|
|
||||||
|
assert_eq!(first.get(), "aazzaaaaaa");
|
||||||
|
assert_eq!(second.get(), "bbbbbbbbbb");
|
||||||
|
assert_eq!(first_runs.get(), 2);
|
||||||
|
assert_eq!(second_runs.get(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn point_at_tracks_multibyte_offsets() {
|
||||||
|
let reactor = Reactor::new();
|
||||||
|
let rope = ReactiveRope::from_text(&reactor, "a\né🙂");
|
||||||
|
|
||||||
|
assert_eq!(rope.point_at(0).byte, 0);
|
||||||
|
assert_eq!(rope.point_at(2).line, 1);
|
||||||
|
assert_eq!(rope.point_at(4).char_offset, 3);
|
||||||
|
assert_eq!(rope.point_at("a\né🙂".len()).utf16_offset, 5);
|
||||||
|
assert_eq!(rope.point_at("a\né".len()).column, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn point_at_tracks_column_across_leaves() {
|
||||||
|
let reactor = Reactor::new();
|
||||||
|
let rope = RopeBuilder::new(&reactor)
|
||||||
|
.leaf_target_bytes(4)
|
||||||
|
.push("ab\n")
|
||||||
|
.push("cde")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let point = rope.point_at("ab\ncd".len());
|
||||||
|
assert_eq!(point.line, 1);
|
||||||
|
assert_eq!(point.column, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chunk_at_and_peek_chunk_respect_boundaries() {
|
||||||
|
let reactor = Reactor::new();
|
||||||
|
let rope = RopeBuilder::new(&reactor)
|
||||||
|
.leaf_target_bytes(4)
|
||||||
|
.push("abcd")
|
||||||
|
.push("efgh")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let first = rope.chunk_at(0).expect("chunk at start");
|
||||||
|
assert_eq!(first.text(), "abcd");
|
||||||
|
|
||||||
|
let second = rope.chunk_at(4).expect("chunk at split");
|
||||||
|
assert_eq!(second.text(), "efgh");
|
||||||
|
assert!(rope.chunk_at(8).is_none());
|
||||||
|
|
||||||
|
let mut cursor = rope.cursor(2);
|
||||||
|
let partial = cursor.peek_chunk().expect("partial chunk");
|
||||||
|
assert_eq!(partial.text(), "cd");
|
||||||
|
let advanced = cursor.next_chunk().expect("advanced chunk");
|
||||||
|
assert_eq!(advanced.text(), "cd");
|
||||||
|
assert_eq!(cursor.offset(), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cursor_stops_at_end_of_document() {
|
||||||
|
let reactor = Reactor::new();
|
||||||
|
let rope = RopeBuilder::new(&reactor)
|
||||||
|
.leaf_target_bytes(4)
|
||||||
|
.push("abcd")
|
||||||
|
.push("ef")
|
||||||
|
.build();
|
||||||
|
let mut cursor = rope.cursor(0);
|
||||||
|
let mut chunks = Vec::new();
|
||||||
|
while let Some(chunk) = cursor.next_chunk() {
|
||||||
|
chunks.push(chunk.text().to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(chunks, vec!["abcd".to_string(), "ef".to_string()]);
|
||||||
|
assert!(cursor.next_chunk().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_edits_return_deltas_and_increment_revision() {
|
||||||
|
let reactor = Reactor::new();
|
||||||
|
let rope = ReactiveRope::from_text(&reactor, "abcdef");
|
||||||
|
let before = rope.snapshot_metrics().revision;
|
||||||
|
|
||||||
|
let deltas = rope.edit_batch([
|
||||||
|
RopeEdit::replace(TextRange::new(1, 3), "ZZ"),
|
||||||
|
RopeEdit::delete(TextRange::new(4, 6)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert_eq!(deltas.len(), 2);
|
||||||
|
assert_eq!(rope.to_string(), "aZZd");
|
||||||
|
assert_eq!(rope.snapshot_metrics().revision, before + 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_to_empty_preserves_valid_empty_rope() {
|
||||||
|
let reactor = Reactor::new();
|
||||||
|
let rope = ReactiveRope::from_text(&reactor, "hello");
|
||||||
|
|
||||||
|
let delta = rope.edit(RopeEdit::delete(TextRange::new(0, 5)));
|
||||||
|
|
||||||
|
assert_eq!(delta.removed_text, "hello");
|
||||||
|
assert!(rope.is_empty());
|
||||||
|
assert_eq!(rope.to_string(), "");
|
||||||
|
assert_eq!(rope.snapshot_metrics().leaves, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn structure_changing_edit_updates_leaf_count() {
|
||||||
|
let reactor = Reactor::new();
|
||||||
|
let rope = RopeBuilder::new(&reactor)
|
||||||
|
.leaf_target_bytes(8)
|
||||||
|
.push("abcdefgh")
|
||||||
|
.push("ijklmnop")
|
||||||
|
.build();
|
||||||
|
let before = rope.snapshot_metrics();
|
||||||
|
|
||||||
|
rope.edit(RopeEdit::insert(8, "QRSTUVWXYZ0123456789"));
|
||||||
|
let after = rope.snapshot_metrics();
|
||||||
|
|
||||||
|
assert!(after.leaves >= before.leaves);
|
||||||
|
assert!(after.text.bytes > before.text.bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_char_boundary_panics() {
|
||||||
|
let reactor = Reactor::new();
|
||||||
|
let rope = ReactiveRope::from_text(&reactor, "é");
|
||||||
|
|
||||||
|
let panic = catch_unwind(AssertUnwindSafe(|| {
|
||||||
|
let _ = rope.slice(TextRange::new(1, 2));
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert!(panic.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn edit_events_emit_immediately() {
|
||||||
|
let reactor = Reactor::new();
|
||||||
|
let rope = ReactiveRope::from_text(&reactor, "abc");
|
||||||
|
let seen = Rc::new(RefCell::new(Vec::new()));
|
||||||
|
let _sub = rope.edit_events().subscribe({
|
||||||
|
let seen = Rc::clone(&seen);
|
||||||
|
move |delta| seen.borrow_mut().push((delta.range, delta.inserted_text.clone()))
|
||||||
|
});
|
||||||
|
|
||||||
|
rope.edit(RopeEdit::insert(1, "Z"));
|
||||||
|
|
||||||
|
assert_eq!(&*seen.borrow(), &[(TextRange::new(1, 1), "Z".to_string())]);
|
||||||
|
}
|
||||||
|
}
|
||||||
2050
lib/reactivity_parsing/src/tokentree.rs
Normal file
2050
lib/reactivity_parsing/src/tokentree.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,9 @@ use ruin_ui::{
|
|||||||
use ruin_ui_platform_wayland::start_wayland_ui;
|
use ruin_ui_platform_wayland::start_wayland_ui;
|
||||||
|
|
||||||
use crate::Result;
|
use crate::Result;
|
||||||
use crate::context::{RenderState, render_with_context};
|
use crate::context::{
|
||||||
|
InteractionSnapshot, RenderState, render_with_context, render_with_context_and_interaction,
|
||||||
|
};
|
||||||
use crate::input::{
|
use crate::input::{
|
||||||
EventBindings, InputState, ShortcutBinding, TextSelection, TextSelectionDrag,
|
EventBindings, InputState, ShortcutBinding, TextSelection, TextSelectionDrag,
|
||||||
TextSelectionState, apply_text_selection_overlay, focused_element_for_pointer,
|
TextSelectionState, apply_text_selection_overlay, focused_element_for_pointer,
|
||||||
@@ -58,6 +60,11 @@ impl Window {
|
|||||||
self.spec = self.spec.base_color(color);
|
self.spec = self.spec.base_color(color);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn resizable(mut self, resizable: bool) -> Self {
|
||||||
|
self.spec.resizable = resizable;
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Window {
|
impl Default for Window {
|
||||||
@@ -160,7 +167,10 @@ impl<M: Mountable> MountedApp<M> {
|
|||||||
let shortcuts = Rc::new(RefCell::new(Vec::<ShortcutBinding>::new()));
|
let shortcuts = Rc::new(RefCell::new(Vec::<ShortcutBinding>::new()));
|
||||||
let current_title = Rc::new(RefCell::new(None::<String>));
|
let current_title = Rc::new(RefCell::new(None::<String>));
|
||||||
let last_min_size = Rc::new(RefCell::new(None::<UiSize>));
|
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_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 input_state = InputState::new();
|
||||||
let mut pointer_router = PointerRouter::new();
|
let mut pointer_router = PointerRouter::new();
|
||||||
|
|
||||||
@@ -174,18 +184,31 @@ impl<M: Mountable> MountedApp<M> {
|
|||||||
let shortcuts = Rc::clone(&shortcuts);
|
let shortcuts = Rc::clone(&shortcuts);
|
||||||
let current_title = Rc::clone(¤t_title);
|
let current_title = Rc::clone(¤t_title);
|
||||||
let last_min_size = Rc::clone(&last_min_size);
|
let last_min_size = Rc::clone(&last_min_size);
|
||||||
|
let last_fixed_size = Rc::clone(&last_fixed_size);
|
||||||
let root = Rc::clone(&root);
|
let root = Rc::clone(&root);
|
||||||
let render_state = Rc::clone(&render_state);
|
let render_state = Rc::clone(&render_state);
|
||||||
let text_selection = Rc::clone(&input_state.text_selection);
|
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 || {
|
move || {
|
||||||
let viewport = viewport.get();
|
let viewport = viewport.get();
|
||||||
let version = scene_version.get().wrapping_add(1);
|
let version = scene_version.get().wrapping_add(1);
|
||||||
scene_version.set(version);
|
scene_version.set(version);
|
||||||
let _ = text_selection.version.get();
|
let _ = text_selection.version.get();
|
||||||
|
let _ = interaction_version.get();
|
||||||
|
|
||||||
let t_effect = Instant::now();
|
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(Rc::clone(&render_state), || root.render());
|
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 render_output.side_effects.window_title != *current_title.borrow() {
|
||||||
if let Some(title) = &render_output.side_effects.window_title {
|
if let Some(title) = &render_output.side_effects.window_title {
|
||||||
@@ -211,7 +234,6 @@ impl<M: Mountable> MountedApp<M> {
|
|||||||
);
|
);
|
||||||
let layout_us = t_layout.elapsed().as_micros();
|
let layout_us = t_layout.elapsed().as_micros();
|
||||||
|
|
||||||
// Compute and send min-size hint: max of layout min and explicit window min.
|
|
||||||
let desired_min = UiSize::new(
|
let desired_min = UiSize::new(
|
||||||
root_min_size
|
root_min_size
|
||||||
.width
|
.width
|
||||||
@@ -220,11 +242,32 @@ impl<M: Mountable> MountedApp<M> {
|
|||||||
.height
|
.height
|
||||||
.max(explicit_min_size.map_or(0.0, |s| s.height)),
|
.max(explicit_min_size.map_or(0.0, |s| s.height)),
|
||||||
);
|
);
|
||||||
|
if window_resizable {
|
||||||
let current_min = *last_min_size.borrow();
|
let current_min = *last_min_size.borrow();
|
||||||
if current_min != Some(desired_min) {
|
if current_min != Some(desired_min) {
|
||||||
let _ = window.update(WindowUpdate::new().min_inner_size(Some(desired_min)));
|
let _ = window.update(WindowUpdate::new().min_inner_size(Some(desired_min)));
|
||||||
*last_min_size.borrow_mut() = 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();
|
let effect_us = t_effect.elapsed().as_micros();
|
||||||
|
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
@@ -355,10 +398,15 @@ impl<M: Mountable> MountedApp<M> {
|
|||||||
.last()
|
.last()
|
||||||
.map(|target| target.cursor)
|
.map(|target| target.cursor)
|
||||||
.unwrap_or(ruin_ui::CursorIcon::Default);
|
.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 {
|
if next_cursor != input_state.current_cursor {
|
||||||
input_state.current_cursor = next_cursor;
|
input_state.current_cursor = next_cursor;
|
||||||
window.set_cursor_icon(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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -458,6 +506,32 @@ impl<M: Mountable> MountedApp<M> {
|
|||||||
|
|
||||||
Ok(())
|
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)]
|
#[doc(hidden)]
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ pub(crate) struct RenderState {
|
|||||||
pub(crate) next_element_id: StdCell<u64>,
|
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)]
|
#[derive(Clone, Default)]
|
||||||
pub(crate) struct RenderSideEffects {
|
pub(crate) struct RenderSideEffects {
|
||||||
pub(crate) window_title: Option<String>,
|
pub(crate) window_title: Option<String>,
|
||||||
@@ -42,6 +48,7 @@ pub(crate) struct RenderContext {
|
|||||||
pub(crate) element_index: Rc<StdCell<usize>>,
|
pub(crate) element_index: Rc<StdCell<usize>>,
|
||||||
pub(crate) side_effects: Rc<RefCell<RenderSideEffects>>,
|
pub(crate) side_effects: Rc<RefCell<RenderSideEffects>>,
|
||||||
pub(crate) context_entries: Rc<Vec<ContextEntry>>,
|
pub(crate) context_entries: Rc<Vec<ContextEntry>>,
|
||||||
|
pub(crate) interaction: Rc<InteractionSnapshot>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RenderContext {
|
impl RenderContext {
|
||||||
@@ -52,6 +59,7 @@ impl RenderContext {
|
|||||||
element_index: Rc::clone(&self.element_index),
|
element_index: Rc::clone(&self.element_index),
|
||||||
side_effects: Rc::clone(&self.side_effects),
|
side_effects: Rc::clone(&self.side_effects),
|
||||||
context_entries,
|
context_entries,
|
||||||
|
interaction: Rc::clone(&self.interaction),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,6 +76,14 @@ thread_local! {
|
|||||||
pub(crate) fn render_with_context(
|
pub(crate) fn render_with_context(
|
||||||
state: Rc<RenderState>,
|
state: Rc<RenderState>,
|
||||||
render: impl FnOnce() -> View,
|
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 {
|
) -> RenderOutput {
|
||||||
let context = RenderContext {
|
let context = RenderContext {
|
||||||
state,
|
state,
|
||||||
@@ -75,6 +91,7 @@ pub(crate) fn render_with_context(
|
|||||||
element_index: Rc::new(StdCell::new(0)),
|
element_index: Rc::new(StdCell::new(0)),
|
||||||
side_effects: Rc::new(RefCell::new(RenderSideEffects::default())),
|
side_effects: Rc::new(RefCell::new(RenderSideEffects::default())),
|
||||||
context_entries: Rc::new(Vec::new()),
|
context_entries: Rc::new(Vec::new()),
|
||||||
|
interaction,
|
||||||
};
|
};
|
||||||
|
|
||||||
let view = with_render_context(context.clone(), render);
|
let view = with_render_context(context.clone(), render);
|
||||||
|
|||||||
@@ -155,6 +155,11 @@ pub fn use_window_title(compute: impl FnOnce() -> String) {
|
|||||||
pub enum Key {
|
pub enum Key {
|
||||||
Character(char),
|
Character(char),
|
||||||
Enter,
|
Enter,
|
||||||
|
Backspace,
|
||||||
|
Escape,
|
||||||
|
Delete,
|
||||||
|
ArrowLeft,
|
||||||
|
ArrowRight,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
Home,
|
Home,
|
||||||
@@ -169,6 +174,11 @@ impl Key {
|
|||||||
.next()
|
.next()
|
||||||
.is_some_and(|actual| actual.eq_ignore_ascii_case(expected)),
|
.is_some_and(|actual| actual.eq_ignore_ascii_case(expected)),
|
||||||
(Self::Enter, KeyboardKey::Enter) => true,
|
(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::ArrowUp, KeyboardKey::ArrowUp) => true,
|
||||||
(Self::ArrowDown, KeyboardKey::ArrowDown) => true,
|
(Self::ArrowDown, KeyboardKey::ArrowDown) => true,
|
||||||
(Self::Home, KeyboardKey::Home) => true,
|
(Self::Home, KeyboardKey::Home) => true,
|
||||||
@@ -209,12 +219,27 @@ impl Shortcut {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn matches(&self, event: &KeyboardEvent) -> bool {
|
pub(crate) fn matches(&self, event: &KeyboardEvent) -> bool {
|
||||||
event.kind == KeyboardEventKind::Pressed
|
if event.kind != KeyboardEventKind::Pressed {
|
||||||
&& self.key.matches(event)
|
return false;
|
||||||
&& event.modifiers.control == self.control
|
}
|
||||||
&& event.modifiers.shift == self.shift
|
if !self.key.matches(event) {
|
||||||
&& event.modifiers.alt == self.alt
|
return false;
|
||||||
&& event.modifiers.super_key == self.super_key
|
}
|
||||||
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,6 +248,12 @@ pub struct FocusScope {
|
|||||||
pub(crate) element_id: Signal<Option<ElementId>>,
|
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)]
|
#[derive(Clone)]
|
||||||
pub enum ShortcutScope {
|
pub enum ShortcutScope {
|
||||||
Application,
|
Application,
|
||||||
@@ -296,6 +327,18 @@ pub fn use_widget_ref<T: 'static>() -> WidgetRef<T> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
pub fn use_shortcut(shortcut: Shortcut, scope: ShortcutScope, action: impl Fn() + 'static) {
|
||||||
use_shortcut_with_context(shortcut, scope, move |_| action());
|
use_shortcut_with_context(shortcut, scope, move |_| action());
|
||||||
}
|
}
|
||||||
@@ -371,4 +414,3 @@ where
|
|||||||
|slot: &mut ResourceSlot<T, E>| slot.resource.clone(),
|
|slot: &mut ResourceSlot<T, E>| slot.resource.clone(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -124,6 +124,9 @@ pub(crate) struct InputState {
|
|||||||
pub(crate) current_cursor: CursorIcon,
|
pub(crate) current_cursor: CursorIcon,
|
||||||
pub(crate) focused_element: Option<ElementId>,
|
pub(crate) focused_element: Option<ElementId>,
|
||||||
pub(crate) text_selection: Rc<TextSelectionState>,
|
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 {
|
impl InputState {
|
||||||
@@ -132,6 +135,9 @@ impl InputState {
|
|||||||
current_cursor: CursorIcon::Default,
|
current_cursor: CursorIcon::Default,
|
||||||
focused_element: None,
|
focused_element: None,
|
||||||
text_selection: Rc::new(TextSelectionState::new()),
|
text_selection: Rc::new(TextSelectionState::new()),
|
||||||
|
hovered_elements: Signal::new(Vec::new()),
|
||||||
|
pressed_elements: Signal::new(Vec::new()),
|
||||||
|
interaction_version: Signal::new(0),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,9 +23,10 @@ pub mod view;
|
|||||||
pub use app::{App, Component, ContextKey, MountedApp, Mountable, Window, __render_mountable_for_test};
|
pub use app::{App, Component, ContextKey, MountedApp, Mountable, Window, __render_mountable_for_test};
|
||||||
pub use converters::{IntoBorder, IntoEdges, IntoShadow};
|
pub use converters::{IntoBorder, IntoEdges, IntoShadow};
|
||||||
pub use hooks::{
|
pub use hooks::{
|
||||||
BlockWidget, FocusScope, Key, Memo, Resource, ResourceState, ScrollBoxWidget, Shortcut,
|
BlockWidget, FocusScope, InteractionState, Key, Memo, Resource, ResourceState,
|
||||||
ShortcutScope, Signal, WidgetRef, provide, use_context, use_effect, use_memo, use_resource,
|
ScrollBoxWidget, Shortcut, ShortcutScope, Signal, WidgetRef, provide, use_context,
|
||||||
use_shortcut, use_shortcut_with_context, use_signal, use_widget_ref, use_window_title,
|
use_effect, use_interaction_state, use_memo, use_resource, use_shortcut,
|
||||||
|
use_shortcut_with_context, use_signal, use_widget_ref, use_window_title,
|
||||||
};
|
};
|
||||||
pub use primitives::{
|
pub use primitives::{
|
||||||
ButtonBuilder, ContainerBuilder, ContainerProps, FontWeight, ScrollBoxBuilder, TextBuilder,
|
ButtonBuilder, ContainerBuilder, ContainerProps, FontWeight, ScrollBoxBuilder, TextBuilder,
|
||||||
@@ -59,19 +60,20 @@ pub type Result<T> = std::result::Result<T, Box<dyn Error>>;
|
|||||||
pub mod prelude {
|
pub mod prelude {
|
||||||
pub use crate::{
|
pub use crate::{
|
||||||
App, BlockWidget, ButtonBuilder, ChildViews, Children, Component, ContainerBuilder,
|
App, BlockWidget, ButtonBuilder, ChildViews, Children, Component, ContainerBuilder,
|
||||||
ContextKey, FocusScope, FontWeight, IntoBorder, IntoEdges, IntoView, Key, Memo, Mountable,
|
ContextKey, FocusScope, FontWeight, InteractionState, IntoBorder, IntoEdges, IntoView,
|
||||||
Pending, Ready, Resource, ResourceState, Result, ScrollBoxBuilder, ScrollBoxWidget,
|
Key, Memo, Mountable, Pending, Ready, Resource, ResourceState, Result, ScrollBoxBuilder,
|
||||||
Shortcut, ShortcutScope, Signal, TextBuilder, TextChildren, TextRole, TextValue, View,
|
ScrollBoxWidget, Shortcut, ShortcutScope, Signal, TextBuilder, TextChildren, TextRole,
|
||||||
WidgetRef, Window, block, button, colors, column, component, context_provider, provide,
|
TextValue, View, WidgetRef, Window, block, button, colors, column, component,
|
||||||
row, scroll_box, surfaces, text, use_context, use_effect, use_memo, use_resource,
|
context_provider, provide, row, scroll_box, surfaces, text, use_context, use_effect,
|
||||||
use_shortcut, use_shortcut_with_context, use_signal, use_widget_ref, use_window_title,
|
use_interaction_state, use_memo, use_resource, use_shortcut, use_shortcut_with_context,
|
||||||
view,
|
use_signal, use_widget_ref, use_window_title, view,
|
||||||
};
|
};
|
||||||
pub use ruin_ui::{
|
pub use ruin_ui::{
|
||||||
AlignItems, Border, Color, CursorIcon, Edges, Element, ElementId, InteractionTree,
|
AlignItems, Border, Color, CursorIcon, Edges, Element, ElementId, InteractionTree,
|
||||||
PointerButton, PointerEventKind, RoutedPointerEvent, RoutedPointerEventKind,
|
PointerButton, PointerEventKind, RoutedPointerEvent, RoutedPointerEventKind,
|
||||||
ScrollbarStyle, TextFontFamily, TextStyle, TextWrap, UiSize,
|
ScrollbarStyle, TextFontFamily, TextStyle, TextWrap, UiSize,
|
||||||
};
|
};
|
||||||
|
pub use crate::PartialTextStyle;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -84,7 +86,9 @@ mod tests {
|
|||||||
use ruin_ui::{KeyboardModifiers, Point, WindowSpec};
|
use ruin_ui::{KeyboardModifiers, Point, WindowSpec};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::context::{RenderState, render_with_context};
|
use crate::context::{
|
||||||
|
InteractionSnapshot, RenderState, render_with_context, render_with_context_and_interaction,
|
||||||
|
};
|
||||||
use crate::input::{
|
use crate::input::{
|
||||||
InputState, KeyHandler, focused_element_for_pointer, scroll_handler_for_event,
|
InputState, KeyHandler, focused_element_for_pointer, scroll_handler_for_event,
|
||||||
};
|
};
|
||||||
@@ -177,6 +181,53 @@ mod tests {
|
|||||||
assert!(debug.contains("body child"), "{debug}");
|
assert!(debug.contains("body child"), "{debug}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn interaction_state_tracks_hover_and_press_for_widget_refs() {
|
||||||
|
let render_state = Rc::new(RenderState::default());
|
||||||
|
let hovered = Rc::new(RefCell::new(None::<bool>));
|
||||||
|
let pressed = Rc::new(RefCell::new(None::<bool>));
|
||||||
|
let widget_ref_slot = Rc::new(RefCell::new(None::<WidgetRef<BlockWidget>>));
|
||||||
|
|
||||||
|
let _ = render_with_context(Rc::clone(&render_state), {
|
||||||
|
let hovered = Rc::clone(&hovered);
|
||||||
|
let pressed = Rc::clone(&pressed);
|
||||||
|
let widget_ref_slot = Rc::clone(&widget_ref_slot);
|
||||||
|
move || {
|
||||||
|
let widget_ref = use_widget_ref::<BlockWidget>();
|
||||||
|
*hovered.borrow_mut() = Some(use_interaction_state(widget_ref.clone()).hovered);
|
||||||
|
*pressed.borrow_mut() = Some(use_interaction_state(widget_ref.clone()).pressed);
|
||||||
|
*widget_ref_slot.borrow_mut() = Some(widget_ref.clone());
|
||||||
|
block().widget_ref(widget_ref).children(())
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let widget_ref = widget_ref_slot.borrow().clone().expect("widget ref should be set");
|
||||||
|
let element_id = widget_ref
|
||||||
|
.element_id()
|
||||||
|
.expect("widget ref should have an element id after render");
|
||||||
|
let _ = render_with_context_and_interaction(
|
||||||
|
render_state,
|
||||||
|
Rc::new(InteractionSnapshot {
|
||||||
|
hovered: vec![element_id],
|
||||||
|
pressed: vec![element_id],
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
let hovered = Rc::clone(&hovered);
|
||||||
|
let pressed = Rc::clone(&pressed);
|
||||||
|
let widget_ref_slot = Rc::clone(&widget_ref_slot);
|
||||||
|
move || {
|
||||||
|
let widget_ref = widget_ref_slot.borrow().clone().expect("widget ref should persist");
|
||||||
|
*hovered.borrow_mut() = Some(use_interaction_state(widget_ref.clone()).hovered);
|
||||||
|
*pressed.borrow_mut() = Some(use_interaction_state(widget_ref.clone()).pressed);
|
||||||
|
block().widget_ref(widget_ref).children(())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(*hovered.borrow(), Some(true));
|
||||||
|
assert_eq!(*pressed.borrow(), Some(true));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn key_dispatch_prefers_the_nearest_focused_ancestor_handler() {
|
fn key_dispatch_prefers_the_nearest_focused_ancestor_handler() {
|
||||||
use ruin_ui::{ElementId, KeyboardEvent, KeyboardEventKind, KeyboardKey};
|
use ruin_ui::{ElementId, KeyboardEvent, KeyboardEventKind, KeyboardKey};
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ pub fn button() -> ButtonBuilder {
|
|||||||
impl ButtonBuilder {
|
impl ButtonBuilder {
|
||||||
impl_props_methods!();
|
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 {
|
pub fn on_press(mut self, handler: impl Fn(&RoutedPointerEvent) + 'static) -> Self {
|
||||||
self.on_press = Some(Rc::new(handler));
|
self.on_press = Some(Rc::new(handler));
|
||||||
self
|
self
|
||||||
@@ -44,6 +49,9 @@ impl ButtonBuilder {
|
|||||||
pub fn children(mut self, children: impl Children) -> View {
|
pub fn children(mut self, children: impl Children) -> View {
|
||||||
let id = allocate_element_id();
|
let id = allocate_element_id();
|
||||||
self.props.element = self.props.element.id(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();
|
let had_style = self.props.partial_text_style.is_some();
|
||||||
if let Some(style) = self.props.partial_text_style.take() {
|
if let Some(style) = self.props.partial_text_style.take() {
|
||||||
push_text_style(style);
|
push_text_style(style);
|
||||||
|
|||||||
@@ -128,16 +128,6 @@ pub fn block() -> ContainerBuilder {
|
|||||||
impl ContainerBuilder {
|
impl ContainerBuilder {
|
||||||
impl_props_methods!();
|
impl_props_methods!();
|
||||||
|
|
||||||
pub fn align_items(mut self, align: AlignItems) -> Self {
|
|
||||||
self.props = self.props.align_items(align);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn align_self(mut self, align: AlignItems) -> Self {
|
|
||||||
self.props = self.props.align_self(align);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn shadow(mut self, shadow: impl IntoShadow) -> Self {
|
pub fn shadow(mut self, shadow: impl IntoShadow) -> Self {
|
||||||
self.props = self.props.shadow(shadow);
|
self.props = self.props.shadow(shadow);
|
||||||
self
|
self
|
||||||
|
|||||||
@@ -45,6 +45,14 @@ macro_rules! impl_props_methods {
|
|||||||
self.props = self.props.flex(flex);
|
self.props = self.props.flex(flex);
|
||||||
self
|
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 {
|
pub fn widget_ref<T>(mut self, widget_ref: $crate::hooks::WidgetRef<T>) -> Self {
|
||||||
self.props = self.props.widget_ref(widget_ref);
|
self.props = self.props.widget_ref(widget_ref);
|
||||||
self
|
self
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -44,6 +44,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>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
191
lib/runtime/src/stdio.rs
Normal file
191
lib/runtime/src/stdio.rs
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
//! Async standard-input helpers.
|
||||||
|
|
||||||
|
use std::cell::Cell;
|
||||||
|
use std::io;
|
||||||
|
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
use crate::op::completion::completion_for_current_thread;
|
||||||
|
use crate::platform::linux_x86_64::runtime::with_current_driver;
|
||||||
|
use crate::platform::linux_x86_64::uring::{IORING_OP_READ, IoUringCqe, IoUringSqe};
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
static STDIN_URING_SUPPORTED: Cell<Option<bool>> = const { Cell::new(None) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILE_CURSOR: u64 = u64::MAX;
|
||||||
|
const READ_CHUNK_BYTES: usize = 1024;
|
||||||
|
|
||||||
|
/// Async line-oriented stdin reader.
|
||||||
|
///
|
||||||
|
/// The reader is `io_uring`-first. When the active stdin fd rejects `IORING_OP_READ`, the module
|
||||||
|
/// falls back to a helper-thread blocking read on the same duplicated fd.
|
||||||
|
pub struct Stdin {
|
||||||
|
fd: OwnedFd,
|
||||||
|
buffer: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens an async stdin reader.
|
||||||
|
pub fn stdin() -> io::Result<Stdin> {
|
||||||
|
let raw = cvt(unsafe { libc::fcntl(libc::STDIN_FILENO, libc::F_DUPFD_CLOEXEC, 0) })?;
|
||||||
|
Ok(Stdin {
|
||||||
|
fd: unsafe { OwnedFd::from_raw_fd(raw) },
|
||||||
|
buffer: Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Stdin {
|
||||||
|
/// Reads a single UTF-8 line, including the trailing newline when present.
|
||||||
|
///
|
||||||
|
/// Returns `Ok(None)` on EOF.
|
||||||
|
pub async fn read_line(&mut self) -> io::Result<Option<String>> {
|
||||||
|
loop {
|
||||||
|
if let Some(index) = self.buffer.iter().position(|byte| *byte == b'\n') {
|
||||||
|
let line = self.buffer.drain(..=index).collect::<Vec<_>>();
|
||||||
|
return decode_line(line).map(Some);
|
||||||
|
}
|
||||||
|
|
||||||
|
let chunk = self.read_chunk().await?;
|
||||||
|
if chunk.is_empty() {
|
||||||
|
if self.buffer.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let line = std::mem::take(&mut self.buffer);
|
||||||
|
return decode_line(line).map(Some);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.buffer.extend_from_slice(&chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_chunk(&self) -> io::Result<Vec<u8>> {
|
||||||
|
let fd = self.fd.as_raw_fd();
|
||||||
|
let support = STDIN_URING_SUPPORTED.with(Cell::get);
|
||||||
|
if support != Some(false) {
|
||||||
|
match submit_uring_read(fd, READ_CHUNK_BYTES).await {
|
||||||
|
Ok(bytes) => {
|
||||||
|
STDIN_URING_SUPPORTED.with(|state| state.set(Some(true)));
|
||||||
|
return Ok(bytes);
|
||||||
|
}
|
||||||
|
Err(error) if should_fallback_to_offload(&error) => {
|
||||||
|
STDIN_URING_SUPPORTED.with(|state| state.set(Some(false)));
|
||||||
|
}
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
offload(move || {
|
||||||
|
let mut buffer = vec![0; READ_CHUNK_BYTES];
|
||||||
|
let read = blocking_read(fd, &mut buffer)?;
|
||||||
|
if read == 0 {
|
||||||
|
buffer.clear();
|
||||||
|
} else {
|
||||||
|
buffer.truncate(read);
|
||||||
|
}
|
||||||
|
Ok(buffer)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn offload<T: Send + 'static>(
|
||||||
|
task: impl FnOnce() -> io::Result<T> + Send + 'static,
|
||||||
|
) -> io::Result<T> {
|
||||||
|
let (future, handle) = completion_for_current_thread::<io::Result<T>>();
|
||||||
|
thread::Builder::new()
|
||||||
|
.name("ruin-runtime-stdio-offload".into())
|
||||||
|
.spawn(move || handle.complete(task()))
|
||||||
|
.map_err(io::Error::other)?;
|
||||||
|
future.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn submit_uring_read(fd: RawFd, len: usize) -> io::Result<Vec<u8>> {
|
||||||
|
let mut buffer = vec![0; len];
|
||||||
|
let ptr = buffer.as_mut_ptr();
|
||||||
|
let capacity = buffer.len();
|
||||||
|
submit_uring(
|
||||||
|
move |sqe| {
|
||||||
|
sqe.opcode = IORING_OP_READ;
|
||||||
|
sqe.fd = fd;
|
||||||
|
sqe.addr = ptr as u64;
|
||||||
|
sqe.len = capacity as u32;
|
||||||
|
sqe.off = FILE_CURSOR;
|
||||||
|
},
|
||||||
|
move |cqe| {
|
||||||
|
let read = cqe_to_result(cqe)? as usize;
|
||||||
|
buffer.truncate(read);
|
||||||
|
Ok(buffer)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn submit_uring<T: Send + 'static, M>(
|
||||||
|
fill: impl FnOnce(&mut IoUringSqe),
|
||||||
|
map: M,
|
||||||
|
) -> io::Result<T>
|
||||||
|
where
|
||||||
|
M: FnOnce(IoUringCqe) -> io::Result<T> + Send + 'static,
|
||||||
|
{
|
||||||
|
let (future, handle) = completion_for_current_thread::<io::Result<T>>();
|
||||||
|
let callback_handle = handle.clone();
|
||||||
|
let token = with_current_driver(|driver| {
|
||||||
|
driver.submit_operation(fill, move |cqe| {
|
||||||
|
callback_handle.complete(map(cqe));
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
|
||||||
|
handle.set_cancel(move || {
|
||||||
|
let _ = with_current_driver(|driver| driver.cancel_operation(token));
|
||||||
|
});
|
||||||
|
|
||||||
|
future.await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn blocking_read(fd: RawFd, buffer: &mut [u8]) -> io::Result<usize> {
|
||||||
|
loop {
|
||||||
|
let read = unsafe {
|
||||||
|
libc::read(
|
||||||
|
fd,
|
||||||
|
buffer.as_mut_ptr().cast::<libc::c_void>(),
|
||||||
|
buffer.len(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if read >= 0 {
|
||||||
|
return Ok(read as usize);
|
||||||
|
}
|
||||||
|
|
||||||
|
let error = io::Error::last_os_error();
|
||||||
|
if error.kind() == io::ErrorKind::Interrupted {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_line(bytes: Vec<u8>) -> io::Result<String> {
|
||||||
|
String::from_utf8(bytes).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cqe_to_result(cqe: IoUringCqe) -> io::Result<i32> {
|
||||||
|
if cqe.res < 0 {
|
||||||
|
Err(io::Error::from_raw_os_error(-cqe.res))
|
||||||
|
} else {
|
||||||
|
Ok(cqe.res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn should_fallback_to_offload(error: &io::Error) -> bool {
|
||||||
|
matches!(
|
||||||
|
error.raw_os_error(),
|
||||||
|
Some(libc::EINVAL | libc::ENOSYS | libc::EOPNOTSUPP)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cvt(value: libc::c_int) -> io::Result<libc::c_int> {
|
||||||
|
if value == -1 {
|
||||||
|
Err(io::Error::last_os_error())
|
||||||
|
} else {
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,14 +3,14 @@
|
|||||||
//! These helpers integrate with the runtime's timer queue and are designed to be used from
|
//! 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,
|
||||||
|
|||||||
@@ -148,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,
|
||||||
@@ -232,6 +233,18 @@ impl State {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn fixed_size_request_for_spec(spec: &WindowSpec) -> Option<(u32, u32)> {
|
||||||
|
if spec.resizable {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let size = spec
|
||||||
|
.requested_inner_size
|
||||||
|
.or(spec.min_inner_size)
|
||||||
|
.or(spec.max_inner_size)
|
||||||
|
.unwrap_or_else(|| UiSize::new(800.0, 500.0));
|
||||||
|
Some((size.width.max(1.0).round() as u32, size.height.max(1.0).round() as u32))
|
||||||
|
}
|
||||||
|
|
||||||
fn wayland_cursor_shape(icon: CursorIcon) -> wp_cursor_shape_device_v1::Shape {
|
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,
|
||||||
@@ -364,6 +377,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,
|
||||||
@@ -697,6 +711,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());
|
||||||
@@ -732,6 +747,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 {
|
||||||
@@ -742,6 +765,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,
|
||||||
@@ -2101,6 +2130,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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2153,7 +2190,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();
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|||||||
Reference in New Issue
Block a user