20 Commits

Author SHA1 Message Date
Will Temple
1be4a249e8 implement flexbox main axis justification 2026-05-22 23:46:05 -04:00
Will Temple
e5ae067bc2 Perf profiling and some low hanging perf improvements in text rendering 2026-05-16 19:07:44 -04:00
Will Temple
4347232b98 Clippy clean 2026-05-16 16:59:09 -04:00
Will Temple
ccc3984b01 Merge origin/main into macos
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-16 16:38:10 -04:00
6dcc2d0d00 Lots of owork on a calculator example, transparency, etc. 2026-05-16 16:18:55 -04:00
Will Temple
23d9f02e3a review pass the last 2026-05-16 16:18:08 -04:00
Will Temple
d9ac6bfeb8 review pass 4 2026-05-16 15:23:43 -04:00
Will Temple
056f356065 Review pass 3 2026-05-16 15:16:49 -04:00
Will Temple
1a083ee12c More cleanup 2026-05-15 16:55:07 -07:00
Will Temple
bfda24ad0a Cleanup 2026-05-15 16:28:06 -07:00
Will Temple
67400f1499 Functional parity with linux 2026-05-15 13:31:59 -07:00
e2c2563b7e Some more layout features, align/stretch. 2026-03-25 22:57:58 -04:00
cd55cf14c5 typical stuff 2026-03-25 22:32:25 -04:00
341d07d82f Refactor ruin_app, add some README files, minor usability in reactivity 2026-03-25 22:32:11 -04:00
5f67c06083 Min size layout constraints 2026-03-23 19:43:14 -04:00
e327516e5e Opacity compositing 2026-03-23 16:33:19 -04:00
6079117f2c Pixel-grid snapping and corrected border box sizing 2026-03-23 15:45:58 -04:00
Will Temple
861bf63621 initial macos porting work 2026-03-23 15:16:20 -04:00
d961dd7491 Bound zxdg_decoration_manager_v1 for compositors that support server-side decorations. Fixed a resize deadlock. Fixed scroll box list rendering. 2026-03-23 10:36:33 -04:00
ad5c233b15 Fix lints, undo broken set_immediate_interval 2026-03-23 10:11:51 -04:00
103 changed files with 18156 additions and 2663 deletions

164
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"
@@ -1306,10 +1359,38 @@ name = "objc2-core-foundation"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags",
"block2",
"dispatch2",
"libc",
"objc2",
]
[[package]]
name = "objc2-core-graphics"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
"bitflags",
"dispatch2",
"objc2",
"objc2-core-foundation",
"objc2-io-surface",
]
[[package]]
name = "objc2-core-video"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
dependencies = [
"bitflags",
"objc2",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-io-surface",
]
[[package]]
@@ -1323,6 +1404,19 @@ name = "objc2-foundation"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags",
"block2",
"libc",
"objc2",
"objc2-core-foundation",
]
[[package]]
name = "objc2-io-surface"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
dependencies = [
"bitflags",
"objc2",
@@ -1348,8 +1442,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
dependencies = [
"bitflags",
"block2",
"libc",
"objc2",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-core-video",
"objc2-foundation",
"objc2-metal",
]
@@ -1793,6 +1891,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"
@@ -1822,15 +1928,33 @@ dependencies = [
"ruin-runtime",
"ruin_reactivity",
"ruin_ui",
"ruin_ui_platform_macos",
"ruin_ui_platform_wayland",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "ruin_perf"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "ruin_perf_scenarios"
version = "0.1.0"
dependencies = [
"ruin_perf",
"ruin_ui",
]
[[package]]
name = "ruin_reactivity"
version = "0.1.0"
dependencies = [
"hashbrown 0.17.0",
"ruin-runtime",
"tracing",
"tracing-subscriber",
@@ -1850,6 +1974,23 @@ dependencies = [
"tracing-subscriber",
]
[[package]]
name = "ruin_ui_platform_macos"
version = "0.1.0"
dependencies = [
"libc",
"objc2",
"objc2-core-foundation",
"objc2-foundation",
"objc2-quartz-core",
"raw-window-handle",
"raw-window-metal",
"ruin-runtime",
"ruin_ui",
"ruin_ui_renderer_wgpu",
"tracing",
]
[[package]]
name = "ruin_ui_platform_wayland"
version = "0.1.0"
@@ -2257,6 +2398,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 +2419,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 +2492,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

@@ -1,3 +1,18 @@
[workspace]
resolver = "3"
members = ["lib/*", "examples/*"]
default-members = [
"lib/reactivity",
"lib/reactivity_parsing",
"lib/ruin_app",
"lib/ruin_app_proc_macros",
"lib/runtime",
"lib/runtime_proc_macros",
"lib/perf",
"lib/ui",
"lib/ui_platform_macos",
"lib/ui_platform_wayland",
"lib/ui_renderer_wgpu",
"examples/calculator",
"examples/perf_scenarios",
]

18
LICENSE.txt Normal file
View File

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

14
README.md Normal file
View File

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

View File

@@ -0,0 +1,94 @@
# Performance profiling workflow
RUIN performance investigations should start with reproducible reports before
deep profiler sessions. The first-pass tooling is intentionally headless and
focused on high-signal app-build, layout, text, scene, and cache counters.
## Run a scenario
Use the `ruin_perf_scenarios` package in release mode:
```bash
cargo run -p ruin_perf_scenarios --release -- \
--scenario large-scroll-list \
--frames 300 \
--output target/ruin-perf/large-scroll-list.json
```
List available scenarios:
```bash
cargo run -p ruin_perf_scenarios -- --list
```
Current scenarios:
| Scenario | What it stresses |
| --- | --- |
| `counter` | tiny reactive-style app tree rebuild and layout baseline |
| `large-scroll-list` | scroll culling, text cache reuse, layout cache behavior |
| `calculator` | realistic small app tree with display, button grid, and history |
| `resize-configure` | repeated viewport-size changes and layout reflow |
| `large-text-body` | huge wrapped text body, text layout cache behavior, scroll clipping |
The large text scenario accepts a body-size option:
```bash
cargo run -p ruin_perf_scenarios --release -- \
--scenario large-text-body \
--frames 100 \
--text-kb 1024
```
Each run writes JSON under `target/ruin-perf/` by default and prints a compact
summary table to stdout.
## Interpreting the report
Start with the timing rows:
- `app_build`: time spent building the deterministic app tree for the frame.
- `layout_total`: full layout snapshot time.
- `layout_intrinsic`: intrinsic measurement work inside layout.
- `text_prepare` / `text_cache_miss`: text shaping/cache miss work.
Then inspect counters:
- High `layout_cache_misses` with low `layout_cache_hits` means the tree or
layout constraints are changing in a way that defeats caching.
- High `viewport_culled` with low `scene_items` is expected for large scroll
lists and means scene culling is working.
- High `text_cache_misses` or `text_cache_miss` time usually means text content,
bounds, style, or cache keys are changing too much.
- Large `scene_items` or `text_glyphs` for tiny workloads suggests excessive UI
tree expansion or missing culling.
## Investigation loop
1. Reproduce the suspected degenerate workload with the closest scenario.
2. Run it in release mode and keep the JSON report.
3. Compare the stdout summary with a known-good run or a smaller frame count.
4. If one phase dominates, use an OS profiler on that scenario:
- macOS: Instruments Time Profiler / Allocations / Metal System Trace.
- Linux: `perf`, `samply`, `heaptrack`, or RenderDoc as appropriate.
5. After identifying a concrete hot path, add or update a narrow Criterion
benchmark before optimizing it.
## Validation
The scenario runner is part of the default workspace. The usual repository
validation should keep it healthy:
```bash
cargo test
cargo clippy --all-targets -- -D warnings
cargo clippy --release --all-targets -- -D warnings
```
## Current limitations
This first pass does not automate GUI input, GPU frame capture, or platform
presentation traces. Renderer frame counters are exposed for platform-backed
runs, but the headless scenario reports focus on app/layout/text degeneracy
first. Platform-backed resize/presentation report generation should be added
after these reports are useful enough to guide concrete optimization work.

View File

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

View File

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

View File

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

@@ -0,0 +1,8 @@
[package]
name = "ruin_perf_scenarios"
version = "0.1.0"
edition = "2024"
[dependencies]
ruin_perf = { path = "../../lib/perf" }
ruin_ui = { path = "../../lib/ui" }

View File

@@ -0,0 +1,366 @@
use std::env;
use std::io;
use std::path::PathBuf;
use std::time::Instant;
use ruin_perf::{PerfReportBuilder, RunMetadata};
use ruin_ui::{
AlignItems, Color, Edges, Element, FlexDirection, LayoutCache, LayoutFrameStats,
ScrollbarStyle, TextStyle, TextSystem, UiSize, layout_snapshot_with_cache_and_stats,
};
const DEFAULT_FRAMES: u64 = 300;
#[derive(Clone, Debug)]
struct Config {
scenario: Scenario,
frames: u64,
output: Option<PathBuf>,
text_kb: usize,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Scenario {
Counter,
LargeScrollList,
Calculator,
ResizeConfigure,
LargeTextBody,
}
impl Scenario {
const ALL: &'static [Self] = &[
Self::Counter,
Self::LargeScrollList,
Self::Calculator,
Self::ResizeConfigure,
Self::LargeTextBody,
];
const fn name(self) -> &'static str {
match self {
Self::Counter => "counter",
Self::LargeScrollList => "large-scroll-list",
Self::Calculator => "calculator",
Self::ResizeConfigure => "resize-configure",
Self::LargeTextBody => "large-text-body",
}
}
fn parse(value: &str) -> Option<Self> {
Self::ALL
.iter()
.copied()
.find(|scenario| scenario.name() == value)
}
}
fn main() -> io::Result<()> {
let config = Config::parse(env::args().skip(1))?;
let report = run_scenario(&config);
let output = config
.output
.clone()
.unwrap_or_else(|| report.default_output_path());
report.write_json(&output)?;
report.print_summary();
println!();
println!("wrote {}", output.display());
Ok(())
}
impl Config {
fn parse(mut args: impl Iterator<Item = String>) -> io::Result<Self> {
let mut scenario = Scenario::LargeScrollList;
let mut frames = DEFAULT_FRAMES;
let mut output = None;
let mut text_kb = 256usize;
while let Some(arg) = args.next() {
match arg.as_str() {
"--scenario" => {
let value = args.next().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "--scenario needs a value")
})?;
scenario = Scenario::parse(&value).ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("unknown scenario '{value}'"),
)
})?;
}
"--frames" => {
let value = args.next().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "--frames needs a value")
})?;
frames = value.parse::<u64>().map_err(|error| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid frame count '{value}': {error}"),
)
})?;
}
"--output" => {
let value = args.next().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "--output needs a value")
})?;
output = Some(PathBuf::from(value));
}
"--text-kb" => {
let value = args.next().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "--text-kb needs a value")
})?;
text_kb = value.parse::<usize>().map_err(|error| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid text size '{value}': {error}"),
)
})?;
}
"--list" => {
for scenario in Scenario::ALL {
println!("{}", scenario.name());
}
std::process::exit(0);
}
"--help" | "-h" => {
print_help();
std::process::exit(0);
}
other => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("unknown argument '{other}'"),
));
}
}
}
Ok(Self {
scenario,
frames,
output,
text_kb,
})
}
}
fn print_help() {
println!(
"ruin_perf_scenarios --scenario <name> [--frames <n>] [--output <path>] [--text-kb <n>]"
);
println!();
println!("scenarios:");
for scenario in Scenario::ALL {
println!(" {}", scenario.name());
}
}
fn run_scenario(config: &Config) -> ruin_perf::PerfReport {
let mut report =
PerfReportBuilder::new(RunMetadata::new(config.scenario.name(), config.frames));
let mut text_system = TextSystem::new();
let mut layout_cache = LayoutCache::new();
report.note("headless layout/app-build scenario; renderer/platform timings are not included");
if config.scenario == Scenario::LargeTextBody {
report.set_counter("text_body_kb", config.text_kb as f64);
}
for frame in 0..config.frames {
let build_started = Instant::now();
let (tree, viewport) = match config.scenario {
Scenario::Counter => counter_tree(frame),
Scenario::LargeScrollList => large_scroll_list_tree(frame),
Scenario::Calculator => calculator_tree(frame),
Scenario::ResizeConfigure => resize_configure_tree(frame),
Scenario::LargeTextBody => large_text_body_tree(frame, config.text_kb),
};
let build_ms = build_started.elapsed().as_secs_f64() * 1_000.0;
let (_snapshot, stats) = layout_snapshot_with_cache_and_stats(
frame + 1,
viewport,
&tree,
&mut text_system,
&mut layout_cache,
);
report.record_ms("app_build", build_ms);
record_layout_stats(&mut report, stats);
}
report.finish()
}
fn record_layout_stats(report: &mut PerfReportBuilder, stats: LayoutFrameStats) {
report.record_ms("layout_total", stats.total_ms);
report.record_ms("layout_intrinsic", stats.intrinsic_ms);
report.record_ms("text_prepare", stats.text_prepare_ms);
report.record_ms("text_cache_miss", stats.text_miss_ms);
report.record("layout_nodes", "count", stats.nodes as f64);
report.record("scene_items", "count", stats.scene_items as f64);
report.record("text_glyphs", "count", stats.text_output_glyphs as f64);
report.add_counter("layout_cache_hits", stats.layout_cache_hits as f64);
report.add_counter("layout_cache_misses", stats.layout_cache_misses as f64);
report.add_counter("intrinsic_cache_hits", stats.intrinsic_cache_hits as f64);
report.add_counter("viewport_culled", stats.viewport_culled as f64);
report.add_counter("text_cache_hits", stats.text_cache_hits as f64);
report.add_counter("text_cache_misses", stats.text_cache_misses as f64);
report.add_counter("text_requests", stats.text_requests as f64);
}
fn text_style(size: f32) -> TextStyle {
TextStyle::new(size, Color::rgb(0x20, 0x20, 0x20))
}
fn counter_tree(frame: u64) -> (Element, UiSize) {
let count = frame as i64;
let root = Element::column()
.gap(12.0)
.padding(Edges::all(24.0))
.align_items(AlignItems::Center)
.child(Element::text("Counter", text_style(28.0)))
.child(Element::text(format!("count = {count}"), text_style(18.0)))
.child(Element::text(
format!("double = {}", count * 2),
text_style(18.0),
))
.child(button_row(["-1", "Reset", "+1"]));
(root, UiSize::new(480.0, 320.0))
}
fn large_scroll_list_tree(frame: u64) -> (Element, UiSize) {
let item_count = 1_000usize;
let item_height = 28.0;
let viewport = UiSize::new(480.0, 640.0);
let max_offset = (item_count as f32 * item_height - viewport.height).max(0.0);
let offset = ((frame as f32 * 19.0) % max_offset.max(1.0)).round();
let mut list = Element::column().gap(0.0);
for index in 0..item_count {
list = list.child(Element::new().height(item_height).child(Element::text(
format!("list item {index:04}"),
text_style(14.0),
)));
}
let root = Element::new().child(
Element::scroll_box(offset)
.width(viewport.width)
.height(viewport.height)
.scrollbar_style(ScrollbarStyle::default())
.child(list),
);
(root, viewport)
}
fn calculator_tree(frame: u64) -> (Element, UiSize) {
let expression = match frame % 6 {
0 => "2",
1 => "2+",
2 => "2+2",
3 => "4",
4 => "4*8",
_ => "32",
};
let history_len = (frame as usize / 12).min(40);
let mut history = Element::column().gap(4.0);
for index in 0..history_len {
history = history.child(Element::text(
format!("{} + {} = {}", index, index + 1, index * 2 + 1),
text_style(12.0),
));
}
let root = Element::column()
.gap(10.0)
.padding(Edges::all(20.0))
.child(Element::text("Calculator", text_style(28.0)))
.child(display_panel(expression))
.child(button_grid())
.child(Element::scroll_box(10_000.0).height(180.0).child(history));
(root, UiSize::new(420.0, 720.0))
}
fn resize_configure_tree(frame: u64) -> (Element, UiSize) {
let width = 360.0 + (frame % 90) as f32 * 4.0;
let height = 280.0 + (frame % 60) as f32 * 3.0;
let mut content = Element::column().gap(8.0).padding(Edges::all(16.0));
for index in 0..40 {
content = content.child(Element::text(
format!("resize row {index:02}: {width:.0} x {height:.0}"),
text_style(14.0),
));
}
(content, UiSize::new(width, height))
}
fn large_text_body_tree(frame: u64, text_kb: usize) -> (Element, UiSize) {
let viewport = UiSize::new(720.0, 720.0);
let text_width = 680.0;
let line_height = 18.0;
let text = large_text_body(text_kb);
let estimated_line_count = (text.len() as f32 / 72.0).ceil();
let estimated_height = (estimated_line_count * line_height).max(viewport.height);
let max_offset = (estimated_height - viewport.height).max(0.0);
let offset = ((frame as f32 * 127.0) % max_offset.max(1.0)).round();
let style = text_style(14.0)
.with_line_height(line_height)
.with_bounds(UiSize::new(text_width, estimated_height));
let root = Element::new().child(
Element::scroll_box(offset)
.width(viewport.width)
.height(viewport.height)
.child(
Element::new()
.width(text_width)
.child(Element::paragraph(text, style)),
),
);
(root, viewport)
}
fn large_text_body(text_kb: usize) -> String {
let target_bytes = text_kb.saturating_mul(1024).max(1);
let paragraph = "RUIN large text profiling paragraph: the quick brown fox jumps over the lazy dog. This line is intentionally ordinary prose with enough words to exercise wrapping, glyph collection, hit-test geometry, and cache behavior.\n";
let mut text = String::with_capacity(target_bytes + paragraph.len());
while text.len() < target_bytes {
text.push_str(paragraph);
}
text.truncate(target_bytes);
text
}
fn display_panel(expression: &str) -> Element {
Element::new()
.padding(Edges::all(12.0))
.border(1.0, Color::rgb(0x80, 0x80, 0x80))
.child(Element::text(expression, text_style(24.0)))
}
fn button_grid() -> Element {
let rows = [
["7", "8", "9", "/"],
["4", "5", "6", "*"],
["1", "2", "3", "-"],
["0", ".", "=", "+"],
];
let mut grid = Element::column().gap(6.0);
for row in rows {
grid = grid.child(button_row(row));
}
grid
}
fn button_row<const N: usize>(labels: [&str; N]) -> Element {
let mut row = Element::new().direction(FlexDirection::Row).gap(6.0);
for label in labels {
row = row.child(
Element::new()
.width(76.0)
.height(36.0)
.border(1.0, Color::rgb(0x90, 0x90, 0x90))
.child(Element::text(label, text_style(16.0))),
);
}
row
}

View File

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

View File

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

View File

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

8
lib/perf/Cargo.toml Normal file
View File

@@ -0,0 +1,8 @@
[package]
name = "ruin_perf"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"

226
lib/perf/src/lib.rs Normal file
View File

