11 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
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
Will Temple
861bf63621 initial macos porting work 2026-03-23 15:16:20 -04:00
76 changed files with 9259 additions and 743 deletions

79
Cargo.lock generated
View File

@@ -1359,10 +1359,38 @@ name = "objc2-core-foundation"
version = "0.3.2" version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" 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 = [ dependencies = [
"bitflags", "bitflags",
"dispatch2", "dispatch2",
"objc2", "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]] [[package]]
@@ -1376,6 +1404,19 @@ name = "objc2-foundation"
version = "0.3.2" version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" 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 = [ dependencies = [
"bitflags", "bitflags",
"objc2", "objc2",
@@ -1401,8 +1442,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
dependencies = [ dependencies = [
"bitflags", "bitflags",
"block2",
"libc",
"objc2", "objc2",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-core-graphics",
"objc2-core-video",
"objc2-foundation", "objc2-foundation",
"objc2-metal", "objc2-metal",
] ]
@@ -1883,11 +1928,28 @@ dependencies = [
"ruin-runtime", "ruin-runtime",
"ruin_reactivity", "ruin_reactivity",
"ruin_ui", "ruin_ui",
"ruin_ui_platform_macos",
"ruin_ui_platform_wayland", "ruin_ui_platform_wayland",
"tracing", "tracing",
"tracing-subscriber", "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]] [[package]]
name = "ruin_reactivity" name = "ruin_reactivity"
version = "0.1.0" version = "0.1.0"
@@ -1912,6 +1974,23 @@ dependencies = [
"tracing-subscriber", "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]] [[package]]
name = "ruin_ui_platform_wayland" name = "ruin_ui_platform_wayland"
version = "0.1.0" version = "0.1.0"

View File

@@ -1,3 +1,18 @@
[workspace] [workspace]
resolver = "3" resolver = "3"
members = ["lib/*", "examples/*"] 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",
]

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

@@ -28,7 +28,11 @@ pub fn Display(state: CalcState) -> impl IntoView {
let result = state.result.clone(); let result = state.result.clone();
move || { move || {
let r = result.get(); let r = result.get();
if r.is_empty() { " ".to_string() } else { format!("= {r}") } if r.is_empty() {
" ".to_string()
} else {
format!("= {r}")
}
} }
}); });
@@ -282,9 +286,9 @@ fn mix(base: Color, overlay: Color, amount: f32) -> Color {
#[component] #[component]
pub fn Keypad(state: CalcState) -> impl IntoView { pub fn Keypad(state: CalcState) -> impl IntoView {
let bg_num = Color::rgba(255, 255, 255, 90); let bg_num = Color::rgba(255, 255, 255, 90);
let bg_op = Color::rgba(210, 230, 255, 140); let bg_op = Color::rgba(210, 230, 255, 140);
let bg_eq = Color::rgba(160, 230, 160, 160); let bg_eq = Color::rgba(160, 230, 160, 160);
let bg_fn = Color::rgba(255, 200, 180, 140); let bg_fn = Color::rgba(255, 200, 180, 140);
macro_rules! digit { macro_rules! digit {
($ch:expr) => {{ ($ch:expr) => {{

View File

@@ -4,7 +4,7 @@ mod state;
use ruin_app::prelude::*; use ruin_app::prelude::*;
use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt; use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::{EnvFilter, fmt}; use tracing_subscriber::{fmt, EnvFilter};
use components::{Display, Keypad, ModeToggle, ScientificPanel}; use components::{Display, Keypad, ModeToggle, ScientificPanel};
use state::{CalcMode, CalcState}; use state::{CalcMode, CalcState};
@@ -59,32 +59,56 @@ fn CalculatorApp() -> impl IntoView {
} else { } else {
display.to_string() display.to_string()
}; };
use_shortcut(Shortcut::new(Key::Character(ch)), ShortcutScope::Application, { use_shortcut(
let text = text.clone(); Shortcut::new(Key::Character(ch)),
move || s.input(&text) ShortcutScope::Application,
}); {
let text = text.clone();
move || s.input(&text)
},
);
} }
// = key also evaluates // = key also evaluates
{ {
let s = state.clone(); let s = state.clone();
use_shortcut(Shortcut::new(Key::Character('=')), ShortcutScope::Application, move || s.evaluate()); use_shortcut(
Shortcut::new(Key::Character('=')),
ShortcutScope::Application,
move || s.evaluate(),
);
} }
{ {
let s = state.clone(); let s = state.clone();
use_shortcut(Shortcut::new(Key::Enter), ShortcutScope::Application, move || s.evaluate()); use_shortcut(
Shortcut::new(Key::Enter),
ShortcutScope::Application,
move || s.evaluate(),
);
} }
{ {
let s = state.clone(); let s = state.clone();
use_shortcut(Shortcut::new(Key::Backspace), ShortcutScope::Application, move || s.backspace()); use_shortcut(
Shortcut::new(Key::Backspace),
ShortcutScope::Application,
move || s.backspace(),
);
} }
{ {
let s = state.clone(); let s = state.clone();
use_shortcut(Shortcut::new(Key::Escape), ShortcutScope::Application, move || s.clear()); use_shortcut(
Shortcut::new(Key::Escape),
ShortcutScope::Application,
move || s.clear(),
);
} }
{ {
let s = state.clone(); let s = state.clone();
use_shortcut(Shortcut::new(Key::Delete), ShortcutScope::Application, move || s.clear()); use_shortcut(
Shortcut::new(Key::Delete),
ShortcutScope::Application,
move || s.clear(),
);
} }
// ---- Layout ---- // ---- Layout ----
@@ -153,7 +177,7 @@ mod tests {
assert_eq!(state.expression.get(), "4"); assert_eq!(state.expression.get(), "4");
assert_eq!(state.result.get(), ""); assert_eq!(state.result.get(), "");
assert_eq!(state.is_error.get(), false); assert!(!state.is_error.get());
assert_eq!(state.history.get().len(), 1); assert_eq!(state.history.get().len(), 1);
assert_eq!(state.history.get()[0].expression, "2+2"); assert_eq!(state.history.get()[0].expression, "2+2");
assert_eq!(state.history.get()[0].result, "4"); assert_eq!(state.history.get()[0].result, "4");

View File

@@ -43,10 +43,10 @@ pub struct CalcState {
impl CalcState { impl CalcState {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
expression: use_signal(|| String::new()), expression: use_signal(String::new),
result: use_signal(|| String::new()), result: use_signal(String::new),
is_error: use_signal(|| false), is_error: use_signal(|| false),
history: use_signal(|| Vec::new()), history: use_signal(Vec::new),
mode: use_signal(|| CalcMode::Basic), mode: use_signal(|| CalcMode::Basic),
fresh: use_signal(|| false), fresh: use_signal(|| false),
history_scroll: use_signal(|| 10_000.0_f32), history_scroll: use_signal(|| 10_000.0_f32),
@@ -77,11 +77,7 @@ impl CalcState {
} }
self.expression.update(|e| { self.expression.update(|e| {
// Pop a full UTF-8 character // Pop a full UTF-8 character
let new_len = e let new_len = e.char_indices().next_back().map(|(i, _)| i).unwrap_or(0);
.char_indices()
.next_back()
.map(|(i, _)| i)
.unwrap_or(0);
e.truncate(new_len); e.truncate(new_len);
}); });
} }
@@ -186,7 +182,11 @@ fn format_result(value: f64) -> String {
return "Error".to_string(); return "Error".to_string();
} }
if value.is_infinite() { if value.is_infinite() {
return if value > 0.0 { "".to_string() } else { "-∞".to_string() }; return if value > 0.0 {
"".to_string()
} else {
"-∞".to_string()
};
} }
if value.fract() == 0.0 && value.abs() < 1e15 { if value.fract() == 0.0 && value.abs() < 1e15 {
return format!("{}", value as i64); return format!("{}", value as i64);

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
}

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

@@ -290,6 +290,14 @@ impl Reactor {
self.inner.ensure_flush_scheduled(); 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 { pub(crate) fn allocate_node(&self) -> NodeId {
let raw = self.inner.next_node.get(); let raw = self.inner.next_node.get();
self.inner.next_node.set(raw.wrapping_add(1)); self.inner.next_node.set(raw.wrapping_add(1));

View File

@@ -66,6 +66,13 @@ struct JsonParser {
next_id: u64, 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> { struct Cursor<'a> {
text: &'a str, text: &'a str,
offset: usize, offset: usize,
@@ -73,10 +80,22 @@ struct Cursor<'a> {
#[derive(Clone)] #[derive(Clone)]
enum Command { enum Command {
Insert { offset: usize, text: String }, Insert {
Replace { start: usize, end: usize, text: String }, offset: usize,
ReplaceAll { text: String }, text: String,
Delete { start: usize, end: usize }, },
Replace {
start: usize,
end: usize,
text: String,
},
ReplaceAll {
text: String,
},
Delete {
start: usize,
end: usize,
},
Print, Print,
Help, Help,
Quit, Quit,
@@ -102,7 +121,7 @@ async fn main() {
let parse_state = cell_in(&reactor, initial_snapshot); let parse_state = cell_in(&reactor, initial_snapshot);
let last_delta = cell_in(&reactor, None::<EditDelta>); let last_delta = cell_in(&reactor, None::<EditDelta>);
let _parser_subscription = { {
let rope = rope.clone(); let rope = rope.clone();
let parser = Rc::clone(&parser); let parser = Rc::clone(&parser);
let parse_state = parse_state.clone(); let parse_state = parse_state.clone();
@@ -130,7 +149,9 @@ async fn main() {
let parse_state = parse_state.clone(); let parse_state = parse_state.clone();
let last_delta = last_delta.clone(); let last_delta = last_delta.clone();
move || { move || {
parse_state.with(|snapshot| print_snapshot(snapshot, &rope, last_delta.with(Clone::clone).as_ref())); parse_state.with(|snapshot| {
print_snapshot(snapshot, &rope, last_delta.with(Clone::clone).as_ref())
});
} }
}) })
.leak(); .leak();
@@ -199,7 +220,9 @@ fn parse_command(input: &str) -> Result<Command, String> {
.split_once(' ') .split_once(' ')
.ok_or_else(|| "insert expects: insert <offset> <text>".to_string())?; .ok_or_else(|| "insert expects: insert <offset> <text>".to_string())?;
return Ok(Command::Insert { return Ok(Command::Insert {
offset: offset.parse().map_err(|_| "invalid insert offset".to_string())?, offset: offset
.parse()
.map_err(|_| "invalid insert offset".to_string())?,
text: text.to_string(), text: text.to_string(),
}); });
} }
@@ -215,7 +238,9 @@ fn parse_command(input: &str) -> Result<Command, String> {
.next() .next()
.ok_or_else(|| "replace expects: replace <start> <end> <text>".to_string())?; .ok_or_else(|| "replace expects: replace <start> <end> <text>".to_string())?;
return Ok(Command::Replace { return Ok(Command::Replace {
start: start.parse().map_err(|_| "invalid replace start".to_string())?, start: start
.parse()
.map_err(|_| "invalid replace start".to_string())?,
end: end.parse().map_err(|_| "invalid replace end".to_string())?, end: end.parse().map_err(|_| "invalid replace end".to_string())?,
text: text.to_string(), text: text.to_string(),
}); });
@@ -230,7 +255,9 @@ fn parse_command(input: &str) -> Result<Command, String> {
.split_once(' ') .split_once(' ')
.ok_or_else(|| "delete expects: delete <start> <end>".to_string())?; .ok_or_else(|| "delete expects: delete <start> <end>".to_string())?;
return Ok(Command::Delete { return Ok(Command::Delete {
start: start.parse().map_err(|_| "invalid delete start".to_string())?, start: start
.parse()
.map_err(|_| "invalid delete start".to_string())?,
end: end.parse().map_err(|_| "invalid delete end".to_string())?, end: end.parse().map_err(|_| "invalid delete end".to_string())?,
}); });
} }
@@ -263,15 +290,18 @@ impl JsonParser {
let mut root_anchor = previous.root_anchor; let mut root_anchor = previous.root_anchor;
root_anchor.map_through(delta); root_anchor.map_through(delta);
let mut reused = 0usize; let mut reused = 0usize;
let mut reuse = ReuseContext {
old_text: &previous.text,
new_text: &text,
delta,
reused: &mut reused,
};
let root = self.reuse_node( let root = self.reuse_node(
&parsed, &parsed,
Some(&previous.root), Some(&previous.root),
previous.root_anchor.byte, previous.root_anchor.byte,
root_anchor.byte, root_anchor.byte,
&previous.text, &mut reuse,
&text,
delta,
&mut reused,
); );
Ok(ParseSnapshot { Ok(ParseSnapshot {
text, text,
@@ -302,20 +332,18 @@ impl JsonParser {
old: Option<&AstNode>, old: Option<&AstNode>,
old_parent_start: usize, old_parent_start: usize,
new_parent_start: usize, new_parent_start: usize,
old_text: &str, reuse: &mut ReuseContext<'_, '_>,
new_text: &str,
delta: &EditDelta,
reused: &mut usize,
) -> AstNode { ) -> AstNode {
let parsed_local = localize(parsed.range, new_parent_start); let parsed_local = localize(parsed.range, new_parent_start);
if let Some(old) = old if let Some(old) = old
&& old.kind == parsed.kind && old.kind == parsed.kind
&& map_range(absolute_range(old, old_parent_start), delta) == parsed.range && map_range(absolute_range(old, old_parent_start), reuse.delta) == parsed.range
&& old_text && reuse.old_text.get(
.get(absolute_range(old, old_parent_start).start..absolute_range(old, old_parent_start).end) absolute_range(old, old_parent_start).start
== new_text.get(parsed.range.start..parsed.range.end) ..absolute_range(old, old_parent_start).end,
) == reuse.new_text.get(parsed.range.start..parsed.range.end)
{ {
*reused += count_nodes(old); *reuse.reused += count_nodes(old);
return AstNode { return AstNode {
id: old.id, id: old.id,
kind: old.kind, kind: old.kind,
@@ -338,10 +366,7 @@ impl JsonParser {
previous_child, previous_child,
next_old_parent_start, next_old_parent_start,
parsed.range.start, parsed.range.start,
old_text, reuse,
new_text,
delta,
reused,
) )
}) })
.collect(); .collect();
@@ -403,7 +428,10 @@ fn parse_json(text: &str) -> Result<ParsedNode, String> {
let node = cursor.parse_value()?; let node = cursor.parse_value()?;
cursor.skip_ws(); cursor.skip_ws();
if !cursor.is_eof() { if !cursor.is_eof() {
return Err(format!("unexpected trailing data at byte {}", cursor.offset)); return Err(format!(
"unexpected trailing data at byte {}",
cursor.offset
));
} }
Ok(node) Ok(node)
} }
@@ -496,7 +524,10 @@ impl<'a> Cursor<'a> {
break; break;
} }
Some(other) => { Some(other) => {
return Err(format!("expected `,` or `}}` at byte {}, found `{other}`", self.offset)); return Err(format!(
"expected `,` or `}}` at byte {}, found `{other}`",
self.offset
));
} }
None => return Err("unterminated object".to_string()), None => return Err("unterminated object".to_string()),
} }
@@ -537,7 +568,10 @@ impl<'a> Cursor<'a> {
break; break;
} }
Some(other) => { Some(other) => {
return Err(format!("expected `,` or `]` at byte {}, found `{other}`", self.offset)); return Err(format!(
"expected `,` or `]` at byte {}, found `{other}`",
self.offset
));
} }
None => return Err("unterminated array".to_string()), None => return Err("unterminated array".to_string()),
} }
@@ -569,14 +603,24 @@ impl<'a> Cursor<'a> {
for _ in 0..4 { for _ in 0..4 {
match self.bump_char() { match self.bump_char() {
Some(ch) if ch.is_ascii_hexdigit() => {} Some(ch) if ch.is_ascii_hexdigit() => {}
_ => return Err(format!("invalid unicode escape at byte {}", self.offset)), _ => {
return Err(format!(
"invalid unicode escape at byte {}",
self.offset
));
}
} }
} }
} }
_ => return Err(format!("invalid escape sequence at byte {}", self.offset)), _ => return Err(format!("invalid escape sequence at byte {}", self.offset)),
}, },
Some(ch) if ch >= '\u{20}' => {} Some(ch) if ch >= '\u{20}' => {}
Some(_) => return Err(format!("control character in string at byte {}", self.offset)), Some(_) => {
return Err(format!(
"control character in string at byte {}",
self.offset
));
}
None => return Err("unterminated string".to_string()), None => return Err("unterminated string".to_string()),
} }
} }

View File

@@ -9,10 +9,22 @@ use ruin_reactivity_parsing::{
use ruin_runtime::{async_main, fs, stdio}; use ruin_runtime::{async_main, fs, stdio};
enum Command { enum Command {
Insert { offset: usize, text: String }, Insert {
Replace { start: usize, end: usize, text: String }, offset: usize,
ReplaceAll { text: String }, text: String,
Delete { start: usize, end: usize }, },
Replace {
start: usize,
end: usize,
text: String,
},
ReplaceAll {
text: String,
},
Delete {
start: usize,
end: usize,
},
Print, Print,
Help, Help,
Quit, Quit,
@@ -108,7 +120,9 @@ fn parse_command(input: &str) -> Result<Command, String> {
.split_once(' ') .split_once(' ')
.ok_or_else(|| "insert expects: insert <offset> <text>".to_string())?; .ok_or_else(|| "insert expects: insert <offset> <text>".to_string())?;
return Ok(Command::Insert { return Ok(Command::Insert {
offset: offset.parse().map_err(|_| "invalid insert offset".to_string())?, offset: offset
.parse()
.map_err(|_| "invalid insert offset".to_string())?,
text: text.to_string(), text: text.to_string(),
}); });
} }
@@ -124,7 +138,9 @@ fn parse_command(input: &str) -> Result<Command, String> {
.next() .next()
.ok_or_else(|| "replace expects: replace <start> <end> <text>".to_string())?; .ok_or_else(|| "replace expects: replace <start> <end> <text>".to_string())?;
return Ok(Command::Replace { return Ok(Command::Replace {
start: start.parse().map_err(|_| "invalid replace start".to_string())?, start: start
.parse()
.map_err(|_| "invalid replace start".to_string())?,
end: end.parse().map_err(|_| "invalid replace end".to_string())?, end: end.parse().map_err(|_| "invalid replace end".to_string())?,
text: text.to_string(), text: text.to_string(),
}); });
@@ -139,35 +155,35 @@ fn parse_command(input: &str) -> Result<Command, String> {
.split_once(' ') .split_once(' ')
.ok_or_else(|| "delete expects: delete <start> <end>".to_string())?; .ok_or_else(|| "delete expects: delete <start> <end>".to_string())?;
return Ok(Command::Delete { return Ok(Command::Delete {
start: start.parse().map_err(|_| "invalid delete start".to_string())?, start: start
.parse()
.map_err(|_| "invalid delete start".to_string())?,
end: end.parse().map_err(|_| "invalid delete end".to_string())?, end: end.parse().map_err(|_| "invalid delete end".to_string())?,
}); });
} }
Err("unknown command; type `help`".to_string()) Err("unknown command; type `help`".to_string())
} }
fn print_snapshot(snapshot: &TokenTreeParse, rope: &ReactiveRope, update: Option<&TokenTreeUpdate>) { fn print_snapshot(
snapshot: &TokenTreeParse,
rope: &ReactiveRope,
update: Option<&TokenTreeUpdate>,
) {
let metrics = rope.snapshot_metrics(); let metrics = rope.snapshot_metrics();
println!(); println!();
println!("Document revision {}", metrics.revision); println!("Document revision {}", metrics.revision);
println!( println!(
"Text bytes={}, leaves={}, nodes={}, reused_on_last_edit={}", "Text bytes={}, leaves={}, nodes={}, reused_on_last_edit={}",
metrics.text.bytes, metrics.text.bytes, metrics.leaves, snapshot.stats.total_nodes, snapshot.stats.reused_nodes
metrics.leaves,
snapshot.stats.total_nodes,
snapshot.stats.reused_nodes
); );
if let Some(update) = update { if let Some(update) = update {
println!( println!(
"Last edit: [{}..{}) -> {:?}", "Last edit: [{}..{}) -> {:?}",
update.delta.range.start, update.delta.range.start, update.delta.range.end, update.delta.inserted_text
update.delta.range.end,
update.delta.inserted_text
); );
println!( println!(
"Diagnostics changed={}, trivia changed={}", "Diagnostics changed={}, trivia changed={}",
update.diagnostics_changed, update.diagnostics_changed, update.trivia_changed
update.trivia_changed
); );
} }
println!("Parse tree:"); println!("Parse tree:");

View File

@@ -9,7 +9,7 @@ mod tokentree;
pub use delta::{Anchor, AnchorBias, EditDelta, RopeEdit, TextRange}; pub use delta::{Anchor, AnchorBias, EditDelta, RopeEdit, TextRange};
pub use metrics::{TextMetrics, TextPoint}; pub use metrics::{TextMetrics, TextPoint};
pub use parse::{ pub use parse::{
IncrementalParser, ParseError, ParseInvalidation, ParseResult, ParserCheckpoint, ParseTree, IncrementalParser, ParseError, ParseInvalidation, ParseResult, ParseTree, ParserCheckpoint,
SyntaxFragment, SyntaxFragment,
}; };
pub use rope::{ pub use rope::{
@@ -17,8 +17,8 @@ pub use rope::{
RopeSnapshotMetrics, RopeSnapshotMetrics,
}; };
pub use tokentree::{ pub use tokentree::{
DelimiterKind, NumberBase, NumberLiteral, NumberSuffix, StringDelimiter, StringFragment, DelimiterKind, NumberBase, NumberLiteral, NumberSuffix, ReactiveTokenTree, StringDelimiter,
ReactiveTokenTree, TokenTreeDiagnostic, TokenTreeDiagnosticKind, TokenTreeNode, StringFragment, TokenTreeDiagnostic, TokenTreeDiagnosticKind, TokenTreeNode, TokenTreeNodeKind,
TokenTreeNodeKind, TokenTreeParse, TokenTreeParser, TokenTreeStats, TokenTreeUpdate, TokenTreeParse, TokenTreeParser, TokenTreeStats, TokenTreeUpdate, TriviaComment, TriviaKind,
TriviaComment, TriviaKind, TriviaStore, TriviaStore,
}; };

View File

@@ -38,6 +38,8 @@ pub struct ParseError {
pub message: String, pub message: String,
} }
pub type ParserResult<C, T, D> = Result<ParseResult<C, T, D>, ParseError>;
impl ParseError { impl ParseError {
pub fn new(message: impl Into<String>) -> Self { pub fn new(message: impl Into<String>) -> Self {
Self { Self {
@@ -66,7 +68,7 @@ pub trait IncrementalParser {
cursor: RopeCursor, cursor: RopeCursor,
fragments: &[SyntaxFragment<Self::Checkpoint, Self::Tree, Self::Diagnostic>], fragments: &[SyntaxFragment<Self::Checkpoint, Self::Tree, Self::Diagnostic>],
invalidation: ParseInvalidation<Self::Checkpoint>, invalidation: ParseInvalidation<Self::Checkpoint>,
) -> Result<ParseResult<Self::Checkpoint, Self::Tree, Self::Diagnostic>, ParseError>; ) -> ParserResult<Self::Checkpoint, Self::Tree, Self::Diagnostic>;
} }
#[cfg(test)] #[cfg(test)]
@@ -102,7 +104,11 @@ mod tests {
fn invalidate( fn invalidate(
&self, &self,
delta_range: TextRange, delta_range: TextRange,
_previous_fragments: &[SyntaxFragment<Self::Checkpoint, Self::Tree, Self::Diagnostic>], _previous_fragments: &[SyntaxFragment<
Self::Checkpoint,
Self::Tree,
Self::Diagnostic,
>],
) -> ParseInvalidation<Self::Checkpoint> { ) -> ParseInvalidation<Self::Checkpoint> {
ParseInvalidation { ParseInvalidation {
resume_at: delta_range.start.saturating_sub(1), resume_at: delta_range.start.saturating_sub(1),
@@ -115,13 +121,17 @@ mod tests {
mut cursor: RopeCursor, mut cursor: RopeCursor,
_fragments: &[SyntaxFragment<Self::Checkpoint, Self::Tree, Self::Diagnostic>], _fragments: &[SyntaxFragment<Self::Checkpoint, Self::Tree, Self::Diagnostic>],
invalidation: ParseInvalidation<Self::Checkpoint>, invalidation: ParseInvalidation<Self::Checkpoint>,
) -> Result<ParseResult<Self::Checkpoint, Self::Tree, Self::Diagnostic>, ParseError> { ) -> Result<ParseResult<Self::Checkpoint, Self::Tree, Self::Diagnostic>, ParseError>
{
self.parse_calls.set(self.parse_calls.get() + 1); self.parse_calls.set(self.parse_calls.get() + 1);
let text = cursor.read_bytes(usize::MAX); let text = cursor.read_bytes(usize::MAX);
Ok(ParseResult { Ok(ParseResult {
tree: ParseTree { root: text.clone() }, tree: ParseTree { root: text.clone() },
fragments: vec![SyntaxFragment { fragments: vec![SyntaxFragment {
range: TextRange::new(invalidation.resume_at, invalidation.resume_at + text.len()), range: TextRange::new(
invalidation.resume_at,
invalidation.resume_at + text.len(),
),
start_checkpoint: invalidation.restart_checkpoint, start_checkpoint: invalidation.restart_checkpoint,
end_checkpoint: 2, end_checkpoint: 2,
tree: text, tree: text,

View File

@@ -1,6 +1,7 @@
use std::cell::{Cell, RefCell}; use std::cell::{Cell, RefCell};
use std::cmp::min; use std::cmp::min;
use std::collections::BTreeSet; use std::collections::BTreeSet;
use std::fmt;
use std::rc::Rc; use std::rc::Rc;
use ruin_reactivity::{Event, Reactor, Source, event_in, source_in}; use ruin_reactivity::{Event, Reactor, Source, event_in, source_in};
@@ -139,10 +140,6 @@ impl ReactiveRope {
self.len_bytes() == 0 self.len_bytes() == 0
} }
pub fn to_string(&self) -> String {
self.slice(TextRange::new(0, self.len_bytes()))
}
pub fn point_at(&self, offset: usize) -> TextPoint { pub fn point_at(&self, offset: usize) -> TextPoint {
self.inner.structure.observe(); self.inner.structure.observe();
let offset = offset.min(self.len_bytes()); let offset = offset.min(self.len_bytes());
@@ -259,7 +256,10 @@ impl ReactiveRope {
fn apply_edit(&self, edit: RopeEdit) -> EditDelta { fn apply_edit(&self, edit: RopeEdit) -> EditDelta {
assert!(edit.range.start <= edit.range.end, "invalid edit range"); assert!(edit.range.start <= edit.range.end, "invalid edit range");
assert!(edit.range.end <= self.len_bytes(), "edit range out of bounds"); 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.start);
self.assert_char_boundary(edit.range.end); self.assert_char_boundary(edit.range.end);
assert!( assert!(
@@ -304,7 +304,8 @@ impl ReactiveRope {
replacement.push_str(&edit.insert); replacement.push_str(&edit.insert);
replacement.push_str(&suffix); replacement.push_str(&suffix);
let structure_changed = last_index > first_index || replacement.len() > DEFAULT_LEAF_MAX_BYTES; let structure_changed =
last_index > first_index || replacement.len() > DEFAULT_LEAF_MAX_BYTES;
if !structure_changed { if !structure_changed {
if let Some(leaf) = leaves.get(first_index) { if let Some(leaf) = leaves.get(first_index) {
leaf.set_text(replacement); leaf.set_text(replacement);
@@ -338,7 +339,9 @@ impl ReactiveRope {
self.inner.structure.trigger(); self.inner.structure.trigger();
} }
self.inner.revision.set(self.inner.revision.get().wrapping_add(1)); self.inner
.revision
.set(self.inner.revision.get().wrapping_add(1));
let new_end = old_start.advance(&edit.insert); let new_end = old_start.advance(&edit.insert);
drop(leaves); drop(leaves);
@@ -399,7 +402,10 @@ impl ReactiveRope {
let end = cursor + bytes; let end = cursor + bytes;
if offset < end { if offset < end {
let text = leaf.read(); let text = leaf.read();
assert!(text.is_char_boundary(offset - cursor), "offset must be a char boundary"); assert!(
text.is_char_boundary(offset - cursor),
"offset must be a char boundary"
);
return; return;
} }
cursor = end; cursor = end;
@@ -407,6 +413,12 @@ impl ReactiveRope {
} }
} }
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 { impl RopeChunk {
pub fn text(&self) -> &str { pub fn text(&self) -> &str {
&self.text &self.text
@@ -710,7 +722,10 @@ mod tests {
let seen = Rc::new(RefCell::new(Vec::new())); let seen = Rc::new(RefCell::new(Vec::new()));
let _sub = rope.edit_events().subscribe({ let _sub = rope.edit_events().subscribe({
let seen = Rc::clone(&seen); let seen = Rc::clone(&seen);
move |delta| seen.borrow_mut().push((delta.range, delta.inserted_text.clone())) move |delta| {
seen.borrow_mut()
.push((delta.range, delta.inserted_text.clone()))
}
}); });
rope.edit(RopeEdit::insert(1, "Z")); rope.edit(RopeEdit::insert(1, "Z"));

View File

@@ -7,9 +7,9 @@ use ruin_reactivity::{Cell, Event, Subscription, cell_in, event_in};
use crate::{Anchor, AnchorBias, EditDelta, ReactiveRope, TextPoint, TextRange}; use crate::{Anchor, AnchorBias, EditDelta, ReactiveRope, TextPoint, TextRange};
const SIGILS: &[&str] = &[ const SIGILS: &[&str] = &[
"<<=", ">>=", "..=", "::", "->", "=>", "==", "!=", "<=", ">=", "&&", "||", "+=", "-=", "<<=", ">>=", "..=", "::", "->", "=>", "==", "!=", "<=", ">=", "&&", "||", "+=", "-=", "*=",
"*=", "/=", "%=", "&=", "|=", "^=", "<<", ">>", "..", "+", "-", "*", "/", "%", "&", "|", "/=", "%=", "&=", "|=", "^=", "<<", ">>", "..", "+", "-", "*", "/", "%", "&", "|", "^", "!",
"^", "!", "~", "=", "<", ">", ":", ";", "?", ".", ",", "@", "#", "$", "~", "=", "<", ">", ":", ";", "?", ".", ",", "@", "#", "$",
]; ];
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -28,8 +28,14 @@ pub enum StringDelimiter {
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub enum StringFragment { pub enum StringFragment {
Text { range: TextRange, text: String }, Text {
Interpolation { range: TextRange, child_index: usize }, range: TextRange,
text: String,
},
Interpolation {
range: TextRange,
child_index: usize,
},
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -317,6 +323,13 @@ pub struct TokenTreeParser {
next_id: u64, next_id: u64,
} }
struct ReuseContext<'a, 'count> {
old_text: &'a str,
new_text: &'a str,
delta: &'a EditDelta,
reused_nodes: &'count mut usize,
}
impl TokenTreeParser { impl TokenTreeParser {
pub fn new() -> Self { pub fn new() -> Self {
Self::default() Self::default()
@@ -352,15 +365,18 @@ impl TokenTreeParser {
let mut root_anchor = previous.root_anchor; let mut root_anchor = previous.root_anchor;
root_anchor.map_through(delta); root_anchor.map_through(delta);
let mut reused_nodes = 0usize; let mut reused_nodes = 0usize;
let mut reuse = ReuseContext {
old_text: &previous.text,
new_text: &text,
delta,
reused_nodes: &mut reused_nodes,
};
let root = self.reuse_node( let root = self.reuse_node(
&raw.root, &raw.root,
Some(&previous.root), Some(&previous.root),
previous.root_anchor.byte, previous.root_anchor.byte,
root_anchor.byte, root_anchor.byte,
&previous.text, &mut reuse,
&text,
delta,
&mut reused_nodes,
); );
let total_nodes = count_nodes(&root); let total_nodes = count_nodes(&root);
@@ -397,14 +413,20 @@ impl TokenTreeParser {
old: Option<&TokenTreeNode>, old: Option<&TokenTreeNode>,
old_parent_start: usize, old_parent_start: usize,
new_parent_start: usize, new_parent_start: usize,
old_text: &str, reuse: &mut ReuseContext<'_, '_>,
new_text: &str,
delta: &EditDelta,
reused_nodes: &mut usize,
) -> TokenTreeNode { ) -> TokenTreeNode {
let parsed_local = localize(parsed.range, new_parent_start); let parsed_local = localize(parsed.range, new_parent_start);
if let Some(old) = old.filter(|old| can_reuse_node(old, old_parent_start, parsed, old_text, new_text, delta)) { if let Some(old) = old.filter(|old| {
*reused_nodes += count_nodes(old); can_reuse_node(
old,
old_parent_start,
parsed,
reuse.old_text,
reuse.new_text,
reuse.delta,
)
}) {
*reuse.reused_nodes += count_nodes(old);
return TokenTreeNode { return TokenTreeNode {
id: old.id, id: old.id,
local_range: parsed_local, local_range: parsed_local,
@@ -423,24 +445,22 @@ impl TokenTreeParser {
let candidate = old let candidate = old
.children .children
.get(index) .get(index)
.filter(|candidate| { .filter(|candidate| !used[index] && can_descend_into_node(candidate, child))
!used[index] .inspect(|_| {
&& can_descend_into_node(candidate, child)
})
.map(|candidate| {
used[index] = true; used[index] = true;
candidate
}) })
.or_else(|| { .or_else(|| {
old.children.iter().enumerate().find_map(|(candidate_index, candidate)| { old.children.iter().enumerate().find_map(
if used[candidate_index] |(candidate_index, candidate)| {
|| !can_descend_into_node(candidate, child) if used[candidate_index]
{ || !can_descend_into_node(candidate, child)
return None; {
} return None;
used[candidate_index] = true; }
Some(candidate) used[candidate_index] = true;
}) Some(candidate)
},
)
}); });
self.reuse_node( self.reuse_node(
@@ -448,10 +468,7 @@ impl TokenTreeParser {
candidate, candidate,
absolute_range(old, old_parent_start).start, absolute_range(old, old_parent_start).start,
parsed.range.start, parsed.range.start,
old_text, reuse,
new_text,
delta,
reused_nodes,
) )
}) })
.collect() .collect()
@@ -460,16 +477,7 @@ impl TokenTreeParser {
.children .children
.iter() .iter()
.map(|child| { .map(|child| {
self.reuse_node( self.reuse_node(child, None, old_parent_start, parsed.range.start, reuse)
child,
None,
old_parent_start,
parsed.range.start,
old_text,
new_text,
delta,
reused_nodes,
)
}) })
.collect() .collect()
}; };
@@ -538,12 +546,12 @@ impl<'a> Cursor<'a> {
break; break;
} }
if let Some(close) = terminator { if let Some(close) = terminator
if self.peek_char() == Some(close) { && self.peek_char() == Some(close)
self.bump_char(); {
terminated = true; self.bump_char();
break; terminated = true;
} break;
} }
let Some(ch) = self.peek_char() else { let Some(ch) = self.peek_char() else {
@@ -602,8 +610,8 @@ impl<'a> Cursor<'a> {
let (_, close) = delimiter_chars(delimiter); let (_, close) = delimiter_chars(delimiter);
self.bump_char(); self.bump_char();
let children = self.parse_sequence(Some(close)); let children = self.parse_sequence(Some(close));
let terminated = let terminated = self.slice(self.offset.saturating_sub(close.len_utf8()), self.offset)
self.slice(self.offset.saturating_sub(close.len_utf8()), self.offset) == close.to_string(); == close.to_string();
RawNode { RawNode {
range: TextRange::new(start, self.offset), range: TextRange::new(start, self.offset),
kind: TokenTreeNodeKind::List { kind: TokenTreeNodeKind::List {
@@ -667,7 +675,9 @@ impl<'a> Cursor<'a> {
fn parse_string(&mut self) -> RawNode { fn parse_string(&mut self) -> RawNode {
let start = self.offset; let start = self.offset;
let delimiter_char = self.bump_char().expect("string should start with delimiter"); let delimiter_char = self
.bump_char()
.expect("string should start with delimiter");
let delimiter = match delimiter_char { let delimiter = match delimiter_char {
'\'' => StringDelimiter::SingleQuote, '\'' => StringDelimiter::SingleQuote,
'"' => StringDelimiter::DoubleQuote, '"' => StringDelimiter::DoubleQuote,
@@ -702,9 +712,8 @@ impl<'a> Cursor<'a> {
let interp_start = self.offset; let interp_start = self.offset;
self.offset += 2; self.offset += 2;
let interpolation_children = self.parse_sequence(Some(')')); let interpolation_children = self.parse_sequence(Some(')'));
let terminated_interp = self let terminated_interp =
.slice(self.offset.saturating_sub(1), self.offset) self.slice(self.offset.saturating_sub(1), self.offset) == ")";
== ")";
if !terminated_interp { if !terminated_interp {
self.diagnostics.push(TokenTreeDiagnostic { self.diagnostics.push(TokenTreeDiagnostic {
kind: TokenTreeDiagnosticKind::UnterminatedInterpolation, kind: TokenTreeDiagnosticKind::UnterminatedInterpolation,
@@ -972,7 +981,11 @@ impl<'a> Cursor<'a> {
fn scan_number( fn scan_number(
&self, &self,
start: usize, start: usize,
) -> (NumberLiteral, usize, Option<(TokenTreeDiagnosticKind, String)>) { ) -> (
NumberLiteral,
usize,
Option<(TokenTreeDiagnosticKind, String)>,
) {
let mut cursor = start; let mut cursor = start;
let mut base = NumberBase::Decimal; let mut base = NumberBase::Decimal;
if self.starts_with_at(start, "0b") { if self.starts_with_at(start, "0b") {
@@ -1190,14 +1203,17 @@ fn can_reuse_node(
let old_abs = absolute_range(old, old_parent_start); let old_abs = absolute_range(old, old_parent_start);
old.kind == parsed.kind old.kind == parsed.kind
&& map_range(old_abs, delta) == parsed.range && map_range(old_abs, delta) == parsed.range
&& old_text.get(old_abs.start..old_abs.end) == new_text.get(parsed.range.start..parsed.range.end) && old_text.get(old_abs.start..old_abs.end)
== new_text.get(parsed.range.start..parsed.range.end)
} }
fn can_descend_into_node(old: &TokenTreeNode, parsed: &RawNode) -> bool { fn can_descend_into_node(old: &TokenTreeNode, parsed: &RawNode) -> bool {
match (&old.kind, &parsed.kind) { match (&old.kind, &parsed.kind) {
(TokenTreeNodeKind::Body, TokenTreeNodeKind::Body) => true, (TokenTreeNodeKind::Body, TokenTreeNodeKind::Body) => true,
( (
TokenTreeNodeKind::List { delimiter: left, .. }, TokenTreeNodeKind::List {
delimiter: left, ..
},
TokenTreeNodeKind::List { TokenTreeNodeKind::List {
delimiter: right, .. delimiter: right, ..
}, },
@@ -1225,15 +1241,12 @@ fn count_nodes(node: &TokenTreeNode) -> usize {
1 + node.children.iter().map(count_nodes).sum::<usize>() 1 + node.children.iter().map(count_nodes).sum::<usize>()
} }
fn number_body_valid( fn number_body_valid(raw: &str, base: NumberBase, saw_radix: bool, suffix_start: usize) -> bool {
raw: &str,
base: NumberBase,
saw_radix: bool,
suffix_start: usize,
) -> bool {
let mut body = &raw[..suffix_start]; let mut body = &raw[..suffix_start];
if matches!(base, NumberBase::Binary | NumberBase::Octal | NumberBase::Decimal | NumberBase::Hexadecimal) if matches!(
&& body.starts_with("0") base,
NumberBase::Binary | NumberBase::Octal | NumberBase::Decimal | NumberBase::Hexadecimal
) && body.starts_with("0")
&& body.len() >= 2 && body.len() >= 2
&& matches!(body.as_bytes()[1], b'b' | b'o' | b'd' | b'x') && matches!(body.as_bytes()[1], b'b' | b'o' | b'd' | b'x')
{ {
@@ -1334,12 +1347,15 @@ mod tests {
} }
fn node_text<'a>(parse: &'a TokenTreeParse, node: &TokenTreeNode) -> &'a str { fn node_text<'a>(parse: &'a TokenTreeParse, node: &TokenTreeNode) -> &'a str {
let range = parse.absolute_range(node.id).expect("node should have absolute range"); let range = parse
.absolute_range(node.id)
.expect("node should have absolute range");
&parse.text()[range.start..range.end] &parse.text()[range.start..range.end]
} }
fn symbol_texts(parse: &TokenTreeParse) -> Vec<&str> { fn symbol_texts(parse: &TokenTreeParse) -> Vec<&str> {
parse.root parse
.root
.children .children
.iter() .iter()
.filter_map(|node| match &node.kind { .filter_map(|node| match &node.kind {
@@ -1350,7 +1366,8 @@ mod tests {
} }
fn sigil_texts(parse: &TokenTreeParse) -> Vec<&str> { fn sigil_texts(parse: &TokenTreeParse) -> Vec<&str> {
parse.root parse
.root
.children .children
.iter() .iter()
.filter_map(|node| match &node.kind { .filter_map(|node| match &node.kind {
@@ -1360,7 +1377,7 @@ mod tests {
.collect() .collect()
} }
fn find_list_child<'a>(node: &'a TokenTreeNode, delimiter: DelimiterKind) -> &'a TokenTreeNode { fn find_list_child(node: &TokenTreeNode, delimiter: DelimiterKind) -> &TokenTreeNode {
node.children node.children
.iter() .iter()
.find(|child| { .find(|child| {
@@ -1376,7 +1393,11 @@ mod tests {
} }
fn diagnostic_kinds(parse: &TokenTreeParse) -> Vec<TokenTreeDiagnosticKind> { fn diagnostic_kinds(parse: &TokenTreeParse) -> Vec<TokenTreeDiagnosticKind> {
parse.diagnostics.iter().map(|diagnostic| diagnostic.kind).collect() parse
.diagnostics
.iter()
.map(|diagnostic| diagnostic.kind)
.collect()
} }
#[test] #[test]
@@ -1399,11 +1420,26 @@ mod tests {
fn parses_mixed_top_level_sequence_in_order() { fn parses_mixed_top_level_sequence_in_order() {
let parse = parse_text("alpha + 12 \"hi\" [beta]"); let parse = parse_text("alpha + 12 \"hi\" [beta]");
assert_eq!(parse.root.children.len(), 5); assert_eq!(parse.root.children.len(), 5);
assert!(matches!(parse.root.children[0].kind, TokenTreeNodeKind::Symbol { .. })); assert!(matches!(
assert!(matches!(parse.root.children[1].kind, TokenTreeNodeKind::Sigil { .. })); parse.root.children[0].kind,
assert!(matches!(parse.root.children[2].kind, TokenTreeNodeKind::Number(..))); TokenTreeNodeKind::Symbol { .. }
assert!(matches!(parse.root.children[3].kind, TokenTreeNodeKind::String { .. })); ));
assert!(matches!(parse.root.children[4].kind, TokenTreeNodeKind::List { .. })); assert!(matches!(
parse.root.children[1].kind,
TokenTreeNodeKind::Sigil { .. }
));
assert!(matches!(
parse.root.children[2].kind,
TokenTreeNodeKind::Number(..)
));
assert!(matches!(
parse.root.children[3].kind,
TokenTreeNodeKind::String { .. }
));
assert!(matches!(
parse.root.children[4].kind,
TokenTreeNodeKind::List { .. }
));
} }
#[test] #[test]
@@ -1418,12 +1454,24 @@ mod tests {
} }
)); ));
let outer = &parse.root.children[0]; let outer = &parse.root.children[0];
assert!(matches!(outer.children[0].kind, TokenTreeNodeKind::Symbol { .. })); assert!(matches!(
outer.children[0].kind,
TokenTreeNodeKind::Symbol { .. }
));
let inner_paren = find_list_child(outer, DelimiterKind::Parenthesis); let inner_paren = find_list_child(outer, DelimiterKind::Parenthesis);
assert!(matches!(inner_paren.children[0].kind, TokenTreeNodeKind::Symbol { .. })); assert!(matches!(
assert!(matches!(inner_paren.children[1].kind, TokenTreeNodeKind::Sigil { .. })); inner_paren.children[0].kind,
TokenTreeNodeKind::Symbol { .. }
));
assert!(matches!(
inner_paren.children[1].kind,
TokenTreeNodeKind::Sigil { .. }
));
let inner_bracket = find_list_child(inner_paren, DelimiterKind::Bracket); let inner_bracket = find_list_child(inner_paren, DelimiterKind::Bracket);
assert!(matches!(inner_bracket.children[0].kind, TokenTreeNodeKind::Symbol { .. })); assert!(matches!(
inner_bracket.children[0].kind,
TokenTreeNodeKind::Symbol { .. }
));
} }
#[test] #[test]
@@ -1491,9 +1539,18 @@ mod tests {
TokenTreeDiagnosticKind::UnmatchedClosingDelimiter, TokenTreeDiagnosticKind::UnmatchedClosingDelimiter,
] ]
); );
assert!(matches!(parse.root.children[0].kind, TokenTreeNodeKind::Error { .. })); assert!(matches!(
assert!(matches!(parse.root.children[1].kind, TokenTreeNodeKind::Symbol { .. })); parse.root.children[0].kind,
assert!(matches!(parse.root.children[2].kind, TokenTreeNodeKind::Error { .. })); TokenTreeNodeKind::Error { .. }
));
assert!(matches!(
parse.root.children[1].kind,
TokenTreeNodeKind::Symbol { .. }
));
assert!(matches!(
parse.root.children[2].kind,
TokenTreeNodeKind::Error { .. }
));
} }
#[test] #[test]
@@ -1514,8 +1571,14 @@ mod tests {
terminated: false terminated: false
} }
)); ));
assert!(matches!(list.children[0].kind, TokenTreeNodeKind::Symbol { .. })); assert!(matches!(
assert!(matches!(list.children[1].kind, TokenTreeNodeKind::Error { .. })); list.children[0].kind,
TokenTreeNodeKind::Symbol { .. }
));
assert!(matches!(
list.children[1].kind,
TokenTreeNodeKind::Error { .. }
));
} }
#[test] #[test]
@@ -1525,8 +1588,14 @@ mod tests {
diagnostic_kinds(&parse), diagnostic_kinds(&parse),
vec![TokenTreeDiagnosticKind::UnmatchedClosingDelimiter] vec![TokenTreeDiagnosticKind::UnmatchedClosingDelimiter]
); );
assert!(matches!(parse.root.children[0].kind, TokenTreeNodeKind::List { .. })); assert!(matches!(
assert!(matches!(parse.root.children[1].kind, TokenTreeNodeKind::Error { .. })); parse.root.children[0].kind,
TokenTreeNodeKind::List { .. }
));
assert!(matches!(
parse.root.children[1].kind,
TokenTreeNodeKind::Error { .. }
));
} }
#[test] #[test]
@@ -1546,7 +1615,10 @@ mod tests {
let parse = parse_text("left+right(foo)"); let parse = parse_text("left+right(foo)");
assert_eq!(symbol_texts(&parse), vec!["left", "right"]); assert_eq!(symbol_texts(&parse), vec!["left", "right"]);
assert_eq!(sigil_texts(&parse), vec!["+"]); assert_eq!(sigil_texts(&parse), vec!["+"]);
assert!(matches!(parse.root.children[3].kind, TokenTreeNodeKind::List { .. })); assert!(matches!(
parse.root.children[3].kind,
TokenTreeNodeKind::List { .. }
));
} }
#[test] #[test]
@@ -1556,14 +1628,21 @@ mod tests {
diagnostic_kinds(&parse), diagnostic_kinds(&parse),
vec![TokenTreeDiagnosticKind::InvalidNumber] vec![TokenTreeDiagnosticKind::InvalidNumber]
); );
assert!(matches!(parse.root.children[0].kind, TokenTreeNodeKind::Error { .. })); assert!(matches!(
parse.root.children[0].kind,
TokenTreeNodeKind::Error { .. }
));
} }
#[test] #[test]
fn every_defined_sigil_parses_as_a_sigil_node() { fn every_defined_sigil_parses_as_a_sigil_node() {
for sigil in SIGILS { for sigil in SIGILS {
let parse = parse_text(sigil); let parse = parse_text(sigil);
assert_eq!(parse.root.children.len(), 1, "sigil `{sigil}` should be a single token"); assert_eq!(
parse.root.children.len(),
1,
"sigil `{sigil}` should be a single token"
);
assert!( assert!(
matches!( matches!(
&parse.root.children[0].kind, &parse.root.children[0].kind,
@@ -1592,8 +1671,14 @@ mod tests {
fn preserves_comments_as_trivia() { fn preserves_comments_as_trivia() {
let parse = parse_text("foo // hi\n/* block */ bar"); let parse = parse_text("foo // hi\n/* block */ bar");
assert_eq!(parse.trivia.comments().len(), 2); assert_eq!(parse.trivia.comments().len(), 2);
assert!(matches!(parse.root.children[0].kind, TokenTreeNodeKind::Symbol { .. })); assert!(matches!(
assert!(matches!(parse.root.children[1].kind, TokenTreeNodeKind::Symbol { .. })); parse.root.children[0].kind,
TokenTreeNodeKind::Symbol { .. }
));
assert!(matches!(
parse.root.children[1].kind,
TokenTreeNodeKind::Symbol { .. }
));
} }
#[test] #[test]
@@ -1602,7 +1687,10 @@ mod tests {
assert!(parse.diagnostics.is_empty()); assert!(parse.diagnostics.is_empty());
assert_eq!(parse.trivia.comments().len(), 1); assert_eq!(parse.trivia.comments().len(), 1);
assert_eq!(parse.trivia.comments()[0].kind, TriviaKind::BlockComment); assert_eq!(parse.trivia.comments()[0].kind, TriviaKind::BlockComment);
assert_eq!(parse.trivia.comments()[0].text, "/* outer /* inner */ done */"); assert_eq!(
parse.trivia.comments()[0].text,
"/* outer /* inner */ done */"
);
} }
#[test] #[test]
@@ -1621,7 +1709,9 @@ mod tests {
let parse = parse_text("foo // hi\nbar /* block */ baz"); let parse = parse_text("foo // hi\nbar /* block */ baz");
let line = &parse.trivia.comments()[0]; let line = &parse.trivia.comments()[0];
let block = &parse.trivia.comments()[1]; let block = &parse.trivia.comments()[1];
let overlapping = parse.trivia.comments_in_range(TextRange::new(line.range.start, block.range.end)); let overlapping = parse
.trivia
.comments_in_range(TextRange::new(line.range.start, block.range.end));
assert_eq!(overlapping.len(), 2); assert_eq!(overlapping.len(), 2);
let before_block = parse.trivia.comments_before(block.range.start); let before_block = parse.trivia.comments_before(block.range.start);
assert_eq!(before_block.len(), 1); assert_eq!(before_block.len(), 1);
@@ -1721,7 +1811,10 @@ mod tests {
); );
assert!(matches!( assert!(matches!(
parse.root.children[0].kind, parse.root.children[0].kind,
TokenTreeNodeKind::String { terminated: true, .. } TokenTreeNodeKind::String {
terminated: true,
..
}
)); ));
} }
@@ -1754,9 +1847,15 @@ mod tests {
assert_eq!(fragments.len(), 4); assert_eq!(fragments.len(), 4);
assert_eq!(string.children.len(), 2); assert_eq!(string.children.len(), 2);
assert!(matches!(fragments[0], StringFragment::Text { .. })); assert!(matches!(fragments[0], StringFragment::Text { .. }));
assert!(matches!(fragments[1], StringFragment::Interpolation { child_index: 0, .. })); assert!(matches!(
fragments[1],
StringFragment::Interpolation { child_index: 0, .. }
));
assert!(matches!(fragments[2], StringFragment::Text { .. })); assert!(matches!(fragments[2], StringFragment::Text { .. }));
assert!(matches!(fragments[3], StringFragment::Interpolation { child_index: 1, .. })); assert!(matches!(
fragments[3],
StringFragment::Interpolation { child_index: 1, .. }
));
assert!(matches!( assert!(matches!(
string.children[0].kind, string.children[0].kind,
TokenTreeNodeKind::List { TokenTreeNodeKind::List {
@@ -1784,7 +1883,10 @@ mod tests {
TokenTreeNodeKind::String { fragments, .. } => { TokenTreeNodeKind::String { fragments, .. } => {
assert_eq!(fragments.len(), 2); assert_eq!(fragments.len(), 2);
assert_eq!(string.children.len(), 1); assert_eq!(string.children.len(), 1);
assert!(matches!(string.children[0].kind, TokenTreeNodeKind::List { .. })); assert!(matches!(
string.children[0].kind,
TokenTreeNodeKind::List { .. }
));
} }
other => panic!("expected string, got {other:?}"), other => panic!("expected string, got {other:?}"),
} }
@@ -1818,19 +1920,52 @@ mod tests {
fn parses_numbers_with_supported_bases_and_suffixes() { fn parses_numbers_with_supported_bases_and_suffixes() {
let cases = [ let cases = [
("0", NumberBase::Decimal, false, None), ("0", NumberBase::Decimal, false, None),
("12.3f64", NumberBase::Decimal, true, Some(NumberSuffix::F64)), (
"12.3f64",
NumberBase::Decimal,
true,
Some(NumberSuffix::F64),
),
("0d1_2_3", NumberBase::Decimal, false, None), ("0d1_2_3", NumberBase::Decimal, false, None),
("0b1010u8", NumberBase::Binary, false, Some(NumberSuffix::U8)), (
"0b1010u8",
NumberBase::Binary,
false,
Some(NumberSuffix::U8),
),
("0o77i16", NumberBase::Octal, false, Some(NumberSuffix::I16)), ("0o77i16", NumberBase::Octal, false, Some(NumberSuffix::I16)),
("0xffu", NumberBase::Hexadecimal, false, Some(NumberSuffix::BigUint)), (
("99d", NumberBase::Decimal, false, Some(NumberSuffix::BigDecimal)), "0xffu",
("42d128", NumberBase::Decimal, false, Some(NumberSuffix::Decimal128)), NumberBase::Hexadecimal,
("5f", NumberBase::Decimal, false, Some(NumberSuffix::BigFloat)), false,
Some(NumberSuffix::BigUint),
),
(
"99d",
NumberBase::Decimal,
false,
Some(NumberSuffix::BigDecimal),
),
(
"42d128",
NumberBase::Decimal,
false,
Some(NumberSuffix::Decimal128),
),
(
"5f",
NumberBase::Decimal,
false,
Some(NumberSuffix::BigFloat),
),
]; ];
for (text, base, has_radix_point, suffix) in cases { for (text, base, has_radix_point, suffix) in cases {
let parse = parse_text(text); let parse = parse_text(text);
assert!(parse.diagnostics.is_empty(), "unexpected diagnostics for `{text}`"); assert!(
parse.diagnostics.is_empty(),
"unexpected diagnostics for `{text}`"
);
match &parse.root.children[0].kind { match &parse.root.children[0].kind {
TokenTreeNodeKind::Number(number) => { TokenTreeNodeKind::Number(number) => {
assert_eq!(number.raw, text); assert_eq!(number.raw, text);
@@ -1848,7 +1983,13 @@ mod tests {
let parse = parse_text("1_2_3 0x_ff 0d12_.3f64 1._5"); let parse = parse_text("1_2_3 0x_ff 0d12_.3f64 1._5");
assert!(parse.diagnostics.is_empty()); assert!(parse.diagnostics.is_empty());
assert_eq!(parse.root.children.len(), 4); assert_eq!(parse.root.children.len(), 4);
assert!(parse.root.children.iter().all(|node| matches!(node.kind, TokenTreeNodeKind::Number(..)))); assert!(
parse
.root
.children
.iter()
.all(|node| matches!(node.kind, TokenTreeNodeKind::Number(..)))
);
} }
#[test] #[test]
@@ -1858,7 +1999,10 @@ mod tests {
diagnostic_kinds(&parse), diagnostic_kinds(&parse),
vec![TokenTreeDiagnosticKind::InvalidNumber] vec![TokenTreeDiagnosticKind::InvalidNumber]
); );
assert!(matches!(parse.root.children[0].kind, TokenTreeNodeKind::Error { .. })); assert!(matches!(
parse.root.children[0].kind,
TokenTreeNodeKind::Error { .. }
));
} }
#[test] #[test]
@@ -1872,28 +2016,52 @@ mod tests {
TokenTreeDiagnosticKind::InvalidNumber, TokenTreeDiagnosticKind::InvalidNumber,
] ]
); );
assert!(parse.root.children.iter().all(|node| matches!(node.kind, TokenTreeNodeKind::Error { .. }))); assert!(
parse
.root
.children
.iter()
.all(|node| matches!(node.kind, TokenTreeNodeKind::Error { .. }))
);
} }
#[test] #[test]
fn numbers_reject_unknown_or_underscored_suffixes() { fn numbers_reject_unknown_or_underscored_suffixes() {
let parse = parse_text("12foo 9u_8"); let parse = parse_text("12foo 9u_8");
assert_eq!(diagnostic_kinds(&parse), vec![TokenTreeDiagnosticKind::InvalidNumber]); assert_eq!(
assert!(matches!(parse.root.children[0].kind, TokenTreeNodeKind::Error { .. })); diagnostic_kinds(&parse),
assert!(matches!(parse.root.children[1].kind, TokenTreeNodeKind::Number(..))); vec![TokenTreeDiagnosticKind::InvalidNumber]
assert!(matches!(parse.root.children[2].kind, TokenTreeNodeKind::Symbol { .. })); );
assert!(matches!(
parse.root.children[0].kind,
TokenTreeNodeKind::Error { .. }
));
assert!(matches!(
parse.root.children[1].kind,
TokenTreeNodeKind::Number(..)
));
assert!(matches!(
parse.root.children[2].kind,
TokenTreeNodeKind::Symbol { .. }
));
} }
#[test] #[test]
fn numbers_stop_before_non_numeric_sigils() { fn numbers_stop_before_non_numeric_sigils() {
let parse = parse_text("0x1..2"); let parse = parse_text("0x1..2");
assert_eq!(parse.root.children.len(), 3); assert_eq!(parse.root.children.len(), 3);
assert!(matches!(parse.root.children[0].kind, TokenTreeNodeKind::Number(..))); assert!(matches!(
parse.root.children[0].kind,
TokenTreeNodeKind::Number(..)
));
assert!(matches!( assert!(matches!(
parse.root.children[1].kind, parse.root.children[1].kind,
TokenTreeNodeKind::Sigil { ref text } if text == ".." TokenTreeNodeKind::Sigil { ref text } if text == ".."
)); ));
assert!(matches!(parse.root.children[2].kind, TokenTreeNodeKind::Number(..))); assert!(matches!(
parse.root.children[2].kind,
TokenTreeNodeKind::Number(..)
));
} }
#[test] #[test]
@@ -1910,9 +2078,13 @@ mod tests {
let (rope, parse) = parse_with_rope("{\n foo\n [bar]\n}"); let (rope, parse) = parse_with_rope("{\n foo\n [bar]\n}");
let outer = &parse.root.children[0]; let outer = &parse.root.children[0];
let bracket = find_list_child(outer, DelimiterKind::Bracket); let bracket = find_list_child(outer, DelimiterKind::Bracket);
let range = parse.absolute_range(bracket.id).expect("bracket should have range"); let range = parse
.absolute_range(bracket.id)
.expect("bracket should have range");
assert_eq!(&parse.text()[range.start..range.end], "[bar]"); assert_eq!(&parse.text()[range.start..range.end], "[bar]");
let (start, end) = parse.absolute_span(&rope, bracket.id).expect("span should exist"); let (start, end) = parse
.absolute_span(&rope, bracket.id)
.expect("span should exist");
assert_eq!(start.line, 2); assert_eq!(start.line, 2);
assert_eq!(start.column, 2); assert_eq!(start.column, 2);
assert_eq!(end.line, 2); assert_eq!(end.line, 2);
@@ -1931,7 +2103,8 @@ mod tests {
#[test] #[test]
fn reparsing_reuses_shifted_subtrees() { fn reparsing_reuses_shifted_subtrees() {
let (_rope, first, second, _delta) = reparse_after_edit("{ a b }", RopeEdit::insert(2, "long ")); let (_rope, first, second, _delta) =
reparse_after_edit("{ a b }", RopeEdit::insert(2, "long "));
let original_b = first.root.children[0] let original_b = first.root.children[0]
.children .children
.iter() .iter()
@@ -1962,16 +2135,22 @@ mod tests {
.find(|node| matches!(&node.kind, TokenTreeNodeKind::Symbol { text } if text == "b")) .find(|node| matches!(&node.kind, TokenTreeNodeKind::Symbol { text } if text == "b"))
.expect("reparsed tree should contain symbol b"); .expect("reparsed tree should contain symbol b");
assert_eq!(original_b.id, reparsed_b.id); assert_eq!(original_b.id, reparsed_b.id);
let first_span = first.absolute_span(&rope, original_b.id).expect("first span should exist"); let first_span = first
let second_span = second.absolute_span(&rope, reparsed_b.id).expect("second span should exist"); .absolute_span(&rope, original_b.id)
.expect("first span should exist");
let second_span = second
.absolute_span(&rope, reparsed_b.id)
.expect("second span should exist");
assert_ne!(first_span, second_span); assert_ne!(first_span, second_span);
assert_eq!(second_span.0.line, 2); assert_eq!(second_span.0.line, 2);
} }
#[test] #[test]
fn reparsing_reuses_unaffected_siblings_when_one_symbol_changes() { fn reparsing_reuses_unaffected_siblings_when_one_symbol_changes() {
let (_rope, first, second, _delta) = let (_rope, first, second, _delta) = reparse_after_edit(
reparse_after_edit("{ left right }", RopeEdit::replace(TextRange::new(2, 6), "long")); "{ left right }",
RopeEdit::replace(TextRange::new(2, 6), "long"),
);
let first_list = &first.root.children[0]; let first_list = &first.root.children[0];
let second_list = &second.root.children[0]; let second_list = &second.root.children[0];
let old_right = first_list.children[1].id; let old_right = first_list.children[1].id;
@@ -1986,8 +2165,14 @@ mod tests {
fn reparsing_does_not_reuse_node_when_kind_changes() { fn reparsing_does_not_reuse_node_when_kind_changes() {
let (_rope, first, second, _delta) = let (_rope, first, second, _delta) =
reparse_after_edit("foo", RopeEdit::replace(TextRange::new(0, 3), "123")); reparse_after_edit("foo", RopeEdit::replace(TextRange::new(0, 3), "123"));
assert!(matches!(first.root.children[0].kind, TokenTreeNodeKind::Symbol { .. })); assert!(matches!(
assert!(matches!(second.root.children[0].kind, TokenTreeNodeKind::Number(..))); first.root.children[0].kind,
TokenTreeNodeKind::Symbol { .. }
));
assert!(matches!(
second.root.children[0].kind,
TokenTreeNodeKind::Number(..)
));
assert_ne!(first.root.children[0].id, second.root.children[0].id); assert_ne!(first.root.children[0].id, second.root.children[0].id);
} }
@@ -2046,5 +2231,4 @@ mod tests {
assert_eq!(seen.len(), 1); assert_eq!(seen.len(), 1);
assert!(seen[0].diagnostics_changed || seen[0].stats.total_nodes > 0); assert!(seen[0].diagnostics_changed || seen[0].stats.total_nodes > 0);
} }
} }

View File

@@ -8,9 +8,14 @@ ruin_reactivity = { path = "../reactivity" }
ruin_runtime = { package = "ruin-runtime", path = "../runtime" } ruin_runtime = { package = "ruin-runtime", path = "../runtime" }
ruin_app_proc_macros = { package = "ruin-app-proc-macros", path = "../ruin_app_proc_macros" } ruin_app_proc_macros = { package = "ruin-app-proc-macros", path = "../ruin_app_proc_macros" }
ruin_ui = { path = "../ui" } ruin_ui = { path = "../ui" }
ruin_ui_platform_wayland = { path = "../ui_platform_wayland" }
tracing = "0.1" 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] [dev-dependencies]
tracing-subscriber = { version = "0.3", default-features = false, features = [ tracing-subscriber = { version = "0.3", default-features = false, features = [
"env-filter", "env-filter",
@@ -47,6 +52,10 @@ path = "example/04_composition_and_context.rs"
name = "05_async_runtime_io" name = "05_async_runtime_io"
path = "example/05_async_runtime_io.rs" path = "example/05_async_runtime_io.rs"
[[example]]
name = "06_justify_content"
path = "example/06_justify_content.rs"
[[example]] [[example]]
name = "07_children_and_slots" name = "07_children_and_slots"
path = "example/07_children_and_slots.rs" 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,8 +1,17 @@
use ruin_app::{PartialTextStyle, prelude::*}; use ruin_app::{PartialTextStyle, prelude::*};
use ruin_ui::{BoxShadow, BoxShadowKind, Point}; use ruin_ui::{BoxShadow, BoxShadowKind, Point};
use tracing_subscriber::EnvFilter;
#[ruin_runtime::async_main] #[ruin_runtime::async_main]
async fn main() -> ruin_app::Result<()> { 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"; let title = "RUIN Counter";
App::new() App::new()

View File

@@ -90,21 +90,12 @@ fn TaskBoard() -> impl IntoView {
} }
}); });
let all_label = if matches!(filter.get(), TaskFilter::All) { let all_label = filter_label(matches!(filter.get(), TaskFilter::All), "All");
"● All" let open_label = filter_label(matches!(filter.get(), TaskFilter::OpenOnly), "Open");
} else { let done_label = filter_label(
"○ All" matches!(filter.get(), TaskFilter::CompletedOnly),
}; "Completed",
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 task_rows = visible_ids.with(|ids| { let task_rows = visible_ids.with(|ids| {
ids.iter() ids.iter()
.map(|task_id| { .map(|task_id| {
@@ -466,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> { fn seed_tasks() -> Vec<Task> {
vec![ vec![
Task { 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] #[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 { if open {
let actions_row = view! { let actions_row = view! {
row(gap = 10.0) { 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>

View File

@@ -4,13 +4,18 @@ use std::iter;
use std::rc::Rc; use std::rc::Rc;
use std::time::Instant; use std::time::Instant;
use ruin_reactivity::effect; use ruin_reactivity::{current as current_reactor, effect};
#[cfg(target_os = "macos")]
use ruin_runtime::{queue_future, spawn_worker};
use ruin_ui::{ use ruin_ui::{
Color, Element, InteractionTree, KeyboardEvent, LayoutCache, LayoutSnapshot, PlatformEvent, Color, Element, InteractionTree, KeyboardEvent, LayoutCache, LayoutSnapshot, PlatformEvent,
PointerButton, PointerEvent, PointerEventKind, PointerRouter, RoutedPointerEvent, PointerButton, PointerEvent, PointerEventKind, PointerRouter, RoutedPointerEvent,
RoutedPointerEventKind, TextSystem, UiSize, WindowController, WindowSpec, WindowUpdate, RoutedPointerEventKind, TextSystem, UiSize, WindowController, WindowSpec, WindowUpdate,
layout_snapshot_with_cache, 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 ruin_ui_platform_wayland::start_wayland_ui;
use crate::Result; use crate::Result;
@@ -24,6 +29,16 @@ use crate::input::{
}; };
use crate::view::View; 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)] #[derive(Clone, Debug)]
pub struct Window { pub struct Window {
pub(crate) spec: WindowSpec, pub(crate) spec: WindowSpec,
@@ -92,8 +107,7 @@ impl App {
pub fn mount<M: Mountable>(self, root: M) -> MountedApp<M> { pub fn mount<M: Mountable>(self, root: M) -> MountedApp<M> {
MountedApp { MountedApp {
window: self.window, window: self.window,
root: Rc::new(root), root,
render_state: Rc::new(RenderState::default()),
} }
} }
} }
@@ -139,19 +153,17 @@ impl Mountable for Element {
pub struct MountedApp<M: Mountable> { pub struct MountedApp<M: Mountable> {
pub(crate) window: Window, pub(crate) window: Window,
pub(crate) root: Rc<M>, pub(crate) root: M,
pub(crate) render_state: Rc<RenderState>,
} }
impl<M: Mountable> MountedApp<M> { impl<M: Mountable> MountedApp<M> {
pub async fn run(self) -> Result<()> { async fn run_with_ui_runtime(
let MountedApp { app_window: Window,
window: app_window, root: M,
root, mut ui: ruin_ui::UiRuntime,
render_state, ) -> Result<()> {
} = self; let root = Rc::new(root);
let render_state = Rc::new(RenderState::default());
let mut ui = start_wayland_ui();
let window = ui.create_window(app_window.spec.clone())?; let window = ui.create_window(app_window.spec.clone())?;
let initial_viewport = app_window let initial_viewport = app_window
.spec .spec
@@ -159,6 +171,7 @@ impl<M: Mountable> MountedApp<M> {
.unwrap_or(UiSize::new(960.0, 640.0)); .unwrap_or(UiSize::new(960.0, 640.0));
let viewport = ruin_reactivity::cell(initial_viewport); let viewport = ruin_reactivity::cell(initial_viewport);
let configure_serial = ruin_reactivity::cell(0_u64);
let scene_version = StdCell::new(0_u64); let scene_version = StdCell::new(0_u64);
let text_system = Rc::new(RefCell::new(TextSystem::new())); let text_system = Rc::new(RefCell::new(TextSystem::new()));
let layout_cache = Rc::new(RefCell::new(LayoutCache::new())); let layout_cache = Rc::new(RefCell::new(LayoutCache::new()));
@@ -177,6 +190,7 @@ impl<M: Mountable> MountedApp<M> {
let _scene_effect = effect({ let _scene_effect = effect({
let window = window.clone(); let window = window.clone();
let viewport = viewport.clone(); let viewport = viewport.clone();
let configure_serial = configure_serial.clone();
let text_system = Rc::clone(&text_system); let text_system = Rc::clone(&text_system);
let layout_cache = Rc::clone(&layout_cache); let layout_cache = Rc::clone(&layout_cache);
let interaction_tree = Rc::clone(&interaction_tree); let interaction_tree = Rc::clone(&interaction_tree);
@@ -193,6 +207,7 @@ impl<M: Mountable> MountedApp<M> {
let interaction_version = input_state.interaction_version.clone(); let interaction_version = input_state.interaction_version.clone();
move || { move || {
let viewport = viewport.get(); let viewport = viewport.get();
let _ = configure_serial.get();
let version = scene_version.get().wrapping_add(1); let version = scene_version.get().wrapping_add(1);
scene_version.set(version); scene_version.set(version);
let _ = text_selection.version.get(); let _ = text_selection.version.get();
@@ -245,7 +260,8 @@ impl<M: Mountable> MountedApp<M> {
if window_resizable { if window_resizable {
let current_min = *last_min_size.borrow(); let current_min = *last_min_size.borrow();
if current_min != Some(desired_min) { if current_min != Some(desired_min) {
let _ = window.update(WindowUpdate::new().min_inner_size(Some(desired_min))); let _ =
window.update(WindowUpdate::new().min_inner_size(Some(desired_min)));
*last_min_size.borrow_mut() = Some(desired_min); *last_min_size.borrow_mut() = Some(desired_min);
} }
} else { } else {
@@ -297,6 +313,7 @@ impl<M: Mountable> MountedApp<M> {
break; break;
}; };
let mut should_flush_reactive_work = false;
for event in iter::once(event).chain(ui.take_pending_events()) { for event in iter::once(event).chain(ui.take_pending_events()) {
match event { match event {
PlatformEvent::Configured { PlatformEvent::Configured {
@@ -310,6 +327,8 @@ impl<M: Mountable> MountedApp<M> {
"app received Configured, queuing layout effect" "app received Configured, queuing layout effect"
); );
let _ = viewport.set(configuration.actual_inner_size); 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() => { PlatformEvent::Pointer { window_id, event } if window_id == window.id() => {
Self::handle_pointer_event( Self::handle_pointer_event(
@@ -320,6 +339,7 @@ impl<M: Mountable> MountedApp<M> {
&mut input_state, &mut input_state,
event, event,
)?; )?;
should_flush_reactive_work = true;
} }
PlatformEvent::Keyboard { window_id, event } if window_id == window.id() => { PlatformEvent::Keyboard { window_id, event } if window_id == window.id() => {
Self::handle_keyboard_event( Self::handle_keyboard_event(
@@ -329,22 +349,74 @@ impl<M: Mountable> MountedApp<M> {
&input_state, &input_state,
event, event,
)?; )?;
should_flush_reactive_work = true;
} }
PlatformEvent::CloseRequested { window_id } if window_id == window.id() => { 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)); let _ = window.update(WindowUpdate::new().open(false));
} }
PlatformEvent::Closed { window_id } if window_id == window.id() => { 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()?; ui.shutdown()?;
return Ok(()); return Ok(());
} }
_ => {} _ => {}
} }
} }
if should_flush_reactive_work {
current_reactor().flush_now();
}
} }
ui.shutdown()?; ui.shutdown()?;
Ok(()) 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( pub(crate) fn handle_pointer_event(
window: &WindowController, window: &WindowController,
@@ -508,7 +580,10 @@ impl<M: Mountable> MountedApp<M> {
} }
fn interaction_ids(targets: &[ruin_ui::HitTarget]) -> Vec<ruin_ui::ElementId> { fn interaction_ids(targets: &[ruin_ui::HitTarget]) -> Vec<ruin_ui::ElementId> {
targets.iter().filter_map(|target| target.element_id).collect() targets
.iter()
.filter_map(|target| target.element_id)
.collect()
} }
fn update_interaction_state( fn update_interaction_state(
@@ -518,8 +593,14 @@ impl<M: Mountable> MountedApp<M> {
) -> bool { ) -> bool {
let previous_hovered = input_state.hovered_elements.get(); let previous_hovered = input_state.hovered_elements.get();
let previous_pressed = input_state.pressed_elements.get(); let previous_pressed = input_state.pressed_elements.get();
let hovered_changed = input_state.hovered_elements.set(hovered_ids.clone()).is_some(); let hovered_changed = input_state
let pressed_changed = input_state.pressed_elements.set(pressed_ids.clone()).is_some(); .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 { if hovered_changed || pressed_changed {
tracing::trace!( tracing::trace!(
target: "ruin_app::interaction", target: "ruin_app::interaction",

View File

@@ -99,10 +99,7 @@ pub(crate) fn render_with_context_and_interaction(
RenderOutput { view, side_effects } RenderOutput { view, side_effects }
} }
pub(crate) fn with_render_context( pub(crate) fn with_render_context(context: RenderContext, render: impl FnOnce() -> View) -> View {
context: RenderContext,
render: impl FnOnce() -> View,
) -> View {
CURRENT_RENDER_CONTEXT.with(|slot| { CURRENT_RENDER_CONTEXT.with(|slot| {
let previous = slot.replace(Some(context.clone())); let previous = slot.replace(Some(context.clone()));

View File

@@ -1,5 +1,5 @@
use std::any::type_name;
use std::any::TypeId; use std::any::TypeId;
use std::any::type_name;
use std::cell::Cell as StdCell; use std::cell::Cell as StdCell;
use std::future::Future; use std::future::Future;
use std::marker::PhantomData; use std::marker::PhantomData;
@@ -9,8 +9,8 @@ use ruin_ui::{ElementId, InteractionTree, KeyboardEvent, KeyboardEventKind, Keyb
use crate::ContextKey; use crate::ContextKey;
use crate::context::{ use crate::context::{
ContextEntry, MemoSlot, ResourceSlot, with_hook_slot, ContextEntry, MemoSlot, ResourceSlot, with_hook_slot, with_render_context,
with_render_context, with_render_context_state, with_render_context_state,
}; };
use crate::input::ShortcutBinding; use crate::input::ShortcutBinding;
@@ -114,7 +114,10 @@ pub fn use_context<C: ContextKey>() -> C::Value {
}) })
} }
pub fn provide<C: ContextKey>(value: C::Value, render: impl FnOnce() -> crate::view::View) -> crate::view::View { pub fn provide<C: ContextKey>(
value: C::Value,
render: impl FnOnce() -> crate::view::View,
) -> crate::view::View {
with_render_context_state(|context| { with_render_context_state(|context| {
let mut context_entries = (*context.context_entries).clone(); let mut context_entries = (*context.context_entries).clone();
context_entries.push(ContextEntry::new::<C>(value)); context_entries.push(ContextEntry::new::<C>(value));

View File

@@ -3,9 +3,8 @@ use std::collections::HashMap;
use std::rc::Rc; use std::rc::Rc;
use ruin_ui::{ use ruin_ui::{
CursorIcon, DisplayItem, ElementId, HitTarget, InteractionTree, KeyboardEvent, CursorIcon, DisplayItem, ElementId, HitTarget, InteractionTree, KeyboardEvent, PointerButton,
PointerButton, PointerEvent, Quad, RoutedPointerEvent, PointerEvent, Quad, RoutedPointerEvent, RoutedPointerEventKind, WindowController,
RoutedPointerEventKind, WindowController,
}; };
use crate::hooks::{Shortcut, ShortcutScope, Signal}; use crate::hooks::{Shortcut, ShortcutScope, Signal};
@@ -76,8 +75,7 @@ impl EventBindings {
event: &KeyboardEvent, event: &KeyboardEvent,
interaction_tree: &InteractionTree, interaction_tree: &InteractionTree,
) { ) {
let Some(handler) = let Some(handler) = key_handler_for_focus(&self.on_key, focused_element, interaction_tree)
key_handler_for_focus(&self.on_key, focused_element, interaction_tree)
else { else {
return; return;
}; };
@@ -200,20 +198,29 @@ pub(crate) fn sync_primary_selection(
interaction_tree: &InteractionTree, interaction_tree: &InteractionTree,
selection: Option<TextSelection>, selection: Option<TextSelection>,
) -> crate::Result<()> { ) -> crate::Result<()> {
let Some(selection) = selection else { #[cfg(not(target_os = "linux"))]
window.set_primary_selection_text(String::new())?; {
return Ok(()); let _ = (window, interaction_tree, selection);
}; Ok(())
}
let Some(text) = interaction_tree.text_for_element(selection.element_id) else { #[cfg(target_os = "linux")]
return Ok(()); {
}; let Some(selection) = selection else {
let copied = text window.set_primary_selection_text(String::new())?;
.selected_text(selection.anchor, selection.focus) return Ok(());
.unwrap_or_default() };
.to_owned();
window.set_primary_selection_text(copied)?; let Some(text) = interaction_tree.text_for_element(selection.element_id) else {
Ok(()) 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>( pub(crate) fn scroll_handler_for_event<'a>(
@@ -325,7 +332,11 @@ pub(crate) fn build_focused_ancestor_chain(
// View helper methods for attaching handlers — used by primitives // View helper methods for attaching handlers — used by primitives
impl View { impl View {
pub(crate) fn with_press_handler(mut self, element_id: ElementId, handler: PressHandler) -> Self { pub(crate) fn with_press_handler(
mut self,
element_id: ElementId,
handler: PressHandler,
) -> Self {
self.bindings.on_press.insert(element_id, handler); self.bindings.on_press.insert(element_id, handler);
self self
} }
@@ -339,11 +350,7 @@ impl View {
self self
} }
pub(crate) fn with_key_handler( pub(crate) fn with_key_handler(mut self, element_id: ElementId, handler: KeyHandler) -> Self {
mut self,
element_id: ElementId,
handler: KeyHandler,
) -> Self {
self.bindings.on_key.insert(element_id, handler); self.bindings.on_key.insert(element_id, handler);
self self
} }

View File

@@ -9,8 +9,8 @@ use std::error::Error;
pub mod app; pub mod app;
pub mod colors; pub mod colors;
pub mod converters;
mod context; mod context;
pub mod converters;
mod hooks; mod hooks;
mod input; mod input;
pub mod primitives; pub mod primitives;
@@ -20,22 +20,22 @@ pub mod view;
// Re-export public items from each module. // Re-export public items from each module.
pub use app::{App, Component, ContextKey, MountedApp, Mountable, Window, __render_mountable_for_test}; pub use app::{
__render_mountable_for_test, App, Component, ContextKey, Mountable, MountedApp, Window,
};
pub use converters::{IntoBorder, IntoEdges, IntoShadow}; pub use converters::{IntoBorder, IntoEdges, IntoShadow};
pub use hooks::{ pub use hooks::{
BlockWidget, FocusScope, InteractionState, Key, Memo, Resource, ResourceState, BlockWidget, FocusScope, InteractionState, Key, Memo, Resource, ResourceState, ScrollBoxWidget,
ScrollBoxWidget, Shortcut, ShortcutScope, Signal, WidgetRef, provide, use_context, Shortcut, ShortcutScope, Signal, WidgetRef, provide, use_context, use_effect,
use_effect, use_interaction_state, use_memo, use_resource, use_shortcut, use_interaction_state, use_memo, use_resource, use_shortcut, use_shortcut_with_context,
use_shortcut_with_context, use_signal, use_widget_ref, use_window_title, use_signal, use_widget_ref, use_window_title,
}; };
pub use primitives::{ pub use primitives::{
ButtonBuilder, ContainerBuilder, ContainerProps, FontWeight, ScrollBoxBuilder, TextBuilder, ButtonBuilder, ContainerBuilder, ContainerProps, FontWeight, ScrollBoxBuilder, TextBuilder,
TextRole, block, button, column, row, scroll_box, text, TextRole, block, button, column, row, scroll_box, text,
}; };
pub use text_style::PartialTextStyle; pub use text_style::PartialTextStyle;
pub use view::{ pub use view::{ChildViews, Children, IntoView, LazyChildren, TextChildren, TextValue, View};
ChildViews, Children, IntoView, LazyChildren, TextChildren, TextValue, View,
};
// Bare string literals as container children produce a text element with context-aware styling. // Bare string literals as container children produce a text element with context-aware styling.
// These impls live here (not in view.rs) so they can call primitives::text(). // These impls live here (not in view.rs) so they can call primitives::text().
@@ -58,10 +58,11 @@ pub use ruin_app_proc_macros::{component, context_provider, view};
pub type Result<T> = std::result::Result<T, Box<dyn Error>>; pub type Result<T> = std::result::Result<T, Box<dyn Error>>;
pub mod prelude { pub mod prelude {
pub use crate::PartialTextStyle;
pub use crate::{ pub use crate::{
App, BlockWidget, ButtonBuilder, ChildViews, Children, Component, ContainerBuilder, App, BlockWidget, ButtonBuilder, ChildViews, Children, Component, ContainerBuilder,
ContextKey, FocusScope, FontWeight, InteractionState, IntoBorder, IntoEdges, IntoView, ContextKey, FocusScope, FontWeight, InteractionState, IntoBorder, IntoEdges, IntoView, Key,
Key, Memo, Mountable, Pending, Ready, Resource, ResourceState, Result, ScrollBoxBuilder, Memo, Mountable, Pending, Ready, Resource, ResourceState, Result, ScrollBoxBuilder,
ScrollBoxWidget, Shortcut, ShortcutScope, Signal, TextBuilder, TextChildren, TextRole, ScrollBoxWidget, Shortcut, ShortcutScope, Signal, TextBuilder, TextChildren, TextRole,
TextValue, View, WidgetRef, Window, block, button, colors, column, component, TextValue, View, WidgetRef, Window, block, button, colors, column, component,
context_provider, provide, row, scroll_box, surfaces, text, use_context, use_effect, context_provider, provide, row, scroll_box, surfaces, text, use_context, use_effect,
@@ -70,10 +71,9 @@ pub mod prelude {
}; };
pub use ruin_ui::{ pub use ruin_ui::{
AlignItems, Border, Color, CursorIcon, Edges, Element, ElementId, InteractionTree, AlignItems, Border, Color, CursorIcon, Edges, Element, ElementId, InteractionTree,
PointerButton, PointerEventKind, RoutedPointerEvent, RoutedPointerEventKind, JustifyContent, PointerButton, PointerEventKind, RoutedPointerEvent,
ScrollbarStyle, TextFontFamily, TextStyle, TextWrap, UiSize, RoutedPointerEventKind, ScrollbarStyle, TextFontFamily, TextStyle, TextWrap, UiSize,
}; };
pub use crate::PartialTextStyle;
} }
#[cfg(test)] #[cfg(test)]
@@ -201,7 +201,10 @@ mod tests {
} }
}); });
let widget_ref = widget_ref_slot.borrow().clone().expect("widget ref should be set"); let widget_ref = widget_ref_slot
.borrow()
.clone()
.expect("widget ref should be set");
let element_id = widget_ref let element_id = widget_ref
.element_id() .element_id()
.expect("widget ref should have an element id after render"); .expect("widget ref should have an element id after render");
@@ -216,7 +219,10 @@ mod tests {
let pressed = Rc::clone(&pressed); let pressed = Rc::clone(&pressed);
let widget_ref_slot = Rc::clone(&widget_ref_slot); let widget_ref_slot = Rc::clone(&widget_ref_slot);
move || { move || {
let widget_ref = widget_ref_slot.borrow().clone().expect("widget ref should persist"); let widget_ref = widget_ref_slot
.borrow()
.clone()
.expect("widget ref should persist");
*hovered.borrow_mut() = Some(use_interaction_state(widget_ref.clone()).hovered); *hovered.borrow_mut() = Some(use_interaction_state(widget_ref.clone()).hovered);
*pressed.borrow_mut() = Some(use_interaction_state(widget_ref.clone()).pressed); *pressed.borrow_mut() = Some(use_interaction_state(widget_ref.clone()).pressed);
block().widget_ref(widget_ref).children(()) block().widget_ref(widget_ref).children(())
@@ -292,7 +298,10 @@ mod tests {
#[test] #[test]
fn scroll_box_arrow_keys_work_after_clicking_text_content() { fn scroll_box_arrow_keys_work_after_clicking_text_content() {
use ruin_ui::{KeyboardEvent, KeyboardEventKind, KeyboardKey, PointerEvent, PointerEventKind, PointerButton}; use ruin_ui::{
KeyboardEvent, KeyboardEventKind, KeyboardKey, PointerButton, PointerEvent,
PointerEventKind,
};
let offset_slot = Rc::new(RefCell::new(None::<Signal<f32>>)); let offset_slot = Rc::new(RefCell::new(None::<Signal<f32>>));
let render = render_with_context(Rc::new(RenderState::default()), { let render = render_with_context(Rc::new(RenderState::default()), {
@@ -428,7 +437,7 @@ mod tests {
#[test] #[test]
fn live_input_path_scrolls_a_scroll_box_rendered_inside_a_branch() { fn live_input_path_scrolls_a_scroll_box_rendered_inside_a_branch() {
use ruin_ui::{PointerEvent, PointerEventKind, PointerButton}; use ruin_ui::{PointerButton, PointerEvent, PointerEventKind};
let offset_slot = Rc::new(RefCell::new(None::<Signal<f32>>)); let offset_slot = Rc::new(RefCell::new(None::<Signal<f32>>));
let render = render_with_context(Rc::new(RenderState::default()), { let render = render_with_context(Rc::new(RenderState::default()), {
@@ -466,8 +475,11 @@ mod tests {
.borrow() .borrow()
.clone() .clone()
.expect("scroll signal should have been captured"); .expect("scroll signal should have been captured");
let snapshot = let snapshot = ruin_ui::layout_snapshot(
ruin_ui::layout_snapshot(1, ruin_ui::UiSize::new(1080.0, 760.0), render.view.element()); 1,
ruin_ui::UiSize::new(1080.0, 760.0),
render.view.element(),
);
let interaction_tree = RefCell::new(Some(snapshot.interaction_tree.clone())); let interaction_tree = RefCell::new(Some(snapshot.interaction_tree.clone()));
let bindings = RefCell::new(render.view.bindings.clone()); let bindings = RefCell::new(render.view.bindings.clone());
let mut pointer_router = ruin_ui::PointerRouter::new(); let mut pointer_router = ruin_ui::PointerRouter::new();
@@ -511,7 +523,7 @@ mod tests {
#[test] #[test]
fn scroll_box_stays_interactive_when_it_appears_on_a_later_render() { fn scroll_box_stays_interactive_when_it_appears_on_a_later_render() {
use ruin_ui::{PointerEvent, PointerEventKind, PointerButton}; use ruin_ui::{PointerButton, PointerEvent, PointerEventKind};
let state = Rc::new(RenderState::default()); let state = Rc::new(RenderState::default());
let ready_slot = Rc::new(RefCell::new(None::<Signal<bool>>)); let ready_slot = Rc::new(RefCell::new(None::<Signal<bool>>));
@@ -571,8 +583,11 @@ mod tests {
let _ = ready.set(true); let _ = ready.set(true);
let render = render_once(state, ready_slot, Rc::clone(&offset_slot)); let render = render_once(state, ready_slot, Rc::clone(&offset_slot));
let snapshot = let snapshot = ruin_ui::layout_snapshot(
ruin_ui::layout_snapshot(1, ruin_ui::UiSize::new(1080.0, 760.0), render.view.element()); 1,
ruin_ui::UiSize::new(1080.0, 760.0),
render.view.element(),
);
let interaction_tree = RefCell::new(Some(snapshot.interaction_tree.clone())); let interaction_tree = RefCell::new(Some(snapshot.interaction_tree.clone()));
let bindings = RefCell::new(render.view.bindings.clone()); let bindings = RefCell::new(render.view.bindings.clone());
let mut pointer_router = ruin_ui::PointerRouter::new(); let mut pointer_router = ruin_ui::PointerRouter::new();
@@ -646,8 +661,11 @@ mod tests {
.borrow() .borrow()
.clone() .clone()
.expect("scroll signal should have been captured"); .expect("scroll signal should have been captured");
let snapshot = let snapshot = ruin_ui::layout_snapshot(
ruin_ui::layout_snapshot(1, ruin_ui::UiSize::new(1080.0, 760.0), render.view.element()); 1,
ruin_ui::UiSize::new(1080.0, 760.0),
render.view.element(),
);
let interaction_tree = RefCell::new(Some(snapshot.interaction_tree.clone())); let interaction_tree = RefCell::new(Some(snapshot.interaction_tree.clone()));
let bindings = RefCell::new(render.view.bindings.clone()); let bindings = RefCell::new(render.view.bindings.clone());
let mut pointer_router = ruin_ui::PointerRouter::new(); let mut pointer_router = ruin_ui::PointerRouter::new();

View File

@@ -5,9 +5,9 @@ use ruin_ui::{CursorIcon, Edges, Element, RoutedPointerEvent};
use crate::context::allocate_element_id; use crate::context::allocate_element_id;
use crate::input::PressHandler; use crate::input::PressHandler;
use crate::primitives::ContainerProps; use crate::primitives::ContainerProps;
use crate::surfaces;
use crate::text_style::{pop_text_style, push_text_style}; use crate::text_style::{pop_text_style, push_text_style};
use crate::view::{Children, View}; use crate::view::{Children, View};
use crate::surfaces;
pub struct ButtonBuilder { pub struct ButtonBuilder {
pub(crate) props: ContainerProps, pub(crate) props: ContainerProps,

View File

@@ -1,4 +1,4 @@
use ruin_ui::{AlignItems, Color, Element, ElementId}; use ruin_ui::{AlignItems, Color, Element, ElementId, JustifyContent};
use crate::context::allocate_element_id; use crate::context::allocate_element_id;
use crate::converters::{IntoBorder, IntoEdges, IntoShadow}; use crate::converters::{IntoBorder, IntoEdges, IntoShadow};
@@ -92,6 +92,11 @@ impl ContainerProps {
self 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 { pub fn shadow(mut self, shadow: impl IntoShadow) -> Self {
self.element = self.element.shadow(shadow.into_shadow()); self.element = self.element.shadow(shadow.into_shadow());
self self

View File

@@ -53,6 +53,10 @@ macro_rules! impl_props_methods {
self.props = self.props.align_self(align); self.props = self.props.align_self(align);
self 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 { pub fn widget_ref<T>(mut self, widget_ref: $crate::hooks::WidgetRef<T>) -> Self {
self.props = self.props.widget_ref(widget_ref); self.props = self.props.widget_ref(widget_ref);
self self

View File

@@ -56,6 +56,8 @@ pub(crate) fn current_text_style() -> PartialTextStyle {
stack stack
.borrow() .borrow()
.iter() .iter()
.fold(PartialTextStyle::default(), |acc, layer| acc.merge_over(layer)) .fold(PartialTextStyle::default(), |acc, layer| {
acc.merge_over(layer)
})
}) })
} }

View File

@@ -86,7 +86,6 @@ impl Children for () {
} }
} }
impl<T: IntoView> Children for T { impl<T: IntoView> Children for T {
fn into_views(self) -> Vec<View> { fn into_views(self) -> Vec<View> {
vec![self.into_view()] vec![self.into_view()]

View File

@@ -60,10 +60,7 @@ fn expand_component(mut function: ItemFn) -> Result<proc_macro2::TokenStream> {
Some(prop) if prop.ident == "children" => { Some(prop) if prop.ident == "children" => {
let prop = inputs.pop().expect("last input should exist"); let prop = inputs.pop().expect("last input should exist");
let kind = parse_children_contract_kind(&prop.ty)?; let kind = parse_children_contract_kind(&prop.ty)?;
Some(ChildContract { Some(ChildContract { ty: prop.ty, kind })
ty: prop.ty,
kind,
})
} }
_ => None, _ => None,
}; };
@@ -87,7 +84,8 @@ fn expand_component(mut function: ItemFn) -> Result<proc_macro2::TokenStream> {
} }
Some(contract) => { Some(contract) => {
let child_arg_ty = child_builder_arg_type_tokens(contract.kind); 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 let child_field_ident = child_field_ident
.as_ref() .as_ref()
.expect("child field ident should exist"); .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( fn wrap_special_value_tokens(kind: SpecialValueKind, ident: &Ident) -> proc_macro2::TokenStream {
kind: SpecialValueKind,
ident: &Ident,
) -> proc_macro2::TokenStream {
match kind { match kind {
SpecialValueKind::Plain => quote! { #ident }, SpecialValueKind::Plain => quote! { #ident },
SpecialValueKind::ChildViews => quote! { ::ruin_app::ChildViews::from_children(#ident) }, SpecialValueKind::ChildViews => quote! { ::ruin_app::ChildViews::from_children(#ident) },

View File

@@ -7,7 +7,7 @@ use std::sync::{Arc, Mutex};
use std::task::{Context, Poll}; use std::task::{Context, Poll};
use crate::op::completion::{CompletionFuture, CompletionHandle}; 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. /// 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 std::task::{Context, Poll};
use crate::op::completion::{CompletionFuture, CompletionHandle}; 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`]. /// 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::io;
use std::os::fd::RawFd; 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. /// Waits until `fd` becomes readable or reports an error/hangup condition.
pub async fn wait_readable(fd: RawFd) -> io::Result<()> { pub async fn wait_readable(fd: RawFd) -> io::Result<()> {
submit_poll(fd, libc::POLLIN | libc::POLLERR | libc::POLLHUP).await crate::sys::current::fd::wait_readable(fd).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(())
} }
#[cfg(test)] #[cfg(test)]
@@ -53,8 +18,8 @@ mod tests {
#[test] #[test]
fn wait_readable_resolves_for_pipe() { fn wait_readable_resolves_for_pipe() {
let mut fds = [0; 2]; let mut fds = [0; 2];
let result = unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC | libc::O_NONBLOCK) }; let result = unsafe { libc::pipe(fds.as_mut_ptr()) };
assert_eq!(result, 0, "pipe2 should succeed"); assert_eq!(result, 0, "pipe should succeed");
let read_fd = fds[0]; let read_fd = fds[0];
let write_fd = fds[1]; let write_fd = fds[1];

View File

@@ -19,7 +19,7 @@ use crate::op::fs::{
FileType as RawFileType, FsOp, MetadataTarget, OpenOptions as OpOpenOptions, FileType as RawFileType, FsOp, MetadataTarget, OpenOptions as OpOpenOptions,
RawDirEntry as OpDirEntry, RawMetadata, RawDirEntry as OpDirEntry, RawMetadata,
}; };
use crate::sys::linux::fs as sys_fs; use crate::sys::current::fs as sys_fs;
struct FileInner { struct FileInner {
fd: OwnedFd, fd: OwnedFd,

View File

@@ -1,7 +1,7 @@
//! Runtime, driver, async I/O, and channel primitives for RUIN. //! Runtime, driver, async I/O, and channel primitives for RUIN.
//! //!
//! The crate is centered around a single-threaded event loop with explicit worker threads, //! 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: //! Most users will start with:
//! //!
@@ -11,17 +11,22 @@
//! //!
//! # Platform support //! # Platform support
//! //!
//! `ruin-runtime` currently targets Linux on `x86_64`. //! `ruin-runtime` currently targets:
//! - Linux `x86_64`
//! - macOS `aarch64`
//! //!
//! RUIN runtime foundations. //! RUIN runtime foundations.
//! //!
//! This crate provides a Linux x86_64 runtime substrate: the mesh allocator, the driver, and a //! This crate provides a platform runtime substrate with a single-threaded runtime loop and
//! single-threaded runtime loop with worker-thread task forwarding. //! worker-thread task forwarding.
#![feature(thread_local)] #![feature(thread_local)]
#[cfg(not(all(target_os = "linux", target_arch = "x86_64")))] #[cfg(not(any(
compile_error!("ruin-runtime currently supports only Linux x86_64."); 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; extern crate alloc;
@@ -60,11 +65,24 @@ pub use ruin_runtime_proc_macros::async_main;
/// thread before calling [`run`]. /// thread before calling [`run`].
pub use ruin_runtime_proc_macros::main; pub use ruin_runtime_proc_macros::main;
/// Driver primitives re-exported from the Linux x86_64 backend. /// Driver primitives re-exported from the active backend.
#[cfg(all(target_os = "linux", target_arch = "x86_64"))] #[cfg(any(
pub use platform::linux_x86_64::driver::{ 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, 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. /// Public mesh-allocator surface re-exported from the Linux x86_64 backend.
#[cfg(all(target_os = "linux", target_arch = "x86_64"))] #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
pub use platform::linux_x86_64::mesh_alloc::{ pub use platform::linux_x86_64::mesh_alloc::{
@@ -81,22 +99,17 @@ pub use platform::linux_x86_64::mesh_alloc::{
/// Additional allocator helpers re-exported from the Linux x86_64 backend. /// Additional allocator helpers re-exported from the Linux x86_64 backend.
#[cfg(all(target_os = "linux", target_arch = "x86_64"))] #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
pub use platform::linux_x86_64::mesh_alloc::{FreelistId, bitmaps_meshable}; 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. /// Returns the default global mesh allocator configuration for this crate.
/// ///
/// This is useful when embedding the allocator in a `#[global_allocator]` static. /// 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 { pub const fn default_global_allocator() -> GlobalMeshAllocator {
GlobalMeshAllocator::with_default_config() GlobalMeshAllocator::with_default_config()
} }
#[cfg(test)] #[cfg(test)]
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
mod tests { mod tests {
use super::{MeshAllocator, page_size}; use super::{MeshAllocator, page_size};

View File

@@ -73,10 +73,10 @@ impl TcpStream {
where where
A: ToSocketAddrs + Send + 'static, 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 mut last_error = None;
for addr in addrs { 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)), Ok(fd) => return Ok(Self::from_owned_fd(fd)),
Err(error) => last_error = Some(error), Err(error) => last_error = Some(error),
} }
@@ -93,7 +93,7 @@ impl TcpStream {
/// Connects to `addr`, failing if the deadline elapses first. /// Connects to `addr`, failing if the deadline elapses first.
pub async fn connect_timeout(addr: &SocketAddr, timeout: Duration) -> io::Result<Self> { pub async fn connect_timeout(addr: &SocketAddr, timeout: Duration) -> io::Result<Self> {
validate_timeout(timeout)?; validate_timeout(timeout)?;
crate::sys::linux::net::connect_stream_timeout(*addr, timeout) crate::sys::current::net::connect_stream_timeout(*addr, timeout)
.await .await
.map(Self::from_owned_fd) .map(Self::from_owned_fd)
} }
@@ -102,10 +102,10 @@ impl TcpStream {
pub async fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> { pub async fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
let data = match self.read_timeout_value() { let data = match self.read_timeout_value() {
Some(timeout) => { 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 => { None => {
crate::sys::linux::net::recv(NetOp::Recv { crate::sys::current::net::recv(NetOp::Recv {
fd: self.raw_fd(), fd: self.raw_fd(),
len: buf.len(), len: buf.len(),
flags: 0, flags: 0,
@@ -137,10 +137,11 @@ impl TcpStream {
pub async fn write(&mut self, buf: &[u8]) -> io::Result<usize> { pub async fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
match self.write_timeout_value() { match self.write_timeout_value() {
Some(timeout) => { 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 => { None => {
crate::sys::linux::net::send(NetOp::Send { crate::sys::current::net::send(NetOp::Send {
fd: self.raw_fd(), fd: self.raw_fd(),
data: buf.to_vec(), data: buf.to_vec(),
flags: 0, flags: 0,
@@ -167,7 +168,7 @@ impl TcpStream {
/// Shuts down the read, write, or both halves of the connection. /// Shuts down the read, write, or both halves of the connection.
pub async fn shutdown(&self, how: Shutdown) -> io::Result<()> { 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(), fd: self.raw_fd(),
how, how,
}) })
@@ -176,39 +177,39 @@ impl TcpStream {
/// Duplicates the underlying stream socket. /// Duplicates the underlying stream socket.
pub async fn try_clone(&self) -> io::Result<Self> { 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 .await
.map(Self::from_owned_fd) .map(Self::from_owned_fd)
} }
/// Returns the local socket address of this stream. /// Returns the local socket address of this stream.
pub fn local_addr(&self) -> io::Result<SocketAddr> { 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. /// Returns the remote peer address of this stream.
pub fn peer_addr(&self) -> io::Result<SocketAddr> { 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. /// Reads the current `TCP_NODELAY` setting.
pub fn nodelay(&self) -> io::Result<bool> { 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`. /// Enables or disables `TCP_NODELAY`.
pub fn set_nodelay(&self, enabled: bool) -> io::Result<()> { 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. /// Reads the socket's IP time-to-live value.
pub fn ttl(&self) -> io::Result<u32> { 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. /// Sets the socket's IP time-to-live value.
pub fn set_ttl(&self, ttl: u32) -> io::Result<()> { 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. /// Returns the read timeout used by async read operations on this handle.
@@ -270,10 +271,10 @@ impl TcpListener {
where where
A: ToSocketAddrs + Send + 'static, 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 mut last_error = None;
for addr in addrs { 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)), Ok(fd) => return Ok(Self::from_owned_fd(fd)),
Err(error) => last_error = Some(error), Err(error) => last_error = Some(error),
} }
@@ -289,7 +290,8 @@ impl TcpListener {
/// Accepts an incoming connection. /// Accepts an incoming connection.
pub async fn accept(&self) -> io::Result<(TcpStream, SocketAddr)> { 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) }); let stream = TcpStream::from_owned_fd(unsafe { OwnedFd::from_raw_fd(accepted.fd) });
Ok((stream, accepted.peer_addr)) Ok((stream, accepted.peer_addr))
@@ -297,17 +299,17 @@ impl TcpListener {
/// Returns the local socket address of this listener. /// Returns the local socket address of this listener.
pub fn local_addr(&self) -> io::Result<SocketAddr> { 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. /// Reads the listener socket's IP time-to-live value.
pub fn ttl(&self) -> io::Result<u32> { 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. /// Sets the listener socket's IP time-to-live value.
pub fn set_ttl(&self, ttl: u32) -> io::Result<()> { 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 { fn from_owned_fd(fd: OwnedFd) -> Self {
@@ -327,10 +329,10 @@ impl UdpSocket {
where where
A: ToSocketAddrs + Send + 'static, 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 mut last_error = None;
for addr in addrs { 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)), Ok(fd) => return Ok(Self::from_owned_fd(fd)),
Err(error) => last_error = Some(error), Err(error) => last_error = Some(error),
} }
@@ -352,10 +354,10 @@ impl UdpSocket {
where where
A: ToSocketAddrs + Send + 'static, 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 mut last_error = None;
for addr in addrs { for addr in addrs {
match crate::sys::linux::net::connect(NetOp::Connect { match crate::sys::current::net::connect(NetOp::Connect {
fd: self.raw_fd(), fd: self.raw_fd(),
addr, addr,
}) })
@@ -378,10 +380,11 @@ impl UdpSocket {
pub async fn send(&self, buf: &[u8]) -> io::Result<usize> { pub async fn send(&self, buf: &[u8]) -> io::Result<usize> {
match self.write_timeout_value() { match self.write_timeout_value() {
Some(timeout) => { 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 => { None => {
crate::sys::linux::net::send(NetOp::Send { crate::sys::current::net::send(NetOp::Send {
fd: self.raw_fd(), fd: self.raw_fd(),
data: buf.to_vec(), data: buf.to_vec(),
flags: 0, flags: 0,
@@ -395,10 +398,10 @@ impl UdpSocket {
pub async fn recv(&self, buf: &mut [u8]) -> io::Result<usize> { pub async fn recv(&self, buf: &mut [u8]) -> io::Result<usize> {
let data = match self.read_timeout_value() { let data = match self.read_timeout_value() {
Some(timeout) => { 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 => { None => {
crate::sys::linux::net::recv(NetOp::Recv { crate::sys::current::net::recv(NetOp::Recv {
fd: self.raw_fd(), fd: self.raw_fd(),
len: buf.len(), len: buf.len(),
flags: 0, flags: 0,
@@ -415,7 +418,7 @@ impl UdpSocket {
pub async fn peek(&self, buf: &mut [u8]) -> io::Result<usize> { pub async fn peek(&self, buf: &mut [u8]) -> io::Result<usize> {
let data = match self.read_timeout_value() { let data = match self.read_timeout_value() {
Some(timeout) => { Some(timeout) => {
crate::sys::linux::net::recv_timeout( crate::sys::current::net::recv_timeout(
self.raw_fd(), self.raw_fd(),
buf.len(), buf.len(),
libc::MSG_PEEK, libc::MSG_PEEK,
@@ -424,7 +427,7 @@ impl UdpSocket {
.await? .await?
} }
None => { None => {
crate::sys::linux::net::recv(NetOp::Recv { crate::sys::current::net::recv(NetOp::Recv {
fd: self.raw_fd(), fd: self.raw_fd(),
len: buf.len(), len: buf.len(),
flags: libc::MSG_PEEK, flags: libc::MSG_PEEK,
@@ -442,13 +445,13 @@ impl UdpSocket {
where where
A: ToSocketAddrs + Send + 'static, 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 mut last_error = None;
let timeout = self.write_timeout_value(); let timeout = self.write_timeout_value();
for addr in addrs { for addr in addrs {
let result = match timeout { let result = match timeout {
Some(timeout) => { Some(timeout) => {
crate::sys::linux::net::send_to_timeout( crate::sys::current::net::send_to_timeout(
self.raw_fd(), self.raw_fd(),
buf.to_vec(), buf.to_vec(),
addr, addr,
@@ -458,7 +461,7 @@ impl UdpSocket {
.await .await
} }
None => { None => {
crate::sys::linux::net::send_to(NetOp::SendTo { crate::sys::current::net::send_to(NetOp::SendTo {
fd: self.raw_fd(), fd: self.raw_fd(),
target: addr, target: addr,
data: buf.to_vec(), data: buf.to_vec(),
@@ -485,11 +488,11 @@ impl UdpSocket {
pub async fn recv_from(&self, buf: &mut [u8]) -> io::Result<(usize, SocketAddr)> { pub async fn recv_from(&self, buf: &mut [u8]) -> io::Result<(usize, SocketAddr)> {
let datagram = match self.read_timeout_value() { let datagram = match self.read_timeout_value() {
Some(timeout) => { 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? .await?
} }
None => { None => {
crate::sys::linux::net::recv_from(NetOp::RecvFrom { crate::sys::current::net::recv_from(NetOp::RecvFrom {
fd: self.raw_fd(), fd: self.raw_fd(),
len: buf.len(), len: buf.len(),
flags: 0, flags: 0,
@@ -506,7 +509,7 @@ impl UdpSocket {
pub async fn peek_from(&self, buf: &mut [u8]) -> io::Result<(usize, SocketAddr)> { pub async fn peek_from(&self, buf: &mut [u8]) -> io::Result<(usize, SocketAddr)> {
let datagram = match self.read_timeout_value() { let datagram = match self.read_timeout_value() {
Some(timeout) => { Some(timeout) => {
crate::sys::linux::net::recv_from_timeout( crate::sys::current::net::recv_from_timeout(
self.raw_fd(), self.raw_fd(),
buf.len(), buf.len(),
libc::MSG_PEEK, libc::MSG_PEEK,
@@ -515,7 +518,7 @@ impl UdpSocket {
.await? .await?
} }
None => { None => {
crate::sys::linux::net::recv_from(NetOp::RecvFrom { crate::sys::current::net::recv_from(NetOp::RecvFrom {
fd: self.raw_fd(), fd: self.raw_fd(),
len: buf.len(), len: buf.len(),
flags: libc::MSG_PEEK, flags: libc::MSG_PEEK,
@@ -530,39 +533,39 @@ impl UdpSocket {
/// Duplicates the underlying UDP socket. /// Duplicates the underlying UDP socket.
pub async fn try_clone(&self) -> io::Result<Self> { 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 .await
.map(Self::from_owned_fd) .map(Self::from_owned_fd)
} }
/// Returns the local socket address of this socket. /// Returns the local socket address of this socket.
pub fn local_addr(&self) -> io::Result<SocketAddr> { 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. /// Returns the connected peer address, if the socket has been connected.
pub fn peer_addr(&self) -> io::Result<SocketAddr> { 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. /// Reads the `SO_BROADCAST` setting.
pub fn broadcast(&self) -> io::Result<bool> { 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`. /// Enables or disables `SO_BROADCAST`.
pub fn set_broadcast(&self, enabled: bool) -> io::Result<()> { 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. /// Reads the socket's IP time-to-live value.
pub fn ttl(&self) -> io::Result<u32> { 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. /// Sets the socket's IP time-to-live value.
pub fn set_ttl(&self, ttl: u32) -> io::Result<()> { 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. /// Returns the read timeout used by async receive operations on this handle.
@@ -628,13 +631,13 @@ impl HyperRead for TcpStream {
if this.pending_read.is_none() { if this.pending_read.is_none() {
this.pending_read = Some(match this.read_timeout_value() { 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(), this.raw_fd(),
buf.remaining(), buf.remaining(),
0, 0,
timeout, 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()),
}); });
} }
@@ -672,13 +675,13 @@ impl HyperWrite for TcpStream {
if this.pending_write.is_none() { if this.pending_write.is_none() {
this.pending_write = Some(match this.write_timeout_value() { 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(), this.raw_fd(),
buf.to_vec(), buf.to_vec(),
0, 0,
timeout, 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()),
}); });
} }
@@ -708,7 +711,7 @@ impl HyperWrite for TcpStream {
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), io::Error>> { fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), io::Error>> {
let this = self.get_mut(); let this = self.get_mut();
if this.pending_shutdown.is_none() { 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(), this.raw_fd(),
Shutdown::Write, Shutdown::Write,
)); ));

View File

@@ -6,7 +6,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::task::{Context, Poll, Waker}; 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>; type CancelCallback = Box<dyn FnOnce() + Send + 'static>;

View File

@@ -13,16 +13,12 @@ use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
use std::time::Duration; use std::time::Duration;
use super::driver::{Driver, ThreadNotifier, create_driver, monotonic_now}; 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; 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 { struct MacroTask {
task: LocalTask, task: LocalTask,
/// Wall time at which this task entered the local queue. Populated only /// Wall time at which this task entered the local queue. Populated only
@@ -493,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() { fn drain_all() {
drain_driver_events(); drain_driver_events();
drain_remote_tasks(); drain_remote_tasks();
@@ -592,8 +681,8 @@ struct ThreadState {
driver: Driver, driver: Driver,
shared: Arc<ThreadShared>, shared: Arc<ThreadShared>,
worker_completion: Option<Arc<WorkerCompletion>>, worker_completion: Option<Arc<WorkerCompletion>>,
local_microtasks: RefCell<VecDeque<LocalTask>>, local_microtasks: RefCell<LocalTaskQueue>,
local_macrotasks: RefCell<VecDeque<MacroTask>>, local_macrotasks: RefCell<MacroTaskQueue<MacroTask>>,
timers: RefCell<TimerHeap>, timers: RefCell<TimerHeap>,
/// Zero-delay intervals bypasses the timer heap entirely. Each entry /// Zero-delay intervals bypasses the timer heap entirely. Each entry
/// re-enqueues itself as a macrotask on every turn. /// re-enqueues itself as a macrotask on every turn.

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"))] #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
pub mod linux_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>;

View File

@@ -1,18 +1,23 @@
//! Async standard-input helpers. //! Async standard-input helpers.
use std::cell::Cell;
use std::io; use std::io;
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}; use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
use std::thread; use std::thread;
use crate::op::completion::completion_for_current_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; 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}; 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! { thread_local! {
static STDIN_URING_SUPPORTED: Cell<Option<bool>> = const { Cell::new(None) }; 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 FILE_CURSOR: u64 = u64::MAX;
const READ_CHUNK_BYTES: usize = 1024; const READ_CHUNK_BYTES: usize = 1024;
@@ -60,17 +65,20 @@ impl Stdin {
async fn read_chunk(&self) -> io::Result<Vec<u8>> { async fn read_chunk(&self) -> io::Result<Vec<u8>> {
let fd = self.fd.as_raw_fd(); let fd = self.fd.as_raw_fd();
let support = STDIN_URING_SUPPORTED.with(Cell::get); #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
if support != Some(false) { {
match submit_uring_read(fd, READ_CHUNK_BYTES).await { let support = STDIN_URING_SUPPORTED.with(Cell::get);
Ok(bytes) => { if support != Some(false) {
STDIN_URING_SUPPORTED.with(|state| state.set(Some(true))); match submit_uring_read(fd, READ_CHUNK_BYTES).await {
return Ok(bytes); 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),
} }
Err(error) if should_fallback_to_offload(&error) => {
STDIN_URING_SUPPORTED.with(|state| state.set(Some(false)));
}
Err(error) => return Err(error),
} }
} }
@@ -99,6 +107,7 @@ async fn offload<T: Send + 'static>(
future.await future.await
} }
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
async fn submit_uring_read(fd: RawFd, len: usize) -> io::Result<Vec<u8>> { async fn submit_uring_read(fd: RawFd, len: usize) -> io::Result<Vec<u8>> {
let mut buffer = vec![0; len]; let mut buffer = vec![0; len];
let ptr = buffer.as_mut_ptr(); let ptr = buffer.as_mut_ptr();
@@ -120,6 +129,7 @@ async fn submit_uring_read(fd: RawFd, len: usize) -> io::Result<Vec<u8>> {
.await .await
} }
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
async fn submit_uring<T: Send + 'static, M>( async fn submit_uring<T: Send + 'static, M>(
fill: impl FnOnce(&mut IoUringSqe), fill: impl FnOnce(&mut IoUringSqe),
map: M, map: M,
@@ -144,13 +154,8 @@ where
fn blocking_read(fd: RawFd, buffer: &mut [u8]) -> io::Result<usize> { fn blocking_read(fd: RawFd, buffer: &mut [u8]) -> io::Result<usize> {
loop { loop {
let read = unsafe { let read =
libc::read( unsafe { libc::read(fd, buffer.as_mut_ptr().cast::<libc::c_void>(), buffer.len()) };
fd,
buffer.as_mut_ptr().cast::<libc::c_void>(),
buffer.len(),
)
};
if read >= 0 { if read >= 0 {
return Ok(read as usize); return Ok(read as usize);
} }
@@ -167,6 +172,7 @@ fn decode_line(bytes: Vec<u8>) -> io::Result<String> {
String::from_utf8(bytes).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) 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> { fn cqe_to_result(cqe: IoUringCqe) -> io::Result<i32> {
if cqe.res < 0 { if cqe.res < 0 {
Err(io::Error::from_raw_os_error(-cqe.res)) Err(io::Error::from_raw_os_error(-cqe.res))
@@ -175,6 +181,7 @@ fn cqe_to_result(cqe: IoUringCqe) -> io::Result<i32> {
} }
} }
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
fn should_fallback_to_offload(error: &io::Error) -> bool { fn should_fallback_to_offload(error: &io::Error) -> bool {
matches!( matches!(
error.raw_os_error(), error.raw_os_error(),

View File

@@ -1,7 +1,7 @@
//! Linux channel wake helpers. //! Linux channel wake helpers.
use crate::op::completion::{CompletionFuture, CompletionHandle, completion}; 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>) { pub(crate) fn runtime_waiter<T: Send + 'static>() -> (CompletionFuture<T>, CompletionHandle<T>) {
let owner = try_current_thread_handle() 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. //! Linux backend modules.
pub mod channel; pub mod channel;
pub mod fd;
pub mod fs; pub mod fs;
pub mod net; pub mod net;

View File

@@ -803,13 +803,9 @@ where
let (future, handle) = completion_for_current_thread::<io::Result<T>>(); let (future, handle) = completion_for_current_thread::<io::Result<T>>();
let callback_handle = handle.clone(); let callback_handle = handle.clone();
let token = with_current_driver(|driver| { let token = with_current_driver(|driver| {
driver.submit_operation_with_linked_timeout( driver.submit_operation_with_linked_timeout(fill, timeout, move |cqe| {
fill, callback_handle.complete(map(cqe));
timeout, })
move |cqe| {
callback_handle.complete(map(cqe));
},
)
})?; })?;
handle.set_cancel(move || { 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) cvt_long(written).map(|written| written as usize)
} }
fn recv_sync(fd: RawFd, len: usize, flags: i32) -> io::Result<Vec<u8>> { fn recv_sync(fd: RawFd, len: usize, flags: i32) -> io::Result<Vec<u8>> {
let mut buffer = vec![0; len]; let mut buffer = vec![0; len];
let read = unsafe { let read = unsafe {
@@ -1127,7 +1122,6 @@ fn close_sync(fd: RawFd) -> io::Result<()> {
cvt(unsafe { libc::close(fd) }).map(|_| ()) cvt(unsafe { libc::close(fd) }).map(|_| ())
} }
/// Wrapper making `Box<libc::iovec>` sendable across the async CQE boundary. /// 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 /// 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"))] #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
pub mod linux; 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

@@ -67,7 +67,9 @@ fn bench_scroll_list(c: &mut Criterion) {
let viewport_height = 640.0; let viewport_height = 640.0;
let n = 500; 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 { for i in 0..n {
scroll_box = scroll_box.child( scroll_box = scroll_box.child(
Element::new() 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); criterion_main!(benches);

View File

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

View File

@@ -7,7 +7,10 @@ use crate::scene::{
UiSize, UiSize,
}; };
use crate::text::TextSystem; use crate::text::TextSystem;
use crate::tree::{AlignItems, CursorIcon, Edges, Element, ElementId, FlexDirection, ImageNode, Style}; use crate::tree::{
AlignItems, CursorIcon, Edges, Element, ElementId, FlexDirection, ImageNode, JustifyContent,
Style,
};
pub fn layout_scene(version: u64, logical_size: UiSize, root: &Element) -> SceneSnapshot { pub fn layout_scene(version: u64, logical_size: UiSize, root: &Element) -> SceneSnapshot {
let mut text_system = TextSystem::new(); let mut text_system = TextSystem::new();
@@ -34,6 +37,35 @@ pub struct LayoutSnapshot {
pub root_min_size: UiSize, pub root_min_size: UiSize,
} }
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct LayoutFrameStats {
pub total_ms: f64,
pub nodes: usize,
pub text_nodes: usize,
pub container_nodes: usize,
pub background_quads: usize,
pub intrinsic_calls: usize,
pub intrinsic_size_calls: usize,
pub intrinsic_text_calls: usize,
pub intrinsic_container_calls: usize,
pub intrinsic_ms: f64,
pub text_prepare_calls: usize,
pub text_prepare_ms: f64,
pub viewport_culled: usize,
pub layout_cache_hits: usize,
pub layout_cache_misses: usize,
pub intrinsic_cache_hits: usize,
pub text_requests: u32,
pub text_cache_hits: u32,
pub text_cache_misses: u32,
pub text_output_glyphs: u32,
pub text_family_resolve_ms: f64,
pub text_buffer_build_ms: f64,
pub text_glyph_collect_ms: f64,
pub text_miss_ms: f64,
pub scene_items: usize,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)] #[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct LayoutPath(Vec<u32>); pub struct LayoutPath(Vec<u32>);
@@ -192,6 +224,16 @@ pub fn layout_snapshot_with_cache(
text_system: &mut TextSystem, text_system: &mut TextSystem,
layout_cache: &mut LayoutCache, layout_cache: &mut LayoutCache,
) -> LayoutSnapshot { ) -> LayoutSnapshot {
layout_snapshot_with_cache_and_stats(version, logical_size, root, text_system, layout_cache).0
}
pub fn layout_snapshot_with_cache_and_stats(
version: u64,
logical_size: UiSize,
root: &Element,
text_system: &mut TextSystem,
layout_cache: &mut LayoutCache,
) -> (LayoutSnapshot, LayoutFrameStats) {
let layout_started = Instant::now(); let layout_started = Instant::now();
let perf_enabled = tracing::enabled!(target: "ruin_ui::layout_perf", tracing::Level::DEBUG); let perf_enabled = tracing::enabled!(target: "ruin_ui::layout_perf", tracing::Level::DEBUG);
let mut perf_stats = LayoutPerfStats::new(perf_enabled); let mut perf_stats = LayoutPerfStats::new(perf_enabled);
@@ -213,48 +255,78 @@ pub fn layout_snapshot_with_cache(
None, None,
); );
let text_stats = text_system.take_frame_stats(); let text_stats = text_system.take_frame_stats();
let stats = LayoutFrameStats {
total_ms: layout_started.elapsed().as_secs_f64() * 1_000.0,
nodes: perf_stats.nodes,
text_nodes: perf_stats.text_nodes,
container_nodes: perf_stats.container_nodes,
background_quads: perf_stats.background_quads,
intrinsic_calls: perf_stats.intrinsic_calls,
intrinsic_size_calls: perf_stats.intrinsic_size_calls,
intrinsic_text_calls: perf_stats.intrinsic_text_calls,
intrinsic_container_calls: perf_stats.intrinsic_container_calls,
intrinsic_ms: perf_stats.intrinsic_ms,
text_prepare_calls: perf_stats.text_prepare_calls,
text_prepare_ms: perf_stats.text_prepare_ms,
viewport_culled: perf_stats.viewport_culled,
layout_cache_hits: perf_stats.layout_cache_hits,
layout_cache_misses: perf_stats.layout_cache_misses,
intrinsic_cache_hits: perf_stats.intrinsic_cache_hits,
text_requests: text_stats.requests,
text_cache_hits: text_stats.cache_hits,
text_cache_misses: text_stats.cache_misses,
text_output_glyphs: text_stats.output_glyphs,
text_family_resolve_ms: text_stats.family_resolve_ms,
text_buffer_build_ms: text_stats.buffer_build_ms,
text_glyph_collect_ms: text_stats.glyph_collect_ms,
text_miss_ms: text_stats.miss_ms,
scene_items: scene.items.len(),
};
if perf_stats.enabled { if perf_stats.enabled {
tracing::debug!( tracing::debug!(
target: "ruin_ui::layout_perf", target: "ruin_ui::layout_perf",
scene_version = version, scene_version = version,
width = logical_size.width, width = logical_size.width,
height = logical_size.height, height = logical_size.height,
total_ms = layout_started.elapsed().as_secs_f64() * 1_000.0, total_ms = stats.total_ms,
nodes = perf_stats.nodes, nodes = stats.nodes,
text_nodes = perf_stats.text_nodes, text_nodes = stats.text_nodes,
container_nodes = perf_stats.container_nodes, container_nodes = stats.container_nodes,
background_quads = perf_stats.background_quads, background_quads = stats.background_quads,
intrinsic_calls = perf_stats.intrinsic_calls, intrinsic_calls = stats.intrinsic_calls,
intrinsic_size_calls = perf_stats.intrinsic_size_calls, intrinsic_size_calls = stats.intrinsic_size_calls,
intrinsic_text_calls = perf_stats.intrinsic_text_calls, intrinsic_text_calls = stats.intrinsic_text_calls,
intrinsic_container_calls = perf_stats.intrinsic_container_calls, intrinsic_container_calls = stats.intrinsic_container_calls,
intrinsic_ms = perf_stats.intrinsic_ms, intrinsic_ms = stats.intrinsic_ms,
text_prepare_calls = perf_stats.text_prepare_calls, text_prepare_calls = stats.text_prepare_calls,
text_prepare_ms = perf_stats.text_prepare_ms, text_prepare_ms = stats.text_prepare_ms,
viewport_culled = perf_stats.viewport_culled, viewport_culled = stats.viewport_culled,
layout_cache_hits = perf_stats.layout_cache_hits, layout_cache_hits = stats.layout_cache_hits,
layout_cache_misses = perf_stats.layout_cache_misses, layout_cache_misses = stats.layout_cache_misses,
intrinsic_cache_hits = perf_stats.intrinsic_cache_hits, intrinsic_cache_hits = stats.intrinsic_cache_hits,
text_requests = text_stats.requests, text_requests = stats.text_requests,
text_cache_hits = text_stats.cache_hits, text_cache_hits = stats.text_cache_hits,
text_cache_misses = text_stats.cache_misses, text_cache_misses = stats.text_cache_misses,
text_output_glyphs = text_stats.output_glyphs, text_output_glyphs = stats.text_output_glyphs,
text_family_resolve_ms = text_stats.family_resolve_ms, text_family_resolve_ms = stats.text_family_resolve_ms,
text_buffer_build_ms = text_stats.buffer_build_ms, text_buffer_build_ms = stats.text_buffer_build_ms,
text_glyph_collect_ms = text_stats.glyph_collect_ms, text_glyph_collect_ms = stats.text_glyph_collect_ms,
text_miss_ms = text_stats.miss_ms, text_miss_ms = stats.text_miss_ms,
scene_items = scene.items.len(), scene_items = stats.scene_items,
"layout snapshot perf" "layout snapshot perf"
); );
} }
let root_min_size = element_min_size(root); let root_min_size = element_min_size(root);
LayoutSnapshot { (
scene, LayoutSnapshot {
interaction_tree: InteractionTree { scene,
root: interaction_root, interaction_tree: InteractionTree {
root: interaction_root,
},
root_min_size,
}, },
root_min_size, stats,
} )
} }
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
@@ -278,20 +350,14 @@ fn layout_element(
&& !rects_overlap(rect, clip) && !rects_overlap(rect, clip)
{ {
perf_stats.viewport_culled += 1; perf_stats.viewport_culled += 1;
return LayoutNode { return layout_interaction_skeleton(
path, element,
element_id: element.id,
rect, rect,
corner_radius: 0.0, path,
clip_rect: None, text_system,
pointer_events: element.style.pointer_events, perf_stats,
focusable: element.style.focusable, layout_cache,
cursor: element.style.cursor.unwrap_or(CursorIcon::Default), );
scroll_metrics: None,
prepared_image: None,
prepared_text: None,
children: Vec::new(),
};
} }
// Incremental layout cache check. // Incremental layout cache check.
@@ -303,6 +369,7 @@ fn layout_element(
subtree_hash, subtree_hash,
avail_width_bits: rect.size.width.round() as u32, avail_width_bits: rect.size.width.round() as u32,
avail_height_bits: rect.size.height.round() as u32, avail_height_bits: rect.size.height.round() as u32,
clip_rect_bits: cache_clip_rect_bits(rect, clip_rect),
}; };
if let Some(cached) = layout_cache.results.get(&cache_key) { if let Some(cached) = layout_cache.results.get(&cache_key) {
let offset = rect.origin; let offset = rect.origin;
@@ -586,13 +653,7 @@ fn layout_element(
text_system, text_system,
perf_stats, perf_stats,
layout_cache, layout_cache,
// Do NOT propagate clip_rect into children: the scroll-box PushClip already clips clip_rect,
// them visually, and propagating the clip causes the layout cache to bake in
// culling results for a specific scroll position. When the scroll changes, the
// same cache entry would replay with stale "empty" children that were culled at
// the previous offset. Culling applies only at the direct-child level of the
// scroll box (which passes Some(viewport_rect) to layout_container_children).
None,
); );
if pushed_clip { if pushed_clip {
@@ -879,25 +940,23 @@ fn layout_container_children(
// children with no explicit cross-axis style, since flex main size is unknown here. // children with no explicit cross-axis style, since flex main size is unknown here.
// align_self on the child overrides the parent's align_items for this child. // align_self on the child overrides the parent's align_items for this child.
let effective_align = child.style.align_self.unwrap_or(element.style.align_items); let effective_align = child.style.align_self.unwrap_or(element.style.align_items);
let natural_cross = let natural_cross = if effective_align != AlignItems::Stretch
if effective_align != AlignItems::Stretch && !is_flex
&& !is_flex && child_cross_size(child, element.style.direction).is_none()
&& child_cross_size(child, element.style.direction).is_none() {
{ let offered = match element.style.direction {
let offered = match element.style.direction { FlexDirection::Row => UiSize::new(measured_main, available_cross),
FlexDirection::Row => UiSize::new(measured_main, available_cross), FlexDirection::Column => UiSize::new(available_cross, measured_main),
FlexDirection::Column => UiSize::new(available_cross, measured_main),
};
let natural =
intrinsic_size(child, offered, text_system, perf_stats, layout_cache);
Some(
cross_axis_size(natural, element.style.direction)
.max(min_cross_outer)
.min(available_cross.max(min_cross_outer)),
)
} else {
None
}; };
let natural = intrinsic_size(child, offered, text_system, perf_stats, layout_cache);
Some(
cross_axis_size(natural, element.style.direction)
.max(min_cross_outer)
.min(available_cross.max(min_cross_outer)),
)
} else {
None
};
if is_flex { if is_flex {
flex_total += child_flex_weight(child); flex_total += child_flex_weight(child);
@@ -913,7 +972,13 @@ fn layout_container_children(
} }
let remaining_main = (available_main - fixed_total).max(0.0); let remaining_main = (available_main - fixed_total).max(0.0);
let mut cursor = main_axis_origin(content, element.style.direction); let free_space = if flex_total > 0.0 { 0.0 } else { remaining_main };
let justify = justify_offsets(
element.style.justify_content,
free_space,
element.children.len(),
);
let mut cursor = main_axis_origin(content, element.style.direction) + justify.leading;
let mut children = Vec::with_capacity(element.children.len()); let mut children = Vec::with_capacity(element.children.len());
for (index, (child, measured)) in element.children.iter().zip(measured_children).enumerate() { for (index, (child, measured)) in element.children.iter().zip(measured_children).enumerate() {
let child_main = if measured.is_flex { let child_main = if measured.is_flex {
@@ -961,7 +1026,263 @@ fn layout_container_children(
layout_cache, layout_cache,
clip_rect, clip_rect,
)); ));
cursor += child_main.max(0.0) + element.style.gap; cursor += child_main.max(0.0) + element.style.gap + justify.between;
}
children
}
fn layout_interaction_skeleton(
element: &Element,
rect: Rect,
path: LayoutPath,
text_system: &mut TextSystem,
perf_stats: &mut LayoutPerfStats,
layout_cache: &mut LayoutCache,
) -> LayoutNode {
let cursor = element.style.cursor.unwrap_or_else(|| {
if element.text_node().is_some() {
CursorIcon::Text
} else {
CursorIcon::Default
}
});
let mut interaction = LayoutNode {
path,
element_id: element.id,
rect,
corner_radius: uniform_corner_radius(&element.style, rect),
clip_rect: None,
pointer_events: element.style.pointer_events,
focusable: element.style.focusable,
cursor,
scroll_metrics: None,
prepared_image: None,
prepared_text: None,
children: Vec::new(),
};
if rect.size.width <= 0.0 || rect.size.height <= 0.0 {
return interaction;
}
if let Some(scroll_box) = element.scroll_box_node() {
let content = inset_rect(rect, content_insets(&element.style));
if content.size.width <= 0.0 || content.size.height <= 0.0 {
return interaction;
}
let gutter_width = scroll_box
.scrollbar
.gutter_width
.max(0.0)
.min(content.size.width);
let viewport_rect = Rect::new(
content.origin.x,
content.origin.y,
(content.size.width - gutter_width).max(0.0),
content.size.height,
);
interaction.clip_rect = Some(viewport_rect);
let content_size = intrinsic_container_content_size(
element,
UiSize::new(
viewport_rect.size.width.max(0.0),
viewport_rect.size.height.max(0.0),
),
text_system,
perf_stats,
layout_cache,
);
let content_height = content_size.height.max(viewport_rect.size.height);
let offset_y = scroll_box
.offset_y
.max(0.0)
.min((content_height - viewport_rect.size.height).max(0.0));
interaction.children = layout_interaction_skeleton_children(
element,
Rect::new(
viewport_rect.origin.x,
viewport_rect.origin.y - offset_y,
viewport_rect.size.width,
content_height,
),
&interaction.path,
text_system,
perf_stats,
layout_cache,
);
let (scrollbar_track, scrollbar_thumb) = scrollbar_geometry(
content,
scroll_box.scrollbar,
content_height,
viewport_rect.size.height,
offset_y,
);
interaction.scroll_metrics = Some(ScrollMetrics {
viewport_rect,
content_height,
offset_y,
max_offset_y: (content_height - viewport_rect.size.height).max(0.0),
scrollbar_track,
scrollbar_thumb,
});
return interaction;
}
if element.text_node().is_some()
|| element.image_node().is_some()
|| element.children.is_empty()
{
return interaction;
}
let content = inset_rect(rect, content_insets(&element.style));
interaction.children = layout_interaction_skeleton_children(
element,
content,
&interaction.path,
text_system,
perf_stats,
layout_cache,
);
interaction
}
fn layout_interaction_skeleton_children(
element: &Element,
content: Rect,
path: &LayoutPath,
text_system: &mut TextSystem,
perf_stats: &mut LayoutPerfStats,
layout_cache: &mut LayoutCache,
) -> Vec<LayoutNode> {
if element.children.is_empty() || content.size.width <= 0.0 || content.size.height <= 0.0 {
return Vec::new();
}
let gap_count = element.children.len().saturating_sub(1) as f32;
let total_gap = element.style.gap * gap_count;
let available_main =
(main_axis_size(content.size, element.style.direction).max(0.0) - total_gap).max(0.0);
let available_cross = cross_axis_size(content.size, element.style.direction).max(0.0);
let mut measured_children = Vec::with_capacity(element.children.len());
let mut fixed_total = 0.0;
let mut flex_total = 0.0;
for child in &element.children {
let child_insets = content_insets(&child.style);
let main_inset = main_axis_padding(child_insets, element.style.direction);
let cross_inset = cross_axis_padding(child_insets, element.style.direction);
let min_cross_outer = child_min_cross_size(child, element.style.direction)
.map(|c| (c + cross_inset).max(0.0))
.unwrap_or(0.0);
let min_main_outer = child_min_main_size(child, element.style.direction)
.map(|m| (m + main_inset).max(0.0))
.unwrap_or(0.0);
let cross = child_cross_size(child, element.style.direction)
.map(|c| (c + cross_inset).max(0.0).max(min_cross_outer))
.unwrap_or(available_cross.max(min_cross_outer))
.min(available_cross.max(min_cross_outer));
let explicit_main = child_main_size(child, element.style.direction)
.map(|main| (main + main_inset).max(0.0).max(min_main_outer));
let is_flex = explicit_main.is_none() && child.style.flex_grow > 0.0;
let measured_main = explicit_main.unwrap_or_else(|| {
if is_flex {
min_main_outer
} else {
intrinsic_main_size(
child,
element.style.direction,
cross,
available_main,
text_system,
perf_stats,
layout_cache,
)
.max(min_main_outer)
}
});
let effective_align = child.style.align_self.unwrap_or(element.style.align_items);
let natural_cross = if effective_align != AlignItems::Stretch
&& !is_flex
&& child_cross_size(child, element.style.direction).is_none()
{
let offered = match element.style.direction {
FlexDirection::Row => UiSize::new(measured_main, available_cross),
FlexDirection::Column => UiSize::new(available_cross, measured_main),
};
let natural = intrinsic_size(child, offered, text_system, perf_stats, layout_cache);
Some(
cross_axis_size(natural, element.style.direction)
.max(min_cross_outer)
.min(available_cross.max(min_cross_outer)),
)
} else {
None
};
if is_flex {
flex_total += child_flex_weight(child);
} else {
fixed_total += measured_main;
}
measured_children.push(MeasuredChild {
cross,
natural_cross,
main: measured_main,
is_flex,
});
}
let remaining_main = (available_main - fixed_total).max(0.0);
let free_space = if flex_total > 0.0 { 0.0 } else { remaining_main };
let justify = justify_offsets(
element.style.justify_content,
free_space,
element.children.len(),
);
let mut cursor = main_axis_origin(content, element.style.direction) + justify.leading;
let mut children = Vec::with_capacity(element.children.len());
for (index, (child, measured)) in element.children.iter().zip(measured_children).enumerate() {
let child_main = if measured.is_flex {
let allocated = if flex_total <= 0.0 {
0.0
} else {
remaining_main * (child_flex_weight(child) / flex_total)
};
let child_insets = content_insets(&child.style);
let main_inset = main_axis_padding(child_insets, element.style.direction);
let min_main_outer = child_min_main_size(child, element.style.direction)
.map(|m| (m + main_inset).max(0.0))
.unwrap_or(0.0);
allocated.max(min_main_outer)
} else {
measured.main
};
let effective_align = child.style.align_self.unwrap_or(element.style.align_items);
let actual_cross = measured.natural_cross.unwrap_or(measured.cross);
let cross_origin = {
let base = cross_axis_origin(content, element.style.direction);
match effective_align {
AlignItems::Stretch | AlignItems::Start => base,
AlignItems::Center => base + (available_cross - actual_cross) * 0.5,
AlignItems::End => base + available_cross - actual_cross,
}
};
let child_rect = child_rect(
cross_origin,
element.style.direction,
cursor,
child_main.max(0.0),
actual_cross,
);
children.push(layout_interaction_skeleton(
child,
child_rect,
path.child(index),
text_system,
perf_stats,
layout_cache,
));
cursor += child_main.max(0.0) + element.style.gap + justify.between;
} }
children children
} }
@@ -1436,6 +1757,7 @@ struct LayoutCacheKey {
subtree_hash: u64, subtree_hash: u64,
avail_width_bits: u32, avail_width_bits: u32,
avail_height_bits: u32, avail_height_bits: u32,
clip_rect_bits: Option<(u32, u32, u32, u32)>,
} }
struct CachedLayout { struct CachedLayout {
@@ -1497,6 +1819,16 @@ fn rects_overlap(a: Rect, b: Rect) -> bool {
&& a.origin.y + a.size.height > b.origin.y && a.origin.y + a.size.height > b.origin.y
} }
fn cache_clip_rect_bits(rect: Rect, clip_rect: Option<Rect>) -> Option<(u32, u32, u32, u32)> {
let clip = clip_rect?;
Some((
(clip.origin.x - rect.origin.x).round().to_bits(),
(clip.origin.y - rect.origin.y).round().to_bits(),
clip.size.width.round().to_bits(),
clip.size.height.round().to_bits(),
))
}
fn prepare_image(image: &ImageNode, rect: Rect, element_id: Option<ElementId>) -> PreparedImage { fn prepare_image(image: &ImageNode, rect: Rect, element_id: Option<ElementId>) -> PreparedImage {
let source_size = image.resource.size(); let source_size = image.resource.size();
let source_aspect = if source_size.height > 0.0 { let source_aspect = if source_size.height > 0.0 {
@@ -1927,13 +2259,197 @@ fn child_rect(
} }
} }
/// Adjustment applied by `justify_content` to the leading offset of the first
/// child and the extra space inserted between adjacent children (in addition
/// to `gap`).
#[derive(Clone, Copy, Debug)]
struct JustifyOffsets {
leading: f32,
between: f32,
}
fn justify_offsets(
justify: JustifyContent,
free_space: f32,
child_count: usize,
) -> JustifyOffsets {
if child_count == 0 {
return JustifyOffsets {
leading: 0.0,
between: 0.0,
};
}
let free = free_space.max(0.0);
match justify {
JustifyContent::Start => JustifyOffsets {
leading: 0.0,
between: 0.0,
},
JustifyContent::End => JustifyOffsets {
leading: free,
between: 0.0,
},
JustifyContent::Center => JustifyOffsets {
leading: free * 0.5,
between: 0.0,
},
JustifyContent::SpaceBetween => {
if child_count < 2 {
JustifyOffsets {
leading: 0.0,
between: 0.0,
}
} else {
JustifyOffsets {
leading: 0.0,
between: free / (child_count - 1) as f32,
}
}
}
JustifyContent::SpaceAround => {
let segment = free / child_count as f32;
JustifyOffsets {
leading: segment * 0.5,
between: segment,
}
}
JustifyContent::SpaceEvenly => {
let segment = free / (child_count + 1) as f32;
JustifyOffsets {
leading: segment,
between: segment,
}
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{LayoutCache, layout_scene, layout_snapshot, layout_snapshot_with_cache}; use super::{
LayoutCache, LayoutSnapshot, justify_offsets, layout_scene, layout_snapshot,
layout_snapshot_with_cache,
};
use crate::scene::{Color, DisplayItem, Point, Quad, Rect, UiSize}; use crate::scene::{Color, DisplayItem, Point, Quad, Rect, UiSize};
use crate::text::TextSystem; use crate::text::TextSystem;
use crate::text::{TextStyle, TextWrap}; use crate::text::{TextStyle, TextWrap};
use crate::tree::{Edges, Element, ElementId, FlexDirection}; use crate::tree::{Edges, Element, ElementId, FlexDirection, JustifyContent};
#[test]
fn justify_offsets_match_each_mode() {
// 3 children, 60 units of free space.
let cases = [
(JustifyContent::Start, (0.0, 0.0)),
(JustifyContent::End, (60.0, 0.0)),
(JustifyContent::Center, (30.0, 0.0)),
(JustifyContent::SpaceBetween, (0.0, 30.0)),
(JustifyContent::SpaceAround, (10.0, 20.0)),
(JustifyContent::SpaceEvenly, (15.0, 15.0)),
];
for (mode, (leading, between)) in cases {
let off = justify_offsets(mode, 60.0, 3);
assert_eq!(
(off.leading, off.between),
(leading, between),
"mode {mode:?}",
);
}
}
#[test]
fn justify_offsets_handle_degenerate_inputs() {
// No children: every mode collapses to zero offsets.
for mode in [
JustifyContent::Start,
JustifyContent::End,
JustifyContent::Center,
JustifyContent::SpaceBetween,
JustifyContent::SpaceAround,
JustifyContent::SpaceEvenly,
] {
let off = justify_offsets(mode, 100.0, 0);
assert_eq!((off.leading, off.between), (0.0, 0.0));
}
// Single child: SpaceBetween pins to start, SpaceAround/SpaceEvenly center.
let one_between = justify_offsets(JustifyContent::SpaceBetween, 60.0, 1);
assert_eq!((one_between.leading, one_between.between), (0.0, 0.0));
let one_around = justify_offsets(JustifyContent::SpaceAround, 60.0, 1);
assert_eq!(one_around.leading, 30.0);
let one_evenly = justify_offsets(JustifyContent::SpaceEvenly, 60.0, 1);
assert_eq!(one_evenly.leading, 30.0);
// Negative free space is clamped to zero.
let off = justify_offsets(JustifyContent::Center, -10.0, 3);
assert_eq!((off.leading, off.between), (0.0, 0.0));
}
/// End-to-end check that `justify_content` actually shifts child rectangles
/// for every mode by laying out three 50-unit boxes in a 300-unit row with
/// no padding or gap (so 150 units of free space).
#[test]
fn justify_content_positions_children_for_each_mode() {
fn child_xs(mode: JustifyContent, child_count: usize) -> Vec<f32> {
let children: Vec<Element> = (0..child_count)
.map(|_| Element::new().width(50.0).background(Color::rgb(1, 0, 0)))
.collect();
let root = Element::row().justify_content(mode).children(children);
let scene = layout_scene(1, UiSize::new(300.0, 100.0), &root);
scene
.items
.iter()
.filter_map(|item| match item {
DisplayItem::Quad(q) => Some(q.rect.origin.x),
_ => None,
})
.collect()
}
// 3 children, free = 300 - 3*50 = 150.
assert_eq!(child_xs(JustifyContent::Start, 3), vec![0.0, 50.0, 100.0]);
assert_eq!(child_xs(JustifyContent::End, 3), vec![150.0, 200.0, 250.0]);
assert_eq!(
child_xs(JustifyContent::Center, 3),
vec![75.0, 125.0, 175.0],
);
// SpaceBetween: between = 150/2 = 75.
assert_eq!(
child_xs(JustifyContent::SpaceBetween, 3),
vec![0.0, 125.0, 250.0],
);
// SpaceAround: segment = 150/3 = 50, leading = 25.
assert_eq!(
child_xs(JustifyContent::SpaceAround, 3),
vec![25.0, 125.0, 225.0],
);
// SpaceEvenly with 4 children to avoid sub-pixel positions:
// free = 300 - 4*50 = 100, segment = 100/5 = 20.
assert_eq!(
child_xs(JustifyContent::SpaceEvenly, 4),
vec![20.0, 90.0, 160.0, 230.0],
);
}
#[test]
fn justify_content_no_op_when_flex_children_consume_remainder() {
// A flex child eats all remaining space, so SpaceBetween should not
// shift the fixed sibling away from its normal cursor position.
let root = Element::row()
.justify_content(JustifyContent::SpaceBetween)
.children([
Element::new().width(50.0).background(Color::rgb(1, 0, 0)),
Element::new().flex(1.0).background(Color::rgb(0, 1, 0)),
]);
let scene = layout_scene(1, UiSize::new(300.0, 100.0), &root);
let xs: Vec<f32> = scene
.items
.iter()
.filter_map(|item| match item {
DisplayItem::Quad(q) => Some(q.rect.origin.x),
_ => None,
})
.collect();
assert_eq!(xs, vec![0.0, 50.0]);
}
#[test] #[test]
fn row_layout_apportions_fixed_and_flex_children() { fn row_layout_apportions_fixed_and_flex_children() {
@@ -2625,4 +3141,142 @@ mod tests {
"expected >= 4 text items visible, got {text_items}" "expected >= 4 text items visible, got {text_items}"
); );
} }
#[test]
fn cached_scroll_container_recomputes_visible_children_after_offset_changes() {
fn scroll_root(offset_y: f32) -> Element {
let mut list = Element::column().gap(0.0);
for i in 0..20 {
list = list.child(
Element::new()
.height(40.0)
.child(Element::text(format!("item {i:02}"), text_style())),
);
}
Element::new().child(
Element::scroll_box(offset_y)
.width(400.0)
.height(200.0)
.child(list),
)
}
fn visible_texts(snapshot: &LayoutSnapshot) -> Vec<&str> {
snapshot
.scene
.items
.iter()
.filter_map(|item| match item {
DisplayItem::Text(text) => Some(text.text.as_ref()),
_ => None,
})
.collect()
}
let mut text_system = TextSystem::new();
let mut layout_cache = LayoutCache::new();
let size = UiSize::new(400.0, 200.0);
let first = layout_snapshot_with_cache(
1,
size,
&scroll_root(0.0),
&mut text_system,
&mut layout_cache,
);
let second = layout_snapshot_with_cache(
2,
size,
&scroll_root(240.0),
&mut text_system,
&mut layout_cache,
);
let first_visible = visible_texts(&first);
let second_visible = visible_texts(&second);
assert!(first_visible.iter().any(|text| text.contains("item 00")));
assert!(
second_visible.iter().any(|text| text.contains("item 06")),
"expected scrolled snapshot to include newly visible rows, got {second_visible:?}"
);
assert!(
!second_visible.iter().any(|text| text.contains("item 00")),
"expected initial rows to be culled after scrolling, got {second_visible:?}"
);
}
#[test]
fn culled_container_keeps_descendant_geometry_for_scroll_into_view() {
let target_id = ElementId::new(42);
let root = Element::new().child(
Element::scroll_box(0.0).width(400.0).height(120.0).child(
Element::column()
.child(
Element::column()
.height(160.0)
.child(Element::text("visible group", text_style())),
)
.child(
Element::column().height(160.0).child(
Element::new()
.id(target_id)
.height(40.0)
.child(Element::text("offscreen target", text_style())),
),
),
),
);
let snapshot = layout_snapshot(1, UiSize::new(400.0, 120.0), &root);
assert!(
snapshot
.scene
.items
.iter()
.all(|item| !matches!(item, DisplayItem::Text(text) if text.text.contains("offscreen target"))),
"offscreen target text should still be scene-culled"
);
assert!(
snapshot
.interaction_tree
.rect_for_element(target_id)
.is_some(),
"offscreen descendants must keep geometry for widget-ref scrolling"
);
}
#[test]
fn scroll_culling_keeps_visible_text_selectable_inside_viewport_clip() {
let text_id = ElementId::new(77);
let root = Element::new().child(
Element::scroll_box(120.0).width(360.0).height(120.0).child(
Element::paragraph(
"line 01\nline 02\nline 03\nline 04\nline 05\nline 06\nline 07\nline 08\nline 09",
text_style().with_line_height(20.0),
)
.id(text_id),
),
);
let snapshot = layout_snapshot(1, UiSize::new(360.0, 120.0), &root);
let text = snapshot
.interaction_tree
.text_for_element(text_id)
.expect("selectable text should remain in the interaction tree");
let visible_hit = snapshot
.interaction_tree
.text_hit_test(Point::new(8.0, 8.0))
.expect("visible scrolled text should be selectable");
assert_eq!(text.element_id, Some(text_id));
assert_eq!(visible_hit.target.element_id, Some(text_id));
assert!(
snapshot
.interaction_tree
.text_hit_test(Point::new(8.0, 160.0))
.is_none(),
"text hit testing should still honor the scroll viewport clip"
);
}
} }

View File

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

View File

@@ -4,8 +4,8 @@ use std::cell::RefCell;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::fmt; use std::fmt;
use std::rc::Rc; use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use ruin_runtime::channel::mpsc; use ruin_runtime::channel::mpsc;
use ruin_runtime::{WorkerHandle, queue_future, queue_microtask, spawn_worker}; use ruin_runtime::{WorkerHandle, queue_future, queue_microtask, spawn_worker};
@@ -18,6 +18,13 @@ use crate::trace_targets;
use crate::tree::CursorIcon; use crate::tree::CursorIcon;
use crate::window::{WindowConfigured, WindowId, WindowLifecycle, WindowSpec, WindowUpdate}; 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)] #[derive(Clone)]
pub struct PlatformProxy { pub struct PlatformProxy {
command_tx: mpsc::UnboundedSender<PlatformRequest>, command_tx: mpsc::UnboundedSender<PlatformRequest>,
@@ -32,7 +39,7 @@ pub struct PlatformProxy {
pub struct PlatformRuntime { pub struct PlatformRuntime {
proxy: PlatformProxy, proxy: PlatformProxy,
events: mpsc::Receiver<PlatformEvent>, events: mpsc::Receiver<PlatformEvent>,
_worker: WorkerHandle, worker: Option<WorkerHandle>,
} }
pub struct PlatformEndpoint { pub struct PlatformEndpoint {
@@ -73,6 +80,7 @@ pub enum PlatformEvent {
window_id: WindowId, window_id: WindowId,
text: String, text: String,
}, },
#[cfg(target_os = "linux")]
PrimarySelectionText { PrimarySelectionText {
window_id: WindowId, window_id: WindowId,
text: String, text: String,
@@ -118,10 +126,12 @@ pub enum PlatformRequest {
RequestClipboardText { RequestClipboardText {
window_id: WindowId, window_id: WindowId,
}, },
#[cfg(target_os = "linux")]
SetPrimarySelectionText { SetPrimarySelectionText {
window_id: WindowId, window_id: WindowId,
text: String, text: String,
}, },
#[cfg(target_os = "linux")]
RequestPrimarySelectionText { RequestPrimarySelectionText {
window_id: WindowId, window_id: WindowId,
}, },
@@ -203,7 +213,30 @@ impl PlatformRuntime {
Self { Self {
proxy, proxy,
events: event_rx, 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> { pub fn take_pending_events(&mut self) -> Vec<PlatformEvent> {
let _ = self.worker.as_ref();
let mut events = Vec::new(); let mut events = Vec::new();
while let Ok(event) = self.events.try_recv() { while let Ok(event) = self.events.try_recv() {
events.push(event); events.push(event);
@@ -247,6 +281,7 @@ impl PlatformProxy {
self.send(PlatformRequest::ReplaceScene { window_id, scene }) self.send(PlatformRequest::ReplaceScene { window_id, scene })
} }
#[cfg(target_os = "linux")]
pub fn set_primary_selection_text( pub fn set_primary_selection_text(
&self, &self,
window_id: WindowId, window_id: WindowId,
@@ -258,6 +293,7 @@ impl PlatformProxy {
}) })
} }
#[cfg(target_os = "linux")]
pub fn request_primary_selection_text( pub fn request_primary_selection_text(
&self, &self,
window_id: WindowId, window_id: WindowId,
@@ -317,7 +353,20 @@ impl PlatformProxy {
} }
fn send(&self, command: PlatformRequest) -> Result<(), PlatformClosed> { 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::SetClipboardText { .. } => {}
PlatformRequest::RequestClipboardText { .. } => {} PlatformRequest::RequestClipboardText { .. } => {}
#[cfg(target_os = "linux")]
PlatformRequest::SetPrimarySelectionText { .. } => {} PlatformRequest::SetPrimarySelectionText { .. } => {}
#[cfg(target_os = "linux")]
PlatformRequest::RequestPrimarySelectionText { .. } => {} PlatformRequest::RequestPrimarySelectionText { .. } => {}
PlatformRequest::SetCursorIcon { .. } => {} PlatformRequest::SetCursorIcon { .. } => {}
PlatformRequest::EmitCloseRequested { window_id } => { PlatformRequest::EmitCloseRequested { window_id } => {

View File

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

View File

@@ -189,7 +189,7 @@ pub struct PreparedTextLine {
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub struct PreparedText { pub struct PreparedText {
pub element_id: Option<ElementId>, pub element_id: Option<ElementId>,
pub text: String, pub text: Arc<str>,
pub origin: Point, pub origin: Point,
pub bounds: Option<UiSize>, pub bounds: Option<UiSize>,
pub font_size: f32, pub font_size: f32,
@@ -206,6 +206,8 @@ pub struct PreparedText {
/// Add `origin` to convert to absolute window coords. /// Add `origin` to convert to absolute window coords.
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub struct TextLayoutData { pub struct TextLayoutData {
pub text: Arc<str>,
pub cache_key: u64,
pub lines: Vec<PreparedTextLine>, pub lines: Vec<PreparedTextLine>,
pub glyphs: Vec<GlyphInstance>, pub glyphs: Vec<GlyphInstance>,
/// Measured (unclamped) size of the laid-out text. /// Measured (unclamped) size of the laid-out text.
@@ -225,7 +227,6 @@ impl PreparedText {
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub(crate) fn from_layout( pub(crate) fn from_layout(
element_id: Option<ElementId>, element_id: Option<ElementId>,
text: String,
origin: Point, origin: Point,
bounds: Option<UiSize>, bounds: Option<UiSize>,
font_size: f32, font_size: f32,
@@ -237,7 +238,7 @@ impl PreparedText {
) -> Self { ) -> Self {
Self { Self {
element_id, element_id,
text, text: layout.text.clone(),
origin, origin,
bounds, bounds,
font_size, font_size,
@@ -256,6 +257,10 @@ impl PreparedText {
Arc::as_ptr(&self.layout) 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. /// Create a monospace `PreparedText` without going through the text system.
/// Used for testing and low-level terminal-style rendering. /// Used for testing and low-level terminal-style rendering.
pub fn monospace( pub fn monospace(
@@ -265,7 +270,7 @@ impl PreparedText {
advance: f32, advance: f32,
color: Color, color: Color,
) -> Self { ) -> Self {
let text = text.into(); let text: Arc<str> = Arc::from(text.into());
// Glyphs are stored in LOCAL (origin-relative) coordinates. // Glyphs are stored in LOCAL (origin-relative) coordinates.
let mut local_x = 0.0f32; let mut local_x = 0.0f32;
let mut glyphs = Vec::with_capacity(text.chars().count()); let mut glyphs = Vec::with_capacity(text.chars().count());
@@ -293,7 +298,7 @@ impl PreparedText {
let size = UiSize::new(local_x, font_size); let size = UiSize::new(local_x, font_size);
Self { Self {
element_id: None, element_id: None,
text, text: text.clone(),
origin, origin,
bounds: None, bounds: None,
font_size, font_size,
@@ -302,6 +307,8 @@ impl PreparedText {
selectable: true, selectable: true,
selection_style: TextSelectionStyle::DEFAULT, selection_style: TextSelectionStyle::DEFAULT,
layout: Arc::new(TextLayoutData { layout: Arc::new(TextLayoutData {
text: text.clone(),
cache_key: monospace_text_cache_key(&text, font_size, advance, color),
lines: vec![line], lines: vec![line],
glyphs, glyphs,
size, size,
@@ -466,7 +473,10 @@ impl PreparedText {
if range.is_empty() { if range.is_empty() {
return; 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 { if glyph.text_end > range.start && glyph.text_start < range.end {
glyph.color = selected_color; glyph.color = selected_color;
} }
@@ -583,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)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum WordClass { enum WordClass {
Word, Word,
@@ -886,6 +918,8 @@ mod tests {
} }
let orig_size = text.layout.size; let orig_size = text.layout.size;
text.layout = Arc::new(TextLayoutData { text.layout = Arc::new(TextLayoutData {
text: text.text.clone(),
cache_key: text.layout.cache_key,
lines, lines,
glyphs, glyphs,
size: orig_size, size: orig_size,

View File

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

View File

@@ -32,6 +32,36 @@ pub enum AlignItems {
End, 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)] #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum CursorIcon { pub enum CursorIcon {
Default, Default,
@@ -192,6 +222,7 @@ pub struct Style {
pub align_items: AlignItems, pub align_items: AlignItems,
/// Per-element override of the parent container's `align_items`. `None` means inherit. /// Per-element override of the parent container's `align_items`. `None` means inherit.
pub align_self: Option<AlignItems>, pub align_self: Option<AlignItems>,
pub justify_content: JustifyContent,
pub width: Option<f32>, pub width: Option<f32>,
pub height: Option<f32>, pub height: Option<f32>,
pub min_width: Option<f32>, pub min_width: Option<f32>,
@@ -214,6 +245,7 @@ impl Default for Style {
direction: FlexDirection::Column, direction: FlexDirection::Column,
align_items: AlignItems::Stretch, align_items: AlignItems::Stretch,
align_self: None, align_self: None,
justify_content: JustifyContent::Start,
width: None, width: None,
height: None, height: None,
min_width: None, min_width: None,
@@ -244,6 +276,7 @@ enum ElementContent {
pub(crate) struct TextNode { pub(crate) struct TextNode {
pub spans: Vec<TextSpan>, pub spans: Vec<TextSpan>,
pub style: TextStyle, pub style: TextStyle,
content_hash: u64,
} }
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
@@ -281,12 +314,14 @@ impl Element {
} }
pub fn spans(spans: impl IntoIterator<Item = TextSpan>, style: TextStyle) -> Self { pub fn spans(spans: impl IntoIterator<Item = TextSpan>, style: TextStyle) -> Self {
let spans = spans.into_iter().collect::<Vec<_>>();
Self { Self {
id: None, id: None,
style: Style::default(), style: Style::default(),
children: Vec::new(), children: Vec::new(),
content: ElementContent::Text(TextNode { content: ElementContent::Text(TextNode {
spans: spans.into_iter().collect(), content_hash: text_node_content_hash(&spans, &style),
spans,
style, style,
}), }),
} }
@@ -424,6 +459,11 @@ impl Element {
self self
} }
pub fn justify_content(mut self, justify: JustifyContent) -> Self {
self.style.justify_content = justify;
self
}
pub fn cursor(mut self, cursor: CursorIcon) -> Self { pub fn cursor(mut self, cursor: CursorIcon) -> Self {
self.style.cursor = Some(cursor); self.style.cursor = Some(cursor);
self self
@@ -575,11 +615,18 @@ impl Hash for AlignItems {
} }
} }
impl Hash for JustifyContent {
fn hash<H: Hasher>(&self, state: &mut H) {
(*self as u8).hash(state);
}
}
impl Hash for Style { impl Hash for Style {
fn hash<H: Hasher>(&self, state: &mut H) { fn hash<H: Hasher>(&self, state: &mut H) {
self.direction.hash(state); self.direction.hash(state);
self.align_items.hash(state); self.align_items.hash(state);
self.align_self.hash(state); self.align_self.hash(state);
self.justify_content.hash(state);
self.width.map(f32::to_bits).hash(state); self.width.map(f32::to_bits).hash(state);
self.height.map(f32::to_bits).hash(state); self.height.map(f32::to_bits).hash(state);
self.min_width.map(f32::to_bits).hash(state); self.min_width.map(f32::to_bits).hash(state);
@@ -599,8 +646,7 @@ impl Hash for Style {
impl Hash for TextNode { impl Hash for TextNode {
fn hash<H: Hasher>(&self, state: &mut H) { fn hash<H: Hasher>(&self, state: &mut H) {
self.spans.hash(state); self.content_hash.hash(state);
self.style.hash(state);
} }
} }
@@ -611,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 { impl Hash for ScrollBoxNode {
fn hash<H: Hasher>(&self, state: &mut H) { fn hash<H: Hasher>(&self, state: &mut H) {
self.offset_y.to_bits().hash(state); self.offset_y.to_bits().hash(state);

View File

@@ -34,6 +34,11 @@ pub enum WindowLifecycle {
#[derive(Clone, Copy, Debug, PartialEq)] #[derive(Clone, Copy, Debug, PartialEq)]
pub struct WindowConfigured { pub struct WindowConfigured {
/// Current logical content size and platform presentation scale.
///
/// Backends should emit a new configuration when either the logical size or
/// scale changes. This lets hosts relayout for size changes while renderers
/// can update drawable/text scale when a window moves between displays.
pub actual_inner_size: UiSize, pub actual_inner_size: UiSize,
pub scale_factor: f32, pub scale_factor: f32,
pub visible: bool, pub visible: bool,

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

View File

@@ -4,12 +4,14 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
libc = "0.2"
raw-window-handle = "0.6"
ruin_runtime = { package = "ruin-runtime", path = "../runtime" } ruin_runtime = { package = "ruin-runtime", path = "../runtime" }
ruin_ui = { path = "../ui" } ruin_ui = { path = "../ui" }
ruin_ui_renderer_wgpu = { path = "../ui_renderer_wgpu" } ruin_ui_renderer_wgpu = { path = "../ui_renderer_wgpu" }
tracing = "0.1" tracing = "0.1"
[target.'cfg(target_os = "linux")'.dependencies]
libc = "0.2"
raw-window-handle = "0.6"
wayland-backend = { version = "0.3", features = ["client_system"] } wayland-backend = { version = "0.3", features = ["client_system"] }
wayland-client = "0.31" wayland-client = "0.31"
wayland-protocols = { version = "0.32", features = ["client", "staging", "unstable"] } wayland-protocols = { version = "0.32", features = ["client", "staging", "unstable"] }

View File

@@ -1,3 +1,5 @@
#![cfg(target_os = "linux")]
use std::cell::RefCell; use std::cell::RefCell;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::error::Error; use std::error::Error;
@@ -40,13 +42,13 @@ use wayland_client::{
use wayland_protocols::wp::cursor_shape::v1::client::{ use wayland_protocols::wp::cursor_shape::v1::client::{
wp_cursor_shape_device_v1, wp_cursor_shape_manager_v1, wp_cursor_shape_device_v1, wp_cursor_shape_manager_v1,
}; };
use wayland_protocols::xdg::decoration::zv1::client::{
zxdg_decoration_manager_v1, zxdg_toplevel_decoration_v1,
};
use wayland_protocols::wp::primary_selection::zv1::client::{ use wayland_protocols::wp::primary_selection::zv1::client::{
zwp_primary_selection_device_manager_v1, zwp_primary_selection_device_v1, zwp_primary_selection_device_manager_v1, zwp_primary_selection_device_v1,
zwp_primary_selection_offer_v1, zwp_primary_selection_source_v1, zwp_primary_selection_offer_v1, zwp_primary_selection_source_v1,
}; };
use wayland_protocols::xdg::decoration::zv1::client::{
zxdg_decoration_manager_v1, zxdg_toplevel_decoration_v1,
};
use wayland_protocols::xdg::shell::client::{xdg_surface, xdg_toplevel, xdg_wm_base}; use wayland_protocols::xdg::shell::client::{xdg_surface, xdg_toplevel, xdg_wm_base};
use xkbcommon::xkb; use xkbcommon::xkb;
@@ -242,7 +244,10 @@ fn fixed_size_request_for_spec(spec: &WindowSpec) -> Option<(u32, u32)> {
.or(spec.min_inner_size) .or(spec.min_inner_size)
.or(spec.max_inner_size) .or(spec.max_inner_size)
.unwrap_or_else(|| UiSize::new(800.0, 500.0)); .unwrap_or_else(|| UiSize::new(800.0, 500.0));
Some((size.width.max(1.0).round() as u32, size.height.max(1.0).round() as u32)) Some((
size.width.max(1.0).round() as u32,
size.height.max(1.0).round() as u32,
))
} }
fn wayland_cursor_shape(icon: CursorIcon) -> wp_cursor_shape_device_v1::Shape { fn wayland_cursor_shape(icon: CursorIcon) -> wp_cursor_shape_device_v1::Shape {
@@ -1352,7 +1357,9 @@ fn spawn_window_worker(
"worker observed resized frame" "worker observed resized frame"
); );
state_ref.pending_viewport = Some(current_viewport); state_ref.pending_viewport = Some(current_viewport);
state_ref.pending_viewport_since.get_or_insert_with(Instant::now); state_ref
.pending_viewport_since
.get_or_insert_with(Instant::now);
// Emit configure BEFORE touching the GPU so the app // Emit configure BEFORE touching the GPU so the app
// thread starts layout immediately in parallel. // thread starts layout immediately in parallel.
// The actual swapchain recreation is deferred to just // The actual swapchain recreation is deferred to just
@@ -1397,7 +1404,9 @@ fn spawn_window_worker(
"scene size does not match current viewport; holding last buffer" "scene size does not match current viewport; holding last buffer"
); );
state_ref.pending_viewport = Some(current_viewport); state_ref.pending_viewport = Some(current_viewport);
state_ref.pending_viewport_since.get_or_insert_with(Instant::now); state_ref
.pending_viewport_since
.get_or_insert_with(Instant::now);
// Deadlock prevention: if the app responded to the // Deadlock prevention: if the app responded to the
// configure we sent (scene matches in_flight) but the // configure we sent (scene matches in_flight) but the
// viewport has since moved on, clear the in_flight // viewport has since moved on, clear the in_flight
@@ -1421,7 +1430,9 @@ fn spawn_window_worker(
// before rendering. Deferring from frame.resized means // before rendering. Deferring from frame.resized means
// rapid configures pay one recreation instead of one // rapid configures pay one recreation instead of one
// per event. // per event.
if let Some((w, h)) = state_ref.pending_swapchain_size.take() { if let Some((w, h)) =
state_ref.pending_swapchain_size.take()
{
let t_resize = std::time::Instant::now(); let t_resize = std::time::Instant::now();
state_ref.renderer.resize(w, h); state_ref.renderer.resize(w, h);
let resize_gpu_us = t_resize.elapsed().as_micros(); let resize_gpu_us = t_resize.elapsed().as_micros();
@@ -1438,7 +1449,8 @@ fn spawn_window_worker(
let t_render = std::time::Instant::now(); let t_render = std::time::Instant::now();
match state_ref.renderer.render(scene) { match state_ref.renderer.render(scene) {
Ok(()) => { Ok(()) => {
let render_gpu_us = t_render.elapsed().as_micros(); let render_gpu_us =
t_render.elapsed().as_micros();
debug!( debug!(
target: "ruin_ui_platform_wayland::perf", target: "ruin_ui_platform_wayland::perf",
window_id = state_ref.window_id.raw(), window_id = state_ref.window_id.raw(),
@@ -1520,10 +1532,10 @@ fn spawn_window_worker(
{ {
let delay = let delay =
repeat.next_at.saturating_duration_since(Instant::now()); repeat.next_at.saturating_duration_since(Instant::now());
state_ref.keyboard_repeat_timer = Some(set_timeout( state_ref.keyboard_repeat_timer =
delay, Some(set_timeout(delay, move || {
move || write_wakeup(pipe_write_fd), write_wakeup(pipe_write_fd)
)); }));
} }
} }
} }

View File

@@ -91,7 +91,8 @@ struct PreparedGlyphBitmap {
struct RasterizedText { struct RasterizedText {
origin_offset: Point, origin_offset: Point,
size: UiSize, draw_size: UiSize,
texture_size: UiSize,
pixels: Vec<u8>, pixels: Vec<u8>,
} }
@@ -168,21 +169,12 @@ struct UploadedAtlasText {
#[derive(Clone, Debug, Eq, Hash, PartialEq)] #[derive(Clone, Debug, Eq, Hash, PartialEq)]
struct TextTextureKey { struct TextTextureKey {
text: String, layout_key: u64,
bounds: Option<(u32, u32)>, bounds: Option<(i32, i32)>,
clip: Option<(i32, i32, i32, i32)>,
font_size_bits: u32, font_size_bits: u32,
line_height_bits: u32, line_height_bits: u32,
color: (u8, u8, u8, u8), color: (u8, u8, u8, u8),
glyphs: Vec<TextTextureGlyph>,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
struct TextTextureGlyph {
local_x_bits: u32,
local_y_bits: u32,
advance_bits: u32,
color: (u8, u8, u8, u8),
cache_key: Option<CacheKey>,
} }
const VERTEX_ATTRIBUTES: [wgpu::VertexAttribute; 11] = [ const VERTEX_ATTRIBUTES: [wgpu::VertexAttribute; 11] = [
@@ -313,6 +305,21 @@ pub enum RenderError {
Validation, Validation,
} }
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct RendererFrameStats {
pub scene_version: u64,
pub scene_items: usize,
pub quad_vertices: usize,
pub image_batches: usize,
pub atlas_text_vertices: u32,
pub fallback_text_batches: usize,
pub fallback_text_vertices: u32,
pub atlas_glyphs_total: u32,
pub atlas_glyphs_clip_culled: u32,
pub text_prepare_ms: f64,
pub render_ms: f64,
}
pub struct WgpuSceneRenderer { pub struct WgpuSceneRenderer {
surface: wgpu::Surface<'static>, surface: wgpu::Surface<'static>,
device: wgpu::Device, device: wgpu::Device,
@@ -330,7 +337,9 @@ pub struct WgpuSceneRenderer {
image_cache_order: VecDeque<u64>, image_cache_order: VecDeque<u64>,
#[allow(dead_code)] #[allow(dead_code)]
glyph_atlas: GlyphAtlas, glyph_atlas: GlyphAtlas,
scale_factor: f32,
base_color: Color, base_color: Color,
last_frame_stats: RendererFrameStats,
} }
const MAX_TEXT_CACHE_ENTRIES: usize = 64; const MAX_TEXT_CACHE_ENTRIES: usize = 64;
@@ -377,8 +386,11 @@ impl WgpuSceneRenderer {
format, format,
width: width.max(1), width: width.max(1),
height: height.max(1), height: height.max(1),
present_mode: wgpu::PresentMode::AutoVsync, // RUIN prioritizes immediate resize/input feedback over frame pacing
desired_maximum_frame_latency: 2, // here. Keep latency to one frame and let callers opt into a more
// power-conservative policy once presentation settings are exposed.
present_mode: wgpu::PresentMode::AutoNoVsync,
desired_maximum_frame_latency: 1,
alpha_mode, alpha_mode,
view_formats: vec![], view_formats: vec![],
}; };
@@ -721,7 +733,9 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
image_cache: HashMap::new(), image_cache: HashMap::new(),
image_cache_order: VecDeque::new(), image_cache_order: VecDeque::new(),
glyph_atlas, glyph_atlas,
scale_factor: 1.0,
base_color, base_color,
last_frame_stats: RendererFrameStats::default(),
}) })
} }
@@ -735,9 +749,37 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
self.surface.configure(&self.device, &self.config); self.surface.configure(&self.device, &self.config);
} }
pub fn set_scale_factor(&mut self, scale_factor: f32) {
let next = scale_factor.max(1.0);
if (self.scale_factor - next).abs() <= f32::EPSILON {
return;
}
self.scale_factor = next;
// Text cache is scale-dependent; flush on scale transitions (e.g. display move).
self.text_cache.clear();
self.text_cache_order.clear();
self.glyph_atlas = create_glyph_atlas(
&self.device,
&self.text_bind_group_layout,
&self.text_sampler,
);
}
pub fn last_frame_stats(&self) -> RendererFrameStats {
self.last_frame_stats
}
pub fn render(&mut self, scene: &SceneSnapshot) -> Result<(), RenderError> { pub fn render(&mut self, scene: &SceneSnapshot) -> Result<(), RenderError> {
self.render_with_logical_size(scene, scene.logical_size)
}
pub fn render_with_logical_size(
&mut self,
scene: &SceneSnapshot,
logical_size: UiSize,
) -> Result<(), RenderError> {
let render_start = std::time::Instant::now(); let render_start = std::time::Instant::now();
let vertices = build_vertices(scene); let vertices = build_vertices(scene, logical_size);
let frame = match self.surface.get_current_texture() { let frame = match self.surface.get_current_texture() {
wgpu::CurrentSurfaceTexture::Success(frame) wgpu::CurrentSurfaceTexture::Success(frame)
| wgpu::CurrentSurfaceTexture::Suboptimal(frame) => frame, | wgpu::CurrentSurfaceTexture::Suboptimal(frame) => frame,
@@ -761,7 +803,8 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
let mut uploaded_images = Vec::new(); let mut uploaded_images = Vec::new();
let mut uploaded_texts = Vec::new(); let mut uploaded_texts = Vec::new();
let mut atlas_perf = AtlasTextPerfStats::default(); let mut atlas_perf = AtlasTextPerfStats::default();
let uploaded_atlas_text = self.prepare_uploaded_atlas_text(scene, &mut atlas_perf); let uploaded_atlas_text =
self.prepare_uploaded_atlas_text(scene, logical_size, &mut atlas_perf);
let mut clip_stack = Vec::new(); let mut clip_stack = Vec::new();
let mut active_clip = ActiveClip::default(); let mut active_clip = ActiveClip::default();
for item in &scene.items { for item in &scene.items {
@@ -772,7 +815,7 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
DisplayItem::PopClip => pop_clip_state(&mut clip_stack, &mut active_clip), DisplayItem::PopClip => pop_clip_state(&mut clip_stack, &mut active_clip),
DisplayItem::Image(image) => { DisplayItem::Image(image) => {
if let Some(uploaded) = if let Some(uploaded) =
self.prepare_uploaded_image(image, scene.logical_size, active_clip) self.prepare_uploaded_image(image, logical_size, active_clip)
{ {
uploaded_images.push(uploaded); uploaded_images.push(uploaded);
} }
@@ -782,7 +825,7 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
continue; continue;
} }
if let Some(uploaded) = if let Some(uploaded) =
self.prepare_uploaded_text(text, scene.logical_size, active_clip) self.prepare_uploaded_text(text, logical_size, active_clip)
{ {
uploaded_texts.push(uploaded); uploaded_texts.push(uploaded);
} }
@@ -853,39 +896,60 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
} }
self.queue.submit([encoder.finish()]); self.queue.submit([encoder.finish()]);
frame.present(); frame.present();
trace!( self.last_frame_stats = RendererFrameStats {
target: "ruin_ui_renderer_wgpu::perf", scene_version: scene.version,
scene_version = scene.version, scene_items: scene.items.len(),
quad_vertices = vertices.len(), quad_vertices: vertices.len(),
image_batches = uploaded_images.len(), image_batches: uploaded_images.len(),
atlas_text_vertices = uploaded_atlas_text atlas_text_vertices: uploaded_atlas_text
.as_ref() .as_ref()
.map_or(0_u32, |text| text.vertex_count), .map_or(0_u32, |text| text.vertex_count),
fallback_text_batches = uploaded_texts.len(), fallback_text_batches: uploaded_texts.len(),
atlas_glyphs_total = atlas_perf.glyphs_total, fallback_text_vertices: uploaded_texts.iter().map(|text| text.vertex_count).sum(),
atlas_glyphs_clip_culled = atlas_perf.glyphs_clip_culled, atlas_glyphs_total: atlas_perf.glyphs_total,
text_prepare_ms = text_prepare_ms, atlas_glyphs_clip_culled: atlas_perf.glyphs_clip_culled,
render_ms = render_start.elapsed().as_secs_f64() * 1_000.0, text_prepare_ms,
render_ms: render_start.elapsed().as_secs_f64() * 1_000.0,
};
trace!(
target: "ruin_ui_renderer_wgpu::perf",
scene_version = self.last_frame_stats.scene_version,
scene_items = self.last_frame_stats.scene_items,
quad_vertices = self.last_frame_stats.quad_vertices,
image_batches = self.last_frame_stats.image_batches,
atlas_text_vertices = self.last_frame_stats.atlas_text_vertices,
fallback_text_batches = self.last_frame_stats.fallback_text_batches,
fallback_text_vertices = self.last_frame_stats.fallback_text_vertices,
atlas_glyphs_total = self.last_frame_stats.atlas_glyphs_total,
atlas_glyphs_clip_culled = self.last_frame_stats.atlas_glyphs_clip_culled,
text_prepare_ms = self.last_frame_stats.text_prepare_ms,
render_ms = self.last_frame_stats.render_ms,
"rendered scene" "rendered scene"
); );
Ok(()) Ok(())
} }
fn rasterize_text(&mut self, text: &PreparedText) -> Option<RasterizedText> { fn rasterize_text(
&mut self,
text: &PreparedText,
raster_clip: Option<Rect>,
) -> Option<RasterizedText> {
if text.glyphs.iter().all(|glyph| glyph.cache_key.is_some()) { if text.glyphs.iter().all(|glyph| glyph.cache_key.is_some()) {
return self.rasterize_prepared_text(text); return self.rasterize_prepared_text(text, raster_clip, self.scale_factor);
} }
let glyphs = self.collect_glyph_bitmaps(text); let clip = raster_clip.map(|clip| scale_rect_to_pixel_rect(clip, self.scale_factor));
let clip = match text.bounds { let glyphs = self.collect_glyph_bitmaps(text, clip);
Some(bounds) => PixelRect { let clip = clip
left: 0, .or_else(|| {
top: 0, text.bounds.map(|bounds| PixelRect {
right: bounds.width.ceil() as i32, left: 0,
bottom: bounds.height.ceil() as i32, top: 0,
}, right: (bounds.width * self.scale_factor).ceil() as i32,
None => glyph_union(&glyphs)?, bottom: (bounds.height * self.scale_factor).ceil() as i32,
}; })
})
.or_else(|| glyph_union(&glyphs))?;
if clip.width() == 0 || clip.height() == 0 { if clip.width() == 0 || clip.height() == 0 {
return None; return None;
} }
@@ -896,23 +960,38 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
} }
Some(RasterizedText { Some(RasterizedText {
origin_offset: Point::new(clip.left as f32, clip.top as f32), origin_offset: Point::new(
size: UiSize::new(clip.width() as f32, clip.height() as f32), clip.left as f32 / self.scale_factor,
clip.top as f32 / self.scale_factor,
),
draw_size: UiSize::new(
clip.width() as f32 / self.scale_factor,
clip.height() as f32 / self.scale_factor,
),
texture_size: UiSize::new(clip.width() as f32, clip.height() as f32),
pixels, pixels,
}) })
} }
fn rasterize_prepared_text(&mut self, text: &PreparedText) -> Option<RasterizedText> { fn rasterize_prepared_text(
let glyphs = self.collect_prepared_glyphs(text); &mut self,
let clip = match text.bounds { text: &PreparedText,
Some(bounds) => PixelRect { raster_clip: Option<Rect>,
left: 0, scale_factor: f32,
top: 0, ) -> Option<RasterizedText> {
right: bounds.width.ceil() as i32, let clip = raster_clip.map(|clip| scale_rect_to_pixel_rect(clip, scale_factor));
bottom: bounds.height.ceil() as i32, let glyphs = self.collect_prepared_glyphs(text, raster_clip, clip, scale_factor);
}, let clip = raster_clip
None => glyph_union_prepared(&glyphs)?, .map(|clip| scale_rect_to_pixel_rect(clip, scale_factor))
}; .or_else(|| {
text.bounds.map(|bounds| PixelRect {
left: 0,
top: 0,
right: (bounds.width * scale_factor).ceil() as i32,
bottom: (bounds.height * scale_factor).ceil() as i32,
})
})
.or_else(|| glyph_union_prepared(&glyphs))?;
if clip.width() == 0 || clip.height() == 0 { if clip.width() == 0 || clip.height() == 0 {
return None; return None;
} }
@@ -930,16 +1009,41 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
} }
Some(RasterizedText { Some(RasterizedText {
origin_offset: Point::new(clip.left as f32, clip.top as f32), origin_offset: Point::new(
size: UiSize::new(clip.width() as f32, clip.height() as f32), clip.left as f32 / scale_factor,
clip.top as f32 / scale_factor,
),
draw_size: UiSize::new(
clip.width() as f32 / scale_factor,
clip.height() as f32 / scale_factor,
),
texture_size: UiSize::new(clip.width() as f32, clip.height() as f32),
pixels, pixels,
}) })
} }
fn collect_prepared_glyphs(&mut self, text: &PreparedText) -> Vec<PreparedGlyphBitmap> { fn collect_prepared_glyphs(
let mut glyphs = Vec::with_capacity(text.glyphs.len()); &mut self,
for glyph in &text.glyphs { text: &PreparedText,
let Some(cache_key) = glyph.cache_key else { logical_clip: Option<Rect>,
pixel_clip: Option<PixelRect>,
scale_factor: f32,
) -> Vec<PreparedGlyphBitmap> {
let absolute_clip = logical_clip.map(|clip| {
Rect::new(
text.origin.x + clip.origin.x,
text.origin.y + clip.origin.y,
clip.size.width,
clip.size.height,
)
});
let visible_glyphs = clip_visible_glyphs(text, absolute_clip);
let mut glyphs = Vec::with_capacity(visible_glyphs.len());
for glyph in visible_glyphs {
let Some(cache_key) = glyph
.cache_key
.map(|cache_key| scale_glyph_cache_key(cache_key, scale_factor))
else {
continue; continue;
}; };
let Some(image) = self let Some(image) = self
@@ -956,15 +1060,19 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
} }
// Glyph positions are LOCAL (origin-relative); no subtraction needed. // Glyph positions are LOCAL (origin-relative); no subtraction needed.
let local_x = glyph.position.x.round() as i32; let local_x = (glyph.position.x * scale_factor).round() as i32;
let local_y = glyph.position.y.round() as i32; let local_y = (glyph.position.y * scale_factor).round() as i32;
let rect = PixelRect {
left: local_x + image.placement.left,
top: local_y - image.placement.top,
right: local_x + image.placement.left + width,
bottom: local_y - image.placement.top + height,
};
if pixel_clip.is_some_and(|clip| !pixel_rects_intersect(rect, clip)) {
continue;
}
glyphs.push(PreparedGlyphBitmap { glyphs.push(PreparedGlyphBitmap {
rect: PixelRect { rect,
left: local_x + image.placement.left,
top: local_y - image.placement.top,
right: local_x + image.placement.left + width,
bottom: local_y - image.placement.top + height,
},
cache_key, cache_key,
color: resolve_glyph_color(glyph.color, text.default_color), color: resolve_glyph_color(glyph.color, text.default_color),
}); });
@@ -972,19 +1080,33 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
glyphs glyphs
} }
fn collect_glyph_bitmaps(&mut self, text: &PreparedText) -> Vec<GlyphBitmap> { fn collect_glyph_bitmaps(
let mut buffer = Buffer::new_empty(Metrics::new(text.font_size, text.line_height)); &mut self,
text: &PreparedText,
raster_clip: Option<PixelRect>,
) -> Vec<GlyphBitmap> {
let mut buffer = Buffer::new_empty(Metrics::new(
text.font_size * self.scale_factor,
text.line_height * self.scale_factor,
));
{ {
let mut borrowed = buffer.borrow_with(&mut self.font_system); let mut borrowed = buffer.borrow_with(&mut self.font_system);
borrowed.set_size( borrowed.set_size(
text.bounds.map(|bounds| bounds.width), text.bounds.map(|bounds| bounds.width * self.scale_factor),
text.bounds.map(|bounds| bounds.height), text.bounds.map(|bounds| bounds.height * self.scale_factor),
); );
borrowed.set_text(&text.text, &Attrs::new(), Shaping::Advanced, None); borrowed.set_text(&text.text, &Attrs::new(), Shaping::Advanced, None);
} }
let mut glyphs = Vec::new(); let mut glyphs = Vec::new();
for run in buffer.layout_runs() { for run in buffer.layout_runs() {
if let Some(clip) = raster_clip {
let line_top = run.line_top.floor() as i32;
let line_bottom = (run.line_top + run.line_height).ceil() as i32;
if line_bottom < clip.top || line_top > clip.bottom {
continue;
}
}
for glyph in run.glyphs { for glyph in run.glyphs {
let physical = glyph.physical((0.0, run.line_y), 1.0); let physical = glyph.physical((0.0, run.line_y), 1.0);
let Some(image) = self let Some(image) = self
@@ -999,13 +1121,17 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
if width == 0 || height == 0 { if width == 0 || height == 0 {
continue; continue;
} }
let rect = PixelRect {
left: physical.x + image.placement.left,
top: physical.y - image.placement.top,
right: physical.x + image.placement.left + width,
bottom: physical.y - image.placement.top + height,
};
if raster_clip.is_some_and(|clip| !pixel_rects_intersect(rect, clip)) {
continue;
}
glyphs.push(GlyphBitmap { glyphs.push(GlyphBitmap {
rect: PixelRect { rect,
left: physical.x + image.placement.left,
top: physical.y - image.placement.top,
right: physical.x + image.placement.left + width,
bottom: physical.y - image.placement.top + height,
},
content: image.content, content: image.content,
color: glyph color: glyph
.color_opt .color_opt
@@ -1020,6 +1146,7 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
fn prepare_uploaded_atlas_text( fn prepare_uploaded_atlas_text(
&mut self, &mut self,
scene: &SceneSnapshot, scene: &SceneSnapshot,
logical_size: UiSize,
perf: &mut AtlasTextPerfStats, perf: &mut AtlasTextPerfStats,
) -> Option<UploadedAtlasText> { ) -> Option<UploadedAtlasText> {
let mut vertices = Vec::new(); let mut vertices = Vec::new();
@@ -1053,7 +1180,8 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
if active_clip.rect_active && logical_clip_rect.is_none() { if active_clip.rect_active && logical_clip_rect.is_none() {
continue; continue;
} }
let clip_rect = logical_clip_rect.map(rect_to_pixel_rect); let clip_rect = logical_clip_rect
.map(|rect| scale_rect_to_pixel_rect(rect, self.scale_factor));
// Only iterate glyphs whose lines fall within the clip rect. // Only iterate glyphs whose lines fall within the clip rect.
// For large text nodes (e.g. full Cargo.lock) this reduces // For large text nodes (e.g. full Cargo.lock) this reduces
@@ -1064,7 +1192,10 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
perf.glyphs_clip_culled += (all_count - visible.len()) as u32; perf.glyphs_clip_culled += (all_count - visible.len()) as u32;
for glyph in visible { for glyph in visible {
let Some(cache_key) = glyph.cache_key else { let Some(cache_key) = glyph
.cache_key
.map(|cache_key| scale_glyph_cache_key(cache_key, self.scale_factor))
else {
continue; continue;
}; };
let resolved_color = resolve_glyph_color(glyph.color, text.default_color); let resolved_color = resolve_glyph_color(glyph.color, text.default_color);
@@ -1073,9 +1204,12 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
continue; continue;
}; };
// Glyph positions are LOCAL; add text.origin for absolute screen coords. // Atlas glyphs are rasterized in device pixels while scene geometry remains
let abs_x = (text.origin.x + glyph.position.x).round() as i32; // logical. Build and clip in device space, then convert back for vertices.
let abs_y = (text.origin.y + glyph.position.y).round() as i32; let abs_x =
((text.origin.x + glyph.position.x) * self.scale_factor).round() as i32;
let abs_y =
((text.origin.y + glyph.position.y) * self.scale_factor).round() as i32;
let glyph_rect = PixelRect { let glyph_rect = PixelRect {
left: abs_x + atlas_glyph.placement_left, left: abs_x + atlas_glyph.placement_left,
top: abs_y - atlas_glyph.placement_top, top: abs_y - atlas_glyph.placement_top,
@@ -1090,9 +1224,12 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
glyph_rect, glyph_rect,
atlas_glyph.atlas_rect, atlas_glyph.atlas_rect,
clip_rect, clip_rect,
scene.logical_size, GlyphVertexContext {
resolved_color, logical_size,
active_clip, scale_factor: self.scale_factor,
color: resolved_color,
clip: active_clip,
},
); );
} }
} }
@@ -1263,9 +1400,10 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
logical_size: UiSize, logical_size: UiSize,
clip: ActiveClip, clip: ActiveClip,
) -> Option<UploadedText> { ) -> Option<UploadedText> {
let key = text_texture_key(text); let raster_clip = text_raster_clip(text, clip, logical_size)?;
let key = text_texture_key(text, raster_clip, self.scale_factor);
if !self.text_cache.contains_key(&key) { if !self.text_cache.contains_key(&key) {
let rasterized = self.rasterize_text(text)?; let rasterized = self.rasterize_text(text, raster_clip)?;
let cached = self.create_cached_text_texture(&rasterized); let cached = self.create_cached_text_texture(&rasterized);
self.text_cache.insert(key.clone(), cached); self.text_cache.insert(key.clone(), cached);
} }
@@ -1333,8 +1471,8 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
let texture = self.device.create_texture(&wgpu::TextureDescriptor { let texture = self.device.create_texture(&wgpu::TextureDescriptor {
label: Some("ruin-ui-renderer-wgpu-texture"), label: Some("ruin-ui-renderer-wgpu-texture"),
size: wgpu::Extent3d { size: wgpu::Extent3d {
width: text.size.width as u32, width: text.texture_size.width as u32,
height: text.size.height as u32, height: text.texture_size.height as u32,
depth_or_array_layers: 1, depth_or_array_layers: 1,
}, },
mip_level_count: 1, mip_level_count: 1,
@@ -1354,12 +1492,12 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
&text.pixels, &text.pixels,
wgpu::TexelCopyBufferLayout { wgpu::TexelCopyBufferLayout {
offset: 0, offset: 0,
bytes_per_row: Some(text.size.width as u32 * 4), bytes_per_row: Some(text.texture_size.width as u32 * 4),
rows_per_image: Some(text.size.height as u32), rows_per_image: Some(text.texture_size.height as u32),
}, },
wgpu::Extent3d { wgpu::Extent3d {
width: text.size.width as u32, width: text.texture_size.width as u32,
height: text.size.height as u32, height: text.texture_size.height as u32,
depth_or_array_layers: 1, depth_or_array_layers: 1,
}, },
); );
@@ -1382,7 +1520,7 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
_texture: texture, _texture: texture,
bind_group, bind_group,
origin_offset: text.origin_offset, origin_offset: text.origin_offset,
size: text.size, size: text.draw_size,
} }
} }
@@ -1813,32 +1951,44 @@ fn build_textured_vertices(
]) ])
} }
#[allow(dead_code)]
#[derive(Clone, Copy)]
struct GlyphVertexContext {
logical_size: UiSize,
scale_factor: f32,
color: Color,
clip: ActiveClip,
}
#[allow(dead_code)] #[allow(dead_code)]
fn push_glyph_vertices( fn push_glyph_vertices(
vertices: &mut Vec<TextVertex>, vertices: &mut Vec<TextVertex>,
glyph_rect: PixelRect, glyph_rect: PixelRect,
atlas_rect: AtlasRect, atlas_rect: AtlasRect,
clip_rect: Option<PixelRect>, clip_rect: Option<PixelRect>,
logical_size: UiSize, context: GlyphVertexContext,
color: Color,
clip: ActiveClip,
) { ) {
let Some((dest_rect, uv_rect)) = clipped_glyph_quad(glyph_rect, atlas_rect, clip_rect) else { let Some((dest_rect, uv_rect)) = clipped_glyph_quad(glyph_rect, atlas_rect, clip_rect) else {
return; return;
}; };
let left = to_ndc_x(dest_rect.left as f32, logical_size.width.max(1.0)); let scale_factor = context.scale_factor.max(1.0);
let right = to_ndc_x(dest_rect.right as f32, logical_size.width.max(1.0)); let logical_left = dest_rect.left as f32 / scale_factor;
let top = to_ndc_y(dest_rect.top as f32, logical_size.height.max(1.0)); let logical_top = dest_rect.top as f32 / scale_factor;
let bottom = to_ndc_y(dest_rect.bottom as f32, logical_size.height.max(1.0)); let logical_right = dest_rect.right as f32 / scale_factor;
let logical_bottom = dest_rect.bottom as f32 / scale_factor;
let left = to_ndc_x(logical_left, context.logical_size.width.max(1.0));
let right = to_ndc_x(logical_right, context.logical_size.width.max(1.0));
let top = to_ndc_y(logical_top, context.logical_size.height.max(1.0));
let bottom = to_ndc_y(logical_bottom, context.logical_size.height.max(1.0));
let color = color_to_f32(color); let color = color_to_f32(context.color);
let clip_rect = clip_rect_array(clip); let clip_rect = clip_rect_array(context.clip);
let (rounded_clip_rect, clip_params) = rounded_clip_arrays(clip); let (rounded_clip_rect, clip_params) = rounded_clip_arrays(context.clip);
vertices.extend_from_slice(&[ vertices.extend_from_slice(&[
TextVertex { TextVertex {
position: [left, top], position: [left, top],
world_position: [dest_rect.left as f32, dest_rect.top as f32], world_position: [logical_left, logical_top],
uv: [uv_rect.0, uv_rect.1], uv: [uv_rect.0, uv_rect.1],
color, color,
clip_rect, clip_rect,
@@ -1847,7 +1997,7 @@ fn push_glyph_vertices(
}, },
TextVertex { TextVertex {
position: [left, bottom], position: [left, bottom],
world_position: [dest_rect.left as f32, dest_rect.bottom as f32], world_position: [logical_left, logical_bottom],
uv: [uv_rect.0, uv_rect.3], uv: [uv_rect.0, uv_rect.3],
color, color,
clip_rect, clip_rect,
@@ -1856,7 +2006,7 @@ fn push_glyph_vertices(
}, },
TextVertex { TextVertex {
position: [right, top], position: [right, top],
world_position: [dest_rect.right as f32, dest_rect.top as f32], world_position: [logical_right, logical_top],
uv: [uv_rect.2, uv_rect.1], uv: [uv_rect.2, uv_rect.1],
color, color,
clip_rect, clip_rect,
@@ -1865,7 +2015,7 @@ fn push_glyph_vertices(
}, },
TextVertex { TextVertex {
position: [right, top], position: [right, top],
world_position: [dest_rect.right as f32, dest_rect.top as f32], world_position: [logical_right, logical_top],
uv: [uv_rect.2, uv_rect.1], uv: [uv_rect.2, uv_rect.1],
color, color,
clip_rect, clip_rect,
@@ -1874,7 +2024,7 @@ fn push_glyph_vertices(
}, },
TextVertex { TextVertex {
position: [left, bottom], position: [left, bottom],
world_position: [dest_rect.left as f32, dest_rect.bottom as f32], world_position: [logical_left, logical_bottom],
uv: [uv_rect.0, uv_rect.3], uv: [uv_rect.0, uv_rect.3],
color, color,
clip_rect, clip_rect,
@@ -1883,7 +2033,7 @@ fn push_glyph_vertices(
}, },
TextVertex { TextVertex {
position: [right, bottom], position: [right, bottom],
world_position: [dest_rect.right as f32, dest_rect.bottom as f32], world_position: [logical_right, logical_bottom],
uv: [uv_rect.2, uv_rect.3], uv: [uv_rect.2, uv_rect.3],
color, color,
clip_rect, clip_rect,
@@ -1902,9 +2052,9 @@ fn color_to_f32(color: Color) -> [f32; 4] {
] ]
} }
fn build_vertices(scene: &SceneSnapshot) -> Vec<Vertex> { fn build_vertices(scene: &SceneSnapshot, logical_size: UiSize) -> Vec<Vertex> {
let width = scene.logical_size.width.max(1.0); let width = logical_size.width.max(1.0);
let height = scene.logical_size.height.max(1.0); let height = logical_size.height.max(1.0);
let mut vertices = Vec::new(); let mut vertices = Vec::new();
let mut clip_stack = Vec::new(); let mut clip_stack = Vec::new();
let mut active_clip = ActiveClip::default(); let mut active_clip = ActiveClip::default();
@@ -2157,15 +2307,22 @@ fn expand_rect(rect: Rect, inset: f32) -> Rect {
) )
} }
fn rect_to_pixel_rect(rect: Rect) -> PixelRect { fn scale_rect_to_pixel_rect(rect: Rect, scale: f32) -> PixelRect {
PixelRect { PixelRect {
left: rect.origin.x.floor() as i32, left: (rect.origin.x * scale).floor() as i32,
top: rect.origin.y.floor() as i32, top: (rect.origin.y * scale).floor() as i32,
right: (rect.origin.x + rect.size.width).ceil() as i32, right: ((rect.origin.x + rect.size.width) * scale).ceil() as i32,
bottom: (rect.origin.y + rect.size.height).ceil() as i32, bottom: ((rect.origin.y + rect.size.height) * scale).ceil() as i32,
} }
} }
fn pixel_rects_intersect(first: PixelRect, second: PixelRect) -> bool {
first.left < second.right
&& first.right > second.left
&& first.top < second.bottom
&& first.bottom > second.top
}
fn shadow_blur_extent(blur: f32) -> f32 { fn shadow_blur_extent(blur: f32) -> f32 {
blur.max(0.0) * 2.0 blur.max(0.0) * 2.0
} }
@@ -2225,6 +2382,31 @@ fn intersect_rects(first: Option<Rect>, second: Option<Rect>) -> Option<Rect> {
} }
} }
fn text_raster_clip(
text: &PreparedText,
clip: ActiveClip,
logical_size: UiSize,
) -> Option<Option<Rect>> {
let mut absolute_clip = Some(Rect::new(0.0, 0.0, logical_size.width, logical_size.height));
if clip.rect_active {
absolute_clip = intersect_rects(absolute_clip, Some(clip.resolved_rect()?));
}
if let Some(bounds) = text.bounds {
let text_bounds = Rect::new(text.origin.x, text.origin.y, bounds.width, bounds.height);
absolute_clip = intersect_rects(absolute_clip, Some(text_bounds));
}
absolute_clip?;
Some(absolute_clip.map(|rect| {
Rect::new(
rect.origin.x - text.origin.x,
rect.origin.y - text.origin.y,
rect.size.width,
rect.size.height,
)
}))
}
fn empty_clip_rect() -> Rect { fn empty_clip_rect() -> Rect {
Rect::new(1.0, 1.0, -1.0, -1.0) Rect::new(1.0, 1.0, -1.0, -1.0)
} }
@@ -2334,7 +2516,7 @@ fn clip_visible_glyphs(text: &PreparedText, clip: Option<Rect>) -> &[GlyphInstan
let local_bottom = local_top + clip.size.height; let local_bottom = local_top + clip.size.height;
// First line whose bottom edge reaches or passes the clip top. // First line whose bottom edge reaches or passes the clip top.
let start = lines.partition_point(|l| l.rect.origin.y + l.rect.size.height < local_top); let start = lines.partition_point(|l| l.rect.origin.y + l.rect.size.height <= local_top);
// First line whose top edge is strictly past the clip bottom. // First line whose top edge is strictly past the clip bottom.
let end = lines.partition_point(|l| l.rect.origin.y <= local_bottom); let end = lines.partition_point(|l| l.rect.origin.y <= local_bottom);
@@ -2347,12 +2529,21 @@ fn clip_visible_glyphs(text: &PreparedText, clip: Option<Rect>) -> &[GlyphInstan
&text.glyphs[glyph_start..glyph_end.min(text.glyphs.len())] &text.glyphs[glyph_start..glyph_end.min(text.glyphs.len())]
} }
fn text_texture_key(text: &PreparedText) -> TextTextureKey { fn text_texture_key(
text: &PreparedText,
raster_clip: Option<Rect>,
scale_factor: f32,
) -> TextTextureKey {
TextTextureKey { TextTextureKey {
text: text.text.clone(), layout_key: text.layout_cache_key(),
bounds: text bounds: text.bounds.map(|bounds| {
.bounds (
.map(|bounds| (bounds.width.to_bits(), bounds.height.to_bits())), (bounds.width * scale_factor).ceil() as i32,
(bounds.height * scale_factor).ceil() as i32,
)
}),
clip: raster_clip
.map(|clip| pixel_rect_tuple(scale_rect_to_pixel_rect(clip, scale_factor))),
font_size_bits: text.font_size.to_bits(), font_size_bits: text.font_size.to_bits(),
line_height_bits: text.line_height.to_bits(), line_height_bits: text.line_height.to_bits(),
color: ( color: (
@@ -2361,28 +2552,33 @@ fn text_texture_key(text: &PreparedText) -> TextTextureKey {
text.default_color.b, text.default_color.b,
text.default_color.a, text.default_color.a,
), ),
glyphs: text }
.glyphs }
.iter()
.map(|glyph| TextTextureGlyph { fn pixel_rect_tuple(rect: PixelRect) -> (i32, i32, i32, i32) {
// Glyph positions are already LOCAL; no subtraction needed. (rect.left, rect.top, rect.right, rect.bottom)
local_x_bits: glyph.position.x.to_bits(), }
local_y_bits: glyph.position.y.to_bits(),
advance_bits: glyph.advance.to_bits(), fn scale_glyph_cache_key(cache_key: CacheKey, scale_factor: f32) -> CacheKey {
color: (glyph.color.r, glyph.color.g, glyph.color.b, glyph.color.a), CacheKey {
cache_key: glyph.cache_key, font_size_bits: (f32::from_bits(cache_key.font_size_bits) * scale_factor.max(1.0))
}) .to_bits(),
.collect(), ..cache_key
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
ActiveClip, blend_rgba, build_text_vertices, build_vertices, clip_rect_array, ActiveClip, AtlasRect, GlyphVertexContext, PixelRect, blend_rgba, build_text_vertices,
push_clip_state, rounded_clip_arrays, text_texture_key, build_vertices, clip_rect_array, clip_visible_glyphs, push_clip_state, push_glyph_vertices,
rounded_clip_arrays, scale_glyph_cache_key, text_texture_key,
};
use cosmic_text::{CacheKey, CacheKeyFlags, SubpixelBin, fontdb};
use ruin_ui::{
ClipRegion, Color, Point, PreparedText, Rect, SceneSnapshot, TextStyle, TextSystem,
TextWrap, UiSize,
}; };
use ruin_ui::{ClipRegion, Color, Point, PreparedText, Rect, SceneSnapshot, UiSize};
#[test] #[test]
fn quad_scenes_expand_to_six_vertices_per_quad() { fn quad_scenes_expand_to_six_vertices_per_quad() {
@@ -2403,7 +2599,7 @@ mod tests {
Color::rgb(0xFF, 0xFF, 0xFF), Color::rgb(0xFF, 0xFF, 0xFF),
)); ));
let vertices = build_vertices(&scene); let vertices = build_vertices(&scene, scene.logical_size);
assert_eq!(vertices.len(), 12); assert_eq!(vertices.len(), 12);
} }
@@ -2435,7 +2631,97 @@ mod tests {
Color::rgb(0xEE, 0xEE, 0xEE), Color::rgb(0xEE, 0xEE, 0xEE),
); );
assert_eq!(text_texture_key(&first), text_texture_key(&second)); assert_eq!(
text_texture_key(&first, None, 1.0),
text_texture_key(&second, None, 1.0)
);
}
#[test]
fn text_texture_key_normalizes_clip_to_device_pixels() {
let text = PreparedText::monospace(
"scrolling text",
Point::new(20.0, 30.0),
16.0,
8.0,
Color::rgb(0xEE, 0xEE, 0xEE),
);
let first = text_texture_key(&text, Some(Rect::new(0.001, 4.001, 80.001, 20.001)), 2.0);
let second = text_texture_key(&text, Some(Rect::new(0.002, 4.002, 80.002, 20.002)), 2.0);
let different_pixel =
text_texture_key(&text, Some(Rect::new(0.6, 4.001, 80.001, 20.001)), 2.0);
assert_eq!(first, second);
assert_ne!(first, different_pixel);
}
#[test]
fn clip_visible_glyphs_returns_only_lines_intersecting_clip() {
let mut text_system = TextSystem::new();
let style = TextStyle::new(16.0, Color::rgb(0xEE, 0xEE, 0xEE))
.with_wrap(TextWrap::None)
.with_line_height(20.0);
let text = text_system.prepare("abcd\nefgh", Point::new(10.0, 20.0), &style);
let visible = clip_visible_glyphs(&text, Some(Rect::new(10.0, 40.0, 100.0, 20.0)));
assert_eq!(visible.len(), 4);
assert_eq!(visible[0].text_start, 5);
assert_eq!(visible[3].text_end, 9);
}
#[test]
fn scale_glyph_cache_key_scales_font_size_only() {
let key = CacheKey {
font_id: fontdb::ID::dummy(),
glyph_id: 42,
font_size_bits: 13.0_f32.to_bits(),
x_bin: SubpixelBin::One,
y_bin: SubpixelBin::Two,
font_weight: fontdb::Weight::BOLD,
flags: CacheKeyFlags::FAKE_ITALIC,
};
let scaled = scale_glyph_cache_key(key, 2.0);
assert_eq!(f32::from_bits(scaled.font_size_bits), 26.0);
assert_eq!(scaled.font_id, key.font_id);
assert_eq!(scaled.glyph_id, key.glyph_id);
assert_eq!(scaled.x_bin, key.x_bin);
assert_eq!(scaled.y_bin, key.y_bin);
assert_eq!(scaled.font_weight, key.font_weight);
assert_eq!(scaled.flags, key.flags);
}
#[test]
fn scaled_atlas_vertices_convert_device_pixels_to_logical_space() {
let mut vertices = Vec::new();
push_glyph_vertices(
&mut vertices,
PixelRect {
left: 20,
top: 10,
right: 40,
bottom: 30,
},
AtlasRect {
x: 0,
y: 0,
width: 20,
height: 20,
},
None,
GlyphVertexContext {
logical_size: UiSize::new(100.0, 100.0),
scale_factor: 2.0,
color: Color::rgb(0xFF, 0xFF, 0xFF),
clip: ActiveClip::default(),
},
);
assert_eq!(vertices.len(), 6);
assert_eq!(vertices[0].world_position, [10.0, 5.0]);
assert_eq!(vertices[5].world_position, [20.0, 15.0]);
} }
#[test] #[test]