Lots of owork on a calculator example, transparency, etc.

This commit is contained in:
2026-05-16 16:18:55 -04:00
parent e2c2563b7e
commit 6dcc2d0d00
40 changed files with 5783 additions and 125 deletions

85
Cargo.lock generated
View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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("".to_string(), bg, move || {
s.expression.update(|e| {
let wrapped = format!("({e})^2");
*e = wrapped;
});
})
};
view! {
column(gap = 6.0, align_self = AlignItems::Stretch) {
btn_row(vec![sci!("sin(", "sin("), sci!("cos(", "cos("), sci!("tan(", "tan("), sci!("asin(", "asin(")])
btn_row(vec![sci!("log(", "log("), sci!("ln(", "ln("), sci!("√(", "√("), sq_btn])
btn_row(vec![sci!("", "^"), sci!("π", "π"), sci!("e", "e"), sci!("fact(", "fact(")])
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
use std::cell::{Cell, RefCell};
use std::rc::{Rc, Weak};
use alloc::boxed::Box;
use alloc::rc::{Rc, Weak};
use core::cell::{Cell, RefCell};
use crate::reactor::ObserverHook;
use crate::{NodeId, Reactor, current, trace_targets};
@@ -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
/// to later dispose of.
pub fn leak(self) {
std::mem::forget(self);
core::mem::forget(self);
}
/// Disposes the effect immediately.

View File

@@ -1,6 +1,9 @@
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::rc::Rc;
use alloc::boxed::Box;
use alloc::rc::Rc;
use alloc::vec::Vec;
use core::cell::{Cell, RefCell};
use hashbrown::HashMap;
use crate::{NodeId, Reactor, current, trace_targets};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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);
}
}

View File

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

View File

@@ -0,0 +1,24 @@
//! Reactive rope text storage and incremental parsing substrate for RUIN.
mod delta;
mod metrics;
mod parse;
mod rope;
mod tokentree;
pub use delta::{Anchor, AnchorBias, EditDelta, RopeEdit, TextRange};
pub use metrics::{TextMetrics, TextPoint};
pub use parse::{
IncrementalParser, ParseError, ParseInvalidation, ParseResult, 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,
};

View File

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

View File

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