@@ -0,0 +1,226 @@
use std::cmp::Ordering;
use std::collections::BTreeMap;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RunMetadata {
pub scenario: String,
pub frames: u64,
pub profile: String,
pub target_os: String,
pub target_arch: String,
pub started_unix_ms: u128,
}
impl RunMetadata {
pub fn new(scenario: impl Into<String>, frames: u64) -> Self {
Self {
scenario: scenario.into(),
frames,
profile: if cfg!(debug_assertions) {
"debug".into()
} else {
"release".into()
},
target_os: std::env::consts::OS.into(),
target_arch: std::env::consts::ARCH.into(),
started_unix_ms: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or(Duration::ZERO)
.as_millis(),
}
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct SampleSeries {
pub name: String,
pub unit: String,
pub samples: Vec<f64>,
}
impl SampleSeries {
pub fn new(name: impl Into<String>, unit: impl Into<String>) -> Self {
Self {
name: name.into(),
unit: unit.into(),
samples: Vec::new(),
}
}
pub fn push(&mut self, sample: f64) {
if sample.is_finite() {
self.samples.push(sample);
}
}
pub fn summary(&self) -> SampleSummary {
SampleSummary::from_samples(&self.name, &self.unit, &self.samples)
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SampleSummary {
pub name: String,
pub unit: String,
pub count: usize,
pub avg: f64,
pub p50: f64,
pub p95: f64,
pub max: f64,
}
impl SampleSummary {
fn from_samples(name: &str, unit: &str, samples: &[f64]) -> Self {
if samples.is_empty() {
return Self {
name: name.into(),
unit: unit.into(),
count: 0,
avg: 0.0,
p50: 0.0,
p95: 0.0,
max: 0.0,
};
}
let mut sorted = samples.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
let sum = sorted.iter().sum::<f64>();
Self {
name: name.into(),
unit: unit.into(),
count: sorted.len(),
avg: sum / sorted.len() as f64,
p50: percentile(&sorted, 0.50),
p95: percentile(&sorted, 0.95),
max: *sorted.last().unwrap_or(&0.0),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PerfReport {
pub metadata: RunMetadata,
pub summaries: Vec<SampleSummary>,
pub counters: BTreeMap<String, f64>,
pub notes: Vec<String>,
}
#[derive(Clone, Debug)]
pub struct PerfReportBuilder {
metadata: RunMetadata,
series: BTreeMap<String, SampleSeries>,
counters: BTreeMap<String, f64>,
notes: Vec<String>,
}
impl PerfReportBuilder {
pub fn new(metadata: RunMetadata) -> Self {
Self {
metadata,
series: BTreeMap::new(),
counters: BTreeMap::new(),
notes: Vec::new(),
}
}
pub fn record_ms(&mut self, name: impl Into<String>, value: f64) {
self.record(name, "ms", value);
}
pub fn record(&mut self, name: impl Into<String>, unit: impl Into<String>, value: f64) {
let name = name.into();
let unit = unit.into();
self.series
.entry(name.clone())
.or_insert_with(|| SampleSeries::new(name, unit))
.push(value);
}
pub fn add_counter(&mut self, name: impl Into<String>, value: impl Into<f64>) {
*self.counters.entry(name.into()).or_insert(0.0) += value.into();
}
pub fn set_counter(&mut self, name: impl Into<String>, value: impl Into<f64>) {
self.counters.insert(name.into(), value.into());
}
pub fn note(&mut self, note: impl Into<String>) {
self.notes.push(note.into());
}
pub fn finish(self) -> PerfReport {
PerfReport {
metadata: self.metadata,
summaries: self.series.values().map(SampleSeries::summary).collect(),
counters: self.counters,
notes: self.notes,
}
}
}
impl PerfReport {
pub fn write_json(&self, path: impl AsRef<Path>) -> io::Result<()> {
let path = path.as_ref();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(self).map_err(io::Error::other)?;
fs::write(path, json)?;
Ok(())
}
pub fn default_output_path(&self) -> PathBuf {
PathBuf::from("target")
.join("ruin-perf")
.join(format!("{}.json", self.metadata.scenario))
}
pub fn print_summary(&self) {
println!(
"scenario={} frames={} profile={} target={}/{}",
self.metadata.scenario,
self.metadata.frames,
self.metadata.profile,
self.metadata.target_os,
self.metadata.target_arch
);
println!(
"{:<32} {:>8} {:>10} {:>10} {:>10} {:>10}",
"metric", "count", "avg", "p50", "p95", "max"
);
for summary in &self.summaries {
println!(
"{:<32} {:>8} {:>10.3} {:>10.3} {:>10.3} {:>10.3} {}",
summary.name,
summary.count,
summary.avg,
summary.p50,
summary.p95,
summary.max,
summary.unit
);
}
if !self.counters.is_empty() {
println!();
println!("{:<32} {:>12}", "counter", "value");
for (name, value) in &self.counters {
println!("{name:<32} {value:>12.3}");
}
}
}
}
fn percentile(sorted: &[f64], percentile: f64) -> f64 {
if sorted.is_empty() {
return 0.0;
}
let index = ((sorted.len() - 1) as f64 * percentile).round() as usize;
sorted[index.min(sorted.len() - 1)]
}

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",
] }

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

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
use std::cell::RefCell;
use 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,10 +1,14 @@
use std::cell::{Cell, RefCell};
use std::rc::{Rc, Weak};
use alloc::boxed::Box;
use alloc::rc::{Rc, Weak};
use core::cell::{Cell, RefCell};
use crate::reactor::ObserverHook;
use crate::{NodeId, Reactor, current, trace_targets};
/// Creates an effect in the current thread's default reactor.
///
/// The effect is scheduled immediately and then re-scheduled whenever one of its dependencies
/// changes.
pub fn effect(f: impl Fn() + 'static) -> EffectHandle {
current().effect(f)
}
@@ -16,6 +20,7 @@ pub fn effect_in(reactor: &Reactor, f: impl Fn() + 'static) -> EffectHandle {
/// Disposable handle for a reactive effect.
#[derive(Clone)]
#[must_use = "effects are automatically disposed when dropped, so you must use the handle or explicitly leak it"]
pub struct EffectHandle {
inner: Rc<EffectInner>,
}
@@ -55,6 +60,14 @@ impl EffectHandle {
Self { inner }
}
/// Consumes the effect and leaks it, allowing it to run for the remainder of the program without automatically
/// disposing when dropped. Use this for effects that you want to run for the lifetime of the program. You CANNOT
/// recover an EffectHandle after calling this method, so be sure to call it on an EffectHandle that you don't need
/// to later dispose of.
pub fn leak(self) {
core::mem::forget(self);
}
/// Disposes the effect immediately.
pub fn dispose(&self) {
self.inner.dispose();

View File

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

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`.
@@ -282,6 +290,14 @@ impl Reactor {
self.inner.ensure_flush_scheduled();
}
/// Flushes currently queued reactive jobs immediately on the calling thread.
///
/// This is useful when host integrations need synchronous propagation
/// (for example, during native resize loops).
pub fn flush_now(&self) {
Rc::clone(&self.inner).flush_jobs();
}
pub(crate) fn allocate_node(&self) -> NodeId {
let raw = self.inner.next_node.get();
self.inner.next_node.set(raw.wrapping_add(1));
@@ -344,11 +360,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 +373,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 +437,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 +452,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 +469,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 +477,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,789 @@
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 ReuseContext<'a, 'count> {
old_text: &'a str,
new_text: &'a str,
delta: &'a EditDelta,
reused: &'count mut usize,
}
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 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 mut reuse = ReuseContext {
old_text: &previous.text,
new_text: &text,
delta,
reused: &mut reused,
};
let root = self.reuse_node(
&parsed,
Some(&previous.root),
previous.root_anchor.byte,
root_anchor.byte,
&mut reuse,
);
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,
reuse: &mut ReuseContext<'_, '_>,
) -> 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), reuse.delta) == parsed.range
&& reuse.old_text.get(
absolute_range(old, old_parent_start).start
..absolute_range(old, old_parent_start).end,
) == reuse.new_text.get(parsed.range.start..parsed.range.end)
{
*reuse.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,
reuse,
)
})
.collect();
AstNode {
id: self.alloc_id(),
kind: parsed.kind,
local_range: parsed_local,
children,
}
}
fn alloc_id(&mut self) -> u64 {
self.next_id = self.next_id.wrapping_add(1);
self.next_id
}
}
fn read_rope_text(rope: &ReactiveRope) -> String {
let mut cursor = rope.cursor(0);
let mut text = String::new();
while let Some(chunk) = cursor.next_chunk() {
text.push_str(chunk.text());
}
text
}
fn localize(range: TextRange, parent_start: usize) -> TextRange {
TextRange::new(
range.start.saturating_sub(parent_start),
range.end.saturating_sub(parent_start),
)
}
fn absolute_range(node: &AstNode, parent_start: usize) -> TextRange {
TextRange::new(
parent_start + node.local_range.start,
parent_start + node.local_range.end,
)
}
fn map_range(range: TextRange, delta: &EditDelta) -> TextRange {
if range.end <= delta.range.start {
return range;
}
if range.start >= delta.range.end {
let shift = delta.net_byte_delta();
return TextRange::new(
range.start.saturating_add_signed(shift),
range.end.saturating_add_signed(shift),
);
}
TextRange::new(usize::MAX, usize::MAX)
}
fn parse_json(text: &str) -> Result<ParsedNode, String> {
let mut cursor = Cursor { text, offset: 0 };
cursor.skip_ws();
let node = cursor.parse_value()?;
cursor.skip_ws();
if !cursor.is_eof() {
return Err(format!(
"unexpected trailing data at byte {}",
cursor.offset
));
}
Ok(node)
}
impl<'a> Cursor<'a> {
fn is_eof(&self) -> bool {
self.offset >= self.text.len()
}
fn skip_ws(&mut self) {
while let Some(ch) = self.peek_char() {
if ch.is_ascii_whitespace() {
self.offset += ch.len_utf8();
} else {
break;
}
}
}
fn peek_char(&self) -> Option<char> {
self.text[self.offset..].chars().next()
}
fn bump_char(&mut self) -> Option<char> {
let ch = self.peek_char()?;
self.offset += ch.len_utf8();
Some(ch)
}
fn expect_char(&mut self, expected: char) -> Result<(), String> {
match self.bump_char() {
Some(ch) if ch == expected => Ok(()),
Some(ch) => Err(format!(
"expected `{expected}` at byte {}, found `{ch}`",
self.offset.saturating_sub(ch.len_utf8())
)),
None => Err(format!("expected `{expected}` at end of file")),
}
}
fn parse_value(&mut self) -> Result<ParsedNode, String> {
self.skip_ws();
match self.peek_char() {
Some('{') => self.parse_object(),
Some('[') => self.parse_array(),
Some('"') => self.parse_string(),
Some('t') => self.parse_literal("true", JsonKind::True),
Some('f') => self.parse_literal("false", JsonKind::False),
Some('n') => self.parse_literal("null", JsonKind::Null),
Some('-' | '0'..='9') => self.parse_number(),
Some(other) => Err(format!("unexpected `{other}` at byte {}", self.offset)),
None => Err("unexpected end of JSON input".to_string()),
}
}
fn parse_object(&mut self) -> Result<ParsedNode, String> {
let start = self.offset;
self.expect_char('{')?;
self.skip_ws();
let mut children = Vec::new();
if self.peek_char() == Some('}') {
self.bump_char();
return Ok(ParsedNode {
kind: JsonKind::Object,
range: TextRange::new(start, self.offset),
children,
});
}
loop {
self.skip_ws();
let key = self.parse_string()?;
self.skip_ws();
self.expect_char(':')?;
self.skip_ws();
let value = self.parse_value()?;
let pair = ParsedNode {
kind: JsonKind::Pair,
range: TextRange::new(key.range.start, value.range.end),
children: vec![key, value],
};
children.push(pair);
self.skip_ws();
match self.peek_char() {
Some(',') => {
self.bump_char();
}
Some('}') => {
self.bump_char();
break;
}
Some(other) => {
return Err(format!(
"expected `,` or `}}` at byte {}, found `{other}`",
self.offset
));
}
None => return Err("unterminated object".to_string()),
}
}
Ok(ParsedNode {
kind: JsonKind::Object,
range: TextRange::new(start, self.offset),
children,
})
}
fn parse_array(&mut self) -> Result<ParsedNode, String> {
let start = self.offset;
self.expect_char('[')?;
self.skip_ws();
let mut children = Vec::new();
if self.peek_char() == Some(']') {
self.bump_char();
return Ok(ParsedNode {
kind: JsonKind::Array,
range: TextRange::new(start, self.offset),
children,
});
}
loop {
let value = self.parse_value()?;
children.push(value);
self.skip_ws();
match self.peek_char() {
Some(',') => {
self.bump_char();
self.skip_ws();
}
Some(']') => {
self.bump_char();
break;
}
Some(other) => {
return Err(format!(
"expected `,` or `]` at byte {}, found `{other}`",
self.offset
));
}
None => return Err("unterminated array".to_string()),
}
}
Ok(ParsedNode {
kind: JsonKind::Array,
range: TextRange::new(start, self.offset),
children,
})
}
fn parse_string(&mut self) -> Result<ParsedNode, String> {
let start = self.offset;
self.expect_char('"')?;
loop {
match self.bump_char() {
Some('"') => {
return Ok(ParsedNode {
kind: JsonKind::String,
range: TextRange::new(start, self.offset),
children: Vec::new(),
});
}
Some('\\') => match self.bump_char() {
Some('"') | Some('\\') | Some('/') | Some('b') | Some('f') | Some('n')
| Some('r') | Some('t') => {}
Some('u') => {
for _ in 0..4 {
match self.bump_char() {
Some(ch) if ch.is_ascii_hexdigit() => {}
_ => {
return Err(format!(
"invalid unicode escape at byte {}",
self.offset
));
}
}
}
}
_ => return Err(format!("invalid escape sequence at byte {}", self.offset)),
},
Some(ch) if ch >= '\u{20}' => {}
Some(_) => {
return Err(format!(
"control character in string at byte {}",
self.offset
));
}
None => return Err("unterminated string".to_string()),
}
}
}
fn parse_number(&mut self) -> Result<ParsedNode, String> {
let start = self.offset;
if self.peek_char() == Some('-') {
self.bump_char();
}
match self.peek_char() {
Some('0') => {
self.bump_char();
}
Some('1'..='9') => {
self.bump_char();
while matches!(self.peek_char(), Some('0'..='9')) {
self.bump_char();
}
}
_ => return Err(format!("invalid number at byte {}", self.offset)),
}
if self.peek_char() == Some('.') {
self.bump_char();
let mut digits = 0usize;
while matches!(self.peek_char(), Some('0'..='9')) {
digits += 1;
self.bump_char();
}
if digits == 0 {
return Err(format!("expected fraction digits at byte {}", self.offset));
}
}
if matches!(self.peek_char(), Some('e' | 'E')) {
self.bump_char();
if matches!(self.peek_char(), Some('+' | '-')) {
self.bump_char();
}
let mut digits = 0usize;
while matches!(self.peek_char(), Some('0'..='9')) {
digits += 1;
self.bump_char();
}
if digits == 0 {
return Err(format!("expected exponent digits at byte {}", self.offset));
}
}
Ok(ParsedNode {
kind: JsonKind::Number,
range: TextRange::new(start, self.offset),
children: Vec::new(),
})
}
fn parse_literal(&mut self, literal: &str, kind: JsonKind) -> Result<ParsedNode, String> {
let start = self.offset;
if !self.text[self.offset..].starts_with(literal) {
return Err(format!("expected `{literal}` at byte {}", self.offset));
}
self.offset += literal.len();
Ok(ParsedNode {
kind,
range: TextRange::new(start, self.offset),
children: Vec::new(),
})
}
}
fn print_snapshot(snapshot: &ParseSnapshot, rope: &ReactiveRope, delta: Option<&EditDelta>) {
let metrics = rope.snapshot_metrics();
println!();
println!("Document revision {}", metrics.revision);
println!(
"Text bytes={}, leaves={}, nodes={}, reused_on_last_edit={}",
metrics.text.bytes, metrics.leaves, snapshot.total_nodes, snapshot.reused_nodes
);
if let Some(delta) = delta {
println!(
"Last edit: [{}..{}) -> {:?}",
delta.range.start, delta.range.end, delta.inserted_text
);
let focus = delta.range.start.min(snapshot.text.len().saturating_sub(1));
let path = path_for_offset(&snapshot.root, snapshot.root_anchor.byte, focus);
println!("Changed path:");
for (node, range) in path {
let start = rope.point_at(range.start);
let end = rope.point_at(range.end);
println!(
" id={} kind={} span={}",
node.id,
node.kind,
format_span(start, end)
);
}
}
if !snapshot.diagnostics.is_empty() {
println!("Diagnostics:");
for diagnostic in &snapshot.diagnostics {
println!(" - {diagnostic}");
}
}
println!("Root id={} kind={}", snapshot.root.id, snapshot.root.kind);
println!("Parse tree:");
print_tree(&snapshot.root, snapshot.root_anchor.byte, rope, 0);
println!("Current text:");
println!("{}", snapshot.text);
}
fn print_tree(node: &AstNode, parent_start: usize, rope: &ReactiveRope, depth: usize) {
let range = absolute_range(node, parent_start);
let start = rope.point_at(range.start);
let end = rope.point_at(range.end);
let indent = " ".repeat(depth);
println!(
"{indent}- id={} kind={} span={}",
node.id,
node.kind,
format_span(start, end)
);
for child in &node.children {
print_tree(child, range.start, rope, depth + 1);
}
}
fn path_for_offset(root: &AstNode, root_start: usize, offset: usize) -> Vec<(&AstNode, TextRange)> {
fn walk<'a>(
node: &'a AstNode,
parent_start: usize,
offset: usize,
out: &mut Vec<(&'a AstNode, TextRange)>,
) -> bool {
let range = absolute_range(node, parent_start);
if offset < range.start || offset >= range.end {
return false;
}
out.push((node, range));
for child in &node.children {
if walk(child, range.start, offset, out) {
return true;
}
}
true
}
let mut path = Vec::new();
let _ = walk(root, root_start, offset, &mut path);
path
}
fn format_span(start: TextPoint, end: TextPoint) -> String {
format!(
"{}:{}..{}:{}",
start.line + 1,
start.column + 1,
end.line + 1,
end.column + 1
)
}
fn count_nodes(node: &AstNode) -> usize {
1 + node.children.iter().map(count_nodes).sum::<usize>()
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,172 @@
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,
}
pub type ParserResult<C, T, D> = Result<ParseResult<C, T, D>, ParseError>;
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>,
) -> ParserResult<Self::Checkpoint, Self::Tree, Self::Diagnostic>;
}
#[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,735 @@
use std::cell::{Cell, RefCell};
use std::cmp::min;
use std::collections::BTreeSet;
use std::fmt;
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 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 fmt::Display for ReactiveRope {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.slice(TextRange::new(0, self.len_bytes())))
}
}
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

@@ -8,9 +8,14 @@ ruin_reactivity = { path = "../reactivity" }
ruin_runtime = { package = "ruin-runtime", path = "../runtime" }
ruin_app_proc_macros = { package = "ruin-app-proc-macros", path = "../ruin_app_proc_macros" }
ruin_ui = { path = "../ui" }
ruin_ui_platform_wayland = { path = "../ui_platform_wayland" }
tracing = "0.1"
[target.'cfg(target_os = "linux")'.dependencies]
ruin_ui_platform_wayland = { path = "../ui_platform_wayland" }
[target.'cfg(target_os = "macos")'.dependencies]
ruin_ui_platform_macos = { path = "../ui_platform_macos" }
[dev-dependencies]
tracing-subscriber = { version = "0.3", default-features = false, features = [
"env-filter",
@@ -47,6 +52,10 @@ path = "example/04_composition_and_context.rs"
name = "05_async_runtime_io"
path = "example/05_async_runtime_io.rs"
[[example]]
name = "06_justify_content"
path = "example/06_justify_content.rs"
[[example]]
name = "07_children_and_slots"
path = "example/07_children_and_slots.rs"

18
lib/ruin_app/build.rs Normal file
View File

@@ -0,0 +1,18 @@
use std::env;
use std::path::PathBuf;
fn main() {
println!("cargo:rerun-if-changed=macos/Info.plist");
let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
if target_os != "macos" {
return;
}
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("manifest dir"));
let plist = manifest_dir.join("macos/Info.plist");
let arg = format!("-Wl,-sectcreate,__TEXT,__info_plist,{}", plist.display());
// Ensure all ruin_app executables/examples opt into Retina rendering.
println!("cargo:rustc-link-arg-examples={arg}");
}

View File

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

View File

@@ -4,8 +4,7 @@ use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::{EnvFilter, fmt};
fn install_tracing() {
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("warn"));
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn"));
let fmt_layer = fmt::layer()
.with_target(true)
.with_thread_ids(true)
@@ -91,21 +90,12 @@ fn TaskBoard() -> impl IntoView {
}
});
let all_label = if matches!(filter.get(), TaskFilter::All) {
"● All"
} else {
"○ All"
};
let open_label = if matches!(filter.get(), TaskFilter::OpenOnly) {
"● Open"
} else {
"○ Open"
};
let done_label = if matches!(filter.get(), TaskFilter::CompletedOnly) {
"● Completed"
} else {
"○ Completed"
};
let all_label = filter_label(matches!(filter.get(), TaskFilter::All), "All");
let open_label = filter_label(matches!(filter.get(), TaskFilter::OpenOnly), "Open");
let done_label = filter_label(
matches!(filter.get(), TaskFilter::CompletedOnly),
"Completed",
);
let task_rows = visible_ids.with(|ids| {
ids.iter()
.map(|task_id| {
@@ -467,6 +457,14 @@ fn move_task(tasks: &Signal<Vec<Task>>, task_id: u64, direction: MoveDirection)
});
}
fn filter_label(active: bool, label: &str) -> String {
if active {
format!("[x] {label}")
} else {
format!("[ ] {label}")
}
}
fn seed_tasks() -> Vec<Task> {
vec![
Task {

View File

@@ -0,0 +1,194 @@
//! Visual demonstration of every supported `justify_content` mode.
//!
//! The window arranges one labeled cell per mode in a two-column grid. Each
//! cell is a flex container of the same fixed width with four
//! identically-sized children, so the only visible difference between cells is
//! how `justify_content` distributes the leftover space along the main axis.
use ruin_app::prelude::*;
const TRACK_WIDTH: f32 = 360.0;
const ITEM_SIZE: f32 = 44.0;
#[ruin_runtime::async_main]
async fn main() -> ruin_app::Result<()> {
App::new()
.window(
Window::new()
.title("RUIN justify_content")
.app_id("dev.ruin.justify-content")
.size(960.0, 620.0),
)
.mount(view! {
JustifyContentGallery() {}
})
.run()
.await
}
#[component]
fn JustifyContentGallery() -> impl IntoView {
let canvas = Color::rgb(0x10, 0x16, 0x22);
let panel = Color::rgb(0x1A, 0x23, 0x33);
let accent = Color::rgb(0x71, 0xA7, 0xF7);
let text_color = Color::rgb(0xF5, 0xF7, 0xFB);
let muted = Color::rgb(0x9A, 0xA9, 0xC2);
view! {
column(gap = 18.0, padding = 24.0, background = canvas) {
text(role = TextRole::Heading(1), size = 28.0, weight = FontWeight::Semibold, color = text_color) {
"justify_content"
}
text(color = muted, wrap = TextWrap::Word) {
"Each cell below is the same width and contains the same four boxes. The only \
thing that changes between cells is `justify_content`, which controls how the \
leftover space along the main axis is distributed."
}
row(gap = 16.0, align_items = AlignItems::Start) {
column(gap = 14.0, flex = 1.0) {
JustifyRow(
label = "Start",
description = "Pack children at the start of the main axis (default).",
justify = JustifyContent::Start,
panel = panel,
accent = accent,
text_color = text_color,
muted = muted,
) {}
JustifyRow(
label = "End",
description = "Pack children at the end of the main axis.",
justify = JustifyContent::End,
panel = panel,
accent = accent,
text_color = text_color,
muted = muted,
) {}
JustifyRow(
label = "Center",
description = "Pack children as a group, centered on the main axis.",
justify = JustifyContent::Center,
panel = panel,
accent = accent,
text_color = text_color,
muted = muted,
) {}
}
column(gap = 14.0, flex = 1.0) {
JustifyRow(
label = "SpaceBetween",
description = "First child flush with the start, last with the end, equal gaps between.",
justify = JustifyContent::SpaceBetween,
panel = panel,
accent = accent,
text_color = text_color,
muted = muted,
) {}
JustifyRow(
label = "SpaceAround",
description = "Equal space around each child; end gaps are half the gap between children.",
justify = JustifyContent::SpaceAround,
panel = panel,
accent = accent,
text_color = text_color,
muted = muted,
) {}
JustifyRow(
label = "SpaceEvenly",
description = "Equal space between children and between children and both edges.",
justify = JustifyContent::SpaceEvenly,
panel = panel,
accent = accent,
text_color = text_color,
muted = muted,
) {}
}
}
}
}
}
#[component]
fn JustifyRow(
label: &'static str,
description: &'static str,
justify: JustifyContent,
panel: Color,
accent: Color,
text_color: Color,
muted: Color,
) -> impl IntoView {
view! {
column(
gap = 6.0,
padding = 12.0,
background = panel,
border_radius = 10.0,
border = (1.0, accent),
) {
row(gap = 10.0, align_items = AlignItems::Center) {
text(size = 16.0, weight = FontWeight::Semibold, color = text_color) { label }
text(color = muted) { description }
}
row(
width = TRACK_WIDTH,
height = ITEM_SIZE + 16.0,
padding = 8.0,
gap = 8.0,
background = Color::rgba(0xFF, 0xFF, 0xFF, 0x10),
border_radius = 6.0,
align_items = AlignItems::Center,
justify_content = justify,
) {
JustifyItem(label = "1", accent = accent, text_color = text_color) {}
JustifyItem(label = "2", accent = accent, text_color = text_color) {}
JustifyItem(label = "3", accent = accent, text_color = text_color) {}
JustifyItem(label = "4", accent = accent, text_color = text_color) {}
}
}
}
}
#[component]
fn JustifyItem(label: &'static str, accent: Color, text_color: Color) -> impl IntoView {
view! {
block(
width = ITEM_SIZE,
height = ITEM_SIZE,
background = accent,
border_radius = 6.0,
align_items = AlignItems::Center,
) {
row(
flex = 1.0,
align_items = AlignItems::Center,
justify_content = JustifyContent::Center,
) {
text(size = 18.0, weight = FontWeight::Semibold, color = text_color) { label }
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gallery_renders_all_labels() {
let root = JustifyContentGallery::builder().children(());
let view = ruin_app::__render_mountable_for_test(&root);
let snapshot = ruin_ui::layout_snapshot(1, UiSize::new(960.0, 620.0), view.element());
for label in ["Start", "End", "Center", "SpaceBetween", "SpaceAround", "SpaceEvenly"] {
assert!(
snapshot.scene.items.iter().any(|item| matches!(
item,
ruin_ui::DisplayItem::Text(text) if text.text.contains(label)
)),
"expected `{label}` label to appear in the rendered scene",
);
}
}
}

View File

@@ -259,7 +259,12 @@ fn CardFrame(title: View, toolbar: ChildViews, children: ChildViews) -> impl Int
}
#[component]
fn InlineDialog(open: bool, title: View, actions: ChildViews, children: ChildViews) -> impl IntoView {
fn InlineDialog(
open: bool,
title: View,
actions: ChildViews,
children: ChildViews,
) -> impl IntoView {
if open {
let actions_row = view! {
row(gap = 10.0) {

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>RUIN</string>
<key>CFBundleIdentifier</key>
<string>dev.ruin.example</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>NSHighResolutionCapable</key>
<true/>
</dict>
</plist>

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

@@ -0,0 +1,621 @@
use std::cell::Cell as StdCell;
use std::cell::RefCell;
use std::iter;
use std::rc::Rc;
use std::time::Instant;
use ruin_reactivity::{current as current_reactor, effect};
#[cfg(target_os = "macos")]
use ruin_runtime::{queue_future, spawn_worker};
use ruin_ui::{
Color, Element, InteractionTree, KeyboardEvent, LayoutCache, LayoutSnapshot, PlatformEvent,
PointerButton, PointerEvent, PointerEventKind, PointerRouter, RoutedPointerEvent,
RoutedPointerEventKind, TextSystem, UiSize, WindowController, WindowSpec, WindowUpdate,
layout_snapshot_with_cache,
};
#[cfg(target_os = "macos")]
use ruin_ui_platform_macos::{run_macos_app_event_loop, start_macos_ui};
#[cfg(target_os = "linux")]
use ruin_ui_platform_wayland::start_wayland_ui;
use crate::Result;
use crate::context::{
InteractionSnapshot, RenderState, render_with_context, render_with_context_and_interaction,
};
use crate::input::{
EventBindings, InputState, ShortcutBinding, TextSelection, TextSelectionDrag,
TextSelectionState, apply_text_selection_overlay, focused_element_for_pointer,
sync_primary_selection,
};
use crate::view::View;
#[cfg(target_os = "linux")]
fn start_platform_ui() -> ruin_ui::UiRuntime {
start_wayland_ui()
}
#[cfg(target_os = "macos")]
fn start_platform_ui() -> ruin_ui::UiRuntime {
start_macos_ui()
}
#[derive(Clone, Debug)]
pub struct Window {
pub(crate) spec: WindowSpec,
}
impl Window {
pub fn new() -> Self {
Self {
spec: WindowSpec::new("RUIN App"),
}
}
pub fn title(mut self, title: impl Into<String>) -> Self {
self.spec.title = title.into();
self
}
pub fn app_id(mut self, app_id: impl Into<String>) -> Self {
self.spec = self.spec.app_id(app_id);
self
}
pub fn size(mut self, width: f32, height: f32) -> Self {
self.spec = self.spec.requested_inner_size(UiSize::new(width, height));
self
}
pub fn min_size(mut self, min_width: f32, min_height: f32) -> Self {
self.spec = self.spec.min_inner_size(UiSize::new(min_width, min_height));
self
}
pub fn base_color(mut self, color: Color) -> Self {
self.spec = self.spec.base_color(color);
self
}
pub fn resizable(mut self, resizable: bool) -> Self {
self.spec.resizable = resizable;
self
}
}
impl Default for Window {
fn default() -> Self {
Self::new()
}
}
pub struct App {
window: Window,
}
impl App {
pub fn new() -> Self {
Self {
window: Window::new(),
}
}
pub fn window(mut self, window: Window) -> Self {
self.window = window;
self
}
pub fn mount<M: Mountable>(self, root: M) -> MountedApp<M> {
MountedApp {
window: self.window,
root,
}
}
}
impl Default for App {
fn default() -> Self {
Self::new()
}
}
pub trait Mountable: 'static {
fn render(&self) -> View;
}
pub trait Component: 'static {
type Builder;
fn builder() -> Self::Builder;
fn render(&self) -> View;
}
pub trait ContextKey: 'static {
type Value: Clone + 'static;
}
impl<T: Component> Mountable for T {
fn render(&self) -> View {
Component::render(self)
}
}
impl Mountable for View {
fn render(&self) -> View {
self.clone()
}
}
impl Mountable for Element {
fn render(&self) -> View {
View::from_element(self.clone())
}
}
pub struct MountedApp<M: Mountable> {
pub(crate) window: Window,
pub(crate) root: M,
}
impl<M: Mountable> MountedApp<M> {
async fn run_with_ui_runtime(
app_window: Window,
root: M,
mut ui: ruin_ui::UiRuntime,
) -> Result<()> {
let root = Rc::new(root);
let render_state = Rc::new(RenderState::default());
let window = ui.create_window(app_window.spec.clone())?;
let initial_viewport = app_window
.spec
.requested_inner_size
.unwrap_or(UiSize::new(960.0, 640.0));
let viewport = ruin_reactivity::cell(initial_viewport);
let configure_serial = ruin_reactivity::cell(0_u64);
let scene_version = StdCell::new(0_u64);
let text_system = Rc::new(RefCell::new(TextSystem::new()));
let layout_cache = Rc::new(RefCell::new(LayoutCache::new()));
let interaction_tree = Rc::new(RefCell::new(None::<InteractionTree>));
let bindings = Rc::new(RefCell::new(EventBindings::default()));
let shortcuts = Rc::new(RefCell::new(Vec::<ShortcutBinding>::new()));
let current_title = Rc::new(RefCell::new(None::<String>));
let last_min_size = Rc::new(RefCell::new(None::<UiSize>));
let last_fixed_size = Rc::new(RefCell::new(None::<UiSize>));
let explicit_min_size = app_window.spec.min_inner_size;
let explicit_max_size = app_window.spec.max_inner_size;
let window_resizable = app_window.spec.resizable;
let mut input_state = InputState::new();
let mut pointer_router = PointerRouter::new();
let _scene_effect = effect({
let window = window.clone();
let viewport = viewport.clone();
let configure_serial = configure_serial.clone();
let text_system = Rc::clone(&text_system);
let layout_cache = Rc::clone(&layout_cache);
let interaction_tree = Rc::clone(&interaction_tree);
let bindings = Rc::clone(&bindings);
let shortcuts = Rc::clone(&shortcuts);
let current_title = Rc::clone(&current_title);
let last_min_size = Rc::clone(&last_min_size);
let last_fixed_size = Rc::clone(&last_fixed_size);
let root = Rc::clone(&root);
let render_state = Rc::clone(&render_state);
let text_selection = Rc::clone(&input_state.text_selection);
let hovered_elements = input_state.hovered_elements.clone();
let pressed_elements = input_state.pressed_elements.clone();
let interaction_version = input_state.interaction_version.clone();
move || {
let viewport = viewport.get();
let _ = configure_serial.get();
let version = scene_version.get().wrapping_add(1);
scene_version.set(version);
let _ = text_selection.version.get();
let _ = interaction_version.get();
let t_effect = Instant::now();
let interaction_snapshot = Rc::new(InteractionSnapshot {
hovered: hovered_elements.get(),
pressed: pressed_elements.get(),
});
let render_output = render_with_context_and_interaction(
Rc::clone(&render_state),
interaction_snapshot,
|| root.render(),
);
if render_output.side_effects.window_title != *current_title.borrow() {
if let Some(title) = &render_output.side_effects.window_title {
window
.update(WindowUpdate::new().title(title.clone()))
.expect("window should remain alive while the app is running");
}
*current_title.borrow_mut() = render_output.side_effects.window_title.clone();
}
let render_us = t_effect.elapsed().as_micros();
let t_layout = Instant::now();
let LayoutSnapshot {
mut scene,
interaction_tree: next_interaction_tree,
root_min_size,
} = layout_snapshot_with_cache(
version,
viewport,
render_output.view.element(),
&mut text_system.borrow_mut(),
&mut layout_cache.borrow_mut(),
);
let layout_us = t_layout.elapsed().as_micros();
let desired_min = UiSize::new(
root_min_size
.width
.max(explicit_min_size.map_or(0.0, |s| s.width)),
root_min_size
.height
.max(explicit_min_size.map_or(0.0, |s| s.height)),
);
if window_resizable {
let current_min = *last_min_size.borrow();
if current_min != Some(desired_min) {
let _ =
window.update(WindowUpdate::new().min_inner_size(Some(desired_min)));
*last_min_size.borrow_mut() = Some(desired_min);
}
} else {
let desired_fixed = UiSize::new(
desired_min
.width
.min(explicit_max_size.map_or(f32::INFINITY, |s| s.width)),
desired_min
.height
.min(explicit_max_size.map_or(f32::INFINITY, |s| s.height)),
);
let current_fixed = *last_fixed_size.borrow();
if current_fixed != Some(desired_fixed) {
let _ = window.update(
WindowUpdate::new()
.requested_inner_size(Some(desired_fixed))
.min_inner_size(Some(desired_fixed))
.max_inner_size(Some(desired_fixed)),
);
*last_fixed_size.borrow_mut() = Some(desired_fixed);
}
}
let effect_us = t_effect.elapsed().as_micros();
tracing::debug!(
target: "ruin_app::resize",
version,
width = viewport.width,
height = viewport.height,
render_us,
layout_us,
effect_us,
"scene effect complete, sending ReplaceScene"
);
apply_text_selection_overlay(&mut scene, *text_selection.selection.borrow());
*interaction_tree.borrow_mut() = Some(next_interaction_tree);
*bindings.borrow_mut() = render_output.view.bindings;
*shortcuts.borrow_mut() = render_output.side_effects.shortcuts.clone();
window
.replace_scene(scene)
.expect("window should remain alive while the app is running");
}
});
loop {
let Some(event) = ui.next_event().await else {
break;
};
let mut should_flush_reactive_work = false;
for event in iter::once(event).chain(ui.take_pending_events()) {
match event {
PlatformEvent::Configured {
window_id,
configuration,
} if window_id == window.id() => {
tracing::debug!(
target: "ruin_app::resize",
width = configuration.actual_inner_size.width,
height = configuration.actual_inner_size.height,
"app received Configured, queuing layout effect"
);
let _ = viewport.set(configuration.actual_inner_size);
configure_serial.update(|value| *value = value.wrapping_add(1));
should_flush_reactive_work = true;
}
PlatformEvent::Pointer { window_id, event } if window_id == window.id() => {
Self::handle_pointer_event(
&window,
&interaction_tree,
&bindings,
&mut pointer_router,
&mut input_state,
event,
)?;
should_flush_reactive_work = true;
}
PlatformEvent::Keyboard { window_id, event } if window_id == window.id() => {
Self::handle_keyboard_event(
&interaction_tree,
&bindings,
&shortcuts,
&input_state,
event,
)?;
should_flush_reactive_work = true;
}
PlatformEvent::CloseRequested { window_id } if window_id == window.id() => {
tracing::debug!(
target: "ruin_app::lifecycle",
?window_id,
"app received CloseRequested, issuing open(false)"
);
let _ = window.update(WindowUpdate::new().open(false));
}
PlatformEvent::Closed { window_id } if window_id == window.id() => {
tracing::debug!(
target: "ruin_app::lifecycle",
?window_id,
"app received Closed, shutting down ui runtime"
);
ui.shutdown()?;
return Ok(());
}
_ => {}
}
}
if should_flush_reactive_work {
current_reactor().flush_now();
}
}
ui.shutdown()?;
Ok(())
}
}
impl<M: Mountable> MountedApp<M> {
#[cfg(not(target_os = "macos"))]
pub async fn run(self) -> Result<()> {
let ui = start_platform_ui();
Self::run_with_ui_runtime(self.window, self.root, ui).await
}
#[cfg(target_os = "macos")]
pub async fn run(self) -> Result<()>
where
M: Send + 'static,
{
let ui = start_platform_ui();
let (done_tx, done_rx) = std::sync::mpsc::channel::<std::result::Result<(), String>>();
let window = self.window;
let root = self.root;
let _worker = spawn_worker(
move || {
queue_future(async move {
let result = Self::run_with_ui_runtime(window, root, ui)
.await
.map_err(|err| err.to_string());
let _ = done_tx.send(result);
});
},
|| {},
);
run_macos_app_event_loop();
match done_rx.recv() {
Ok(Ok(())) => Ok(()),
Ok(Err(err)) => Err(err.into()),
Err(_) => Err("ui worker terminated before completion".into()),
}
}
pub(crate) fn handle_pointer_event(
window: &WindowController,
interaction_tree: &RefCell<Option<InteractionTree>>,
bindings: &RefCell<EventBindings>,
pointer_router: &mut PointerRouter,
input_state: &mut InputState,
event: PointerEvent,
) -> Result<()> {
if matches!(
event.kind,
PointerEventKind::Down {
button: PointerButton::Primary | PointerButton::Middle,
}
) {
let interaction_tree = interaction_tree.borrow();
if let Some(interaction_tree) = interaction_tree.as_ref() {
input_state.focused_element = focused_element_for_pointer(interaction_tree, &event);
}
}
let routed = {
let interaction_tree = interaction_tree.borrow();
let Some(interaction_tree) = interaction_tree.as_ref() else {
return Ok(());
};
pointer_router.route(interaction_tree, event)
};
{
let interaction_tree = interaction_tree.borrow();
let Some(interaction_tree) = interaction_tree.as_ref() else {
return Ok(());
};
let hovered_targets = pointer_router.hovered_targets();
for routed_event in &routed {
bindings
.borrow()
.dispatch(routed_event, interaction_tree, hovered_targets);
Self::handle_text_selection_event(
window,
interaction_tree,
routed_event,
&input_state.text_selection,
)?;
}
}
let next_cursor = pointer_router
.hovered_targets()
.last()
.map(|target| target.cursor)
.unwrap_or(ruin_ui::CursorIcon::Default);
let hovered_ids = Self::interaction_ids(pointer_router.hovered_targets());
let pressed_ids = Self::interaction_ids(pointer_router.pressed_targets());
if next_cursor != input_state.current_cursor {
input_state.current_cursor = next_cursor;
window.set_cursor_icon(next_cursor)?;
}
if Self::update_interaction_state(input_state, hovered_ids, pressed_ids) {
input_state.interaction_version.update(|value| *value += 1);
}
Ok(())
}
pub(crate) fn handle_keyboard_event(
interaction_tree: &RefCell<Option<InteractionTree>>,
bindings: &RefCell<EventBindings>,
shortcuts: &RefCell<Vec<ShortcutBinding>>,
input_state: &InputState,
event: KeyboardEvent,
) -> Result<()> {
let interaction_tree = interaction_tree.borrow();
let Some(interaction_tree) = interaction_tree.as_ref() else {
return Ok(());
};
let shortcut_bindings = shortcuts.borrow().clone();
let mut consumed = false;
for shortcut in shortcut_bindings {
if shortcut.matches(&event, input_state.focused_element, interaction_tree) {
shortcut.trigger(interaction_tree);
consumed = true;
}
}
if consumed {
return Ok(());
}
bindings
.borrow()
.dispatch_key(input_state.focused_element, &event, interaction_tree);
Ok(())
}
fn handle_text_selection_event(
window: &WindowController,
interaction_tree: &InteractionTree,
event: &RoutedPointerEvent,
text_selection: &TextSelectionState,
) -> Result<()> {
let mut selection_changed = false;
match event.kind {
RoutedPointerEventKind::Down {
button: PointerButton::Primary,
} => {
if let Some(hit) = interaction_tree.text_hit_test(event.position)
&& let Some(element_id) = hit.target.element_id
{
let next = Some(TextSelection {
element_id,
anchor: hit.byte_offset,
focus: hit.byte_offset,
});
if *text_selection.selection.borrow() != next {
*text_selection.selection.borrow_mut() = next;
selection_changed = true;
}
*text_selection.drag.borrow_mut() = Some(TextSelectionDrag {
element_id,
anchor: hit.byte_offset,
});
} else {
selection_changed = text_selection.selection.borrow_mut().take().is_some();
let _ = text_selection.drag.borrow_mut().take();
}
}
RoutedPointerEventKind::Move => {
let Some(drag) = *text_selection.drag.borrow() else {
return Ok(());
};
let Some(text) = interaction_tree.text_for_element(drag.element_id) else {
return Ok(());
};
let next = Some(TextSelection {
element_id: drag.element_id,
anchor: drag.anchor,
focus: text.byte_offset_for_position(event.position),
});
if *text_selection.selection.borrow() != next {
*text_selection.selection.borrow_mut() = next;
selection_changed = true;
}
}
RoutedPointerEventKind::Up {
button: PointerButton::Primary,
} => {
if text_selection.drag.borrow_mut().take().is_some() {
selection_changed = true;
}
}
_ => {}
}
if selection_changed {
text_selection.version.update(|value| *value += 1);
sync_primary_selection(window, interaction_tree, *text_selection.selection.borrow())?;
}
Ok(())
}
fn interaction_ids(targets: &[ruin_ui::HitTarget]) -> Vec<ruin_ui::ElementId> {
targets
.iter()
.filter_map(|target| target.element_id)
.collect()
}
fn update_interaction_state(
input_state: &mut InputState,
hovered_ids: Vec<ruin_ui::ElementId>,
pressed_ids: Vec<ruin_ui::ElementId>,
) -> bool {
let previous_hovered = input_state.hovered_elements.get();
let previous_pressed = input_state.pressed_elements.get();
let hovered_changed = input_state
.hovered_elements
.set(hovered_ids.clone())
.is_some();
let pressed_changed = input_state
.pressed_elements
.set(pressed_ids.clone())
.is_some();
if hovered_changed || pressed_changed {
tracing::trace!(
target: "ruin_app::interaction",
hovered_before = ?previous_hovered,
hovered_after = ?hovered_ids,
pressed_before = ?previous_pressed,
pressed_after = ?pressed_ids,
"interaction state changed"
);
}
hovered_changed || pressed_changed
}
}
#[doc(hidden)]
pub fn __render_mountable_for_test<M: Mountable>(mountable: &M) -> View {
render_with_context(Rc::new(RenderState::default()), || mountable.render()).view
}

View File

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

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

@@ -0,0 +1,201 @@
use std::any::{Any, TypeId};
use std::cell::{Cell as StdCell, RefCell};
use std::rc::Rc;
use ruin_ui::ElementId;
use crate::ContextKey;
use crate::view::View;
#[derive(Clone)]
pub(crate) struct ContextEntry {
pub(crate) key: TypeId,
pub(crate) value: Rc<dyn Any>,
}
impl ContextEntry {
pub(crate) fn new<C: ContextKey>(value: C::Value) -> Self {
Self {
key: TypeId::of::<C>(),
value: Rc::new(value),
}
}
}
#[derive(Default)]
pub(crate) struct RenderState {
pub(crate) hooks: RefCell<Vec<Box<dyn Any>>>,
pub(crate) element_ids: RefCell<Vec<ElementId>>,
pub(crate) next_element_id: StdCell<u64>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) struct InteractionSnapshot {
pub(crate) hovered: Vec<ElementId>,
pub(crate) pressed: Vec<ElementId>,
}
#[derive(Clone, Default)]
pub(crate) struct RenderSideEffects {
pub(crate) window_title: Option<String>,
pub(crate) shortcuts: Vec<crate::input::ShortcutBinding>,
}
#[derive(Clone)]
pub(crate) struct RenderContext {
pub(crate) state: Rc<RenderState>,
pub(crate) hook_index: Rc<StdCell<usize>>,
pub(crate) element_index: Rc<StdCell<usize>>,
pub(crate) side_effects: Rc<RefCell<RenderSideEffects>>,
pub(crate) context_entries: Rc<Vec<ContextEntry>>,
pub(crate) interaction: Rc<InteractionSnapshot>,
}
impl RenderContext {
pub(crate) fn with_context_entries(&self, context_entries: Rc<Vec<ContextEntry>>) -> Self {
Self {
state: Rc::clone(&self.state),
hook_index: Rc::clone(&self.hook_index),
element_index: Rc::clone(&self.element_index),
side_effects: Rc::clone(&self.side_effects),
context_entries,
interaction: Rc::clone(&self.interaction),
}
}
}
pub(crate) struct RenderOutput {
pub(crate) view: View,
pub(crate) side_effects: RenderSideEffects,
}
thread_local! {
pub(crate) static CURRENT_RENDER_CONTEXT: RefCell<Option<RenderContext>> = const { RefCell::new(None) };
}
pub(crate) fn render_with_context(
state: Rc<RenderState>,
render: impl FnOnce() -> View,
) -> RenderOutput {
render_with_context_and_interaction(state, Rc::new(InteractionSnapshot::default()), render)
}
pub(crate) fn render_with_context_and_interaction(
state: Rc<RenderState>,
interaction: Rc<InteractionSnapshot>,
render: impl FnOnce() -> View,
) -> RenderOutput {
let context = RenderContext {
state,
hook_index: Rc::new(StdCell::new(0)),
element_index: Rc::new(StdCell::new(0)),
side_effects: Rc::new(RefCell::new(RenderSideEffects::default())),
context_entries: Rc::new(Vec::new()),
interaction,
};
let view = with_render_context(context.clone(), render);
let side_effects = context.side_effects.borrow().clone();
RenderOutput { view, side_effects }
}
pub(crate) fn with_render_context(context: RenderContext, render: impl FnOnce() -> View) -> View {
CURRENT_RENDER_CONTEXT.with(|slot| {
let previous = slot.replace(Some(context.clone()));
struct Guard<'a> {
slot: &'a RefCell<Option<RenderContext>>,
previous: Option<RenderContext>,
}
impl Drop for Guard<'_> {
fn drop(&mut self) {
let _ = self.slot.replace(self.previous.take());
}
}
let _guard = Guard { slot, previous };
render()
})
}
pub(crate) fn with_render_context_state<R>(f: impl FnOnce(&RenderContext) -> R) -> R {
CURRENT_RENDER_CONTEXT.with(|slot| {
let context = slot
.borrow()
.clone()
.expect("ruin_app hooks can only run while rendering a mounted component");
f(&context)
})
}
pub(crate) fn with_hook_slot<T: 'static, R>(
init: impl FnOnce() -> T,
f: impl FnOnce(&mut T) -> R,
) -> R {
with_render_context_state(|context| {
let index = context.hook_index.get();
context.hook_index.set(index + 1);
let mut hooks = context.state.hooks.borrow_mut();
if hooks.len() == index {
hooks.push(Box::new(init()));
}
let slot = hooks[index]
.downcast_mut::<T>()
.expect("ruin_app hook call order changed between renders");
f(slot)
})
}
pub(crate) fn allocate_element_id() -> ElementId {
with_render_context_state(|context| {
let index = context.element_index.get();
context.element_index.set(index + 1);
let mut element_ids = context.state.element_ids.borrow_mut();
if element_ids.len() == index {
let next = context.state.next_element_id.get().wrapping_add(1);
context.state.next_element_id.set(next);
element_ids.push(ElementId::new(next));
}
element_ids[index]
})
}
pub(crate) struct MemoSlot<T> {
pub(crate) compute: Rc<RefCell<Box<dyn Fn() -> T>>>,
pub(crate) handle: crate::hooks::Memo<T>,
}
pub(crate) struct ResourceSlot<T, E> {
pub(crate) resource: crate::hooks::Resource<T, E>,
pub(crate) _effect: ruin_reactivity::EffectHandle,
}
impl<T: 'static> MemoSlot<T> {
pub(crate) fn new(compute: Rc<RefCell<Box<dyn Fn() -> T>>>) -> Self {
let handle = crate::hooks::Memo {
compute: Rc::new({
let compute = Rc::clone(&compute);
move || {
let compute = compute.borrow();
(compute.as_ref())()
}
}),
};
Self { compute, handle }
}
pub(crate) fn replace_compute(&mut self, compute: Rc<RefCell<Box<dyn Fn() -> T>>>) {
self.compute = Rc::clone(&compute);
self.handle.compute = Rc::new({
let compute = Rc::clone(&compute);
move || {
let compute = compute.borrow();
(compute.as_ref())()
}
});
}
}

View File

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

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

@@ -0,0 +1,419 @@
use std::any::TypeId;
use std::any::type_name;
use std::cell::Cell as StdCell;
use std::future::Future;
use std::marker::PhantomData;
use std::rc::Rc;
use ruin_ui::{ElementId, InteractionTree, KeyboardEvent, KeyboardEventKind, KeyboardKey};
use crate::ContextKey;
use crate::context::{
ContextEntry, MemoSlot, ResourceSlot, with_hook_slot, with_render_context,
with_render_context_state,
};
use crate::input::ShortcutBinding;
pub struct SignalInner<T> {
pub(crate) cell: ruin_reactivity::Cell<T>,
}
pub struct Signal<T> {
pub(crate) inner: Rc<SignalInner<T>>,
}
impl<T> Clone for Signal<T> {
fn clone(&self) -> Self {
Self {
inner: Rc::clone(&self.inner),
}
}
}
impl<T: 'static> Signal<T> {
pub(crate) fn new(initial: T) -> Self {
Self {
inner: Rc::new(SignalInner {
cell: ruin_reactivity::cell(initial),
}),
}
}
pub fn with<R>(&self, f: impl FnOnce(&T) -> R) -> R {
self.inner.cell.with(f)
}
pub fn replace(&self, value: T) -> T {
self.inner.cell.replace(value)
}
pub fn update<R>(&self, f: impl FnOnce(&mut T) -> R) -> R {
self.inner.cell.update(f)
}
}
impl<T: Clone + 'static> Signal<T> {
pub fn get(&self) -> T {
self.inner.cell.get()
}
}
impl<T: PartialEq + 'static> Signal<T> {
pub fn set(&self, value: T) -> Option<T> {
self.inner.cell.set(value)
}
}
pub struct Memo<T> {
pub(crate) compute: Rc<dyn Fn() -> T>,
}
impl<T> Clone for Memo<T> {
fn clone(&self) -> Self {
Self {
compute: Rc::clone(&self.compute),
}
}
}
impl<T> Memo<T> {
pub fn with<R>(&self, f: impl FnOnce(&T) -> R) -> R {
let value = (self.compute)();
f(&value)
}
}
impl<T: Clone> Memo<T> {
pub fn get(&self) -> T {
(self.compute)()
}
}
pub fn use_signal<T: 'static>(initial: impl FnOnce() -> T) -> Signal<T> {
with_hook_slot(|| Signal::new(initial()), |signal| signal.clone())
}
pub fn use_context<C: ContextKey>() -> C::Value {
with_render_context_state(|context| {
context
.context_entries
.iter()
.rev()
.find_map(|entry| {
(entry.key == TypeId::of::<C>())
.then(|| entry.value.downcast_ref::<C::Value>())
.flatten()
.cloned()
})
.unwrap_or_else(|| {
panic!(
"missing context provider for {} while rendering",
type_name::<C>()
)
})
})
}
pub fn provide<C: ContextKey>(
value: C::Value,
render: impl FnOnce() -> crate::view::View,
) -> crate::view::View {
with_render_context_state(|context| {
let mut context_entries = (*context.context_entries).clone();
context_entries.push(ContextEntry::new::<C>(value));
with_render_context(
context.with_context_entries(Rc::new(context_entries)),
render,
)
})
}
pub fn use_memo<T: 'static>(compute: impl Fn() -> T + 'static) -> Memo<T> {
use std::cell::RefCell;
let compute: Rc<RefCell<Box<dyn Fn() -> T>>> =
Rc::new(RefCell::new(Box::new(compute) as Box<dyn Fn() -> T>));
with_hook_slot(
{
let compute = Rc::clone(&compute);
move || MemoSlot::new(compute)
},
|slot: &mut MemoSlot<T>| {
slot.replace_compute(Rc::clone(&compute));
slot.handle.clone()
},
)
}
pub fn use_effect(effect: impl Fn() + 'static) {
with_hook_slot(|| ruin_reactivity::effect(effect), |_| ());
}
pub fn use_window_title(compute: impl FnOnce() -> String) {
with_render_context_state(|context| {
context.side_effects.borrow_mut().window_title = Some(compute());
});
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Key {
Character(char),
Enter,
Backspace,
Escape,
Delete,
ArrowLeft,
ArrowRight,
ArrowUp,
ArrowDown,
Home,
End,
}
impl Key {
pub(crate) fn matches(&self, event: &KeyboardEvent) -> bool {
match (self, &event.key) {
(Self::Character(expected), KeyboardKey::Character(actual)) => actual
.chars()
.next()
.is_some_and(|actual| actual.eq_ignore_ascii_case(expected)),
(Self::Enter, KeyboardKey::Enter) => true,
(Self::Backspace, KeyboardKey::Backspace) => true,
(Self::Escape, KeyboardKey::Escape) => true,
(Self::Delete, KeyboardKey::Delete) => true,
(Self::ArrowLeft, KeyboardKey::ArrowLeft) => true,
(Self::ArrowRight, KeyboardKey::ArrowRight) => true,
(Self::ArrowUp, KeyboardKey::ArrowUp) => true,
(Self::ArrowDown, KeyboardKey::ArrowDown) => true,
(Self::Home, KeyboardKey::Home) => true,
(Self::End, KeyboardKey::End) => true,
_ => false,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Shortcut {
pub(crate) key: Key,
pub(crate) control: bool,
pub(crate) shift: bool,
pub(crate) alt: bool,
pub(crate) super_key: bool,
}
impl Shortcut {
pub fn new(key: Key) -> Self {
Self {
key,
control: false,
shift: false,
alt: false,
super_key: false,
}
}
pub fn with_ctrl(mut self) -> Self {
self.control = true;
self
}
pub fn with_shift(mut self) -> Self {
self.shift = true;
self
}
pub(crate) fn matches(&self, event: &KeyboardEvent) -> bool {
if event.kind != KeyboardEventKind::Pressed {
return false;
}
if !self.key.matches(event) {
return false;
}
if event.modifiers.control != self.control {
return false;
}
if event.modifiers.alt != self.alt {
return false;
}
if event.modifiers.super_key != self.super_key {
return false;
}
// For character shortcuts the produced character already encodes the shift
// state, so don't additionally require that Shift is (un)held.
if !matches!(self.key, Key::Character(_)) && event.modifiers.shift != self.shift {
return false;
}
true
}
}
#[derive(Clone)]
pub struct FocusScope {
pub(crate) element_id: Signal<Option<ElementId>>,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct InteractionState {
pub hovered: bool,
pub pressed: bool,
}
#[derive(Clone)]
pub enum ShortcutScope {
Application,
FocusedWithin(FocusScope),
}
impl ShortcutScope {
pub(crate) fn matches(
&self,
focused_element: Option<ElementId>,
interaction_tree: &InteractionTree,
) -> bool {
match self {
Self::Application => true,
Self::FocusedWithin(scope) => {
let Some(scope_element) = scope.element_id.get() else {
return false;
};
let Some(focused_element) = focused_element else {
return false;
};
crate::input::element_contains_element(
interaction_tree,
scope_element,
focused_element,
)
}
}
}
}
pub struct WidgetRef<T> {
pub(crate) element_id: Signal<Option<ElementId>>,
pub(crate) _marker: PhantomData<fn() -> T>,
}
impl<T> Clone for WidgetRef<T> {
fn clone(&self) -> Self {
Self {
element_id: self.element_id.clone(),
_marker: PhantomData,
}
}
}
impl<T> WidgetRef<T> {
pub(crate) fn new() -> Self {
Self {
element_id: Signal::new(None),
_marker: PhantomData,
}
}
pub fn element_id(&self) -> Option<ElementId> {
self.element_id.get()
}
pub fn focus_scope(&self) -> FocusScope {
FocusScope {
element_id: self.element_id.clone(),
}
}
}
pub struct ScrollBoxWidget;
pub struct BlockWidget;
pub fn use_widget_ref<T: 'static>() -> WidgetRef<T> {
with_hook_slot(WidgetRef::new, |widget_ref: &mut WidgetRef<T>| {
widget_ref.clone()
})
}
pub fn use_interaction_state<T: 'static>(widget_ref: WidgetRef<T>) -> InteractionState {
with_render_context_state(|context| {
let Some(element_id) = widget_ref.element_id() else {
return InteractionState::default();
};
InteractionState {
hovered: context.interaction.hovered.contains(&element_id),
pressed: context.interaction.pressed.contains(&element_id),
}
})
}
pub fn use_shortcut(shortcut: Shortcut, scope: ShortcutScope, action: impl Fn() + 'static) {
use_shortcut_with_context(shortcut, scope, move |_| action());
}
pub fn use_shortcut_with_context(
shortcut: Shortcut,
scope: ShortcutScope,
action: impl Fn(&InteractionTree) + 'static,
) {
with_render_context_state(|context| {
context
.side_effects
.borrow_mut()
.shortcuts
.push(ShortcutBinding {
shortcut,
scope,
action: Rc::new(action),
});
});
}
#[derive(Clone)]
pub struct Resource<T, E> {
pub(crate) state: Signal<ResourceState<T, E>>,
}
impl<T: Clone + 'static, E: Clone + 'static> Resource<T, E> {
pub fn read(&self) -> ResourceState<T, E> {
self.state.get()
}
}
#[derive(Clone)]
pub enum ResourceState<T, E> {
Pending,
Ready(std::result::Result<T, E>),
}
pub fn use_resource<T, E, Fut, F>(factory: F) -> Resource<T, E>
where
T: Clone + 'static,
E: Clone + 'static,
Fut: Future<Output = std::result::Result<T, E>> + 'static,
F: Fn() -> Fut + 'static,
{
with_hook_slot(
|| {
let resource = Resource {
state: Signal::new(ResourceState::Pending),
};
let generation = Rc::new(StdCell::new(0_u64));
let _effect = ruin_reactivity::effect({
let resource = resource.clone();
let generation = Rc::clone(&generation);
move || {
let next_generation = generation.get().wrapping_add(1);
generation.set(next_generation);
let _ = resource.state.replace(ResourceState::Pending);
let resource = resource.clone();
let future = factory();
let generation = Rc::clone(&generation);
std::mem::drop(ruin_runtime::queue_future(async move {
let result = future.await;
if generation.get() == next_generation {
let _ = resource.state.replace(ResourceState::Ready(result));
}
}));
}
});
ResourceSlot { resource, _effect }
},
|slot: &mut ResourceSlot<T, E>| slot.resource.clone(),
)
}

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

@@ -0,0 +1,357 @@
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use ruin_ui::{
CursorIcon, DisplayItem, ElementId, HitTarget, InteractionTree, KeyboardEvent, PointerButton,
PointerEvent, Quad, RoutedPointerEvent, RoutedPointerEventKind, WindowController,
};
use crate::hooks::{Shortcut, ShortcutScope, Signal};
use crate::view::View;
pub(crate) type PressHandler = Rc<dyn Fn(&RoutedPointerEvent) + 'static>;
pub(crate) type ScrollHandler = Rc<dyn Fn(&RoutedPointerEvent, &InteractionTree) + 'static>;
pub(crate) type KeyHandler = Rc<dyn Fn(&KeyboardEvent, &InteractionTree) -> bool + 'static>;
#[derive(Clone, Default)]
pub(crate) struct EventBindings {
pub(crate) on_press: HashMap<ElementId, PressHandler>,
pub(crate) on_scroll: HashMap<ElementId, ScrollHandler>,
pub(crate) on_key: HashMap<ElementId, KeyHandler>,
}
impl EventBindings {
pub(crate) fn extend(&mut self, other: EventBindings) {
self.on_press.extend(other.on_press);
self.on_scroll.extend(other.on_scroll);
self.on_key.extend(other.on_key);
}
pub(crate) fn dispatch(
&self,
event: &RoutedPointerEvent,
interaction_tree: &InteractionTree,
hovered_targets: &[HitTarget],
) {
match event.kind {
RoutedPointerEventKind::Up {
button: PointerButton::Primary,
} => {
if let Some(element_id) = event.target.element_id
&& let Some(handler) = self.on_press.get(&element_id)
{
handler(event);
}
if let Some(handler) = scroll_handler_for_event(
&self.on_scroll,
event.target.element_id,
hovered_targets,
) {
handler(event, interaction_tree);
}
}
RoutedPointerEventKind::Down {
button: PointerButton::Primary,
}
| RoutedPointerEventKind::Move
| RoutedPointerEventKind::Scroll { .. }
| RoutedPointerEventKind::Leave => {
if let Some(handler) = scroll_handler_for_event(
&self.on_scroll,
event.target.element_id,
hovered_targets,
) {
handler(event, interaction_tree);
}
}
_ => {}
}
}
pub(crate) fn dispatch_key(
&self,
focused_element: Option<ElementId>,
event: &KeyboardEvent,
interaction_tree: &InteractionTree,
) {
let Some(handler) = key_handler_for_focus(&self.on_key, focused_element, interaction_tree)
else {
return;
};
let _ = handler(event, interaction_tree);
}
}
#[derive(Clone, Copy, PartialEq)]
pub(crate) struct ScrollbarDrag {
pub(crate) start_pointer_y: f32,
pub(crate) start_offset_y: f32,
}
#[derive(Clone, Copy, PartialEq)]
pub(crate) struct TextSelection {
pub(crate) element_id: ElementId,
pub(crate) anchor: usize,
pub(crate) focus: usize,
}
#[derive(Clone, Copy, PartialEq)]
pub(crate) struct TextSelectionDrag {
pub(crate) element_id: ElementId,
pub(crate) anchor: usize,
}
pub(crate) struct TextSelectionState {
pub(crate) selection: RefCell<Option<TextSelection>>,
pub(crate) drag: RefCell<Option<TextSelectionDrag>>,
pub(crate) version: Signal<u64>,
}
impl TextSelectionState {
pub(crate) fn new() -> Self {
Self {
selection: RefCell::new(None),
drag: RefCell::new(None),
version: Signal::new(0),
}
}
}
pub(crate) struct InputState {
pub(crate) current_cursor: CursorIcon,
pub(crate) focused_element: Option<ElementId>,
pub(crate) text_selection: Rc<TextSelectionState>,
pub(crate) hovered_elements: Signal<Vec<ElementId>>,
pub(crate) pressed_elements: Signal<Vec<ElementId>>,
pub(crate) interaction_version: Signal<u64>,
}
impl InputState {
pub(crate) fn new() -> Self {
Self {
current_cursor: CursorIcon::Default,
focused_element: None,
text_selection: Rc::new(TextSelectionState::new()),
hovered_elements: Signal::new(Vec::new()),
pressed_elements: Signal::new(Vec::new()),
interaction_version: Signal::new(0),
}
}
}
#[derive(Clone)]
pub(crate) struct ShortcutBinding {
pub(crate) shortcut: Shortcut,
pub(crate) scope: ShortcutScope,
pub(crate) action: Rc<dyn Fn(&InteractionTree)>,
}
impl ShortcutBinding {
pub(crate) fn matches(
&self,
event: &KeyboardEvent,
focused_element: Option<ElementId>,
interaction_tree: &InteractionTree,
) -> bool {
self.shortcut.matches(event) && self.scope.matches(focused_element, interaction_tree)
}
pub(crate) fn trigger(&self, interaction_tree: &InteractionTree) {
(self.action)(interaction_tree);
}
}
pub(crate) fn clamp_scroll_offset(offset_y: f32, max_offset_y: f32) -> f32 {
offset_y.clamp(0.0, max_offset_y.max(0.0))
}
pub(crate) fn apply_text_selection_overlay(
scene: &mut ruin_ui::SceneSnapshot,
selection: Option<TextSelection>,
) {
let Some(selection) = selection else {
return;
};
let mut next_items = Vec::with_capacity(scene.items.len());
for item in scene.items.drain(..) {
match item {
DisplayItem::Text(mut text) if text.element_id == Some(selection.element_id) => {
for rect in text.selection_rects(selection.anchor, selection.focus) {
next_items.push(DisplayItem::Quad(Quad::new(
rect,
text.selection_style.highlight_color,
)));
}
text.apply_selected_text_color(selection.anchor, selection.focus);
next_items.push(DisplayItem::Text(text));
}
other => next_items.push(other),
}
}
scene.items = next_items;
}
pub(crate) fn sync_primary_selection(
window: &WindowController,
interaction_tree: &InteractionTree,
selection: Option<TextSelection>,
) -> crate::Result<()> {
#[cfg(not(target_os = "linux"))]
{
let _ = (window, interaction_tree, selection);
Ok(())
}
#[cfg(target_os = "linux")]
{
let Some(selection) = selection else {
window.set_primary_selection_text(String::new())?;
return Ok(());
};
let Some(text) = interaction_tree.text_for_element(selection.element_id) else {
return Ok(());
};
let copied = text
.selected_text(selection.anchor, selection.focus)
.unwrap_or_default()
.to_owned();
window.set_primary_selection_text(copied)?;
Ok(())
}
}
pub(crate) fn scroll_handler_for_event<'a>(
handlers: &'a HashMap<ElementId, ScrollHandler>,
direct_target: Option<ElementId>,
hovered_targets: &[HitTarget],
) -> Option<&'a ScrollHandler> {
if let Some(element_id) = direct_target
&& let Some(handler) = handlers.get(&element_id)
{
return Some(handler);
}
hovered_targets
.iter()
.rev()
.filter_map(|target| target.element_id)
.find_map(|element_id| handlers.get(&element_id))
}
pub(crate) fn focused_element_for_pointer(
interaction_tree: &InteractionTree,
event: &PointerEvent,
) -> Option<ElementId> {
let hit_path = interaction_tree.hit_path(event.position);
hit_path
.iter()
.rev()
.find_map(|target| target.focusable.then_some(target.element_id).flatten())
.or_else(|| hit_path.iter().rev().find_map(|target| target.element_id))
}
pub(crate) fn element_contains_element(
interaction_tree: &InteractionTree,
ancestor: ElementId,
descendant: ElementId,
) -> bool {
fn contains_descendant(node: &ruin_ui::LayoutNode, descendant: ElementId) -> bool {
if node.element_id == Some(descendant) {
return true;
}
node.children
.iter()
.any(|child| contains_descendant(child, descendant))
}
fn ancestor_contains(
node: &ruin_ui::LayoutNode,
ancestor: ElementId,
descendant: ElementId,
) -> bool {
if node.element_id == Some(ancestor) {
return contains_descendant(node, descendant);
}
node.children
.iter()
.any(|child| ancestor_contains(child, ancestor, descendant))
}
ancestor_contains(&interaction_tree.root, ancestor, descendant)
}
pub(crate) fn key_handler_for_focus<'a>(
handlers: &'a HashMap<ElementId, KeyHandler>,
focused_element: Option<ElementId>,
interaction_tree: &InteractionTree,
) -> Option<&'a KeyHandler> {
let focused_element = focused_element?;
focused_ancestor_chain(&interaction_tree.root, focused_element)?
.into_iter()
.find_map(|element_id| handlers.get(&element_id))
}
pub(crate) fn focused_ancestor_chain(
node: &ruin_ui::LayoutNode,
focused_element: ElementId,
) -> Option<Vec<ElementId>> {
let mut chain = Vec::new();
if build_focused_ancestor_chain(node, focused_element, &mut chain) {
Some(chain)
} else {
None
}
}
pub(crate) fn build_focused_ancestor_chain(
node: &ruin_ui::LayoutNode,
focused_element: ElementId,
chain: &mut Vec<ElementId>,
) -> bool {
if node.element_id == Some(focused_element) {
if let Some(element_id) = node.element_id {
chain.push(element_id);
}
return true;
}
for child in &node.children {
if build_focused_ancestor_chain(child, focused_element, chain) {
if let Some(element_id) = node.element_id {
chain.push(element_id);
}
return true;
}
}
false
}
// View helper methods for attaching handlers — used by primitives
impl View {
pub(crate) fn with_press_handler(
mut self,
element_id: ElementId,
handler: PressHandler,
) -> Self {
self.bindings.on_press.insert(element_id, handler);
self
}
pub(crate) fn with_scroll_handler(
mut self,
element_id: ElementId,
handler: ScrollHandler,
) -> Self {
self.bindings.on_scroll.insert(element_id, handler);
self
}
pub(crate) fn with_key_handler(mut self, element_id: ElementId, handler: KeyHandler) -> Self {
self.bindings.on_key.insert(element_id, handler);
self
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,69 @@
use std::rc::Rc;
use ruin_ui::{CursorIcon, Edges, Element, RoutedPointerEvent};
use crate::context::allocate_element_id;
use crate::input::PressHandler;
use crate::primitives::ContainerProps;
use crate::surfaces;
use crate::text_style::{pop_text_style, push_text_style};
use crate::view::{Children, View};
pub struct ButtonBuilder {
pub(crate) props: ContainerProps,
pub(crate) on_press: Option<PressHandler>,
}
pub fn button() -> ButtonBuilder {
ButtonBuilder {
props: ContainerProps::new(
Element::column()
.padding(Edges::symmetric(14.0, 10.0))
.background(surfaces::interactive())
.corner_radius(10.0)
.cursor(CursorIcon::Pointer)
.focusable(true),
),
on_press: None,
}
}
impl ButtonBuilder {
impl_props_methods!();
pub fn shadow(mut self, shadow: impl crate::converters::IntoShadow) -> Self {
self.props = self.props.shadow(shadow);
self
}
pub fn on_press(mut self, handler: impl Fn(&RoutedPointerEvent) + 'static) -> Self {
self.on_press = Some(Rc::new(handler));
self
}
pub fn text_style(mut self, style: crate::text_style::PartialTextStyle) -> Self {
self.props = self.props.text_style(style);
self
}
pub fn children(mut self, children: impl Children) -> View {
let id = allocate_element_id();
self.props.element = self.props.element.id(id);
if let Some(widget_ref) = &self.props.widget_ref {
let _ = widget_ref.set(Some(id));
}
let had_style = self.props.partial_text_style.is_some();
if let Some(style) = self.props.partial_text_style.take() {
push_text_style(style);
}
let children_views = children.into_views();
if had_style {
pop_text_style();
}
let view = View::from_container(self.props.element, children_views);
match self.on_press {
Some(handler) => view.with_press_handler(id, handler),
None => view,
}
}
}

View File

@@ -0,0 +1,162 @@
use ruin_ui::{AlignItems, Color, Element, ElementId, JustifyContent};
use crate::context::allocate_element_id;
use crate::converters::{IntoBorder, IntoEdges, IntoShadow};
use crate::hooks::{Signal, WidgetRef};
use crate::text_style::{PartialTextStyle, pop_text_style, push_text_style};
use crate::view::{Children, View};
/// Shared box-model properties common to all container-like builders.
pub struct ContainerProps {
pub(crate) element: Element,
pub(crate) widget_ref: Option<Signal<Option<ElementId>>>,
pub(crate) partial_text_style: Option<PartialTextStyle>,
}
impl ContainerProps {
pub(crate) fn new(element: Element) -> Self {
Self {
element,
widget_ref: None,
partial_text_style: None,
}
}
pub fn text_style(mut self, style: PartialTextStyle) -> Self {
self.partial_text_style = Some(style);
self
}
pub fn gap(mut self, gap: f32) -> Self {
self.element = self.element.gap(gap);
self
}
pub fn padding(mut self, padding: impl IntoEdges) -> Self {
self.element = self.element.padding(padding.into_edges());
self
}
pub fn background(mut self, color: Color) -> Self {
self.element = self.element.background(color);
self
}
pub fn border(mut self, border: impl IntoBorder) -> Self {
let border = border.into_border();
self.element = self.element.border(border.width, border.color);
self
}
pub fn border_radius(mut self, radius: f32) -> Self {
self.element = self.element.corner_radius(radius);
self
}
pub fn width(mut self, width: f32) -> Self {
self.element = self.element.width(width);
self
}
pub fn height(mut self, height: f32) -> Self {
self.element = self.element.height(height);
self
}
pub fn min_width(mut self, min_width: f32) -> Self {
self.element = self.element.min_width(min_width);
self
}
pub fn min_height(mut self, min_height: f32) -> Self {
self.element = self.element.min_height(min_height);
self
}
pub fn min_size(self, w: f32, h: f32) -> Self {
self.min_width(w).min_height(h)
}
pub fn flex(mut self, flex: f32) -> Self {
self.element = self.element.flex(flex);
self
}
pub fn align_items(mut self, align: AlignItems) -> Self {
self.element = self.element.align_items(align);
self
}
pub fn align_self(mut self, align: AlignItems) -> Self {
self.element = self.element.align_self(align);
self
}
pub fn justify_content(mut self, justify: JustifyContent) -> Self {
self.element = self.element.justify_content(justify);
self
}
pub fn shadow(mut self, shadow: impl IntoShadow) -> Self {
self.element = self.element.shadow(shadow.into_shadow());
self
}
pub fn widget_ref<T>(mut self, widget_ref: WidgetRef<T>) -> Self {
self.widget_ref = Some(widget_ref.element_id.clone());
self
}
}
pub struct ContainerBuilder {
pub(crate) props: ContainerProps,
}
pub fn column() -> ContainerBuilder {
ContainerBuilder {
props: ContainerProps::new(Element::column()),
}
}
pub fn row() -> ContainerBuilder {
ContainerBuilder {
props: ContainerProps::new(Element::row()),
}
}
pub fn block() -> ContainerBuilder {
ContainerBuilder {
props: ContainerProps::new(Element::column()),
}
}
impl ContainerBuilder {
impl_props_methods!();
pub fn shadow(mut self, shadow: impl IntoShadow) -> Self {
self.props = self.props.shadow(shadow);
self
}
pub fn text_style(mut self, style: PartialTextStyle) -> Self {
self.props = self.props.text_style(style);
self
}
pub fn children(mut self, children: impl Children) -> View {
if let Some(widget_ref) = &self.props.widget_ref {
let element_id = allocate_element_id();
self.props.element = self.props.element.id(element_id);
let _ = widget_ref.set(Some(element_id));
}
let had_style = self.props.partial_text_style.is_some();
if let Some(style) = self.props.partial_text_style.take() {
push_text_style(style);
}
let children_views = children.into_views();
if had_style {
pop_text_style();
}
View::from_container(self.props.element, children_views)
}
}

View File

@@ -0,0 +1,75 @@
/// Generates builder methods that delegate to a `props: ContainerProps` field.
/// Must be invoked inside an `impl SomeBuilder { ... }` block.
macro_rules! impl_props_methods {
() => {
pub fn gap(mut self, gap: f32) -> Self {
self.props = self.props.gap(gap);
self
}
pub fn padding(mut self, padding: impl $crate::converters::IntoEdges) -> Self {
self.props = self.props.padding(padding);
self
}
pub fn background(mut self, color: ruin_ui::Color) -> Self {
self.props = self.props.background(color);
self
}
pub fn border(mut self, border: impl $crate::converters::IntoBorder) -> Self {
self.props = self.props.border(border);
self
}
pub fn border_radius(mut self, radius: f32) -> Self {
self.props = self.props.border_radius(radius);
self
}
pub fn width(mut self, width: f32) -> Self {
self.props = self.props.width(width);
self
}
pub fn height(mut self, height: f32) -> Self {
self.props = self.props.height(height);
self
}
pub fn min_width(mut self, min_width: f32) -> Self {
self.props = self.props.min_width(min_width);
self
}
pub fn min_height(mut self, min_height: f32) -> Self {
self.props = self.props.min_height(min_height);
self
}
pub fn min_size(self, w: f32, h: f32) -> Self {
self.min_width(w).min_height(h)
}
pub fn flex(mut self, flex: f32) -> Self {
self.props = self.props.flex(flex);
self
}
pub fn align_items(mut self, align: ruin_ui::AlignItems) -> Self {
self.props = self.props.align_items(align);
self
}
pub fn align_self(mut self, align: ruin_ui::AlignItems) -> Self {
self.props = self.props.align_self(align);
self
}
pub fn justify_content(mut self, justify: ruin_ui::JustifyContent) -> Self {
self.props = self.props.justify_content(justify);
self
}
pub fn widget_ref<T>(mut self, widget_ref: $crate::hooks::WidgetRef<T>) -> Self {
self.props = self.props.widget_ref(widget_ref);
self
}
};
}
pub mod button;
pub mod container;
pub mod scroll_box;
pub mod text;
pub use button::{ButtonBuilder, button};
pub use container::{ContainerBuilder, ContainerProps, block, column, row};
pub use scroll_box::{ScrollBoxBuilder, scroll_box};
pub use text::{FontWeight, TextBuilder, TextRole, text};

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,63 @@
use std::cell::RefCell;
use ruin_ui::{Color, TextFontFamily, TextWrap};
use crate::primitives::FontWeight;
/// Partially-specified text style for context-based inheritance.
///
/// Set on a container via `.text_style(...)`. All descendant `text()` nodes inherit
/// any fields that are `Some`, falling back to their own defaults for fields that are `None`.
/// Inner (closer) ancestors take precedence over outer ancestors.
#[derive(Clone, Default)]
pub struct PartialTextStyle {
pub size: Option<f32>,
pub color: Option<Color>,
pub weight: Option<FontWeight>,
pub font_family: Option<TextFontFamily>,
pub wrap: Option<TextWrap>,
pub line_height: Option<f32>,
}
impl PartialTextStyle {
/// Merge `other` on top of `self` — `other`'s `Some` fields win.
fn merge_over(self, other: &PartialTextStyle) -> Self {
Self {
size: other.size.or(self.size),
color: other.color.or(self.color),
weight: other.weight.or(self.weight),
font_family: other.font_family.clone().or(self.font_family),
wrap: other.wrap.or(self.wrap),
line_height: other.line_height.or(self.line_height),
}
}
}
thread_local! {
static TEXT_STYLE_STACK: RefCell<Vec<PartialTextStyle>> = const { RefCell::new(Vec::new()) };
}
/// Push a partial style onto the context stack. Must be paired with `pop_text_style`.
pub(crate) fn push_text_style(style: PartialTextStyle) {
TEXT_STYLE_STACK.with(|stack| stack.borrow_mut().push(style));
}
/// Pop the top partial style from the context stack.
pub(crate) fn pop_text_style() {
TEXT_STYLE_STACK.with(|stack| {
stack.borrow_mut().pop();
});
}
/// Returns the effective `PartialTextStyle` at the current render position by merging
/// all layers from outermost to innermost (innermost wins).
pub(crate) fn current_text_style() -> PartialTextStyle {
TEXT_STYLE_STACK.with(|stack| {
stack
.borrow()
.iter()
.fold(PartialTextStyle::default(), |acc, layer| {
acc.merge_over(layer)
})
})
}

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

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

View File

@@ -60,10 +60,7 @@ fn expand_component(mut function: ItemFn) -> Result<proc_macro2::TokenStream> {
Some(prop) if prop.ident == "children" => {
let prop = inputs.pop().expect("last input should exist");
let kind = parse_children_contract_kind(&prop.ty)?;
Some(ChildContract {
ty: prop.ty,
kind,
})
Some(ChildContract { ty: prop.ty, kind })
}
_ => None,
};
@@ -87,7 +84,8 @@ fn expand_component(mut function: ItemFn) -> Result<proc_macro2::TokenStream> {
}
Some(contract) => {
let child_arg_ty = child_builder_arg_type_tokens(contract.kind);
let child_value = wrap_special_value_tokens(contract.kind, &format_ident!("children"));
let child_value =
wrap_special_value_tokens(contract.kind, &format_ident!("children"));
let child_field_ident = child_field_ident
.as_ref()
.expect("child field ident should exist");
@@ -574,10 +572,7 @@ fn child_builder_arg_type_tokens(kind: SpecialValueKind) -> proc_macro2::TokenSt
}
}
fn wrap_special_value_tokens(
kind: SpecialValueKind,
ident: &Ident,
) -> proc_macro2::TokenStream {
fn wrap_special_value_tokens(kind: SpecialValueKind, ident: &Ident) -> proc_macro2::TokenStream {
match kind {
SpecialValueKind::Plain => quote! { #ident },
SpecialValueKind::ChildViews => quote! { ::ruin_app::ChildViews::from_children(#ident) },
@@ -686,23 +681,49 @@ fn expand_node(node: &Node) -> proc_macro2::TokenStream {
let value = &property.value;
quote! { .#name(#value) }
});
let children = expand_children(&node.children);
if is_component_path(path) {
// Component nodes: eager children so #[component] builder impls keep working.
let children = expand_children(&node.children);
quote! {
#path::builder()
#(#prop_calls)*
.children(#children)
}
} else {
} else if is_text_path(path) {
// text(): children are TextChildren (string/value), not Views — keep eager.
let children = expand_children(&node.children);
quote! {
::ruin_app::#path()
#(#prop_calls)*
.children(#children)
}
} else {
// Primitive container nodes (column, row, block, button, scroll_box, …):
// Wrap children in a LazyChildren closure so that PartialTextStyle context is
// pushed BEFORE children run, enabling correct text style inheritance.
//
// Each child is spread via Children::into_views(), which handles:
// - Child::Node results (View) via impl<T: IntoView> Children for T
// - &str literals via Children for &str
// - ChildViews (slot values) via Children for ChildViews (flattens)
let items = node.children.iter().map(expand_child);
quote! {
::ruin_app::#path()
#(#prop_calls)*
.children(::ruin_app::LazyChildren(|| {
let mut __v: ::std::vec::Vec<::ruin_app::View> = ::std::vec::Vec::new();
#(__v.extend(::ruin_app::Children::into_views(#items));)*
__v
}))
}
}
}
fn is_text_path(path: &Path) -> bool {
path.segments.len() == 1 && path.segments[0].ident == "text"
}
fn expand_provider_node(node: &Node) -> proc_macro2::TokenStream {
let path = &node.path;
let value = match node.props.as_slice() {

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

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

View File

@@ -7,7 +7,7 @@ use std::sync::{Arc, Mutex};
use std::task::{Context, Poll};
use crate::op::completion::{CompletionFuture, CompletionHandle};
use crate::sys::linux::channel::runtime_waiter;
use crate::sys::current::channel::runtime_waiter;
/// Creates a bounded channel with room for at most `capacity` queued messages.
///

View File

@@ -6,7 +6,7 @@ use std::sync::{Arc, Mutex};
use std::task::{Context, Poll};
use crate::op::completion::{CompletionFuture, CompletionHandle};
use crate::sys::linux::channel::runtime_waiter;
use crate::sys::current::channel::runtime_waiter;
/// Creates a single-use channel for transferring one value from a [`Sender`] to a [`Receiver`].
///

View File

@@ -3,44 +3,9 @@
use std::io;
use std::os::fd::RawFd;
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_POLL_ADD, IoUringCqe};
/// Waits until `fd` becomes readable or reports an error/hangup condition.
pub async fn wait_readable(fd: RawFd) -> io::Result<()> {
submit_poll(fd, libc::POLLIN | libc::POLLERR | libc::POLLHUP).await
}
async fn submit_poll(fd: RawFd, mask: i16) -> io::Result<()> {
let (future, handle) = completion_for_current_thread::<io::Result<()>>();
let callback_handle = handle.clone();
let token = with_current_driver(|driver| {
driver.submit_operation(
move |sqe| {
sqe.opcode = IORING_OP_POLL_ADD;
sqe.fd = fd;
sqe.len = 0;
sqe.op_flags = mask as u32;
},
move |cqe| {
callback_handle.complete(cqe_to_result(cqe));
},
)
})?;
handle.set_cancel(move || {
let _ = with_current_driver(|driver| driver.cancel_operation(token));
});
future.await
}
fn cqe_to_result(cqe: IoUringCqe) -> io::Result<()> {
if cqe.res < 0 {
return Err(io::Error::from_raw_os_error(-cqe.res));
}
Ok(())
crate::sys::current::fd::wait_readable(fd).await
}
#[cfg(test)]
@@ -53,8 +18,8 @@ mod tests {
#[test]
fn wait_readable_resolves_for_pipe() {
let mut fds = [0; 2];
let result = unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC | libc::O_NONBLOCK) };
assert_eq!(result, 0, "pipe2 should succeed");
let result = unsafe { libc::pipe(fds.as_mut_ptr()) };
assert_eq!(result, 0, "pipe should succeed");
let read_fd = fds[0];
let write_fd = fds[1];

View File

@@ -8,17 +8,18 @@
//! 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,
RawDirEntry as OpDirEntry, RawMetadata,
};
use crate::sys::linux::fs as sys_fs;
use crate::sys::current::fs as sys_fs;
struct FileInner {
fd: OwnedFd,

View File

@@ -1,7 +1,7 @@
//! Runtime, driver, async I/O, and channel primitives for RUIN.
//!
//! The crate is centered around a single-threaded event loop with explicit worker threads,
//! JavaScript-style microtask/macrotask scheduling, and Linux `io_uring`-backed I/O.
//! JavaScript-style microtask/macrotask scheduling and platform-specific async I/O backends.
//!
//! Most users will start with:
//!
@@ -11,17 +11,22 @@
//!
//! # Platform support
//!
//! `ruin-runtime` currently targets Linux on `x86_64`.
//! `ruin-runtime` currently targets:
//! - Linux `x86_64`
//! - macOS `aarch64`
//!
//! RUIN runtime foundations.
//!
//! This crate provides a Linux x86_64 runtime substrate: the mesh allocator, the driver, and a
//! single-threaded runtime loop with worker-thread task forwarding.
//! This crate provides a platform runtime substrate with a single-threaded runtime loop and
//! worker-thread task forwarding.
#![feature(thread_local)]
#[cfg(not(all(target_os = "linux", target_arch = "x86_64")))]
compile_error!("ruin-runtime currently supports only Linux x86_64.");
#[cfg(not(any(
all(target_os = "linux", target_arch = "x86_64"),
all(target_os = "macos", target_arch = "aarch64")
)))]
compile_error!("ruin-runtime currently supports Linux x86_64 and macOS aarch64.");
extern crate alloc;
@@ -29,7 +34,10 @@ pub(crate) mod trace_targets {
pub const DRIVER: &str = "ruin_runtime::driver";
pub const RUNTIME: &str = "ruin_runtime::runtime";
pub const SCHEDULER: &str = "ruin_runtime::scheduler";
#[cfg(debug_assertions)]
pub const TIMER: &str = "ruin_runtime::timer";
#[cfg(debug_assertions)]
pub const ASYNC: &str = "ruin_runtime::async";
}
@@ -41,6 +49,7 @@ pub mod net;
pub mod op;
#[doc(hidden)]
pub mod platform;
pub mod stdio;
#[doc(hidden)]
pub mod sys;
pub mod time;
@@ -56,11 +65,24 @@ pub use ruin_runtime_proc_macros::async_main;
/// thread before calling [`run`].
pub use ruin_runtime_proc_macros::main;
/// Driver primitives re-exported from the Linux x86_64 backend.
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
pub use platform::linux_x86_64::driver::{
/// Driver primitives re-exported from the active backend.
#[cfg(any(
all(target_os = "linux", target_arch = "x86_64"),
all(target_os = "macos", target_arch = "aarch64")
))]
pub use platform::current::driver::{
Driver, ReadyEvents, ThreadNotifier, create, create_driver, monotonic_now,
};
/// Runtime/event-loop primitives re-exported from the active backend.
#[cfg(any(
all(target_os = "linux", target_arch = "x86_64"),
all(target_os = "macos", target_arch = "aarch64")
))]
pub use platform::current::runtime::{
IntervalHandle, JoinHandle, ThreadHandle, TimeoutHandle, WorkerHandle, clear_interval,
clear_timeout, current_thread_handle, queue_future, queue_microtask, queue_task, run,
run_ready_tasks, run_until_stalled, set_interval, set_timeout, spawn_worker, yield_now,
};
/// Public mesh-allocator surface re-exported from the Linux x86_64 backend.
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
pub use platform::linux_x86_64::mesh_alloc::{
@@ -77,22 +99,17 @@ pub use platform::linux_x86_64::mesh_alloc::{
/// Additional allocator helpers re-exported from the Linux x86_64 backend.
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
pub use platform::linux_x86_64::mesh_alloc::{FreelistId, bitmaps_meshable};
/// Runtime/event-loop primitives re-exported from the Linux x86_64 backend.
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
pub use platform::linux_x86_64::runtime::{
IntervalHandle, JoinHandle, ThreadHandle, TimeoutHandle, WorkerHandle, clear_interval,
clear_timeout, current_thread_handle, queue_future, queue_microtask, queue_task, run,
set_interval, set_timeout, spawn_worker, yield_now,
};
/// Returns the default global mesh allocator configuration for this crate.
///
/// This is useful when embedding the allocator in a `#[global_allocator]` static.
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
pub const fn default_global_allocator() -> GlobalMeshAllocator {
GlobalMeshAllocator::with_default_config()
}
#[cfg(test)]
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
mod tests {
use super::{MeshAllocator, page_size};

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};
@@ -72,10 +73,10 @@ impl TcpStream {
where
A: ToSocketAddrs + Send + 'static,
{
let addrs = crate::sys::linux::net::resolve_addrs(addr).await?;
let addrs = crate::sys::current::net::resolve_addrs(addr).await?;
let mut last_error = None;
for addr in addrs {
match crate::sys::linux::net::connect_stream(addr).await {
match crate::sys::current::net::connect_stream(addr).await {
Ok(fd) => return Ok(Self::from_owned_fd(fd)),
Err(error) => last_error = Some(error),
}
@@ -92,7 +93,7 @@ impl TcpStream {
/// Connects to `addr`, failing if the deadline elapses first.
pub async fn connect_timeout(addr: &SocketAddr, timeout: Duration) -> io::Result<Self> {
validate_timeout(timeout)?;
crate::sys::linux::net::connect_stream_timeout(*addr, timeout)
crate::sys::current::net::connect_stream_timeout(*addr, timeout)
.await
.map(Self::from_owned_fd)
}
@@ -101,10 +102,10 @@ impl TcpStream {
pub async fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
let data = match self.read_timeout_value() {
Some(timeout) => {
crate::sys::linux::net::recv_timeout(self.raw_fd(), buf.len(), 0, timeout).await?
crate::sys::current::net::recv_timeout(self.raw_fd(), buf.len(), 0, timeout).await?
}
None => {
crate::sys::linux::net::recv(NetOp::Recv {
crate::sys::current::net::recv(NetOp::Recv {
fd: self.raw_fd(),
len: buf.len(),
flags: 0,
@@ -136,10 +137,11 @@ impl TcpStream {
pub async fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
match self.write_timeout_value() {
Some(timeout) => {
crate::sys::linux::net::send_timeout(self.raw_fd(), buf.to_vec(), 0, timeout).await
crate::sys::current::net::send_timeout(self.raw_fd(), buf.to_vec(), 0, timeout)
.await
}
None => {
crate::sys::linux::net::send(NetOp::Send {
crate::sys::current::net::send(NetOp::Send {
fd: self.raw_fd(),
data: buf.to_vec(),
flags: 0,
@@ -166,7 +168,7 @@ impl TcpStream {
/// Shuts down the read, write, or both halves of the connection.
pub async fn shutdown(&self, how: Shutdown) -> io::Result<()> {
crate::sys::linux::net::shutdown(NetOp::Shutdown {
crate::sys::current::net::shutdown(NetOp::Shutdown {
fd: self.raw_fd(),
how,
})
@@ -175,39 +177,39 @@ impl TcpStream {
/// Duplicates the underlying stream socket.
pub async fn try_clone(&self) -> io::Result<Self> {
crate::sys::linux::net::duplicate(self.raw_fd())
crate::sys::current::net::duplicate(self.raw_fd())
.await
.map(Self::from_owned_fd)
}
/// Returns the local socket address of this stream.
pub fn local_addr(&self) -> io::Result<SocketAddr> {
crate::sys::linux::net::local_addr(self.raw_fd())
crate::sys::current::net::local_addr(self.raw_fd())
}
/// Returns the remote peer address of this stream.
pub fn peer_addr(&self) -> io::Result<SocketAddr> {
crate::sys::linux::net::peer_addr(self.raw_fd())
crate::sys::current::net::peer_addr(self.raw_fd())
}
/// Reads the current `TCP_NODELAY` setting.
pub fn nodelay(&self) -> io::Result<bool> {
crate::sys::linux::net::nodelay(self.raw_fd())
crate::sys::current::net::nodelay(self.raw_fd())
}
/// Enables or disables `TCP_NODELAY`.
pub fn set_nodelay(&self, enabled: bool) -> io::Result<()> {
crate::sys::linux::net::set_nodelay(self.raw_fd(), enabled)
crate::sys::current::net::set_nodelay(self.raw_fd(), enabled)
}
/// Reads the socket's IP time-to-live value.
pub fn ttl(&self) -> io::Result<u32> {
crate::sys::linux::net::ttl(self.raw_fd())
crate::sys::current::net::ttl(self.raw_fd())
}
/// Sets the socket's IP time-to-live value.
pub fn set_ttl(&self, ttl: u32) -> io::Result<()> {
crate::sys::linux::net::set_ttl(self.raw_fd(), ttl)
crate::sys::current::net::set_ttl(self.raw_fd(), ttl)
}
/// Returns the read timeout used by async read operations on this handle.
@@ -269,10 +271,10 @@ impl TcpListener {
where
A: ToSocketAddrs + Send + 'static,
{
let addrs = crate::sys::linux::net::resolve_addrs(addr).await?;
let addrs = crate::sys::current::net::resolve_addrs(addr).await?;
let mut last_error = None;
for addr in addrs {
match crate::sys::linux::net::bind_listener(addr, None).await {
match crate::sys::current::net::bind_listener(addr, None).await {
Ok(fd) => return Ok(Self::from_owned_fd(fd)),
Err(error) => last_error = Some(error),
}
@@ -288,7 +290,8 @@ impl TcpListener {
/// Accepts an incoming connection.
pub async fn accept(&self) -> io::Result<(TcpStream, SocketAddr)> {
let accepted = crate::sys::linux::net::accept(NetOp::Accept { fd: self.raw_fd() }).await?;
let accepted =
crate::sys::current::net::accept(NetOp::Accept { fd: self.raw_fd() }).await?;
let stream = TcpStream::from_owned_fd(unsafe { OwnedFd::from_raw_fd(accepted.fd) });
Ok((stream, accepted.peer_addr))
@@ -296,17 +299,17 @@ impl TcpListener {
/// Returns the local socket address of this listener.
pub fn local_addr(&self) -> io::Result<SocketAddr> {
crate::sys::linux::net::local_addr(self.raw_fd())
crate::sys::current::net::local_addr(self.raw_fd())
}
/// Reads the listener socket's IP time-to-live value.
pub fn ttl(&self) -> io::Result<u32> {
crate::sys::linux::net::ttl(self.raw_fd())
crate::sys::current::net::ttl(self.raw_fd())
}
/// Sets the listener socket's IP time-to-live value.
pub fn set_ttl(&self, ttl: u32) -> io::Result<()> {
crate::sys::linux::net::set_ttl(self.raw_fd(), ttl)
crate::sys::current::net::set_ttl(self.raw_fd(), ttl)
}
fn from_owned_fd(fd: OwnedFd) -> Self {
@@ -326,10 +329,10 @@ impl UdpSocket {
where
A: ToSocketAddrs + Send + 'static,
{
let addrs = crate::sys::linux::net::resolve_addrs(addr).await?;
let addrs = crate::sys::current::net::resolve_addrs(addr).await?;
let mut last_error = None;
for addr in addrs {
match crate::sys::linux::net::bind_datagram(addr).await {
match crate::sys::current::net::bind_datagram(addr).await {
Ok(fd) => return Ok(Self::from_owned_fd(fd)),
Err(error) => last_error = Some(error),
}
@@ -351,10 +354,10 @@ impl UdpSocket {
where
A: ToSocketAddrs + Send + 'static,
{
let addrs = crate::sys::linux::net::resolve_addrs(addr).await?;
let addrs = crate::sys::current::net::resolve_addrs(addr).await?;
let mut last_error = None;
for addr in addrs {
match crate::sys::linux::net::connect(NetOp::Connect {
match crate::sys::current::net::connect(NetOp::Connect {
fd: self.raw_fd(),
addr,
})
@@ -377,10 +380,11 @@ impl UdpSocket {
pub async fn send(&self, buf: &[u8]) -> io::Result<usize> {
match self.write_timeout_value() {
Some(timeout) => {
crate::sys::linux::net::send_timeout(self.raw_fd(), buf.to_vec(), 0, timeout).await
crate::sys::current::net::send_timeout(self.raw_fd(), buf.to_vec(), 0, timeout)
.await
}
None => {
crate::sys::linux::net::send(NetOp::Send {
crate::sys::current::net::send(NetOp::Send {
fd: self.raw_fd(),
data: buf.to_vec(),
flags: 0,
@@ -394,10 +398,10 @@ impl UdpSocket {
pub async fn recv(&self, buf: &mut [u8]) -> io::Result<usize> {
let data = match self.read_timeout_value() {
Some(timeout) => {
crate::sys::linux::net::recv_timeout(self.raw_fd(), buf.len(), 0, timeout).await?
crate::sys::current::net::recv_timeout(self.raw_fd(), buf.len(), 0, timeout).await?
}
None => {
crate::sys::linux::net::recv(NetOp::Recv {
crate::sys::current::net::recv(NetOp::Recv {
fd: self.raw_fd(),
len: buf.len(),
flags: 0,
@@ -414,7 +418,7 @@ impl UdpSocket {
pub async fn peek(&self, buf: &mut [u8]) -> io::Result<usize> {
let data = match self.read_timeout_value() {
Some(timeout) => {
crate::sys::linux::net::recv_timeout(
crate::sys::current::net::recv_timeout(
self.raw_fd(),
buf.len(),
libc::MSG_PEEK,
@@ -423,7 +427,7 @@ impl UdpSocket {
.await?
}
None => {
crate::sys::linux::net::recv(NetOp::Recv {
crate::sys::current::net::recv(NetOp::Recv {
fd: self.raw_fd(),
len: buf.len(),
flags: libc::MSG_PEEK,
@@ -441,13 +445,13 @@ impl UdpSocket {
where
A: ToSocketAddrs + Send + 'static,
{
let addrs = crate::sys::linux::net::resolve_addrs(addr).await?;
let addrs = crate::sys::current::net::resolve_addrs(addr).await?;
let mut last_error = None;
let timeout = self.write_timeout_value();
for addr in addrs {
let result = match timeout {
Some(timeout) => {
crate::sys::linux::net::send_to_timeout(
crate::sys::current::net::send_to_timeout(
self.raw_fd(),
buf.to_vec(),
addr,
@@ -457,7 +461,7 @@ impl UdpSocket {
.await
}
None => {
crate::sys::linux::net::send_to(NetOp::SendTo {
crate::sys::current::net::send_to(NetOp::SendTo {
fd: self.raw_fd(),
target: addr,
data: buf.to_vec(),
@@ -484,11 +488,11 @@ impl UdpSocket {
pub async fn recv_from(&self, buf: &mut [u8]) -> io::Result<(usize, SocketAddr)> {
let datagram = match self.read_timeout_value() {
Some(timeout) => {
crate::sys::linux::net::recv_from_timeout(self.raw_fd(), buf.len(), 0, timeout)
crate::sys::current::net::recv_from_timeout(self.raw_fd(), buf.len(), 0, timeout)
.await?
}
None => {
crate::sys::linux::net::recv_from(NetOp::RecvFrom {
crate::sys::current::net::recv_from(NetOp::RecvFrom {
fd: self.raw_fd(),
len: buf.len(),
flags: 0,
@@ -505,7 +509,7 @@ impl UdpSocket {
pub async fn peek_from(&self, buf: &mut [u8]) -> io::Result<(usize, SocketAddr)> {
let datagram = match self.read_timeout_value() {
Some(timeout) => {
crate::sys::linux::net::recv_from_timeout(
crate::sys::current::net::recv_from_timeout(
self.raw_fd(),
buf.len(),
libc::MSG_PEEK,
@@ -514,7 +518,7 @@ impl UdpSocket {
.await?
}
None => {
crate::sys::linux::net::recv_from(NetOp::RecvFrom {
crate::sys::current::net::recv_from(NetOp::RecvFrom {
fd: self.raw_fd(),
len: buf.len(),
flags: libc::MSG_PEEK,
@@ -529,39 +533,39 @@ impl UdpSocket {
/// Duplicates the underlying UDP socket.
pub async fn try_clone(&self) -> io::Result<Self> {
crate::sys::linux::net::duplicate(self.raw_fd())
crate::sys::current::net::duplicate(self.raw_fd())
.await
.map(Self::from_owned_fd)
}
/// Returns the local socket address of this socket.
pub fn local_addr(&self) -> io::Result<SocketAddr> {
crate::sys::linux::net::local_addr(self.raw_fd())
crate::sys::current::net::local_addr(self.raw_fd())
}
/// Returns the connected peer address, if the socket has been connected.
pub fn peer_addr(&self) -> io::Result<SocketAddr> {
crate::sys::linux::net::peer_addr(self.raw_fd())
crate::sys::current::net::peer_addr(self.raw_fd())
}
/// Reads the `SO_BROADCAST` setting.
pub fn broadcast(&self) -> io::Result<bool> {
crate::sys::linux::net::broadcast(self.raw_fd())
crate::sys::current::net::broadcast(self.raw_fd())
}
/// Enables or disables `SO_BROADCAST`.
pub fn set_broadcast(&self, enabled: bool) -> io::Result<()> {
crate::sys::linux::net::set_broadcast(self.raw_fd(), enabled)
crate::sys::current::net::set_broadcast(self.raw_fd(), enabled)
}
/// Reads the socket's IP time-to-live value.
pub fn ttl(&self) -> io::Result<u32> {
crate::sys::linux::net::ttl(self.raw_fd())
crate::sys::current::net::ttl(self.raw_fd())
}
/// Sets the socket's IP time-to-live value.
pub fn set_ttl(&self, ttl: u32) -> io::Result<()> {
crate::sys::linux::net::set_ttl(self.raw_fd(), ttl)
crate::sys::current::net::set_ttl(self.raw_fd(), ttl)
}
/// Returns the read timeout used by async receive operations on this handle.
@@ -627,13 +631,13 @@ impl HyperRead for TcpStream {
if this.pending_read.is_none() {
this.pending_read = Some(match this.read_timeout_value() {
Some(timeout) => Box::pin(crate::sys::linux::net::recv_timeout(
Some(timeout) => Box::pin(crate::sys::current::net::recv_timeout(
this.raw_fd(),
buf.remaining(),
0,
timeout,
)),
None => crate::sys::linux::net::recv_future(this.raw_fd(), buf.remaining()),
None => crate::sys::current::net::recv_future(this.raw_fd(), buf.remaining()),
});
}
@@ -671,13 +675,13 @@ impl HyperWrite for TcpStream {
if this.pending_write.is_none() {
this.pending_write = Some(match this.write_timeout_value() {
Some(timeout) => Box::pin(crate::sys::linux::net::send_timeout(
Some(timeout) => Box::pin(crate::sys::current::net::send_timeout(
this.raw_fd(),
buf.to_vec(),
0,
timeout,
)),
None => crate::sys::linux::net::send_future(this.raw_fd(), buf.to_vec()),
None => crate::sys::current::net::send_future(this.raw_fd(), buf.to_vec()),
});
}
@@ -707,7 +711,7 @@ impl HyperWrite for TcpStream {
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), io::Error>> {
let this = self.get_mut();
if this.pending_shutdown.is_none() {
this.pending_shutdown = Some(crate::sys::linux::net::shutdown_future(
this.pending_shutdown = Some(crate::sys::current::net::shutdown_future(
this.raw_fd(),
Shutdown::Write,
));

View File

@@ -6,7 +6,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::task::{Context, Poll, Waker};
use crate::platform::linux_x86_64::runtime::{ThreadHandle, current_thread_handle};
use crate::platform::current::runtime::{ThreadHandle, current_thread_handle};
type CancelCallback = Box<dyn FnOnce() + Send + 'static>;

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>>,
}

View File

@@ -13,16 +13,12 @@ use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
use std::time::Duration;
use super::driver::{Driver, ThreadNotifier, create_driver, monotonic_now};
use crate::platform::runtime_shared::{
IntervalCallback, LocalBoxFuture, LocalTask, LocalTaskQueue, MICROTASK_STARVATION_THRESHOLD,
MacroTaskQueue, SendTask,
};
use crate::trace_targets;
type LocalTask = Box<dyn FnOnce() + 'static>;
type SendTask = Box<dyn FnOnce() + Send + 'static>;
type LocalBoxFuture = Pin<Box<dyn Future<Output = ()> + 'static>>;
/// If the microtask queue runs more than this many tasks in a single turn
/// without yielding to the macrotask queue, a warning is emitted.
const MICROTASK_STARVATION_THRESHOLD: u64 = 1000;
struct MacroTask {
task: LocalTask,
/// Wall time at which this task entered the local queue. Populated only
@@ -54,6 +50,12 @@ pub struct TimeoutHandle {
_local: Rc<()>,
}
impl TimeoutHandle {
pub fn clear(&self) {
clear_timeout(self);
}
}
#[derive(Clone)]
/// Handle returned by [`set_interval`].
pub struct IntervalHandle {
@@ -62,6 +64,12 @@ pub struct IntervalHandle {
_local: Rc<()>,
}
impl IntervalHandle {
pub fn clear(&self) {
clear_interval(self);
}
}
/// Handle returned by [`queue_future`].
///
/// Awaiting a join handle yields the output of the queued future.
@@ -72,9 +80,10 @@ pub struct JoinHandle<T> {
/// Future returned by [`yield_now`].
///
/// Awaiting this future will immediately yield control back to the runtime scheduler, allowing other queued microtasks
/// to run before the current task continues executing. Note that continuation of futures runs as a microtask, so this
/// to run before the current task continues executing. Note that continuations of futures run as microtasks, so this
/// can only yield to other microtasks and not to macrotasks (driver events such as file or network I/O, timers, or
/// channel messages).
/// channel messages). To yield to macrotasks, you must allow the flow of execution to return to the runtime event loop
/// and flush the full microtask queue, for example by awaiting a timer.
pub struct YieldNow {
yielded: bool,
}
@@ -217,17 +226,19 @@ where
);
if delay.is_zero() {
unimplemented!("Zero-delay intervals are not yet implemented.")
// TODO: vibeshit got this completely wrong
// Zero-delay intervals never touch the timer heap or arm an io_uring
// timer (a past-deadline kernel timer would fire on every event loop
// iteration, spinning the runtime at 100 % CPU). Instead the callback
// is stored in `immediate_intervals` and self-schedules as a macrotask
// each turn, mirroring JS `setInterval(f, 0)` semantics.
let callback = Rc::new(RefCell::new(Box::new(callback) as Box<dyn FnMut()>));
current_thread()
.immediate_intervals
.borrow_mut()
.insert(id, Rc::clone(&callback));
schedule_immediate_interval(owner, id);
// let callback = Rc::new(RefCell::new(Box::new(callback) as Box<dyn FnMut()>));
// current_thread()
// .immediate_intervals
// .borrow_mut()
// .insert(id, Rc::clone(&callback));
// schedule_immediate_interval(owner, id);
} else {
let deadline = deadline_from_now(delay);
#[cfg(debug_assertions)]
@@ -478,6 +489,99 @@ pub fn run() {
}
}
/// Drains ready work on the current runtime thread without blocking for future work.
///
/// Unlike [`run`], this returns as soon as there are no immediately runnable
/// microtasks or macrotasks left. It is intended for host integrations that
/// need to re-enter the scheduler while an outer platform loop remains active.
pub fn run_until_stalled() {
let _ = current_thread();
loop {
drain_all();
let mut microtasks_run: u64 = 0;
while let Some(task) = pop_microtask() {
task();
microtasks_run += 1;
drain_all();
}
if microtasks_run >= MICROTASK_STARVATION_THRESHOLD {
tracing::warn!(
target: trace_targets::SCHEDULER,
event = "microtask_starvation",
count = microtasks_run,
"microtask queue ran {microtasks_run} tasks in a single turn; macrotask handlers may be starved",
);
}
if let Some(task) = pop_macrotask() {
task();
continue;
}
drain_all();
if has_ready_work() {
continue;
}
current_thread()
.shared
.closing
.store(false, Ordering::Release);
return;
}
}
/// Drains already-queued work on the current runtime thread without polling the
/// driver for timers or I/O readiness.
///
/// This is intended for host integrations that need to flush application work
/// from inside a host callback without re-entering timer callbacks.
pub fn run_ready_tasks() {
let _ = current_thread();
loop {
drain_remote_tasks();
drain_completed_workers();
let mut microtasks_run: u64 = 0;
while let Some(task) = pop_microtask() {
task();
microtasks_run += 1;
drain_remote_tasks();
drain_completed_workers();
}
if microtasks_run >= MICROTASK_STARVATION_THRESHOLD {
tracing::warn!(
target: trace_targets::SCHEDULER,
event = "microtask_starvation",
count = microtasks_run,
"microtask queue ran {microtasks_run} tasks in a single turn; macrotask handlers may be starved",
);
}
if let Some(task) = pop_macrotask() {
task();
continue;
}
drain_remote_tasks();
drain_completed_workers();
if has_ready_work() {
continue;
}
current_thread()
.shared
.closing
.store(false, Ordering::Release);
return;
}
}
fn drain_all() {
drain_driver_events();
drain_remote_tasks();
@@ -571,16 +675,18 @@ impl Future for YieldNow {
}
}
type ImmediateIntervals = RefCell<HashMap<usize, Rc<RefCell<Box<dyn FnMut()>>>>>;
struct ThreadState {
driver: Driver,
shared: Arc<ThreadShared>,
worker_completion: Option<Arc<WorkerCompletion>>,
local_microtasks: RefCell<VecDeque<LocalTask>>,
local_macrotasks: RefCell<VecDeque<MacroTask>>,
local_microtasks: RefCell<LocalTaskQueue>,
local_macrotasks: RefCell<MacroTaskQueue<MacroTask>>,
timers: RefCell<TimerHeap>,
/// Zero-delay intervals bypasses the timer heap entirely. Each entry
/// re-enqueues itself as a macrotask on every turn.
immediate_intervals: RefCell<HashMap<usize, Rc<RefCell<Box<dyn FnMut()>>>>>,
immediate_intervals: ImmediateIntervals,
next_timer_id: Cell<usize>,
children: RefCell<Vec<ChildWorker>>,
}
@@ -1162,27 +1268,32 @@ fn clear_timer(owner: *const ThreadState, id: usize) {
}
}
/// Enqueues one macrotask turn for a zero-delay interval.
///
/// The macrotask checks whether the interval is still live (not cleared), fires
/// the callback, and re-enqueues itself for the next turn.
fn schedule_immediate_interval(owner: *const ThreadState, id: usize) {
push_local_macrotask(Box::new(move || {
let callback = current_thread()
.immediate_intervals
.borrow()
.get(&id)
.map(Rc::clone);
let Some(callback) = callback else {
return; // interval was cleared before this turn ran
};
(callback.borrow_mut())();
// Re-enqueue for the next turn if still live.
if current_thread().immediate_intervals.borrow().contains_key(&id) {
schedule_immediate_interval(owner, id);
}
}));
}
// TODO: vibeshit got this completely wrong
// /// Enqueues one macrotask turn for a zero-delay interval.
// ///
// /// The macrotask checks whether the interval is still live (not cleared), fires
// /// the callback, and re-enqueues itself for the next turn.
// fn schedule_immediate_interval(owner: *const ThreadState, id: usize) {
// push_local_macrotask(Box::new(move || {
// let callback = current_thread()
// .immediate_intervals
// .borrow()
// .get(&id)
// .map(Rc::clone);
// let Some(callback) = callback else {
// return; // interval was cleared before this turn ran
// };
// (callback.borrow_mut())();
// // Re-enqueue for the next turn if still live.
// if current_thread()
// .immediate_intervals
// .borrow()
// .contains_key(&id)
// {
// schedule_immediate_interval(owner, id);
// }
// }));
// }
fn dispatch_expired_timers() {
let now = deadline_from_now(Duration::ZERO);
@@ -1207,12 +1318,8 @@ fn dispatch_expired_timers() {
.deadline
.checked_add(interval)
.unwrap_or(Duration::MAX);
let next_timer = TimerNode::interval(
timer.id,
next_deadline,
interval,
Rc::clone(&callback),
);
let next_timer =
TimerNode::interval(timer.id, next_deadline, interval, Rc::clone(&callback));
current_thread().timers.borrow_mut().insert(next_timer);
push_local_macrotask(Box::new(move || {
@@ -1394,8 +1501,7 @@ mod tests {
// turns by awaiting a sleep between checks.
let count = Rc::new(Cell::new(0usize));
let count_clone = Rc::clone(&count);
let handle_slot: Rc<RefCell<Option<super::IntervalHandle>>> =
Rc::new(RefCell::new(None));
let handle_slot: Rc<RefCell<Option<super::IntervalHandle>>> = Rc::new(RefCell::new(None));
let handle_slot_clone = Rc::clone(&handle_slot);
let handle = set_interval(Duration::ZERO, move || {

View File

@@ -0,0 +1,504 @@
//! Public runtime driver primitives for macOS.
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::io;
use std::os::fd::RawFd;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use crate::op::completion::CompletionHandle;
type FdCompletion = CompletionHandle<io::Result<()>>;
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
pub(crate) struct FdReadinessToken(u64);
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
pub(crate) enum FdInterest {
Readable,
Writable,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
struct FdKey {
fd: RawFd,
interest: FdInterest,
}
struct FdWaiter {
token: FdReadinessToken,
completion: FdCompletion,
}
#[derive(Clone)]
struct NotifierInner {
write_fd: RawFd,
closed: Arc<AtomicBool>,
}
impl NotifierInner {
fn notify(&self) -> io::Result<()> {
if self.closed.load(Ordering::Acquire) {
return Err(io::Error::new(
io::ErrorKind::BrokenPipe,
"target runtime driver is closed",
));
}
let byte = 1u8;
let written = unsafe {
libc::write(
self.write_fd,
&byte as *const u8 as *const libc::c_void,
std::mem::size_of::<u8>(),
)
};
if written < 0 {
let error = io::Error::last_os_error();
if error.kind() == io::ErrorKind::WouldBlock {
return Ok(());
}
return Err(error);
}
Ok(())
}
}
#[derive(Clone)]
/// Cross-thread notifier for a runtime thread's driver.
pub struct ThreadNotifier {
inner: NotifierInner,
}
impl ThreadNotifier {
/// Sends a wake notification to the target runtime thread.
pub fn notify(&self) -> io::Result<()> {
self.inner.notify()
}
}
#[derive(Debug, Default, Clone, Copy, Eq, PartialEq)]
/// Readiness information returned by [`Driver::poll`].
pub struct ReadyEvents {
/// One or more timer expirations are pending.
pub timer: bool,
/// One or more cross-thread wake notifications are pending.
pub wake: bool,
}
/// Low-level macOS runtime driver backed by `kqueue` and a wake pipe.
pub struct Driver {
kqueue_fd: RawFd,
wake_read_fd: RawFd,
wake_write_fd: RawFd,
closed: Arc<AtomicBool>,
timer_deadline: Cell<Option<Duration>>,
pending_wakes: Cell<u64>,
pending_timers: Cell<u64>,
next_fd_token: Cell<u64>,
fd_waiters: RefCell<HashMap<FdKey, FdWaiter>>,
}
/// Creates a new driver and its paired [`ThreadNotifier`].
pub fn create() -> io::Result<(Driver, ThreadNotifier)> {
create_driver()
}
/// Creates a new driver and its paired [`ThreadNotifier`].
pub fn create_driver() -> io::Result<(Driver, ThreadNotifier)> {
let kqueue_fd = cvt(unsafe { libc::kqueue() })?;
let mut pipe_fds = [0; 2];
cvt(unsafe { libc::pipe(pipe_fds.as_mut_ptr()) })?;
let wake_read_fd = pipe_fds[0];
let wake_write_fd = pipe_fds[1];
set_nonblocking(wake_read_fd)?;
set_nonblocking(wake_write_fd)?;
let event = libc::kevent {
ident: wake_read_fd as usize,
filter: libc::EVFILT_READ,
flags: libc::EV_ADD | libc::EV_ENABLE,
fflags: 0,
data: 0,
udata: std::ptr::null_mut(),
};
let submitted = unsafe {
libc::kevent(
kqueue_fd,
&event,
1,
std::ptr::null_mut(),
0,
std::ptr::null(),
)
};
if submitted < 0 {
let error = io::Error::last_os_error();
unsafe {
libc::close(wake_read_fd);
libc::close(wake_write_fd);
libc::close(kqueue_fd);
}
return Err(error);
}
let closed = Arc::new(AtomicBool::new(false));
let driver = Driver {
kqueue_fd,
wake_read_fd,
wake_write_fd,
closed: Arc::clone(&closed),
timer_deadline: Cell::new(None),
pending_wakes: Cell::new(0),
pending_timers: Cell::new(0),
next_fd_token: Cell::new(1),
fd_waiters: RefCell::new(HashMap::new()),
};
let notifier = ThreadNotifier {
inner: NotifierInner {
write_fd: wake_write_fd,
closed,
},
};
Ok((driver, notifier))
}
impl Driver {
pub(crate) fn bind_current_thread(&self) {}
pub(crate) fn unbind_current_thread(&self) {}
/// Polls the driver without blocking.
pub fn poll(&self) -> io::Result<Option<ReadyEvents>> {
let mut pending = ReadyEvents::default();
if self.pending_wakes.get() > 0 {
pending.wake = true;
}
if self.pending_timers.get() > 0 {
pending.timer = true;
}
if pending.wake || pending.timer {
return Ok(Some(pending));
}
self.process(Some(Duration::ZERO))
}
/// Blocks until at least one event is available.
pub fn wait(&self) -> io::Result<()> {
let now = monotonic_now()?;
let timeout = self
.timer_deadline
.get()
.map(|deadline| deadline.saturating_sub(now));
let _ = self.process(timeout)?;
Ok(())
}
/// Updates the currently armed timer deadline.
pub fn rearm_timer(&self, deadline: Option<Duration>) -> io::Result<()> {
self.timer_deadline.set(deadline);
Ok(())
}
/// Drains the accumulated wake notification count.
pub fn drain_wake(&self) -> io::Result<u64> {
let wakes = self.pending_wakes.replace(0);
if wakes == 0 {
Err(io::Error::new(
io::ErrorKind::WouldBlock,
"no wake events are pending",
))
} else {
Ok(wakes)
}
}
/// Drains the accumulated timer-expiration count.
pub fn drain_timer(&self) -> io::Result<u64> {
let timers = self.pending_timers.replace(0);
if timers == 0 {
Err(io::Error::new(
io::ErrorKind::WouldBlock,
"no timer events are pending",
))
} else {
Ok(timers)
}
}
pub(crate) fn register_fd_readiness(
&self,
fd: RawFd,
interest: FdInterest,
completion: FdCompletion,
) -> io::Result<FdReadinessToken> {
let key = FdKey { fd, interest };
let removed_stale_waiter = {
let mut waiters = self.fd_waiters.borrow_mut();
match waiters.get(&key) {
Some(waiter) if !waiter.completion.is_interested() => {
waiters.remove(&key);
true
}
Some(_) => {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
"fd readiness already has a waiter for this interest",
));
}
None => false,
}
};
if removed_stale_waiter {
let _ = self.update_fd_interest(key, libc::EV_DELETE);
}
let token = self.allocate_fd_token();
self.update_fd_interest(key, libc::EV_ADD | libc::EV_ENABLE | libc::EV_ONESHOT)?;
self.fd_waiters
.borrow_mut()
.insert(key, FdWaiter { token, completion });
Ok(token)
}
pub(crate) fn cancel_fd_readiness(&self, token: FdReadinessToken) {
let mut empty_key = None;
{
let mut waiters = self.fd_waiters.borrow_mut();
for (key, entry) in waiters.iter() {
if entry.token == token {
empty_key = Some(*key);
break;
}
}
if let Some(key) = empty_key {
waiters.remove(&key);
}
}
if let Some(key) = empty_key {
let _ = self.update_fd_interest(key, libc::EV_DELETE);
}
}
fn process(&self, timeout: Option<Duration>) -> io::Result<Option<ReadyEvents>> {
let mut ready = ReadyEvents::default();
let mut events = [unsafe { std::mem::zeroed::<libc::kevent>() }; 16];
let timeout_spec = timeout_to_timespec(timeout);
let timeout_ptr = timeout_spec
.as_ref()
.map_or(std::ptr::null(), |value| value as *const libc::timespec);
let result = unsafe {
libc::kevent(
self.kqueue_fd,
std::ptr::null(),
0,
events.as_mut_ptr(),
events.len() as i32,
timeout_ptr,
)
};
if result < 0 {
let error = io::Error::last_os_error();
if error.kind() != io::ErrorKind::Interrupted {
return Err(error);
}
}
let mut saw_any = false;
let count = result.max(0) as usize;
if count > 0 {
saw_any = true;
for event in events.iter().take(count) {
if event.ident as RawFd == self.wake_read_fd {
ready.wake = true;
let wakes = drain_wake_pipe(self.wake_read_fd)?;
self.pending_wakes
.set(self.pending_wakes.get().saturating_add(wakes));
} else if let Some(interest) = interest_from_filter(event.filter) {
self.complete_fd_waiters(event.ident as RawFd, interest, event);
}
}
}
if let Some(deadline) = self.timer_deadline.get()
&& monotonic_now()? >= deadline
{
ready.timer = true;
saw_any = true;
self.timer_deadline.set(None);
self.pending_timers
.set(self.pending_timers.get().saturating_add(1));
}
if saw_any { Ok(Some(ready)) } else { Ok(None) }
}
fn allocate_fd_token(&self) -> FdReadinessToken {
let token = self.next_fd_token.get();
self.next_fd_token.set(
token
.checked_add(1)
.expect("fd readiness token space exhausted"),
);
FdReadinessToken(token)
}
fn update_fd_interest(&self, key: FdKey, flags: u16) -> io::Result<()> {
let event = libc::kevent {
ident: key.fd as usize,
filter: filter_for_interest(key.interest),
flags,
fflags: 0,
data: 0,
udata: std::ptr::null_mut(),
};
let submitted = unsafe {
libc::kevent(
self.kqueue_fd,
&event,
1,
std::ptr::null_mut(),
0,
std::ptr::null(),
)
};
if submitted < 0 {
Err(io::Error::last_os_error())
} else {
Ok(())
}
}
fn complete_fd_waiters(&self, fd: RawFd, interest: FdInterest, event: &libc::kevent) {
let key = FdKey { fd, interest };
let waiter = self.fd_waiters.borrow_mut().remove(&key);
let Some(waiter) = waiter else {
return;
};
let result = fd_event_result(event, interest);
waiter.completion.complete(result);
}
}
impl Drop for Driver {
fn drop(&mut self) {
self.closed.store(true, Ordering::Release);
unsafe {
libc::close(self.wake_read_fd);
libc::close(self.wake_write_fd);
libc::close(self.kqueue_fd);
}
}
}
/// Returns the current monotonic clock reading.
pub fn monotonic_now() -> io::Result<Duration> {
let mut now = std::mem::MaybeUninit::<libc::timespec>::uninit();
let result = unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, now.as_mut_ptr()) };
if result < 0 {
return Err(io::Error::last_os_error());
}
let now = unsafe { now.assume_init() };
Ok(Duration::new(now.tv_sec as u64, now.tv_nsec as u32))
}
fn cvt(value: libc::c_int) -> io::Result<libc::c_int> {
if value < 0 {
Err(io::Error::last_os_error())
} else {
Ok(value)
}
}
fn filter_for_interest(interest: FdInterest) -> i16 {
match interest {
FdInterest::Readable => libc::EVFILT_READ,
FdInterest::Writable => libc::EVFILT_WRITE,
}
}
fn interest_from_filter(filter: i16) -> Option<FdInterest> {
if filter == libc::EVFILT_READ {
Some(FdInterest::Readable)
} else if filter == libc::EVFILT_WRITE {
Some(FdInterest::Writable)
} else {
None
}
}
fn fd_event_result(event: &libc::kevent, interest: FdInterest) -> io::Result<()> {
if event.flags & libc::EV_ERROR != 0 && event.data != 0 {
Err(io::Error::from_raw_os_error(event.data as i32))
} else if event.flags & libc::EV_EOF != 0 && interest == FdInterest::Writable {
Err(io::Error::new(
io::ErrorKind::BrokenPipe,
"fd write side reached EOF",
))
} else {
Ok(())
}
}
fn set_nonblocking(fd: RawFd) -> io::Result<()> {
let flags = cvt(unsafe { libc::fcntl(fd, libc::F_GETFL) })?;
cvt(unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) })?;
Ok(())
}
fn timeout_to_timespec(timeout: Option<Duration>) -> Option<libc::timespec> {
timeout.map(|value| libc::timespec {
tv_sec: value.as_secs() as libc::time_t,
tv_nsec: value.subsec_nanos() as libc::c_long,
})
}
fn drain_wake_pipe(fd: RawFd) -> io::Result<u64> {
let mut wakes = 0u64;
let mut buf = [0u8; 256];
loop {
let read = unsafe {
libc::read(
fd,
buf.as_mut_ptr() as *mut libc::c_void,
buf.len() as libc::size_t,
)
};
if read > 0 {
wakes = wakes.saturating_add(read as u64);
continue;
}
if read == 0 {
break;
}
let error = io::Error::last_os_error();
if error.kind() == io::ErrorKind::WouldBlock {
break;
}
if error.kind() == io::ErrorKind::Interrupted {
continue;
}
return Err(error);
}
Ok(wakes.max(1))
}

View File

@@ -0,0 +1,2 @@
pub mod driver;
pub mod runtime;

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +1,11 @@
pub(crate) mod runtime_shared;
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
pub mod linux_x86_64;
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
pub mod macos_aarch64;
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
pub use linux_x86_64 as current;
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
pub use macos_aarch64 as current;

View File

@@ -0,0 +1,17 @@
use std::cell::RefCell;
use std::collections::VecDeque;
use std::future::Future;
use std::pin::Pin;
use std::rc::Rc;
pub(crate) type LocalTask = Box<dyn FnOnce() + 'static>;
pub(crate) type SendTask = Box<dyn FnOnce() + Send + 'static>;
pub(crate) type LocalBoxFuture = Pin<Box<dyn Future<Output = ()> + 'static>>;
pub(crate) type IntervalCallback = Rc<RefCell<Box<dyn FnMut()>>>;
/// If the microtask queue runs more than this many tasks in a single turn
/// without yielding to the macrotask queue, a warning is emitted.
pub(crate) const MICROTASK_STARVATION_THRESHOLD: u64 = 1000;
pub(crate) type LocalTaskQueue = VecDeque<LocalTask>;
pub(crate) type MacroTaskQueue<T> = VecDeque<T>;

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

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

View File

@@ -1,7 +1,7 @@
//! Linux channel wake helpers.
use crate::op::completion::{CompletionFuture, CompletionHandle, completion};
use crate::platform::linux_x86_64::runtime::try_current_thread_handle;
use crate::platform::current::runtime::try_current_thread_handle;
pub(crate) fn runtime_waiter<T: Send + 'static>() -> (CompletionFuture<T>, CompletionHandle<T>) {
let owner = try_current_thread_handle()

View File

@@ -0,0 +1,44 @@
//! Linux fd readiness backend.
use std::io;
use std::os::fd::RawFd;
use crate::op::completion::completion_for_current_thread;
use crate::platform::current::runtime::with_current_driver;
use crate::platform::linux_x86_64::uring::{IORING_OP_POLL_ADD, IoUringCqe};
/// Waits until `fd` becomes readable or reports an error/hangup condition.
pub async fn wait_readable(fd: RawFd) -> io::Result<()> {
submit_poll(fd, libc::POLLIN | libc::POLLERR | libc::POLLHUP).await
}
async fn submit_poll(fd: RawFd, mask: i16) -> io::Result<()> {
let (future, handle) = completion_for_current_thread::<io::Result<()>>();
let callback_handle = handle.clone();
let token = with_current_driver(|driver| {
driver.submit_operation(
move |sqe| {
sqe.opcode = IORING_OP_POLL_ADD;
sqe.fd = fd;
sqe.len = 0;
sqe.op_flags = mask as u32;
},
move |cqe| {
callback_handle.complete(cqe_to_result(cqe));
},
)
})?;
handle.set_cancel(move || {
let _ = with_current_driver(|driver| driver.cancel_operation(token));
});
future.await
}
fn cqe_to_result(cqe: IoUringCqe) -> io::Result<()> {
if cqe.res < 0 {
return Err(io::Error::from_raw_os_error(-cqe.res));
}
Ok(())
}

View File

@@ -1,5 +1,6 @@
//! Linux backend modules.
pub mod channel;
pub mod fd;
pub mod fs;
pub mod net;

View File

@@ -803,13 +803,9 @@ where
let (future, handle) = completion_for_current_thread::<io::Result<T>>();
let callback_handle = handle.clone();
let token = with_current_driver(|driver| {
driver.submit_operation_with_linked_timeout(
fill,
timeout,
move |cqe| {
callback_handle.complete(map(cqe));
},
)
driver.submit_operation_with_linked_timeout(fill, timeout, move |cqe| {
callback_handle.complete(map(cqe));
})
})?;
handle.set_cancel(move || {
@@ -1079,7 +1075,6 @@ fn send_sync(fd: RawFd, data: Vec<u8>, flags: i32) -> io::Result<usize> {
cvt_long(written).map(|written| written as usize)
}
fn recv_sync(fd: RawFd, len: usize, flags: i32) -> io::Result<Vec<u8>> {
let mut buffer = vec![0; len];
let read = unsafe {
@@ -1127,7 +1122,6 @@ fn close_sync(fd: RawFd) -> io::Result<()> {
cvt(unsafe { libc::close(fd) }).map(|_| ())
}
/// Wrapper making `Box<libc::iovec>` sendable across the async CQE boundary.
///
/// Safety: `iov_base` points into a `Vec<u8>` that is owned by the same

View File

@@ -0,0 +1,10 @@
//! macOS channel wake helpers.
use crate::op::completion::{CompletionFuture, CompletionHandle, completion};
use crate::platform::current::runtime::try_current_thread_handle;
pub(crate) fn runtime_waiter<T: Send + 'static>() -> (CompletionFuture<T>, CompletionHandle<T>) {
let owner = try_current_thread_handle()
.expect("async channel operations must be polled on a runtime thread");
completion(owner)
}

View File

@@ -0,0 +1,49 @@
//! macOS fd readiness backend.
use std::io;
use std::os::fd::RawFd;
use crate::op::completion::completion_for_current_thread;
use crate::platform::current::driver::FdInterest;
use crate::platform::current::runtime::{
cancel_fd_readiness, current_thread_handle, with_current_driver,
};
/// Waits until `fd` becomes readable or reports an error/hangup condition.
pub async fn wait_readable(fd: RawFd) -> io::Result<()> {
wait_fd_readiness(fd, FdInterest::Readable).await
}
/// Waits until `fd` becomes writable or reports an error/hangup condition.
pub async fn wait_writable(fd: RawFd) -> io::Result<()> {
wait_fd_readiness(fd, FdInterest::Writable).await
}
async fn wait_fd_readiness(fd: RawFd, interest: FdInterest) -> io::Result<()> {
let (future, handle) = completion_for_current_thread::<io::Result<()>>();
let owner = current_thread_handle();
let token =
with_current_driver(|driver| driver.register_fd_readiness(fd, interest, handle.clone()));
match token {
Ok(token) => {
handle.set_cancel({
let handle = handle.clone();
move || {
let queued_handle = handle.clone();
let queued = owner.queue_task(move || {
cancel_fd_readiness(token);
queued_handle.finish(None);
});
if !queued {
handle.finish(None);
}
}
});
}
Err(error) => {
handle.complete(Err(error));
}
}
future.await
}

View File

@@ -0,0 +1,498 @@
//! macOS filesystem backend.
use std::collections::VecDeque;
use std::ffi::CString;
use std::future::poll_fn;
use std::io;
use std::os::fd::{FromRawFd, OwnedFd};
use std::os::unix::ffi::OsStrExt;
use std::os::unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt};
use std::path::PathBuf;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, mpsc};
use std::task::{Context, Poll, Waker};
use std::thread;
use crate::op::completion::completion_for_current_thread;
use crate::op::fs::{FileType, FsOp, MetadataTarget, RawDirEntry, RawMetadata};
use crate::platform::current::runtime::{ThreadHandle, current_thread_handle};
type BlockingTask = Box<dyn FnOnce() + Send + 'static>;
static BLOCKING_POOL: OnceLock<io::Result<BlockingPool>> = OnceLock::new();
const BLOCKING_QUEUE_CAPACITY: usize = 1024;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ExecutionPath {
Offload,
}
pub fn execution_path(_op: &FsOp) -> ExecutionPath {
ExecutionPath::Offload
}
pub async fn open(op: FsOp) -> io::Result<OwnedFd> {
let FsOp::Open { path, options } = op else {
unreachable!("open backend called with non-open op");
};
offload(move || {
let mut open = std::fs::OpenOptions::new();
open.read(options.read)
.write(options.write)
.append(options.append)
.truncate(options.truncate)
.create(options.create)
.create_new(options.create_new)
.mode(0o666);
let file = open.open(path)?;
Ok(unsafe { OwnedFd::from_raw_fd(std::os::fd::IntoRawFd::into_raw_fd(file)) })
})
.await
}
pub async fn read(op: FsOp) -> io::Result<Vec<u8>> {
let FsOp::Read { fd, offset, len } = op else {
unreachable!("read backend called with non-read op");
};
offload(move || {
let mut buffer = vec![0; len];
let read = match offset {
Some(offset) => unsafe {
libc::pread(
fd,
buffer.as_mut_ptr().cast::<libc::c_void>(),
len,
offset as libc::off_t,
)
},
None => unsafe { libc::read(fd, buffer.as_mut_ptr().cast::<libc::c_void>(), len) },
};
if read < 0 {
return Err(io::Error::last_os_error());
}
buffer.truncate(read as usize);
Ok(buffer)
})
.await
}
pub async fn write(op: FsOp) -> io::Result<usize> {
let FsOp::Write { fd, offset, data } = op else {
unreachable!("write backend called with non-write op");
};
offload(move || {
let written = match offset {
Some(offset) => unsafe {
libc::pwrite(
fd,
data.as_ptr().cast::<libc::c_void>(),
data.len(),
offset as libc::off_t,
)
},
None => unsafe { libc::write(fd, data.as_ptr().cast::<libc::c_void>(), data.len()) },
};
if written < 0 {
return Err(io::Error::last_os_error());
}
Ok(written as usize)
})
.await
}
pub async fn metadata(op: FsOp) -> io::Result<RawMetadata> {
let FsOp::Metadata {
target,
follow_symlinks,
} = op
else {
unreachable!("metadata backend called with non-metadata op");
};
offload(move || {
let metadata = match target {
MetadataTarget::Path(path) => {
if follow_symlinks {
std::fs::metadata(path)
} else {
std::fs::symlink_metadata(path)
}
}
MetadataTarget::File(fd) => {
let mut stat = unsafe { std::mem::zeroed::<libc::stat>() };
let result = unsafe { libc::fstat(fd, &mut stat) };
if result < 0 {
return Err(io::Error::last_os_error());
}
return Ok(raw_metadata_from_stat(&stat));
}
}?;
Ok(raw_metadata_from_std(&metadata))
})
.await
}
pub async fn sync_all(op: FsOp) -> io::Result<()> {
let FsOp::SyncAll { fd } = op else {
unreachable!("sync_all backend called with non-sync_all op");
};
offload(move || cvt(unsafe { libc::fsync(fd) }).map(|_| ())).await
}
pub async fn sync_data(op: FsOp) -> io::Result<()> {
let FsOp::SyncData { fd } = op else {
unreachable!("sync_data backend called with non-sync_data op");
};
offload(move || cvt(unsafe { libc::fcntl(fd, libc::F_FULLFSYNC) }).map(|_| ())).await
}
pub async fn set_len(op: FsOp) -> io::Result<()> {
let FsOp::SetLen { fd, len } = op else {
unreachable!("set_len backend called with non-set_len op");
};
offload(move || cvt(unsafe { libc::ftruncate(fd, len as libc::off_t) }).map(|_| ())).await
}
pub async fn try_clone(op: FsOp) -> io::Result<OwnedFd> {
let FsOp::Duplicate { fd } = op else {
unreachable!("try_clone backend called with non-duplicate op");
};
offload(move || {
let duplicated = cvt(unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 0) })?;
Ok(unsafe { OwnedFd::from_raw_fd(duplicated) })
})
.await
}
pub async fn create_dir(op: FsOp) -> io::Result<()> {
let FsOp::CreateDir { path, mode } = op else {
unreachable!("create_dir backend called with non-create_dir op");
};
offload(move || {
let c_path = path_to_c_string(path)?;
cvt(unsafe { libc::mkdir(c_path.as_ptr(), mode as libc::mode_t) }).map(|_| ())
})
.await
}
pub async fn remove_file(op: FsOp) -> io::Result<()> {
let FsOp::RemoveFile { path } = op else {
unreachable!("remove_file backend called with non-remove_file op");
};
offload(move || std::fs::remove_file(path)).await
}
pub async fn remove_dir(op: FsOp) -> io::Result<()> {
let FsOp::RemoveDir { path } = op else {
unreachable!("remove_dir backend called with non-remove_dir op");
};
offload(move || std::fs::remove_dir(path)).await
}
pub async fn rename(op: FsOp) -> io::Result<()> {
let FsOp::Rename { from, to } = op else {
unreachable!("rename backend called with non-rename op");
};
offload(move || std::fs::rename(from, to)).await
}
pub async fn close(op: FsOp) -> io::Result<()> {
let FsOp::Close { fd } = op else {
unreachable!("close backend called with non-close op");
};
offload(move || cvt(unsafe { libc::close(fd) }).map(|_| ())).await
}
pub fn read_dir(op: FsOp) -> io::Result<ReadDirStream> {
let FsOp::ReadDir { path } = op else {
unreachable!("read_dir backend called with non-read_dir op");
};
ReadDirStream::new(path)
}
pub struct ReadDirStream {
state: Arc<ReadDirState>,
}
impl ReadDirStream {
fn new(path: PathBuf) -> io::Result<Self> {
let state = Arc::new(ReadDirState::new(current_thread_handle()));
let producer = Arc::clone(&state);
if let Err(error) = spawn_blocking(Box::new(move || produce_dir_entries(path, producer))) {
state.release_pending();
return Err(error);
}
Ok(Self { state })
}
pub async fn next_entry(&mut self) -> io::Result<Option<RawDirEntry>> {
poll_fn(|cx| self.state.poll_next(cx)).await
}
}
struct ReadDirState {
owner: ThreadHandle,
queue: Mutex<VecDeque<io::Result<RawDirEntry>>>,
done: AtomicBool,
pending: AtomicBool,
wake_queued: AtomicBool,
waker: Mutex<Option<Waker>>,
}
impl ReadDirState {
fn new(owner: ThreadHandle) -> Self {
owner.begin_async_operation();
Self {
owner,
queue: Mutex::new(VecDeque::new()),
done: AtomicBool::new(false),
pending: AtomicBool::new(true),
wake_queued: AtomicBool::new(false),
waker: Mutex::new(None),
}
}
fn push(self: &Arc<Self>, entry: io::Result<RawDirEntry>) {
self.queue.lock().unwrap().push_back(entry);
self.notify();
}
fn finish(self: &Arc<Self>) {
self.done.store(true, Ordering::Release);
self.release_pending();
self.notify();
}
fn release_pending(&self) {
if self.pending.swap(false, Ordering::AcqRel) {
self.owner.finish_async_operation();
}
}
fn notify(self: &Arc<Self>) {
if self.wake_queued.swap(true, Ordering::AcqRel) {
return;
}
let state = Arc::clone(self);
if !self.owner.queue_task(move || {
state.wake_queued.store(false, Ordering::Release);
if let Some(waker) = state.waker.lock().unwrap().take() {
waker.wake();
}
}) {
self.wake_queued.store(false, Ordering::Release);
}
}
fn poll_next(&self, cx: &mut Context<'_>) -> Poll<io::Result<Option<RawDirEntry>>> {
if let Some(entry) = self.queue.lock().unwrap().pop_front() {
return Poll::Ready(entry.map(Some));
}
if self.done.load(Ordering::Acquire) {
return Poll::Ready(Ok(None));
}
*self.waker.lock().unwrap() = Some(cx.waker().clone());
if let Some(entry) = self.queue.lock().unwrap().pop_front() {
let _ = self.waker.lock().unwrap().take();
return Poll::Ready(entry.map(Some));
}
if self.done.load(Ordering::Acquire) {
let _ = self.waker.lock().unwrap().take();
return Poll::Ready(Ok(None));
}
Poll::Pending
}
}
impl Drop for ReadDirStream {
fn drop(&mut self) {
self.state.release_pending();
}
}
fn produce_dir_entries(path: PathBuf, state: Arc<ReadDirState>) {
match std::fs::read_dir(path) {
Ok(entries) => {
for entry in entries {
match entry {
Ok(entry) => {
let file_name = entry.file_name();
state.push(Ok(RawDirEntry {
path: entry.path(),
file_name,
}));
}
Err(error) => state.push(Err(error)),
}
}
state.finish();
}
Err(error) => {
state.push(Err(error));
state.finish();
}
}
}
async fn offload<T: Send + 'static>(
work: impl FnOnce() -> io::Result<T> + Send + 'static,
) -> io::Result<T> {
let (future, handle) = completion_for_current_thread::<io::Result<T>>();
let handle_for_task = handle.clone();
if let Err(error) = spawn_blocking(Box::new(move || handle_for_task.complete(work()))) {
handle.complete(Err(error));
}
future.await
}
struct BlockingPool {
sender: mpsc::SyncSender<BlockingTask>,
}
impl BlockingPool {
fn spawn(&self, task: BlockingTask) -> io::Result<()> {
self.sender.try_send(task).map_err(|error| match error {
mpsc::TrySendError::Full(_) => io::Error::new(
io::ErrorKind::WouldBlock,
"filesystem blocking worker queue is full",
),
mpsc::TrySendError::Disconnected(_) => io::Error::new(
io::ErrorKind::BrokenPipe,
"filesystem blocking worker pool has stopped",
),
})
}
}
fn spawn_blocking(task: BlockingTask) -> io::Result<()> {
blocking_pool()?.spawn(task)
}
fn blocking_pool() -> io::Result<&'static BlockingPool> {
match BLOCKING_POOL.get_or_init(create_blocking_pool) {
Ok(pool) => Ok(pool),
Err(error) => Err(io::Error::new(error.kind(), error.to_string())),
}
}
fn create_blocking_pool() -> io::Result<BlockingPool> {
let (sender, receiver) = mpsc::sync_channel::<BlockingTask>(BLOCKING_QUEUE_CAPACITY);
let receiver = Arc::new(Mutex::new(receiver));
let worker_count = std::thread::available_parallelism()
.map(usize::from)
.unwrap_or(2)
.clamp(2, 4);
let mut spawned = 0usize;
let mut last_error = None;
for index in 0..worker_count {
let receiver = Arc::clone(&receiver);
match thread::Builder::new()
.name(format!("ruin-runtime-fs-offload-{index}"))
.spawn(move || {
loop {
let task = receiver.lock().unwrap().recv();
match task {
Ok(task) => task(),
Err(_) => break,
}
}
}) {
Ok(_) => spawned += 1,
Err(error) => last_error = Some(error),
}
}
if spawned == 0 {
return Err(io::Error::other(
last_error.expect("at least one worker spawn should have been attempted"),
));
}
Ok(BlockingPool { sender })
}
fn path_to_c_string(path: PathBuf) -> io::Result<CString> {
CString::new(path.as_os_str().as_bytes()).map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidInput,
"path contains interior NUL bytes",
)
})
}
fn raw_metadata_from_std(metadata: &std::fs::Metadata) -> RawMetadata {
let file_type = metadata.file_type();
let kind = if file_type.is_file() {
FileType::File
} else if file_type.is_dir() {
FileType::Directory
} else if file_type.is_symlink() {
FileType::Symlink
} else if file_type.is_block_device() {
FileType::BlockDevice
} else if file_type.is_char_device() {
FileType::CharacterDevice
} else if file_type.is_fifo() {
FileType::Fifo
} else if file_type.is_socket() {
FileType::Socket
} else {
FileType::Unknown
};
RawMetadata {
file_type: kind,
mode: (metadata.mode() & 0o7777) as u16,
len: metadata.len(),
}
}
fn raw_metadata_from_stat(stat: &libc::stat) -> RawMetadata {
let kind = match stat.st_mode & libc::S_IFMT {
libc::S_IFREG => FileType::File,
libc::S_IFDIR => FileType::Directory,
libc::S_IFLNK => FileType::Symlink,
libc::S_IFBLK => FileType::BlockDevice,
libc::S_IFCHR => FileType::CharacterDevice,
libc::S_IFIFO => FileType::Fifo,
libc::S_IFSOCK => FileType::Socket,
_ => FileType::Unknown,
};
RawMetadata {
file_type: kind,
mode: stat.st_mode & 0o7777,
len: stat.st_size as u64,
}
}
fn cvt(value: libc::c_int) -> io::Result<libc::c_int> {
if value < 0 {
Err(io::Error::last_os_error())
} else {
Ok(value)
}
}

View File

@@ -0,0 +1,6 @@
//! macOS backend modules.
pub mod channel;
pub mod fd;
pub mod fs;
pub mod net;

View File

@@ -0,0 +1,895 @@
//! macOS networking backend.
use std::ffi::c_void;
use std::future::Future;
use std::io;
use std::mem::MaybeUninit;
use std::net::{
Ipv4Addr, Ipv6Addr, Shutdown, SocketAddr, SocketAddrV4, SocketAddrV6, ToSocketAddrs,
};
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
use std::pin::Pin;
use std::sync::{Arc, Mutex, OnceLock, mpsc};
use std::thread;
use std::time::Duration;
use crate::op::completion::completion_for_current_thread;
use crate::op::net::{AcceptedSocket, NetOp, ReceivedDatagram};
const DEFAULT_LISTENER_BACKLOG: i32 = 1024;
const DNS_QUEUE_CAPACITY: usize = 256;
type RecvFuture = Pin<Box<dyn Future<Output = io::Result<Vec<u8>>> + 'static>>;
type SendFuture = Pin<Box<dyn Future<Output = io::Result<usize>> + 'static>>;
type ShutdownFuture = Pin<Box<dyn Future<Output = io::Result<()>> + 'static>>;
type BlockingTask = Box<dyn FnOnce() + Send + 'static>;
static DNS_POOL: OnceLock<io::Result<BlockingPool>> = OnceLock::new();
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ExecutionPath {
Kqueue,
Offload,
}
pub fn execution_path(op: &NetOp) -> ExecutionPath {
match op {
NetOp::Socket { .. }
| NetOp::Connect { .. }
| NetOp::Bind { .. }
| NetOp::Listen { .. }
| NetOp::Accept { .. }
| NetOp::Send { .. }
| NetOp::SendTo { .. }
| NetOp::Recv { .. }
| NetOp::RecvFrom { .. }
| NetOp::Shutdown { .. }
| NetOp::Close { .. } => ExecutionPath::Kqueue,
}
}
pub async fn resolve_addrs<A>(addr: A) -> io::Result<Vec<SocketAddr>>
where
A: ToSocketAddrs + Send + 'static,
{
offload(move || {
let addrs = addr.to_socket_addrs()?.collect::<Vec<_>>();
if addrs.is_empty() {
Err(io::Error::new(
io::ErrorKind::InvalidInput,
"address resolved to no socket addresses",
))
} else {
Ok(addrs)
}
})
.await
}
pub async fn socket(op: NetOp) -> io::Result<OwnedFd> {
let NetOp::Socket {
domain,
socket_type,
protocol,
flags,
} = op
else {
unreachable!("socket backend called with non-socket op");
};
socket_sync(domain, socket_type, protocol, flags)
}
pub async fn connect(op: NetOp) -> io::Result<()> {
let NetOp::Connect { fd, addr } = op else {
unreachable!("connect backend called with non-connect op");
};
connect_async(fd, RawSocketAddr::from_socket_addr(addr)).await
}
pub async fn bind(op: NetOp) -> io::Result<()> {
let NetOp::Bind { fd, addr } = op else {
unreachable!("bind backend called with non-bind op");
};
bind_sync(fd, RawSocketAddr::from_socket_addr(addr))
}
pub async fn listen(op: NetOp) -> io::Result<()> {
let NetOp::Listen { fd, backlog } = op else {
unreachable!("listen backend called with non-listen op");
};
listen_sync(fd, backlog)
}
pub async fn accept(op: NetOp) -> io::Result<AcceptedSocket> {
let NetOp::Accept { fd } = op else {
unreachable!("accept backend called with non-accept op");
};
accept_async(fd).await
}
pub async fn send(op: NetOp) -> io::Result<usize> {
let NetOp::Send { fd, data, flags } = op else {
unreachable!("send backend called with non-send op");
};
send_async(fd, data, flags).await
}
pub async fn send_to(op: NetOp) -> io::Result<usize> {
let NetOp::SendTo {
fd,
target,
data,
flags,
} = op
else {
unreachable!("send_to backend called with non-send_to op");
};
send_to_async(fd, target, data, flags).await
}
pub async fn recv(op: NetOp) -> io::Result<Vec<u8>> {
let NetOp::Recv { fd, len, flags } = op else {
unreachable!("recv backend called with non-recv op");
};
recv_async(fd, len, flags).await
}
pub async fn recv_from(op: NetOp) -> io::Result<ReceivedDatagram> {
let NetOp::RecvFrom { fd, len, flags } = op else {
unreachable!("recv_from backend called with non-recv_from op");
};
recv_from_async(fd, len, flags).await
}
pub async fn shutdown(op: NetOp) -> io::Result<()> {
let NetOp::Shutdown { fd, how } = op else {
unreachable!("shutdown backend called with non-shutdown op");
};
shutdown_sync(fd, how)
}
pub async fn close(op: NetOp) -> io::Result<()> {
let NetOp::Close { fd } = op else {
unreachable!("close backend called with non-close op");
};
close_sync(fd)
}
pub async fn connect_stream(addr: SocketAddr) -> io::Result<OwnedFd> {
match connect_stream_inner(addr).await {
Err(error) if should_try_ipv4_loopback(addr, &error) => {
connect_stream_inner(localhost_v4(addr)).await
}
result => result,
}
}
pub async fn bind_listener(addr: SocketAddr, backlog: Option<i32>) -> io::Result<OwnedFd> {
match bind_listener_inner(addr, backlog).await {
Err(error) if should_try_ipv4_loopback(addr, &error) => {
bind_listener_inner(localhost_v4(addr), backlog).await
}
result => result,
}
}
pub async fn bind_datagram(addr: SocketAddr) -> io::Result<OwnedFd> {
match bind_datagram_inner(addr).await {
Err(error) if should_try_ipv4_loopback(addr, &error) => {
bind_datagram_inner(localhost_v4(addr)).await
}
result => result,
}
}
async fn connect_stream_inner(addr: SocketAddr) -> io::Result<OwnedFd> {
let stream = socket(NetOp::Socket {
domain: socket_domain(addr),
socket_type: libc::SOCK_STREAM,
protocol: 0,
flags: 0,
})
.await?;
connect(NetOp::Connect {
fd: stream.as_raw_fd(),
addr,
})
.await?;
Ok(stream)
}
async fn bind_listener_inner(addr: SocketAddr, backlog: Option<i32>) -> io::Result<OwnedFd> {
let listener = socket(NetOp::Socket {
domain: socket_domain(addr),
socket_type: libc::SOCK_STREAM,
protocol: 0,
flags: 0,
})
.await?;
set_reuse_addr(listener.as_raw_fd(), true)?;
bind(NetOp::Bind {
fd: listener.as_raw_fd(),
addr,
})
.await?;
listen(NetOp::Listen {
fd: listener.as_raw_fd(),
backlog: backlog.unwrap_or(DEFAULT_LISTENER_BACKLOG),
})
.await?;
Ok(listener)
}
async fn bind_datagram_inner(addr: SocketAddr) -> io::Result<OwnedFd> {
let socket = socket(NetOp::Socket {
domain: socket_domain(addr),
socket_type: libc::SOCK_DGRAM,
protocol: 0,
flags: 0,
})
.await?;
bind(NetOp::Bind {
fd: socket.as_raw_fd(),
addr,
})
.await?;
Ok(socket)
}
pub async fn duplicate(fd: RawFd) -> io::Result<OwnedFd> {
let duplicated = cvt(unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 0) })?;
set_nonblocking(duplicated)?;
Ok(unsafe { OwnedFd::from_raw_fd(duplicated) })
}
pub async fn recv_timeout(
fd: RawFd,
len: usize,
flags: i32,
timeout: Duration,
) -> io::Result<Vec<u8>> {
io_timeout(timeout, recv_async(fd, len, flags)).await
}
pub async fn send_timeout(
fd: RawFd,
data: Vec<u8>,
flags: i32,
timeout: Duration,
) -> io::Result<usize> {
io_timeout(timeout, send_async(fd, data, flags)).await
}
pub async fn recv_from_timeout(
fd: RawFd,
len: usize,
flags: i32,
timeout: Duration,
) -> io::Result<ReceivedDatagram> {
io_timeout(timeout, recv_from_async(fd, len, flags)).await
}
pub async fn send_to_timeout(
fd: RawFd,
data: Vec<u8>,
target: SocketAddr,
flags: i32,
timeout: Duration,
) -> io::Result<usize> {
io_timeout(timeout, send_to_async(fd, target, data, flags)).await
}
pub async fn connect_stream_timeout(addr: SocketAddr, timeout: Duration) -> io::Result<OwnedFd> {
let fd = socket_sync(socket_domain(addr), libc::SOCK_STREAM, 0, 0)?;
if let Err(error) = io_timeout(
timeout,
connect_async(fd.as_raw_fd(), RawSocketAddr::from_socket_addr(addr)),
)
.await
{
drop(fd);
return Err(error);
}
Ok(fd)
}
pub fn local_addr(fd: RawFd) -> io::Result<SocketAddr> {
socket_addr_with(libc::getsockname, fd)
}
pub fn peer_addr(fd: RawFd) -> io::Result<SocketAddr> {
socket_addr_with(libc::getpeername, fd)
}
pub fn nodelay(fd: RawFd) -> io::Result<bool> {
let mut value = 0;
let mut len = std::mem::size_of::<libc::c_int>() as libc::socklen_t;
cvt(unsafe {
libc::getsockopt(
fd,
libc::IPPROTO_TCP,
libc::TCP_NODELAY,
&mut value as *mut libc::c_int as *mut c_void,
&mut len,
)
})?;
Ok(value != 0)
}
pub fn broadcast(fd: RawFd) -> io::Result<bool> {
getsockopt_int(fd, libc::SOL_SOCKET, libc::SO_BROADCAST).map(|value| value != 0)
}
pub fn set_broadcast(fd: RawFd, enabled: bool) -> io::Result<()> {
setsockopt_int(fd, libc::SOL_SOCKET, libc::SO_BROADCAST, enabled.into())
}
pub fn ttl(fd: RawFd) -> io::Result<u32> {
match socket_family(fd)? {
libc::AF_INET => {
getsockopt_int(fd, libc::IPPROTO_IP, libc::IP_TTL).map(|value| value as u32)
}
libc::AF_INET6 => getsockopt_int(fd, libc::IPPROTO_IPV6, libc::IPV6_UNICAST_HOPS)
.map(|value| value as u32),
family => Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("unsupported socket family {family} for TTL"),
)),
}
}
pub fn set_ttl(fd: RawFd, ttl: u32) -> io::Result<()> {
let ttl = i32::try_from(ttl)
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "TTL exceeds i32 range"))?;
match socket_family(fd)? {
libc::AF_INET => setsockopt_int(fd, libc::IPPROTO_IP, libc::IP_TTL, ttl),
libc::AF_INET6 => setsockopt_int(fd, libc::IPPROTO_IPV6, libc::IPV6_UNICAST_HOPS, ttl),
family => Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("unsupported socket family {family} for TTL"),
)),
}
}
pub fn set_nodelay(fd: RawFd, enabled: bool) -> io::Result<()> {
let value: libc::c_int = enabled.into();
cvt(unsafe {
libc::setsockopt(
fd,
libc::IPPROTO_TCP,
libc::TCP_NODELAY,
&value as *const libc::c_int as *const c_void,
std::mem::size_of_val(&value) as libc::socklen_t,
)
})
.map(|_| ())
}
pub fn recv_future(fd: RawFd, len: usize) -> RecvFuture {
Box::pin(recv(NetOp::Recv { fd, len, flags: 0 }))
}
pub fn send_future(fd: RawFd, data: Vec<u8>) -> SendFuture {
Box::pin(send(NetOp::Send { fd, data, flags: 0 }))
}
pub fn shutdown_future(fd: RawFd, how: Shutdown) -> ShutdownFuture {
Box::pin(shutdown(NetOp::Shutdown { fd, how }))
}
async fn offload<T: Send + 'static>(
work: impl FnOnce() -> io::Result<T> + Send + 'static,
) -> io::Result<T> {
let (future, handle) = completion_for_current_thread::<io::Result<T>>();
if let Err(error) = dns_pool().and_then(|pool| {
pool.spawn(Box::new({
let handle = handle.clone();
move || handle.complete(work())
}))
}) {
handle.complete(Err(error));
}
future.await
}
struct BlockingPool {
sender: mpsc::SyncSender<BlockingTask>,
}
impl BlockingPool {
fn spawn(&self, task: BlockingTask) -> io::Result<()> {
self.sender.try_send(task).map_err(|error| match error {
mpsc::TrySendError::Full(_) => io::Error::new(
io::ErrorKind::WouldBlock,
"DNS resolver blocking worker queue is full",
),
mpsc::TrySendError::Disconnected(_) => io::Error::new(
io::ErrorKind::BrokenPipe,
"DNS resolver blocking worker pool has stopped",
),
})
}
}
fn dns_pool() -> io::Result<&'static BlockingPool> {
match DNS_POOL.get_or_init(create_dns_pool) {
Ok(pool) => Ok(pool),
Err(error) => Err(io::Error::new(error.kind(), error.to_string())),
}
}
fn create_dns_pool() -> io::Result<BlockingPool> {
let (sender, receiver) = mpsc::sync_channel::<BlockingTask>(DNS_QUEUE_CAPACITY);
let receiver = Arc::new(Mutex::new(receiver));
let worker_count = std::thread::available_parallelism()
.map(usize::from)
.unwrap_or(2)
.clamp(2, 4);
let mut spawned = 0usize;
let mut last_error = None;
for index in 0..worker_count {
let receiver = Arc::clone(&receiver);
match thread::Builder::new()
.name(format!("ruin-runtime-dns-{index}"))
.spawn(move || {
loop {
let task = {
let receiver = receiver.lock().expect("DNS worker queue mutex poisoned");
receiver.recv()
};
match task {
Ok(task) => task(),
Err(_) => break,
}
}
}) {
Ok(_) => spawned += 1,
Err(error) => last_error = Some(error),
}
}
if spawned == 0 {
return Err(io::Error::other(last_error.expect(
"at least one DNS worker spawn should have been attempted",
)));
}
Ok(BlockingPool { sender })
}
async fn io_timeout<T>(
timeout: Duration,
future: impl Future<Output = io::Result<T>>,
) -> io::Result<T> {
crate::time::timeout(timeout, future)
.await
.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "operation timed out"))?
}
fn socket_domain(addr: SocketAddr) -> i32 {
match addr {
SocketAddr::V4(_) => libc::AF_INET,
SocketAddr::V6(_) => libc::AF_INET6,
}
}
fn shutdown_how(how: Shutdown) -> i32 {
match how {
Shutdown::Read => libc::SHUT_RD,
Shutdown::Write => libc::SHUT_WR,
Shutdown::Both => libc::SHUT_RDWR,
}
}
fn socket_addr_with(
op: unsafe extern "C" fn(RawFd, *mut libc::sockaddr, *mut libc::socklen_t) -> libc::c_int,
fd: RawFd,
) -> io::Result<SocketAddr> {
let mut storage = MaybeUninit::<libc::sockaddr_storage>::zeroed();
let mut len = std::mem::size_of::<libc::sockaddr_storage>() as libc::socklen_t;
cvt(unsafe { op(fd, storage.as_mut_ptr().cast::<libc::sockaddr>(), &mut len) })?;
let storage = unsafe { storage.assume_init() };
socket_addr_from_storage(&storage, len)
}
fn set_reuse_addr(fd: RawFd, enabled: bool) -> io::Result<()> {
setsockopt_int(fd, libc::SOL_SOCKET, libc::SO_REUSEADDR, enabled.into())
}
fn socket_family(fd: RawFd) -> io::Result<i32> {
let mut storage = MaybeUninit::<libc::sockaddr_storage>::zeroed();
let mut len = std::mem::size_of::<libc::sockaddr_storage>() as libc::socklen_t;
cvt(unsafe { libc::getsockname(fd, storage.as_mut_ptr().cast::<libc::sockaddr>(), &mut len) })?;
let storage = unsafe { storage.assume_init() };
Ok(storage.ss_family as i32)
}
fn getsockopt_int(fd: RawFd, level: i32, name: i32) -> io::Result<i32> {
let mut value = 0;
let mut len = std::mem::size_of::<libc::c_int>() as libc::socklen_t;
cvt(unsafe {
libc::getsockopt(
fd,
level,
name,
&mut value as *mut libc::c_int as *mut c_void,
&mut len,
)
})?;
Ok(value)
}
fn setsockopt_int(fd: RawFd, level: i32, name: i32, value: i32) -> io::Result<()> {
cvt(unsafe {
libc::setsockopt(
fd,
level,
name,
&value as *const libc::c_int as *const c_void,
std::mem::size_of_val(&value) as libc::socklen_t,
)
})
.map(|_| ())
}
fn socket_addr_from_storage(
storage: &libc::sockaddr_storage,
len: libc::socklen_t,
) -> io::Result<SocketAddr> {
match storage.ss_family as i32 {
libc::AF_INET => {
if len < std::mem::size_of::<libc::sockaddr_in>() as libc::socklen_t {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"sockaddr_in length is truncated",
));
}
let addr = unsafe { *(storage as *const _ as *const libc::sockaddr_in) };
Ok(SocketAddr::V4(SocketAddrV4::new(
Ipv4Addr::from(u32::from_be(addr.sin_addr.s_addr)),
u16::from_be(addr.sin_port),
)))
}
libc::AF_INET6 => {
if len < std::mem::size_of::<libc::sockaddr_in6>() as libc::socklen_t {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"sockaddr_in6 length is truncated",
));
}
let addr = unsafe { *(storage as *const _ as *const libc::sockaddr_in6) };
Ok(SocketAddr::V6(SocketAddrV6::new(
Ipv6Addr::from(addr.sin6_addr.s6_addr),
u16::from_be(addr.sin6_port),
addr.sin6_flowinfo,
addr.sin6_scope_id,
)))
}
family => Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("unsupported socket family {family}"),
)),
}
}
#[derive(Clone, Copy)]
struct RawSocketAddr {
storage: libc::sockaddr_storage,
len: libc::socklen_t,
}
impl RawSocketAddr {
fn from_socket_addr(addr: SocketAddr) -> Self {
match addr {
SocketAddr::V4(addr) => {
let sockaddr = libc::sockaddr_in {
sin_len: std::mem::size_of::<libc::sockaddr_in>() as u8,
sin_family: libc::AF_INET as libc::sa_family_t,
sin_port: addr.port().to_be(),
sin_addr: libc::in_addr {
s_addr: u32::from_be_bytes(addr.ip().octets()).to_be(),
},
sin_zero: [0; 8],
};
let mut storage =
unsafe { MaybeUninit::<libc::sockaddr_storage>::zeroed().assume_init() };
unsafe {
std::ptr::write(
&mut storage as *mut libc::sockaddr_storage as *mut libc::sockaddr_in,
sockaddr,
);
}
Self {
storage,
len: std::mem::size_of::<libc::sockaddr_in>() as libc::socklen_t,
}
}
SocketAddr::V6(addr) => {
let sockaddr = libc::sockaddr_in6 {
sin6_len: std::mem::size_of::<libc::sockaddr_in6>() as u8,
sin6_family: libc::AF_INET6 as libc::sa_family_t,
sin6_port: addr.port().to_be(),
sin6_flowinfo: addr.flowinfo(),
sin6_addr: libc::in6_addr {
s6_addr: addr.ip().octets(),
},
sin6_scope_id: addr.scope_id(),
};
let mut storage =
unsafe { MaybeUninit::<libc::sockaddr_storage>::zeroed().assume_init() };
unsafe {
std::ptr::write(
&mut storage as *mut libc::sockaddr_storage as *mut libc::sockaddr_in6,
sockaddr,
);
}
Self {
storage,
len: std::mem::size_of::<libc::sockaddr_in6>() as libc::socklen_t,
}
}
}
}
fn as_ptr(&self) -> *const libc::sockaddr {
&self.storage as *const libc::sockaddr_storage as *const libc::sockaddr
}
fn len(&self) -> libc::socklen_t {
self.len
}
}
fn socket_sync(domain: i32, socket_type: i32, protocol: i32, _flags: u32) -> io::Result<OwnedFd> {
let fd = cvt(unsafe { libc::socket(domain, socket_type, protocol) })?;
set_cloexec(fd)?;
set_nonblocking(fd)?;
Ok(unsafe { OwnedFd::from_raw_fd(fd) })
}
async fn connect_async(fd: RawFd, addr: RawSocketAddr) -> io::Result<()> {
loop {
let result = unsafe { libc::connect(fd, addr.as_ptr(), addr.len()) };
if result == 0 {
return Ok(());
}
let error = io::Error::last_os_error();
match error.raw_os_error() {
Some(libc::EINTR) => continue,
Some(libc::EINPROGRESS) | Some(libc::EALREADY) => {
crate::sys::current::fd::wait_writable(fd).await?;
return socket_error(fd);
}
Some(libc::EISCONN) => return Ok(()),
_ => return Err(error),
}
}
}
fn bind_sync(fd: RawFd, addr: RawSocketAddr) -> io::Result<()> {
cvt(unsafe { libc::bind(fd, addr.as_ptr(), addr.len()) }).map(|_| ())
}
fn listen_sync(fd: RawFd, backlog: i32) -> io::Result<()> {
cvt(unsafe { libc::listen(fd, backlog) }).map(|_| ())
}
fn accept_sync(fd: RawFd) -> io::Result<AcceptedSocket> {
let mut storage = MaybeUninit::<libc::sockaddr_storage>::zeroed();
let mut len = std::mem::size_of::<libc::sockaddr_storage>() as libc::socklen_t;
let accepted_fd =
cvt(unsafe { libc::accept(fd, storage.as_mut_ptr().cast::<libc::sockaddr>(), &mut len) })?;
set_cloexec(accepted_fd)?;
let storage = unsafe { storage.assume_init() };
let peer_addr = socket_addr_from_storage(&storage, len)?;
Ok(AcceptedSocket {
fd: accepted_fd,
peer_addr,
})
}
async fn accept_async(fd: RawFd) -> io::Result<AcceptedSocket> {
loop {
match accept_sync(fd) {
Ok(socket) => {
set_nonblocking(socket.fd)?;
return Ok(socket);
}
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
crate::sys::current::fd::wait_readable(fd).await?;
}
Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
Err(error) => return Err(error),
}
}
}
async fn send_async(fd: RawFd, data: Vec<u8>, flags: i32) -> io::Result<usize> {
loop {
match send_slice_sync(fd, &data, flags) {
Ok(written) => return Ok(written),
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
crate::sys::current::fd::wait_writable(fd).await?;
}
Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
Err(error) => return Err(error),
}
}
}
fn send_slice_sync(fd: RawFd, data: &[u8], flags: i32) -> io::Result<usize> {
let written = unsafe { libc::send(fd, data.as_ptr().cast::<c_void>(), data.len(), flags) };
cvt_long(written).map(|written| written as usize)
}
async fn send_to_async(
fd: RawFd,
target: SocketAddr,
data: Vec<u8>,
flags: i32,
) -> io::Result<usize> {
loop {
match send_to_slice_sync(fd, target, &data, flags) {
Ok(written) => return Ok(written),
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
crate::sys::current::fd::wait_writable(fd).await?;
}
Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
Err(error) => return Err(error),
}
}
}
fn send_to_slice_sync(fd: RawFd, target: SocketAddr, data: &[u8], flags: i32) -> io::Result<usize> {
let addr = RawSocketAddr::from_socket_addr(target);
let written = unsafe {
libc::sendto(
fd,
data.as_ptr().cast::<c_void>(),
data.len(),
flags,
addr.as_ptr(),
addr.len(),
)
};
cvt_long(written).map(|written| written as usize)
}
async fn recv_async(fd: RawFd, len: usize, flags: i32) -> io::Result<Vec<u8>> {
loop {
match recv_sync(fd, len, flags) {
Ok(data) => return Ok(data),
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
crate::sys::current::fd::wait_readable(fd).await?;
}
Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
Err(error) => return Err(error),
}
}
}
fn recv_sync(fd: RawFd, len: usize, flags: i32) -> io::Result<Vec<u8>> {
let mut data = vec![0u8; len];
let read = unsafe { libc::recv(fd, data.as_mut_ptr().cast::<c_void>(), len, flags) };
let read = cvt_long(read)? as usize;
data.truncate(read);
Ok(data)
}
async fn recv_from_async(fd: RawFd, len: usize, flags: i32) -> io::Result<ReceivedDatagram> {
loop {
match recv_from_sync(fd, len, flags) {
Ok(datagram) => return Ok(datagram),
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
crate::sys::current::fd::wait_readable(fd).await?;
}
Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
Err(error) => return Err(error),
}
}
}
fn recv_from_sync(fd: RawFd, len: usize, flags: i32) -> io::Result<ReceivedDatagram> {
let mut data = vec![0u8; len];
let mut storage = MaybeUninit::<libc::sockaddr_storage>::zeroed();
let mut addr_len = std::mem::size_of::<libc::sockaddr_storage>() as libc::socklen_t;
let read = unsafe {
libc::recvfrom(
fd,
data.as_mut_ptr().cast::<c_void>(),
len,
flags,
storage.as_mut_ptr().cast::<libc::sockaddr>(),
&mut addr_len,
)
};
let read = cvt_long(read)? as usize;
data.truncate(read);
let storage = unsafe { storage.assume_init() };
let peer_addr = socket_addr_from_storage(&storage, addr_len)?;
Ok(ReceivedDatagram { data, peer_addr })
}
fn shutdown_sync(fd: RawFd, how: Shutdown) -> io::Result<()> {
cvt(unsafe { libc::shutdown(fd, shutdown_how(how)) }).map(|_| ())
}
fn close_sync(fd: RawFd) -> io::Result<()> {
cvt(unsafe { libc::close(fd) }).map(|_| ())
}
fn set_cloexec(fd: RawFd) -> io::Result<()> {
let flags = cvt(unsafe { libc::fcntl(fd, libc::F_GETFD) })?;
cvt(unsafe { libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC) })?;
Ok(())
}
fn set_nonblocking(fd: RawFd) -> io::Result<()> {
let flags = cvt(unsafe { libc::fcntl(fd, libc::F_GETFL) })?;
cvt(unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) })?;
Ok(())
}
fn should_try_ipv4_loopback(addr: SocketAddr, error: &io::Error) -> bool {
matches!(addr, SocketAddr::V6(v6) if v6.ip().is_loopback())
&& matches!(
error.raw_os_error(),
Some(libc::EADDRNOTAVAIL | libc::EAFNOSUPPORT | libc::ENETUNREACH)
)
}
fn socket_error(fd: RawFd) -> io::Result<()> {
let mut so_error: libc::c_int = 0;
let mut len = std::mem::size_of::<libc::c_int>() as libc::socklen_t;
cvt(unsafe {
libc::getsockopt(
fd,
libc::SOL_SOCKET,
libc::SO_ERROR,
&mut so_error as *mut libc::c_int as *mut c_void,
&mut len,
)
})?;
if so_error == 0 {
Ok(())
} else {
Err(io::Error::from_raw_os_error(so_error))
}
}
fn localhost_v4(addr: SocketAddr) -> SocketAddr {
SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, addr.port()))
}
fn cvt(value: libc::c_int) -> io::Result<libc::c_int> {
if value < 0 {
Err(io::Error::last_os_error())
} else {
Ok(value)
}
}
fn cvt_long(value: libc::ssize_t) -> io::Result<libc::ssize_t> {
if value < 0 {
Err(io::Error::last_os_error())
} else {
Ok(value)
}
}

View File

@@ -2,3 +2,10 @@
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
pub mod linux;
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
pub mod macos;
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
pub use linux as current;
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
pub use macos as current;

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

@@ -67,7 +67,9 @@ fn bench_scroll_list(c: &mut Criterion) {
let viewport_height = 640.0;
let n = 500;
let mut scroll_box = Element::scroll_box(0.0).width(400.0).height(viewport_height);
let mut scroll_box = Element::scroll_box(0.0)
.width(400.0)
.height(viewport_height);
for i in 0..n {
scroll_box = scroll_box.child(
Element::new()
@@ -90,5 +92,10 @@ fn bench_scroll_list(c: &mut Criterion) {
});
}
criterion_group!(benches, bench_static_tree, bench_single_change, bench_scroll_list);
criterion_group!(
benches,
bench_static_tree,
bench_single_change,
bench_scroll_list
);
criterion_main!(benches);

View File

@@ -107,6 +107,7 @@ fn log_platform_event(event: &PlatformEvent) {
"clipboard text received"
);
}
#[cfg(target_os = "linux")]
PlatformEvent::PrimarySelectionText { window_id, text } => {
tracing::debug!(
event = "primary_selection_text",

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,

File diff suppressed because it is too large Load Diff

View File

@@ -6,8 +6,10 @@
//! sibling crates.
pub(crate) mod trace_targets {
#![allow(dead_code)]
pub const PLATFORM: &str = "ruin_ui::platform";
pub const SCENE: &str = "ruin_ui::scene";
#[allow(dead_code)]
pub const TEXT_PERF: &str = "ruin_ui::text_perf";
}
@@ -29,13 +31,15 @@ pub use interaction::{
};
pub use keyboard::{KeyboardEvent, KeyboardEventKind, KeyboardKey, KeyboardModifiers};
pub use layout::{
HitTarget, InteractionTree, LayoutCache, LayoutNode, LayoutPath, LayoutSnapshot, ScrollMetrics,
TextHitTarget, layout_snapshot, layout_snapshot_with_cache, layout_snapshot_with_text_system,
HitTarget, InteractionTree, LayoutCache, LayoutFrameStats, LayoutNode, LayoutPath,
LayoutSnapshot, ScrollMetrics, TextHitTarget, element_min_size, layout_snapshot,
layout_snapshot_with_cache, layout_snapshot_with_cache_and_stats,
layout_snapshot_with_text_system,
};
pub use layout::{layout_scene, layout_scene_with_text_system};
pub use platform::{
PlatformClosed, PlatformEndpoint, PlatformEvent, PlatformProxy, PlatformRequest,
PlatformRuntime, start_headless,
PlatformRuntime, set_command_wake_hook, start_headless,
};
pub use runtime::{EventStreamClosed, UiRuntime, WindowController};
pub use scene::{
@@ -48,8 +52,8 @@ pub use text::{
TextStyle, TextSystem, TextWrap,
};
pub use tree::{
Border, BoxShadow, BoxShadowKind, CornerRadius, CursorIcon, Edges, Element, ElementId,
FlexDirection, ScrollbarStyle, Style,
AlignItems, Border, BoxShadow, BoxShadowKind, CornerRadius, CursorIcon, Edges, Element,
ElementId, FlexDirection, JustifyContent, ScrollbarStyle, Style,
};
pub use window::{
DecorationMode, WindowConfigured, WindowId, WindowLifecycle, WindowSpec, WindowUpdate,

View File

@@ -4,8 +4,8 @@ use std::cell::RefCell;
use std::collections::BTreeMap;
use std::fmt;
use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use ruin_runtime::channel::mpsc;
use ruin_runtime::{WorkerHandle, queue_future, queue_microtask, spawn_worker};
@@ -18,6 +18,13 @@ use crate::trace_targets;
use crate::tree::CursorIcon;
use crate::window::{WindowConfigured, WindowId, WindowLifecycle, WindowSpec, WindowUpdate};
type CommandWakeHook = Arc<dyn Fn() + Send + Sync + 'static>;
fn command_wake_hook() -> &'static Mutex<Option<CommandWakeHook>> {
static HOOK: OnceLock<Mutex<Option<CommandWakeHook>>> = OnceLock::new();
HOOK.get_or_init(|| Mutex::new(None))
}
#[derive(Clone)]
pub struct PlatformProxy {
command_tx: mpsc::UnboundedSender<PlatformRequest>,
@@ -32,7 +39,7 @@ pub struct PlatformProxy {
pub struct PlatformRuntime {
proxy: PlatformProxy,
events: mpsc::Receiver<PlatformEvent>,
_worker: WorkerHandle,
worker: Option<WorkerHandle>,
}
pub struct PlatformEndpoint {
@@ -73,6 +80,7 @@ pub enum PlatformEvent {
window_id: WindowId,
text: String,
},
#[cfg(target_os = "linux")]
PrimarySelectionText {
window_id: WindowId,
text: String,
@@ -118,10 +126,12 @@ pub enum PlatformRequest {
RequestClipboardText {
window_id: WindowId,
},
#[cfg(target_os = "linux")]
SetPrimarySelectionText {
window_id: WindowId,
text: String,
},
#[cfg(target_os = "linux")]
RequestPrimarySelectionText {
window_id: WindowId,
},
@@ -203,7 +213,30 @@ impl PlatformRuntime {
Self {
proxy,
events: event_rx,
_worker: worker,
worker: Some(worker),
}
}
/// Creates a platform runtime hosted by the current runtime thread.
///
/// This is useful for backends that must run on a specific host thread
/// (for example, AppKit on macOS main thread).
pub fn custom_local(start: impl FnOnce(PlatformEndpoint)) -> Self {
let (command_tx, command_rx) = mpsc::unbounded_channel::<PlatformRequest>();
let (event_tx, event_rx) = mpsc::unbounded_channel::<PlatformEvent>();
let proxy = PlatformProxy {
command_tx,
next_window_id: Arc::new(AtomicU64::new(1)),
};
start(PlatformEndpoint {
commands: command_rx,
events: event_tx,
});
Self {
proxy,
events: event_rx,
worker: None,
}
}
@@ -216,6 +249,7 @@ impl PlatformRuntime {
}
pub fn take_pending_events(&mut self) -> Vec<PlatformEvent> {
let _ = self.worker.as_ref();
let mut events = Vec::new();
while let Ok(event) = self.events.try_recv() {
events.push(event);
@@ -247,6 +281,7 @@ impl PlatformProxy {
self.send(PlatformRequest::ReplaceScene { window_id, scene })
}
#[cfg(target_os = "linux")]
pub fn set_primary_selection_text(
&self,
window_id: WindowId,
@@ -258,6 +293,7 @@ impl PlatformProxy {
})
}
#[cfg(target_os = "linux")]
pub fn request_primary_selection_text(
&self,
window_id: WindowId,
@@ -317,7 +353,20 @@ impl PlatformProxy {
}
fn send(&self, command: PlatformRequest) -> Result<(), PlatformClosed> {
self.command_tx.send(command).map_err(|_| PlatformClosed)
self.command_tx.send(command).map_err(|_| PlatformClosed)?;
if let Ok(hook) = command_wake_hook().lock()
&& let Some(hook) = hook.as_ref().cloned()
{
hook();
}
Ok(())
}
}
#[doc(hidden)]
pub fn set_command_wake_hook(hook: Option<CommandWakeHook>) {
if let Ok(mut slot) = command_wake_hook().lock() {
*slot = hook;
}
}
@@ -356,7 +405,9 @@ pub fn start_headless() -> PlatformRuntime {
}
PlatformRequest::SetClipboardText { .. } => {}
PlatformRequest::RequestClipboardText { .. } => {}
#[cfg(target_os = "linux")]
PlatformRequest::SetPrimarySelectionText { .. } => {}
#[cfg(target_os = "linux")]
PlatformRequest::RequestPrimarySelectionText { .. } => {}
PlatformRequest::SetCursorIcon { .. } => {}
PlatformRequest::EmitCloseRequested { window_id } => {

View File

@@ -110,6 +110,7 @@ impl WindowController {
}
/// Copies plain text to the platform primary-selection buffer for this window.
#[cfg(target_os = "linux")]
pub fn set_primary_selection_text(
&self,
text: impl Into<String>,
@@ -128,6 +129,7 @@ impl WindowController {
}
/// Requests the current plain-text primary selection contents from the platform.
#[cfg(target_os = "linux")]
pub fn request_primary_selection_text(&self) -> Result<(), PlatformClosed> {
self.proxy.request_primary_selection_text(self.id)
}

View File

@@ -80,6 +80,9 @@ impl Color {
/// safe to reserve as a sentinel. No user-facing API sets this value directly.
pub const SENTINEL: Self = Self::rgba(0, 0, 0, 0);
pub const BLACK: Self = Self::rgb(0, 0, 0);
pub const WHITE: Self = Self::rgb(255, 255, 255);
pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self {
Self { r, g, b, a }
}
@@ -186,7 +189,7 @@ pub struct PreparedTextLine {
#[derive(Clone, Debug, PartialEq)]
pub struct PreparedText {
pub element_id: Option<ElementId>,
pub text: String,
pub text: Arc<str>,
pub origin: Point,
pub bounds: Option<UiSize>,
pub font_size: f32,
@@ -203,6 +206,8 @@ pub struct PreparedText {
/// Add `origin` to convert to absolute window coords.
#[derive(Clone, Debug, PartialEq)]
pub struct TextLayoutData {
pub text: Arc<str>,
pub cache_key: u64,
pub lines: Vec<PreparedTextLine>,
pub glyphs: Vec<GlyphInstance>,
/// Measured (unclamped) size of the laid-out text.
@@ -219,9 +224,9 @@ impl Deref for PreparedText {
impl PreparedText {
/// Construct a `PreparedText` from shaped data. Called by `TextSystem::prepare_spans`.
#[allow(clippy::too_many_arguments)]
pub(crate) fn from_layout(
element_id: Option<ElementId>,
text: String,
origin: Point,
bounds: Option<UiSize>,
font_size: f32,
@@ -233,7 +238,7 @@ impl PreparedText {
) -> Self {
Self {
element_id,
text,
text: layout.text.clone(),
origin,
bounds,
font_size,
@@ -252,6 +257,10 @@ impl PreparedText {
Arc::as_ptr(&self.layout)
}
pub fn layout_cache_key(&self) -> u64 {
self.layout.cache_key
}
/// Create a monospace `PreparedText` without going through the text system.
/// Used for testing and low-level terminal-style rendering.
pub fn monospace(
@@ -261,7 +270,7 @@ impl PreparedText {
advance: f32,
color: Color,
) -> Self {
let text = text.into();
let text: Arc<str> = Arc::from(text.into());
// Glyphs are stored in LOCAL (origin-relative) coordinates.
let mut local_x = 0.0f32;
let mut glyphs = Vec::with_capacity(text.chars().count());
@@ -289,7 +298,7 @@ impl PreparedText {
let size = UiSize::new(local_x, font_size);
Self {
element_id: None,
text,
text: text.clone(),
origin,
bounds: None,
font_size,
@@ -298,6 +307,8 @@ impl PreparedText {
selectable: true,
selection_style: TextSelectionStyle::DEFAULT,
layout: Arc::new(TextLayoutData {
text: text.clone(),
cache_key: monospace_text_cache_key(&text, font_size, advance, color),
lines: vec![line],
glyphs,
size,
@@ -462,7 +473,10 @@ impl PreparedText {
if range.is_empty() {
return;
}
for glyph in Arc::make_mut(&mut self.layout).glyphs.iter_mut() {
let layout = Arc::make_mut(&mut self.layout);
layout.cache_key =
selected_text_cache_key(layout.cache_key, range.start, range.end, selected_color);
for glyph in layout.glyphs.iter_mut() {
if glyph.text_end > range.start && glyph.text_start < range.end {
glyph.color = selected_color;
}
@@ -579,6 +593,28 @@ impl PreparedText {
}
}
fn monospace_text_cache_key(text: &str, font_size: f32, advance: f32, color: Color) -> u64 {
use std::hash::{DefaultHasher, Hash, Hasher};
let mut hasher = DefaultHasher::new();
text.hash(&mut hasher);
font_size.to_bits().hash(&mut hasher);
advance.to_bits().hash(&mut hasher);
color.hash(&mut hasher);
hasher.finish()
}
fn selected_text_cache_key(base: u64, start: usize, end: usize, color: Color) -> u64 {
use std::hash::{DefaultHasher, Hash, Hasher};
let mut hasher = DefaultHasher::new();
base.hash(&mut hasher);
start.hash(&mut hasher);
end.hash(&mut hasher);
color.hash(&mut hasher);
hasher.finish()
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum WordClass {
Word,
@@ -623,10 +659,18 @@ impl DisplayItem {
/// Return a copy of this item with all positions shifted by `offset`.
pub fn translated(&self, offset: Point) -> Self {
fn translate_rect(r: Rect, o: Point) -> Rect {
Rect::new(r.origin.x + o.x, r.origin.y + o.y, r.size.width, r.size.height)
Rect::new(
r.origin.x + o.x,
r.origin.y + o.y,
r.size.width,
r.size.height,
)
}
match self {
Self::Quad(q) => Self::Quad(Quad { rect: translate_rect(q.rect, offset), ..*q }),
Self::Quad(q) => Self::Quad(Quad {
rect: translate_rect(q.rect, offset),
..*q
}),
Self::RoundedRect(r) => Self::RoundedRect(RoundedRect {
rect: translate_rect(r.rect, offset),
..*r
@@ -838,8 +882,8 @@ mod tests {
#[test]
fn prepared_text_vertical_offset_moves_between_lines() {
use std::sync::Arc;
use super::{PreparedTextLine, TextLayoutData};
use std::sync::Arc;
let mut text = PreparedText::monospace(
"abcdwxyz",
@@ -873,7 +917,13 @@ mod tests {
}
}
let orig_size = text.layout.size;
text.layout = Arc::new(TextLayoutData { lines, glyphs, size: orig_size });
text.layout = Arc::new(TextLayoutData {
text: text.text.clone(),
cache_key: text.layout.cache_key,
lines,
glyphs,
size: orig_size,
});
assert_eq!(text.vertical_offset(2, 1), Some(6));
assert_eq!(text.vertical_offset(6, -1), Some(2));
@@ -892,8 +942,12 @@ mod tests {
let target_line = &text.lines[1];
// Lines store LOCAL coords; add text.origin to get absolute window coords for the query.
let y = text.origin.y + target_line.rect.origin.y + target_line.rect.size.height * 0.5;
let start = text.byte_offset_for_position(Point::new(text.origin.x + target_line.rect.origin.x, y));
let end = text.byte_offset_for_position(Point::new(text.origin.x + target_line.rect.origin.x + 16.0, y));
let start =
text.byte_offset_for_position(Point::new(text.origin.x + target_line.rect.origin.x, y));
let end = text.byte_offset_for_position(Point::new(
text.origin.x + target_line.rect.origin.x + 16.0,
y,
));
let rects = text.selection_rects(start, end);
assert_eq!(rects.len(), 1);

View File

@@ -10,8 +10,9 @@ use cosmic_text::{
};
use fontconfig::Fontconfig;
use crate::{Color, GlyphInstance, Point, PreparedText, PreparedTextLine, Rect, TextLayoutData,
UiSize};
use crate::{
Color, GlyphInstance, Point, PreparedText, PreparedTextLine, Rect, TextLayoutData, UiSize,
};
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum TextAlign {
@@ -268,7 +269,7 @@ pub struct TextSystem {
}
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct TextFrameStats {
pub struct TextFrameStats {
pub requests: u32,
pub cache_hits: u32,
pub cache_misses: u32,
@@ -340,7 +341,6 @@ impl TextSystem {
bounds: Option<UiSize>,
) -> PreparedText {
let bounds = bounds.or(style.bounds);
let text = combined_text(spans);
// Use the cached Arc directly — glyphs are in local (origin-0) coords.
let layout_data = self.layout(
spans,
@@ -351,7 +351,6 @@ impl TextSystem {
PreparedText::from_layout(
None,
text,
origin,
bounds,
style.font_size,
@@ -443,7 +442,11 @@ impl TextSystem {
// For no-wrap + start-aligned text, width doesn't affect shaping.
// Pass None so cosmic-text doesn't truncate and the result is
// consistent with the width-independent cache key.
let effective_width = if width_affects_layout(style) { width } else { None };
let effective_width = if width_affects_layout(style) {
width
} else {
None
};
borrowed.set_size(effective_width, None);
let default_attrs = default_attrs_for_style(style, default_family.as_deref());
if uses_plain_text_fast_path {
@@ -471,7 +474,8 @@ impl TextSystem {
}
self.frame_stats.buffer_build_ms += buffer_build_started.elapsed().as_secs_f64() * 1_000.0;
let line_starts = line_start_offsets(&combined_text(spans));
let text = combined_text(spans);
let line_starts = line_start_offsets(&text);
let mut measured_width: f32 = 0.0;
let mut measured_height: f32 = 0.0;
let mut lines = Vec::new();
@@ -495,9 +499,7 @@ impl TextSystem {
// is color-independent. Per-span colors (from glyph.color_opt)
// are stored directly since they are part of the shape key via
// the spans hash.
color: glyph
.color_opt
.map_or(Color::SENTINEL, color_from_cosmic),
color: glyph.color_opt.map_or(Color::SENTINEL, color_from_cosmic),
cache_key: Some(physical.cache_key),
text_start: line_offset + glyph.start,
text_end: line_offset + glyph.end,
@@ -524,6 +526,8 @@ impl TextSystem {
glyph_collect_started.elapsed().as_secs_f64() * 1_000.0;
let layout = Arc::new(TextLayoutData {
text: Arc::from(text),
cache_key,
lines,
glyphs,
size: UiSize::new(measured_width.max(0.0), measured_height.max(0.0)),
@@ -696,16 +700,16 @@ fn attrs_for_span<'a>(
base
};
base.weight(match span.weight {
TextSpanWeight::Normal => CosmicWeight::NORMAL,
TextSpanWeight::Medium => CosmicWeight::MEDIUM,
TextSpanWeight::Semibold => CosmicWeight::SEMIBOLD,
TextSpanWeight::Bold => CosmicWeight::BOLD,
})
.style(match span.slant {
TextSpanSlant::Normal => CosmicStyle::Normal,
TextSpanSlant::Italic => CosmicStyle::Italic,
TextSpanSlant::Oblique => CosmicStyle::Oblique,
})
TextSpanWeight::Normal => CosmicWeight::NORMAL,
TextSpanWeight::Medium => CosmicWeight::MEDIUM,
TextSpanWeight::Semibold => CosmicWeight::SEMIBOLD,
TextSpanWeight::Bold => CosmicWeight::BOLD,
})
.style(match span.slant {
TextSpanSlant::Normal => CosmicStyle::Normal,
TextSpanSlant::Italic => CosmicStyle::Italic,
TextSpanSlant::Oblique => CosmicStyle::Oblique,
})
}
fn to_cosmic_color(color: Color) -> CosmicColor {
@@ -930,7 +934,9 @@ impl std::hash::Hash for TextStyle {
self.line_height.to_bits().hash(state);
self.color.hash(state);
self.font_family.hash(state);
self.bounds.map(|b| (b.width.to_bits(), b.height.to_bits())).hash(state);
self.bounds
.map(|b| (b.width.to_bits(), b.height.to_bits()))
.hash(state);
self.wrap.hash(state);
self.align.hash(state);
self.max_lines.hash(state);
@@ -1038,16 +1044,32 @@ mod tests {
let blue = text_system.prepare("hello", origin, &style_blue);
// Both PreparedTexts must have identical glyph shapes and sentinel colors —
// the second call hits the shape cache and produces the same layout data.
assert_eq!(red.glyphs.len(), blue.glyphs.len(), "glyph count must match");
assert_eq!(
red.glyphs.len(),
blue.glyphs.len(),
"glyph count must match"
);
for (r, b) in red.glyphs.iter().zip(blue.glyphs.iter()) {
assert_eq!(r.position, b.position, "glyph positions must match");
assert_eq!(r.cache_key, b.cache_key, "glyph cache keys must match");
assert_eq!(r.color, Color::SENTINEL, "default-color glyphs must use SENTINEL");
assert_eq!(b.color, Color::SENTINEL, "default-color glyphs must use SENTINEL");
assert_eq!(
r.color,
Color::SENTINEL,
"default-color glyphs must use SENTINEL"
);
assert_eq!(
b.color,
Color::SENTINEL,
"default-color glyphs must use SENTINEL"
);
}
// PreparedText::clone() is an Arc clone — O(1).
let cloned = red.clone();
assert_eq!(cloned.layout_ptr(), red.layout_ptr(), "clone must share the same Arc");
assert_eq!(
cloned.layout_ptr(),
red.layout_ptr(),
"clone must share the same Arc"
);
// But they should carry different default colors.
assert_eq!(red.default_color, Color::rgb(0xFF, 0x00, 0x00));
assert_eq!(blue.default_color, Color::rgb(0x00, 0x00, 0xFF));

View File

@@ -23,6 +23,45 @@ pub enum FlexDirection {
Column,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum AlignItems {
#[default]
Stretch,
Start,
Center,
End,
}
/// Controls how children are positioned and how leftover space is distributed
/// along the main axis (horizontal for `FlexDirection::Row`, vertical for
/// `FlexDirection::Column`).
///
/// When a container has flex children (`flex_grow > 0`) all remaining space is
/// consumed by them, so `justify_content` has no visible effect on the main
/// axis distribution in that case. The container's `gap` is always honored as
/// a minimum between adjacent children — `SpaceBetween`, `SpaceAround`, and
/// `SpaceEvenly` distribute leftover space *in addition to* `gap`.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum JustifyContent {
/// Pack children at the start of the main axis. This is the default and
/// matches the behavior before `justify_content` existed.
#[default]
Start,
/// Pack children at the end of the main axis.
End,
/// Pack children as a group, centered along the main axis.
Center,
/// Distribute children with the first child flush with the start, the last
/// child flush with the end, and equal extra space between adjacent pairs.
SpaceBetween,
/// Distribute children with equal extra space around each child, so the
/// space at the ends is half of the space between adjacent children.
SpaceAround,
/// Distribute children so the space between adjacent children and between
/// the children and both edges of the container is identical.
SpaceEvenly,
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum CursorIcon {
Default,
@@ -180,8 +219,14 @@ impl Default for ScrollbarStyle {
#[derive(Clone, Debug, PartialEq)]
pub struct Style {
pub direction: FlexDirection,
pub align_items: AlignItems,
/// Per-element override of the parent container's `align_items`. `None` means inherit.
pub align_self: Option<AlignItems>,
pub justify_content: JustifyContent,
pub width: Option<f32>,
pub height: Option<f32>,
pub min_width: Option<f32>,
pub min_height: Option<f32>,
pub flex_grow: f32,
pub gap: f32,
pub padding: Edges,
@@ -198,8 +243,13 @@ impl Default for Style {
fn default() -> Self {
Self {
direction: FlexDirection::Column,
align_items: AlignItems::Stretch,
align_self: None,
justify_content: JustifyContent::Start,
width: None,
height: None,
min_width: None,
min_height: None,
flex_grow: 0.0,
gap: 0.0,
padding: Edges::ZERO,
@@ -226,6 +276,7 @@ enum ElementContent {
pub(crate) struct TextNode {
pub spans: Vec<TextSpan>,
pub style: TextStyle,
content_hash: u64,
}
#[derive(Clone, Debug, PartialEq)]
@@ -263,12 +314,14 @@ impl Element {
}
pub fn spans(spans: impl IntoIterator<Item = TextSpan>, style: TextStyle) -> Self {
let spans = spans.into_iter().collect::<Vec<_>>();
Self {
id: None,
style: Style::default(),
children: Vec::new(),
content: ElementContent::Text(TextNode {
spans: spans.into_iter().collect(),
content_hash: text_node_content_hash(&spans, &style),
spans,
style,
}),
}
@@ -332,6 +385,20 @@ impl Element {
self
}
pub fn min_width(mut self, min_width: f32) -> Self {
self.style.min_width = Some(min_width.max(0.0));
self
}
pub fn min_height(mut self, min_height: f32) -> Self {
self.style.min_height = Some(min_height.max(0.0));
self
}
pub fn min_size(self, min_width: f32, min_height: f32) -> Self {
self.min_width(min_width).min_height(min_height)
}
pub fn flex(mut self, flex_grow: f32) -> Self {
self.style.flex_grow = flex_grow.max(0.0);
self
@@ -382,6 +449,21 @@ impl Element {
self
}
pub fn align_items(mut self, align: AlignItems) -> Self {
self.style.align_items = align;
self
}
pub fn align_self(mut self, align: AlignItems) -> Self {
self.style.align_self = Some(align);
self
}
pub fn justify_content(mut self, justify: JustifyContent) -> Self {
self.style.justify_content = justify;
self
}
pub fn cursor(mut self, cursor: CursorIcon) -> Self {
self.style.cursor = Some(cursor);
self
@@ -527,11 +609,28 @@ impl Hash for ScrollbarStyle {
}
}
impl Hash for AlignItems {
fn hash<H: Hasher>(&self, state: &mut H) {
(*self as u8).hash(state);
}
}
impl Hash for JustifyContent {
fn hash<H: Hasher>(&self, state: &mut H) {
(*self as u8).hash(state);
}
}
impl Hash for Style {
fn hash<H: Hasher>(&self, state: &mut H) {
self.direction.hash(state);
self.align_items.hash(state);
self.align_self.hash(state);
self.justify_content.hash(state);
self.width.map(f32::to_bits).hash(state);
self.height.map(f32::to_bits).hash(state);
self.min_width.map(f32::to_bits).hash(state);
self.min_height.map(f32::to_bits).hash(state);
self.flex_grow.to_bits().hash(state);
self.gap.to_bits().hash(state);
self.padding.hash(state);
@@ -547,8 +646,7 @@ impl Hash for Style {
impl Hash for TextNode {
fn hash<H: Hasher>(&self, state: &mut H) {
self.spans.hash(state);
self.style.hash(state);
self.content_hash.hash(state);
}
}
@@ -559,6 +657,15 @@ impl Hash for ImageNode {
}
}
fn text_node_content_hash(spans: &[TextSpan], style: &TextStyle) -> u64 {
use std::hash::DefaultHasher;
let mut hasher = DefaultHasher::new();
spans.hash(&mut hasher);
style.hash(&mut hasher);
hasher.finish()
}
impl Hash for ScrollBoxNode {
fn hash<H: Hasher>(&self, state: &mut H) {
self.offset_y.to_bits().hash(state);

View File

@@ -1,6 +1,6 @@
//! Common window model types.
use crate::scene::UiSize;
use crate::scene::{Color, UiSize};
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct WindowId(u64);
@@ -34,6 +34,11 @@ pub enum WindowLifecycle {
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct WindowConfigured {
/// Current logical content size and platform presentation scale.
///
/// Backends should emit a new configuration when either the logical size or
/// scale changes. This lets hosts relayout for size changes while renderers
/// can update drawable/text scale when a window moves between displays.
pub actual_inner_size: UiSize,
pub scale_factor: f32,
pub visible: bool,
@@ -55,6 +60,7 @@ pub struct WindowSpec {
pub maximized: bool,
pub fullscreen: bool,
pub resizable: bool,
pub base_color: Color,
}
impl WindowSpec {
@@ -72,9 +78,15 @@ impl WindowSpec {
maximized: false,
fullscreen: false,
resizable: true,
base_color: Color::rgb(0x08, 0x0C, 0x14),
}
}
pub fn base_color(mut self, color: Color) -> Self {
self.base_color = color;
self
}
pub fn key(mut self, key: impl Into<String>) -> Self {
self.key = Some(key.into());
self

View File

@@ -0,0 +1,19 @@
[package]
name = "ruin_ui_platform_macos"
version = "0.1.0"
edition = "2024"
[dependencies]
ruin_ui = { path = "../ui" }
ruin_ui_renderer_wgpu = { path = "../ui_renderer_wgpu" }
ruin_runtime = { package = "ruin-runtime", path = "../runtime" }
tracing = "0.1"
[target.'cfg(target_os = "macos")'.dependencies]
libc = "0.2"
objc2 = "0.6.4"
objc2-core-foundation = "0.3.2"
objc2-foundation = "0.3.2"
objc2-quartz-core = "0.3.2"
raw-window-handle = "0.6"
raw-window-metal = "1.1.0"

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More