Compare commits
2 Commits
macos
...
e5ae067bc2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5ae067bc2 | ||
|
|
4347232b98 |
16
Cargo.lock
generated
16
Cargo.lock
generated
@@ -1934,6 +1934,22 @@ dependencies = [
|
|||||||
"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"
|
||||||
|
|||||||
15
Cargo.toml
15
Cargo.toml
@@ -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",
|
||||||
|
]
|
||||||
|
|||||||
94
docs/performance-profiling.md
Normal file
94
docs/performance-profiling.md
Normal 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.
|
||||||
@@ -177,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");
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
8
examples/perf_scenarios/Cargo.toml
Normal file
8
examples/perf_scenarios/Cargo.toml
Normal 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" }
|
||||||
366
examples/perf_scenarios/src/main.rs
Normal file
366
examples/perf_scenarios/src/main.rs
Normal 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
8
lib/perf/Cargo.toml
Normal 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
226
lib/perf/src/lib.rs
Normal 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)]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
@@ -114,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();
|
||||||
@@ -283,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,
|
||||||
@@ -322,21 +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.get(
|
&& reuse.old_text.get(
|
||||||
absolute_range(old, old_parent_start).start
|
absolute_range(old, old_parent_start).start
|
||||||
..absolute_range(old, old_parent_start).end,
|
..absolute_range(old, old_parent_start).end,
|
||||||
) == new_text.get(parsed.range.start..parsed.range.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,
|
||||||
@@ -359,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();
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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());
|
||||||
@@ -416,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
|
||||||
|
|||||||
@@ -323,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()
|
||||||
@@ -358,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);
|
||||||
|
|
||||||
@@ -403,16 +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
|
if let Some(old) = old.filter(|old| {
|
||||||
.filter(|old| can_reuse_node(old, old_parent_start, parsed, old_text, new_text, delta))
|
can_reuse_node(
|
||||||
{
|
old,
|
||||||
*reused_nodes += count_nodes(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,
|
||||||
@@ -432,9 +446,8 @@ impl TokenTreeParser {
|
|||||||
.children
|
.children
|
||||||
.get(index)
|
.get(index)
|
||||||
.filter(|candidate| !used[index] && can_descend_into_node(candidate, child))
|
.filter(|candidate| !used[index] && can_descend_into_node(candidate, child))
|
||||||
.map(|candidate| {
|
.inspect(|_| {
|
||||||
used[index] = true;
|
used[index] = true;
|
||||||
candidate
|
|
||||||
})
|
})
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
old.children.iter().enumerate().find_map(
|
old.children.iter().enumerate().find_map(
|
||||||
@@ -455,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()
|
||||||
@@ -467,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()
|
||||||
};
|
};
|
||||||
@@ -545,13 +546,13 @@ 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();
|
self.bump_char();
|
||||||
terminated = true;
|
terminated = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let Some(ch) = self.peek_char() else {
|
let Some(ch) = self.peek_char() else {
|
||||||
break;
|
break;
|
||||||
@@ -1376,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| {
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ pub(crate) fn sync_primary_selection(
|
|||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
{
|
{
|
||||||
let _ = (window, interaction_tree, selection);
|
let _ = (window, interaction_tree, selection);
|
||||||
return Ok(());
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
|
|||||||
@@ -36,6 +36,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>);
|
||||||
|
|
||||||
@@ -194,6 +223,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);
|
||||||
@@ -215,48 +254,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 {
|
LayoutSnapshot {
|
||||||
scene,
|
scene,
|
||||||
interaction_tree: InteractionTree {
|
interaction_tree: InteractionTree {
|
||||||
root: interaction_root,
|
root: interaction_root,
|
||||||
},
|
},
|
||||||
root_min_size,
|
root_min_size,
|
||||||
}
|
},
|
||||||
|
stats,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
@@ -2903,7 +2972,7 @@ mod tests {
|
|||||||
.items
|
.items
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|item| match item {
|
.filter_map(|item| match item {
|
||||||
DisplayItem::Text(text) => Some(text.text.as_str()),
|
DisplayItem::Text(text) => Some(text.text.as_ref()),
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
|
|||||||
@@ -31,8 +31,9 @@ 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};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -269,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,
|
||||||
@@ -341,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,
|
||||||
@@ -352,7 +351,6 @@ impl TextSystem {
|
|||||||
|
|
||||||
PreparedText::from_layout(
|
PreparedText::from_layout(
|
||||||
None,
|
None,
|
||||||
text,
|
|
||||||
origin,
|
origin,
|
||||||
bounds,
|
bounds,
|
||||||
style.font_size,
|
style.font_size,
|
||||||
@@ -476,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();
|
||||||
@@ -527,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)),
|
||||||
|
|||||||
@@ -244,6 +244,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 +282,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,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
@@ -599,8 +602,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 +613,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);
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[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"
|
libc = "0.2"
|
||||||
objc2 = "0.6.4"
|
objc2 = "0.6.4"
|
||||||
objc2-core-foundation = "0.3.2"
|
objc2-core-foundation = "0.3.2"
|
||||||
@@ -11,7 +17,3 @@ objc2-foundation = "0.3.2"
|
|||||||
objc2-quartz-core = "0.3.2"
|
objc2-quartz-core = "0.3.2"
|
||||||
raw-window-handle = "0.6"
|
raw-window-handle = "0.6"
|
||||||
raw-window-metal = "1.1.0"
|
raw-window-metal = "1.1.0"
|
||||||
ruin_ui = { path = "../ui" }
|
|
||||||
ruin_ui_renderer_wgpu = { path = "../ui_renderer_wgpu" }
|
|
||||||
ruin_runtime = { package = "ruin-runtime", path = "../runtime" }
|
|
||||||
tracing = "0.1"
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
//!
|
//!
|
||||||
//! Native backend implemented on top of Cocoa/AppKit runtime messaging.
|
//! Native backend implemented on top of Cocoa/AppKit runtime messaging.
|
||||||
|
|
||||||
|
#![cfg(target_os = "macos")]
|
||||||
|
|
||||||
use std::cell::{Cell, RefCell};
|
use std::cell::{Cell, RefCell};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::ffi::{CStr, CString, c_void};
|
use std::ffi::{CStr, CString, c_void};
|
||||||
@@ -353,9 +355,7 @@ impl ResizePipeline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn note_presented_scene(&mut self, scene_viewport: UiSize) -> Option<WindowConfigured> {
|
fn note_presented_scene(&mut self, scene_viewport: UiSize) -> Option<WindowConfigured> {
|
||||||
let Some(presented_configuration) = self.active_resize else {
|
let presented_configuration = self.active_resize?;
|
||||||
return None;
|
|
||||||
};
|
|
||||||
if presented_configuration.actual_inner_size != scene_viewport {
|
if presented_configuration.actual_inner_size != scene_viewport {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -365,9 +365,7 @@ impl ResizePipeline {
|
|||||||
self.next_resize = None;
|
self.next_resize = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(next_resize) = self.next_resize.take() else {
|
let next_resize = self.next_resize.take()?;
|
||||||
return None;
|
|
||||||
};
|
|
||||||
self.active_resize = Some(next_resize);
|
self.active_resize = Some(next_resize);
|
||||||
Some(next_resize)
|
Some(next_resize)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"] }
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -169,22 +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<(i32, i32)>,
|
bounds: Option<(i32, i32)>,
|
||||||
clip: Option<(i32, i32, 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] = [
|
||||||
@@ -315,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,
|
||||||
@@ -334,6 +339,7 @@ pub struct WgpuSceneRenderer {
|
|||||||
glyph_atlas: GlyphAtlas,
|
glyph_atlas: GlyphAtlas,
|
||||||
scale_factor: f32,
|
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;
|
||||||
@@ -729,6 +735,7 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
|
|||||||
glyph_atlas,
|
glyph_atlas,
|
||||||
scale_factor: 1.0,
|
scale_factor: 1.0,
|
||||||
base_color,
|
base_color,
|
||||||
|
last_frame_stats: RendererFrameStats::default(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -758,6 +765,10 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
self.render_with_logical_size(scene, scene.logical_size)
|
||||||
}
|
}
|
||||||
@@ -885,19 +896,34 @@ 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(())
|
||||||
@@ -908,10 +934,8 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
|
|||||||
text: &PreparedText,
|
text: &PreparedText,
|
||||||
raster_clip: Option<Rect>,
|
raster_clip: Option<Rect>,
|
||||||
) -> Option<RasterizedText> {
|
) -> Option<RasterizedText> {
|
||||||
if (self.scale_factor - 1.0).abs() <= f32::EPSILON
|
if text.glyphs.iter().all(|glyph| glyph.cache_key.is_some()) {
|
||||||
&& text.glyphs.iter().all(|glyph| glyph.cache_key.is_some())
|
return self.rasterize_prepared_text(text, raster_clip, self.scale_factor);
|
||||||
{
|
|
||||||
return self.rasterize_prepared_text(text, raster_clip);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let clip = raster_clip.map(|clip| scale_rect_to_pixel_rect(clip, self.scale_factor));
|
let clip = raster_clip.map(|clip| scale_rect_to_pixel_rect(clip, self.scale_factor));
|
||||||
@@ -953,16 +977,18 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
|
|||||||
&mut self,
|
&mut self,
|
||||||
text: &PreparedText,
|
text: &PreparedText,
|
||||||
raster_clip: Option<Rect>,
|
raster_clip: Option<Rect>,
|
||||||
|
scale_factor: f32,
|
||||||
) -> Option<RasterizedText> {
|
) -> Option<RasterizedText> {
|
||||||
let glyphs = self.collect_prepared_glyphs(text);
|
let clip = raster_clip.map(|clip| scale_rect_to_pixel_rect(clip, scale_factor));
|
||||||
|
let glyphs = self.collect_prepared_glyphs(text, raster_clip, clip, scale_factor);
|
||||||
let clip = raster_clip
|
let clip = raster_clip
|
||||||
.map(rect_to_pixel_rect)
|
.map(|clip| scale_rect_to_pixel_rect(clip, scale_factor))
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
text.bounds.map(|bounds| PixelRect {
|
text.bounds.map(|bounds| PixelRect {
|
||||||
left: 0,
|
left: 0,
|
||||||
top: 0,
|
top: 0,
|
||||||
right: bounds.width.ceil() as i32,
|
right: (bounds.width * scale_factor).ceil() as i32,
|
||||||
bottom: bounds.height.ceil() as i32,
|
bottom: (bounds.height * scale_factor).ceil() as i32,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.or_else(|| glyph_union_prepared(&glyphs))?;
|
.or_else(|| glyph_union_prepared(&glyphs))?;
|
||||||
@@ -983,17 +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(
|
||||||
draw_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),
|
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
|
||||||
@@ -1010,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;
|
||||||
glyphs.push(PreparedGlyphBitmap {
|
let rect = PixelRect {
|
||||||
rect: PixelRect {
|
|
||||||
left: local_x + image.placement.left,
|
left: local_x + image.placement.left,
|
||||||
top: local_y - image.placement.top,
|
top: local_y - image.placement.top,
|
||||||
right: local_x + image.placement.left + width,
|
right: local_x + image.placement.left + width,
|
||||||
bottom: local_y - image.placement.top + height,
|
bottom: local_y - image.placement.top + height,
|
||||||
},
|
};
|
||||||
|
if pixel_clip.is_some_and(|clip| !pixel_rects_intersect(rect, clip)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
glyphs.push(PreparedGlyphBitmap {
|
||||||
|
rect,
|
||||||
cache_key,
|
cache_key,
|
||||||
color: resolve_glyph_color(glyph.color, text.default_color),
|
color: resolve_glyph_color(glyph.color, text.default_color),
|
||||||
});
|
});
|
||||||
@@ -1046,6 +1100,13 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
|
|||||||
|
|
||||||
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
|
||||||
@@ -1163,10 +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,
|
||||||
|
GlyphVertexContext {
|
||||||
logical_size,
|
logical_size,
|
||||||
self.scale_factor,
|
scale_factor: self.scale_factor,
|
||||||
resolved_color,
|
color: resolved_color,
|
||||||
active_clip,
|
clip: active_clip,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1888,34 +1951,40 @@ 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,
|
||||||
scale_factor: f32,
|
|
||||||
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 scale_factor = scale_factor.max(1.0);
|
let scale_factor = context.scale_factor.max(1.0);
|
||||||
let logical_left = dest_rect.left as f32 / scale_factor;
|
let logical_left = dest_rect.left as f32 / scale_factor;
|
||||||
let logical_top = dest_rect.top as f32 / scale_factor;
|
let logical_top = dest_rect.top as f32 / scale_factor;
|
||||||
let logical_right = dest_rect.right as f32 / scale_factor;
|
let logical_right = dest_rect.right as f32 / scale_factor;
|
||||||
let logical_bottom = dest_rect.bottom as f32 / scale_factor;
|
let logical_bottom = dest_rect.bottom as f32 / scale_factor;
|
||||||
let left = to_ndc_x(logical_left, logical_size.width.max(1.0));
|
let left = to_ndc_x(logical_left, context.logical_size.width.max(1.0));
|
||||||
let right = to_ndc_x(logical_right, 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, logical_size.height.max(1.0));
|
let top = to_ndc_y(logical_top, context.logical_size.height.max(1.0));
|
||||||
let bottom = to_ndc_y(logical_bottom, 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],
|
||||||
@@ -2238,15 +2307,6 @@ fn expand_rect(rect: Rect, inset: f32) -> Rect {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rect_to_pixel_rect(rect: Rect) -> PixelRect {
|
|
||||||
PixelRect {
|
|
||||||
left: rect.origin.x.floor() as i32,
|
|
||||||
top: rect.origin.y.floor() as i32,
|
|
||||||
right: (rect.origin.x + rect.size.width).ceil() as i32,
|
|
||||||
bottom: (rect.origin.y + rect.size.height).ceil() as i32,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn scale_rect_to_pixel_rect(rect: Rect, scale: f32) -> PixelRect {
|
fn scale_rect_to_pixel_rect(rect: Rect, scale: f32) -> PixelRect {
|
||||||
PixelRect {
|
PixelRect {
|
||||||
left: (rect.origin.x * scale).floor() as i32,
|
left: (rect.origin.x * scale).floor() as i32,
|
||||||
@@ -2335,9 +2395,7 @@ fn text_raster_clip(
|
|||||||
let text_bounds = Rect::new(text.origin.x, text.origin.y, bounds.width, bounds.height);
|
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 = intersect_rects(absolute_clip, Some(text_bounds));
|
||||||
}
|
}
|
||||||
if absolute_clip.is_none() {
|
absolute_clip?;
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(absolute_clip.map(|rect| {
|
Some(absolute_clip.map(|rect| {
|
||||||
Rect::new(
|
Rect::new(
|
||||||
@@ -2458,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);
|
||||||
|
|
||||||
@@ -2477,7 +2535,7 @@ fn text_texture_key(
|
|||||||
scale_factor: f32,
|
scale_factor: f32,
|
||||||
) -> TextTextureKey {
|
) -> TextTextureKey {
|
||||||
TextTextureKey {
|
TextTextureKey {
|
||||||
text: text.text.clone(),
|
layout_key: text.layout_cache_key(),
|
||||||
bounds: text.bounds.map(|bounds| {
|
bounds: text.bounds.map(|bounds| {
|
||||||
(
|
(
|
||||||
(bounds.width * scale_factor).ceil() as i32,
|
(bounds.width * scale_factor).ceil() as i32,
|
||||||
@@ -2494,18 +2552,6 @@ fn text_texture_key(
|
|||||||
text.default_color.b,
|
text.default_color.b,
|
||||||
text.default_color.a,
|
text.default_color.a,
|
||||||
),
|
),
|
||||||
glyphs: text
|
|
||||||
.glyphs
|
|
||||||
.iter()
|
|
||||||
.map(|glyph| TextTextureGlyph {
|
|
||||||
// Glyph positions are already LOCAL; no subtraction needed.
|
|
||||||
local_x_bits: glyph.position.x.to_bits(),
|
|
||||||
local_y_bits: glyph.position.y.to_bits(),
|
|
||||||
advance_bits: glyph.advance.to_bits(),
|
|
||||||
color: (glyph.color.r, glyph.color.g, glyph.color.b, glyph.color.a),
|
|
||||||
cache_key: glyph.cache_key,
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2524,12 +2570,15 @@ fn scale_glyph_cache_key(cache_key: CacheKey, scale_factor: f32) -> CacheKey {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
ActiveClip, AtlasRect, PixelRect, blend_rgba, build_text_vertices, build_vertices,
|
ActiveClip, AtlasRect, GlyphVertexContext, PixelRect, blend_rgba, build_text_vertices,
|
||||||
clip_rect_array, push_clip_state, push_glyph_vertices, rounded_clip_arrays,
|
build_vertices, clip_rect_array, clip_visible_glyphs, push_clip_state, push_glyph_vertices,
|
||||||
scale_glyph_cache_key, text_texture_key,
|
rounded_clip_arrays, scale_glyph_cache_key, text_texture_key,
|
||||||
};
|
};
|
||||||
use cosmic_text::{CacheKey, CacheKeyFlags, SubpixelBin, fontdb};
|
use cosmic_text::{CacheKey, CacheKeyFlags, SubpixelBin, fontdb};
|
||||||
use ruin_ui::{ClipRegion, Color, Point, PreparedText, Rect, SceneSnapshot, UiSize};
|
use ruin_ui::{
|
||||||
|
ClipRegion, Color, Point, PreparedText, Rect, SceneSnapshot, TextStyle, TextSystem,
|
||||||
|
TextWrap, UiSize,
|
||||||
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn quad_scenes_expand_to_six_vertices_per_quad() {
|
fn quad_scenes_expand_to_six_vertices_per_quad() {
|
||||||
@@ -2607,6 +2656,20 @@ mod tests {
|
|||||||
assert_ne!(first, different_pixel);
|
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]
|
#[test]
|
||||||
fn scale_glyph_cache_key_scales_font_size_only() {
|
fn scale_glyph_cache_key_scales_font_size_only() {
|
||||||
let key = CacheKey {
|
let key = CacheKey {
|
||||||
@@ -2648,10 +2711,12 @@ mod tests {
|
|||||||
height: 20,
|
height: 20,
|
||||||
},
|
},
|
||||||
None,
|
None,
|
||||||
UiSize::new(100.0, 100.0),
|
GlyphVertexContext {
|
||||||
2.0,
|
logical_size: UiSize::new(100.0, 100.0),
|
||||||
Color::rgb(0xFF, 0xFF, 0xFF),
|
scale_factor: 2.0,
|
||||||
ActiveClip::default(),
|
color: Color::rgb(0xFF, 0xFF, 0xFF),
|
||||||
|
clip: ActiveClip::default(),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(vertices.len(), 6);
|
assert_eq!(vertices.len(), 6);
|
||||||
|
|||||||
Reference in New Issue
Block a user