Files
Marcos Paulo 5e8020aa38
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled
feat: Obsidian basics, editor previews, graph rework, query blocks, stacked tabs, pop-out windows
Editor previews (colour swatches, arrows, embeds), the 18 obsidian-basics tasks (find and replace
with regex, back/forward, auto-pair, footnotes, folding, search operators, split view, PDF/audio/
video embeds, spelling, drag and drop, pinned tabs, settings) and round 2: ```query blocks, stacked
tabs, pop-out windows, tasks ticked inside embeds, text around an embed, embeds that follow edits,
block menu shortcut. New crates: notescraft-regex (linear-time engine) and notescraft-syntax.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 11:21:19 -03:00

203 lines
8.5 KiB
Rust

//! 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<Duration> = (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<Run> = 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<Duration> = runs.iter().map(|r| r.first).collect();
let steadies: Vec<Duration> = 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);
}