View 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())]);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,9 @@ use ruin_ui::{
use ruin_ui_platform_wayland::start_wayland_ui;
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::{
EventBindings, InputState, ShortcutBinding, TextSelection, TextSelectionDrag,
TextSelectionState, apply_text_selection_overlay, focused_element_for_pointer,
@@ -58,6 +60,11 @@ impl Window {
self.spec = self.spec.base_color(color);
self
}
pub fn resizable(mut self, resizable: bool) -> Self {
self.spec.resizable = resizable;
self
}
}
impl Default for Window {
@@ -160,7 +167,10 @@ impl<M: Mountable> MountedApp<M> {
let shortcuts = Rc::new(RefCell::new(Vec::<ShortcutBinding>::new()));
let current_title = Rc::new(RefCell::new(None::<String>));
let last_min_size = Rc::new(RefCell::new(None::<UiSize>));
let last_fixed_size = Rc::new(RefCell::new(None::<UiSize>));
let explicit_min_size = app_window.spec.min_inner_size;
let explicit_max_size = app_window.spec.max_inner_size;
let window_resizable = app_window.spec.resizable;
let mut input_state = InputState::new();
let mut pointer_router = PointerRouter::new();
@@ -174,18 +184,31 @@ impl<M: Mountable> MountedApp<M> {
let shortcuts = Rc::clone(&shortcuts);
let current_title = Rc::clone(&current_title);
let last_min_size = Rc::clone(&last_min_size);
let last_fixed_size = Rc::clone(&last_fixed_size);
let root = Rc::clone(&root);
let render_state = Rc::clone(&render_state);
let text_selection = Rc::clone(&input_state.text_selection);
let hovered_elements = input_state.hovered_elements.clone();
let pressed_elements = input_state.pressed_elements.clone();
let interaction_version = input_state.interaction_version.clone();
move || {
let viewport = viewport.get();
let version = scene_version.get().wrapping_add(1);
scene_version.set(version);
let _ = text_selection.version.get();
let _ = interaction_version.get();
let t_effect = Instant::now();
let interaction_snapshot = Rc::new(InteractionSnapshot {
hovered: hovered_elements.get(),
pressed: pressed_elements.get(),
});
let render_output = render_with_context(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 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();
// Compute and send min-size hint: max of layout min and explicit window min.
let desired_min = UiSize::new(
root_min_size
.width
@@ -220,10 +242,31 @@ impl<M: Mountable> MountedApp<M> {
.height
.max(explicit_min_size.map_or(0.0, |s| s.height)),
);
let current_min = *last_min_size.borrow();
if current_min != Some(desired_min) {
let _ = window.update(WindowUpdate::new().min_inner_size(Some(desired_min)));
*last_min_size.borrow_mut() = Some(desired_min);
if window_resizable {
let current_min = *last_min_size.borrow();
if current_min != Some(desired_min) {
let _ = window.update(WindowUpdate::new().min_inner_size(Some(desired_min)));
*last_min_size.borrow_mut() = Some(desired_min);
}
} else {
let desired_fixed = UiSize::new(
desired_min
.width
.min(explicit_max_size.map_or(f32::INFINITY, |s| s.width)),
desired_min
.height
.min(explicit_max_size.map_or(f32::INFINITY, |s| s.height)),
);
let current_fixed = *last_fixed_size.borrow();
if current_fixed != Some(desired_fixed) {
let _ = window.update(
WindowUpdate::new()
.requested_inner_size(Some(desired_fixed))
.min_inner_size(Some(desired_fixed))
.max_inner_size(Some(desired_fixed)),
);
*last_fixed_size.borrow_mut() = Some(desired_fixed);
}
}
let effect_us = t_effect.elapsed().as_micros();
@@ -355,10 +398,15 @@ impl<M: Mountable> MountedApp<M> {
.last()
.map(|target| target.cursor)
.unwrap_or(ruin_ui::CursorIcon::Default);
let hovered_ids = Self::interaction_ids(pointer_router.hovered_targets());
let pressed_ids = Self::interaction_ids(pointer_router.pressed_targets());
if next_cursor != input_state.current_cursor {
input_state.current_cursor = next_cursor;
window.set_cursor_icon(next_cursor)?;
}
if Self::update_interaction_state(input_state, hovered_ids, pressed_ids) {
input_state.interaction_version.update(|value| *value += 1);
}
Ok(())
}
@@ -458,6 +506,32 @@ impl<M: Mountable> MountedApp<M> {
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)]

View File

@@ -29,6 +29,12 @@ pub(crate) struct RenderState {
pub(crate) next_element_id: StdCell<u64>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) struct InteractionSnapshot {
pub(crate) hovered: Vec<ElementId>,
pub(crate) pressed: Vec<ElementId>,
}
#[derive(Clone, Default)]
pub(crate) struct RenderSideEffects {
pub(crate) window_title: Option<String>,
@@ -42,6 +48,7 @@ pub(crate) struct RenderContext {
pub(crate) element_index: Rc<StdCell<usize>>,
pub(crate) side_effects: Rc<RefCell<RenderSideEffects>>,
pub(crate) context_entries: Rc<Vec<ContextEntry>>,
pub(crate) interaction: Rc<InteractionSnapshot>,
}
impl RenderContext {
@@ -52,6 +59,7 @@ impl RenderContext {
element_index: Rc::clone(&self.element_index),
side_effects: Rc::clone(&self.side_effects),
context_entries,
interaction: Rc::clone(&self.interaction),
}
}
}
@@ -68,6 +76,14 @@ thread_local! {
pub(crate) fn render_with_context(
state: Rc<RenderState>,
render: impl FnOnce() -> View,
) -> RenderOutput {
render_with_context_and_interaction(state, Rc::new(InteractionSnapshot::default()), render)
}
pub(crate) fn render_with_context_and_interaction(
state: Rc<RenderState>,
interaction: Rc<InteractionSnapshot>,
render: impl FnOnce() -> View,
) -> RenderOutput {
let context = RenderContext {
state,
@@ -75,6 +91,7 @@ pub(crate) fn render_with_context(
element_index: Rc::new(StdCell::new(0)),
side_effects: Rc::new(RefCell::new(RenderSideEffects::default())),
context_entries: Rc::new(Vec::new()),
interaction,
};
let view = with_render_context(context.clone(), render);

View File

@@ -155,6 +155,11 @@ pub fn use_window_title(compute: impl FnOnce() -> String) {
pub enum Key {
Character(char),
Enter,
Backspace,
Escape,
Delete,
ArrowLeft,
ArrowRight,
ArrowUp,
ArrowDown,
Home,
@@ -169,6 +174,11 @@ impl Key {
.next()
.is_some_and(|actual| actual.eq_ignore_ascii_case(expected)),
(Self::Enter, KeyboardKey::Enter) => true,
(Self::Backspace, KeyboardKey::Backspace) => true,
(Self::Escape, KeyboardKey::Escape) => true,
(Self::Delete, KeyboardKey::Delete) => true,
(Self::ArrowLeft, KeyboardKey::ArrowLeft) => true,
(Self::ArrowRight, KeyboardKey::ArrowRight) => true,
(Self::ArrowUp, KeyboardKey::ArrowUp) => true,
(Self::ArrowDown, KeyboardKey::ArrowDown) => true,
(Self::Home, KeyboardKey::Home) => true,
@@ -209,12 +219,27 @@ impl Shortcut {
}
pub(crate) fn matches(&self, event: &KeyboardEvent) -> bool {
event.kind == KeyboardEventKind::Pressed
&& self.key.matches(event)
&& event.modifiers.control == self.control
&& event.modifiers.shift == self.shift
&& event.modifiers.alt == self.alt
&& event.modifiers.super_key == self.super_key
if event.kind != KeyboardEventKind::Pressed {
return false;
}
if !self.key.matches(event) {
return false;
}
if event.modifiers.control != self.control {
return false;
}
if event.modifiers.alt != self.alt {
return false;
}
if event.modifiers.super_key != self.super_key {
return false;
}
// For character shortcuts the produced character already encodes the shift
// state, so don't additionally require that Shift is (un)held.
if !matches!(self.key, Key::Character(_)) && event.modifiers.shift != self.shift {
return false;
}
true
}
}
@@ -223,6 +248,12 @@ pub struct FocusScope {
pub(crate) element_id: Signal<Option<ElementId>>,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct InteractionState {
pub hovered: bool,
pub pressed: bool,
}
#[derive(Clone)]
pub enum ShortcutScope {
Application,
@@ -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) {
use_shortcut_with_context(shortcut, scope, move |_| action());
}
@@ -371,4 +414,3 @@ where
|slot: &mut ResourceSlot<T, E>| slot.resource.clone(),
)
}

View File

@@ -124,6 +124,9 @@ pub(crate) struct InputState {
pub(crate) current_cursor: CursorIcon,
pub(crate) focused_element: Option<ElementId>,
pub(crate) text_selection: Rc<TextSelectionState>,
pub(crate) hovered_elements: Signal<Vec<ElementId>>,
pub(crate) pressed_elements: Signal<Vec<ElementId>>,
pub(crate) interaction_version: Signal<u64>,
}
impl InputState {
@@ -132,6 +135,9 @@ impl InputState {
current_cursor: CursorIcon::Default,
focused_element: None,
text_selection: Rc::new(TextSelectionState::new()),
hovered_elements: Signal::new(Vec::new()),
pressed_elements: Signal::new(Vec::new()),
interaction_version: Signal::new(0),
}
}
}

View File

@@ -23,9 +23,10 @@ pub mod view;
pub use app::{App, Component, ContextKey, MountedApp, Mountable, Window, __render_mountable_for_test};
pub use converters::{IntoBorder, IntoEdges, IntoShadow};
pub use hooks::{
BlockWidget, FocusScope, Key, Memo, Resource, ResourceState, ScrollBoxWidget, Shortcut,
ShortcutScope, Signal, WidgetRef, provide, use_context, use_effect, use_memo, use_resource,
use_shortcut, use_shortcut_with_context, use_signal, use_widget_ref, use_window_title,
BlockWidget, FocusScope, InteractionState, Key, Memo, Resource, ResourceState,
ScrollBoxWidget, Shortcut, ShortcutScope, Signal, WidgetRef, provide, use_context,
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::{
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 use crate::{
App, BlockWidget, ButtonBuilder, ChildViews, Children, Component, ContainerBuilder,
ContextKey, FocusScope, FontWeight, IntoBorder, IntoEdges, IntoView, Key, Memo, Mountable,
Pending, Ready, Resource, ResourceState, Result, ScrollBoxBuilder, ScrollBoxWidget,
Shortcut, ShortcutScope, Signal, TextBuilder, TextChildren, TextRole, TextValue, View,
WidgetRef, Window, block, button, colors, column, component, context_provider, provide,
row, scroll_box, surfaces, text, use_context, use_effect, use_memo, use_resource,
use_shortcut, use_shortcut_with_context, use_signal, use_widget_ref, use_window_title,
view,
ContextKey, FocusScope, FontWeight, InteractionState, IntoBorder, IntoEdges, IntoView,
Key, Memo, Mountable, Pending, Ready, Resource, ResourceState, Result, ScrollBoxBuilder,
ScrollBoxWidget, Shortcut, ShortcutScope, Signal, TextBuilder, TextChildren, TextRole,
TextValue, View, WidgetRef, Window, block, button, colors, column, component,
context_provider, provide, row, scroll_box, surfaces, text, use_context, use_effect,
use_interaction_state, use_memo, use_resource, use_shortcut, use_shortcut_with_context,
use_signal, use_widget_ref, use_window_title, view,
};
pub use ruin_ui::{
AlignItems, Border, Color, CursorIcon, Edges, Element, ElementId, InteractionTree,
PointerButton, PointerEventKind, RoutedPointerEvent, RoutedPointerEventKind,
ScrollbarStyle, TextFontFamily, TextStyle, TextWrap, UiSize,
};
pub use crate::PartialTextStyle;
}
#[cfg(test)]
@@ -84,7 +86,9 @@ mod tests {
use ruin_ui::{KeyboardModifiers, Point, WindowSpec};
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::{
InputState, KeyHandler, focused_element_for_pointer, scroll_handler_for_event,
};
@@ -177,6 +181,53 @@ mod tests {
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]
fn key_dispatch_prefers_the_nearest_focused_ancestor_handler() {
use ruin_ui::{ElementId, KeyboardEvent, KeyboardEventKind, KeyboardKey};

View File

@@ -31,6 +31,11 @@ pub fn button() -> ButtonBuilder {
impl ButtonBuilder {
impl_props_methods!();
pub fn shadow(mut self, shadow: impl crate::converters::IntoShadow) -> Self {
self.props = self.props.shadow(shadow);
self
}
pub fn on_press(mut self, handler: impl Fn(&RoutedPointerEvent) + 'static) -> Self {
self.on_press = Some(Rc::new(handler));
self
@@ -44,6 +49,9 @@ impl ButtonBuilder {
pub fn children(mut self, children: impl Children) -> View {
let id = allocate_element_id();
self.props.element = self.props.element.id(id);
if let Some(widget_ref) = &self.props.widget_ref {
let _ = widget_ref.set(Some(id));
}
let had_style = self.props.partial_text_style.is_some();
if let Some(style) = self.props.partial_text_style.take() {
push_text_style(style);

View File

@@ -128,16 +128,6 @@ pub fn block() -> ContainerBuilder {
impl ContainerBuilder {
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 {
self.props = self.props.shadow(shadow);
self

View File

@@ -45,6 +45,14 @@ macro_rules! impl_props_methods {
self.props = self.props.flex(flex);
self
}
pub fn align_items(mut self, align: ruin_ui::AlignItems) -> Self {
self.props = self.props.align_items(align);
self
}
pub fn align_self(mut self, align: ruin_ui::AlignItems) -> Self {
self.props = self.props.align_self(align);
self
}
pub fn widget_ref<T>(mut self, widget_ref: $crate::hooks::WidgetRef<T>) -> Self {
self.props = self.props.widget_ref(widget_ref);
self

View File

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

View File

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

View File

@@ -44,6 +44,7 @@ pub mod net;
pub mod op;
#[doc(hidden)]
pub mod platform;
pub mod stdio;
#[doc(hidden)]
pub mod sys;
pub mod time;

View File

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

View File

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

191
lib/runtime/src/stdio.rs Normal file
View 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)
}
}

View File

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

View File

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

View File

@@ -148,6 +148,7 @@ enum InternalBackendEvent {
struct State {
running: bool,
fixed_size_request: Option<(u32, u32)>,
_connection: Connection,
_compositor: wl_compositor::WlCompositor,
_surface: wl_surface::WlSurface,
@@ -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 {
match icon {
CursorIcon::Default => wp_cursor_shape_device_v1::Shape::Default,
@@ -364,6 +377,7 @@ impl WaylandWindow {
surface_target,
state: State {
running: true,
fixed_size_request: fixed_size_request_for_spec(&spec),
_connection: connection,
_compositor: compositor,
_surface: surface,
@@ -697,6 +711,7 @@ impl WaylandWindow {
}
pub fn apply_spec(&mut self, spec: &WindowSpec) -> Result<(), Box<dyn Error>> {
self.state.fixed_size_request = fixed_size_request_for_spec(spec);
self.state._toplevel.set_title(spec.title.clone());
if let Some(app_id) = spec.app_id.as_ref() {
self.state._toplevel.set_app_id(app_id.clone());
@@ -732,6 +747,14 @@ impl WaylandWindow {
}
if let Some((width, height)) = self.state.pending_size.take() {
trace!(
target: "ruin_ui_platform_wayland::resize",
width,
height,
previous_width = self.state.current_size.0,
previous_height = self.state.current_size.1,
"prepare_frame observed pending size change"
);
self.state.current_size = (width, height);
self.state.needs_redraw = false;
return Some(FrameRequest {
@@ -742,6 +765,12 @@ impl WaylandWindow {
}
if self.state.needs_redraw {
trace!(
target: "ruin_ui_platform_wayland::resize",
width = self.state.current_size.0,
height = self.state.current_size.1,
"prepare_frame servicing redraw without size change"
);
self.state.needs_redraw = false;
return Some(FrameRequest {
width: self.state.current_size.0,
@@ -2101,6 +2130,14 @@ impl Dispatch<xdg_surface::XdgSurface, ()> for State {
if let xdg_surface::Event::Configure { serial } = event {
xdg_surface.ack_configure(serial);
state.configured = true;
trace!(
target: "ruin_ui_platform_wayland::resize",
serial,
current_width = state.current_size.0,
current_height = state.current_size.1,
has_pending_size = state.pending_size.is_some(),
"received xdg_surface configure"
);
state.request_redraw();
}
}
@@ -2153,7 +2190,37 @@ impl Dispatch<xdg_toplevel::XdgToplevel, ()> for State {
height,
"received Wayland toplevel configure"
);
state.pending_size = Some((width, height));
let next_size = (width, height);
if let Some(expected_size) = state.fixed_size_request {
if next_size != expected_size {
trace!(
target: "ruin_ui_platform_wayland::resize",
width,
height,
expected_width = expected_size.0,
expected_height = expected_size.1,
"ignoring stale toplevel configure for fixed-size window"
);
state.request_redraw();
return;
}
}
let size_changed =
next_size != state.current_size && state.pending_size != Some(next_size);
trace!(
target: "ruin_ui_platform_wayland::resize",
width,
height,
current_width = state.current_size.0,
current_height = state.current_size.1,
pending_width = state.pending_size.map(|size| size.0),
pending_height = state.pending_size.map(|size| size.1),
size_changed,
"processed Wayland toplevel configure"
);
if size_changed {
state.pending_size = Some(next_size);
}
state.request_redraw();
}
_ => {}