//! Frame-time measurements for notes full of fenced code blocks (run on demand, release build): //! `cargo test --release -p notescraft-ui-egui --test perf_code_blocks -- --ignored --nocapture`. //! //! Reports the first frame (layout, glyph shaping, paint of every visible row) and the steady //! frames after the worker-thread syntax colours have landed, median over [`RUNS`] fresh contexts. //! The release target for 50 small blocks is a first frame under 50 ms; the assertions here are //! deliberately loose so the test also runs in a debug build. use std::fmt::Write as _; use std::time::{Duration, Instant}; use egui::{Color32, Context, Pos2, RawInput, Rect, epaint::Shape, vec2}; use notescraft_markdown::Document; use notescraft_ui_egui::note_view::{self, CalloutFolds}; use notescraft_ui_egui::theme::{Tokens, color}; /// Fresh contexts per measurement. const RUNS: usize = 5; /// Fenced blocks in the many-blocks note. const BLOCKS: usize = 50; /// Lines inside each of those blocks. const BLOCK_LINES: usize = 20; /// Lines in the single huge block note. const HUGE_LINES: usize = 5000; /// Frames timed after the first one, per run. const STEADY_FRAMES: usize = 3; /// How long to wait for the worker-thread syntax colours before the steady measurement. const COLOUR_WAIT: Duration = Duration::from_secs(2); /// Sleep between the extra frames of the colour poll. const POLL_WAIT: Duration = Duration::from_millis(5); /// Generous debug-build bounds; the real budget is 50 ms first frame in release. const FIRST_FRAME_BUDGET: Duration = Duration::from_millis(2000); const STEADY_BUDGET: Duration = Duration::from_millis(250); /// One measured run over a fresh context. struct Run { /// The very first frame of the context. first: Duration, /// Median of the [`STEADY_FRAMES`] frames after the colours landed. steady: Duration, /// Whether a keyword-coloured run ever appeared. coloured: bool, /// How long that took. colour_after: Duration, /// Extra frames burned by the poll before the steady frames. polls: u32, } /// A note of `count` fenced blocks, `lines` lines each, cycling through four labels. fn blocks_note(count: usize, lines: usize) -> String { let labels = ["rust", "json", "python", ""]; let mut out = String::from("# Code blocks\n\n"); for block in 0..count { let label = labels.get(block % labels.len()).copied().unwrap_or_default(); let _ = writeln!(out, "```{label}"); for line in 0..lines { match label { "rust" => { let _ = writeln!(out, "fn row_{block}_{line}(n: usize) -> usize {{ n + {line} }}"); } "json" => { let _ = writeln!(out, "{{\"id\": {line}, \"name\": \"row_{line}\", \"ok\": true}},"); } "python" => { let _ = writeln!(out, "def row_{line}(n):\n return n + {line}"); } _ => { let _ = writeln!(out, "plain row {line} of block {block}"); } } } out.push_str("```\n\n"); } out } /// A note with one very long block; `rust` so the syntax worker still has colours to deliver. fn huge_note(lines: usize) -> String { let mut body = String::new(); for line in 0..lines { let _ = writeln!(body, "fn row_{line}(n: usize) -> usize {{ n + {line} }}"); } format!("```rust\n{body}```\n") } /// Theme tokens plus the colour a keyword token should end up with. fn theme() -> (Tokens, Color32) { let tokens = Tokens::new(notescraft_themes::builtin("catppuccin-mocha").expect("builtin theme")); let keyword = color(tokens.theme.syntax.keyword); (tokens, keyword) } /// A context with the app fonts installed and warmed, so the first timed frame is layout only. fn fresh_ctx() -> Context { let ctx = Context::default(); notescraft_ui_egui::fonts::install(&ctx); let mut warm = ctx.run_ui(RawInput::default(), |_| {}); warm.textures_delta.clear(); ctx } /// Renders the note body once and returns the frame with its wall time. fn frame(ctx: &Context, tokens: &Tokens, doc: &Document) -> (egui::FullOutput, Duration) { let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(900.0, 700.0))), ..RawInput::default() }; let start = Instant::now(); let mut out = ctx.run_ui(input, |ui| { let mut folds = CalloutFolds::default(); let _ = note_view::show_note(ui, tokens, doc, &|_: &str| true, &mut folds); }); let took = start.elapsed(); out.textures_delta.clear(); (out, took) } /// Every painted text run as (text, colour); colours live in the layout job sections. fn runs(out: &egui::FullOutput) -> Vec<(String, Color32)> { let mut painted = Vec::new(); for shape in &out.shapes { if let Shape::Text(text) = &shape.shape { let job = &text.galley.job; for section in &job.sections { let run = job.text.get(section.byte_range.start.0..section.byte_range.end.0).unwrap_or_default(); painted.push((run.to_owned(), section.format.color)); } } } painted } /// `true` once the syntax worker delivered at least one keyword-coloured run. fn has_keyword(out: &egui::FullOutput, keyword: Color32) -> bool { runs(out).iter().any(|(_, colour)| *colour == keyword) } /// Median of a slice; even lengths take the lower of the two middles. fn median(values: &[Duration]) -> Duration { let mut sorted = values.to_vec(); sorted.sort_unstable(); let middle = sorted.len() / 2; sorted.get(middle).copied().unwrap_or_default() } /// First frame, then the steady frames, on one fresh context. fn measure(doc: &Document, tokens: &Tokens, keyword: Color32) -> Run { let ctx = fresh_ctx(); let (out, first) = frame(&ctx, tokens, doc); let mut coloured = has_keyword(&out, keyword); let mut polls = 0; let waited = Instant::now(); while !coloured && waited.elapsed() < COLOUR_WAIT { std::thread::sleep(POLL_WAIT); let (out, _) = frame(&ctx, tokens, doc); coloured = has_keyword(&out, keyword); polls += 1; } let colour_after = waited.elapsed(); let steady: Vec = (0..STEADY_FRAMES).map(|_| frame(&ctx, tokens, doc).1).collect(); Run { first, steady: median(&steady), coloured, colour_after, polls } } /// Times `note` `RUNS` times and prints one line per run plus the medians. fn report(label: &str, source: &str) -> Run { let (tokens, keyword) = theme(); let parsed = Instant::now(); let doc = notescraft_markdown::parse(source); eprintln!("perf: {label}: {} bytes, parsed in {:.2?}", source.len(), parsed.elapsed()); let mut runs: Vec = Vec::with_capacity(RUNS); for n in 0..RUNS { let run = measure(&doc, &tokens, keyword); eprintln!( "perf: {label} run {n}: first {:.2?}, steady {:.2?}, colours after {:.2?} ({}, {} poll frames)", run.first, run.steady, run.colour_after, if run.coloured { "arrived" } else { "missing" }, run.polls ); runs.push(run); } let firsts: Vec = runs.iter().map(|r| r.first).collect(); let steadies: Vec = runs.iter().map(|r| r.steady).collect(); let coloured = runs.iter().filter(|r| r.coloured).count(); eprintln!("perf: {label}: colours in {coloured}/{RUNS} runs"); Run { first: median(&firsts), steady: median(&steadies), coloured: coloured == RUNS, colour_after: Duration::ZERO, polls: 0 } } #[test] #[ignore = "measurement, run on demand in a release build"] fn frame_cost_of_a_note_full_of_code_blocks() { let blocks = report("50 blocks", &blocks_note(BLOCKS, BLOCK_LINES)); eprintln!("perf: 50 blocks first frame {:.2} ms, steady {:.2} ms", blocks.first.as_secs_f64() * 1000.0, blocks.steady.as_secs_f64() * 1000.0); let huge = report("5000-line block", &huge_note(HUGE_LINES)); eprintln!("perf: 5000-line block first frame {:.2} ms, steady {:.2} ms", huge.first.as_secs_f64() * 1000.0, huge.steady.as_secs_f64() * 1000.0); assert!(blocks.first < FIRST_FRAME_BUDGET, "50 blocks first frame {:?} over {:?}", blocks.first, FIRST_FRAME_BUDGET); assert!(blocks.steady < STEADY_BUDGET, "50 blocks steady frame {:?} over {:?}", blocks.steady, STEADY_BUDGET); assert!(huge.first < FIRST_FRAME_BUDGET, "5000-line block first frame {:?} over {:?}", huge.first, FIRST_FRAME_BUDGET); assert!(huge.steady < STEADY_BUDGET, "5000-line block steady frame {:?} over {:?}", huge.steady, STEADY_BUDGET); }