mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-25 02:47:33 -06:00
ui.wheel injects a mouse-wheel / trackpad scroll at a window point (the control channel had no scroll input). perf_probe runs the real UI headless, opens a document and times the frames that handle typing, scrolling, zooming, ribbon tabs and resizes (numbers only). Co-authored-by: przemek-arch <263159043+przemek-arch@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
150 lines
7.2 KiB
Rust
150 lines
7.2 KiB
Rust
//! Responsiveness probe: runs the real WordCraft UI headless (like `ui_shot`, without a window or
|
|
//! focus), opens a document, and times the frames that handle typing, scrolling, zooming,
|
|
//! ribbon tabs and window resizes. Prints numbers only (JSON), never document text.
|
|
//!
|
|
//! `cargo run --release -p wordcraft-ui-egui --example perf_probe -- doc.docx [block]`
|
|
//!
|
|
//! A frame's time is the CPU time of `WordApp::logic` + `ui` for that frame (layout, page
|
|
//! rasterisation, widgets). What the user sees adds the wait for the next frame and the
|
|
//! presentation on screen (up to one display refresh).
|
|
|
|
use std::time::Instant;
|
|
|
|
use serde_json::json;
|
|
use wordcraft_engine::Session;
|
|
use wordcraft_ui_egui::{Services, WordApp};
|
|
|
|
static READY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
|
|
|
fn step(h: &mut egui_kittest::Harness<'_, WordApp>) -> f64 {
|
|
let mut raw = std::mem::take(h.input_mut());
|
|
h.state_mut().raw_input_hook(&mut raw);
|
|
*h.input_mut() = raw;
|
|
let t = Instant::now();
|
|
h.step();
|
|
t.elapsed().as_secs_f64() * 1000.0
|
|
}
|
|
|
|
fn stats(v: &[f64]) -> serde_json::Value {
|
|
let mut s = v.to_vec();
|
|
s.sort_by(|a, b| a.total_cmp(b));
|
|
let at = |q: f64| s.get(((s.len() as f64 - 1.0) * q).round() as usize).copied().unwrap_or(0.0);
|
|
json!({"n": s.len(), "med": (at(0.5) * 10.0).round() / 10.0, "p95": (at(0.95) * 10.0).round() / 10.0, "max": (at(1.0) * 10.0).round() / 10.0})
|
|
}
|
|
|
|
fn main() {
|
|
let path = std::env::args().nth(1).expect("usage: perf_probe doc.docx [block]");
|
|
let block: Option<usize> = std::env::args().nth(2).and_then(|b| b.parse().ok());
|
|
let t_open = Instant::now();
|
|
let mut session = Session::new(wordcraft_doc::Document::new());
|
|
session.run("file.open", &json!({"path": path})).expect("open");
|
|
let open_ms = t_open.elapsed().as_secs_f64() * 1000.0;
|
|
let app = WordApp::new(session, Services::default());
|
|
let mut h =
|
|
egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_pixels_per_point(2.0).with_max_steps(1_000_000).build_ui_state(
|
|
|ui, app: &mut WordApp| {
|
|
if !READY.load(std::sync::atomic::Ordering::Relaxed) {
|
|
return;
|
|
}
|
|
let ctx = ui.ctx().clone();
|
|
app.logic(&ctx);
|
|
app.ui(ui);
|
|
},
|
|
app,
|
|
);
|
|
READY.store(true, std::sync::atomic::Ordering::Relaxed);
|
|
let first: Vec<f64> = (0..6).map(|_| step(&mut h)).collect();
|
|
let mut out =
|
|
json!({"open_ms": (open_ms * 10.0).round() / 10.0, "first_frames_ms": first.iter().map(|x| (x * 10.0).round() / 10.0).collect::<Vec<_>>()});
|
|
|
|
// Typing: the caret in the middle of `block` (default: the longest paragraph), 40 characters.
|
|
let doc_block = block.unwrap_or_else(|| {
|
|
let s = &h.state().session;
|
|
s.doc.body.iter().enumerate().filter_map(|(i, b)| b.as_para().map(|p| (i, p.len()))).max_by_key(|(_, l)| *l).map(|(i, _)| i).unwrap_or(0)
|
|
});
|
|
let typing = |h: &mut egui_kittest::Harness<'_, WordApp>, pos: serde_json::Value| -> serde_json::Value {
|
|
h.state_mut().session.run("caret.set", &json!({"pos": pos})).ok();
|
|
h.state_mut().canvas.want_focus = true;
|
|
for _ in 0..3 {
|
|
step(h);
|
|
}
|
|
let mut frames = Vec::new();
|
|
let (mut lay, mut ren) = (Vec::new(), Vec::new());
|
|
for _ in 0..40 {
|
|
h.input_mut().events.push(egui::Event::Text("a".into()));
|
|
frames.push(step(h));
|
|
// the frame after (relayout of a long paragraph may finish there)
|
|
frames.push(step(h));
|
|
lay.push(h.state_mut().session.layout().ms);
|
|
ren.push(h.state().canvas.render_ms);
|
|
}
|
|
let typed = frames.chunks(2).map(|c| c[0] + c[1]).collect::<Vec<_>>();
|
|
json!({"frame_with_key_ms": stats(&frames.iter().step_by(2).copied().collect::<Vec<_>>()), "two_frames_ms": stats(&typed), "layout_ms": stats(&lay), "render_ms": stats(&ren)})
|
|
};
|
|
let mid = h.state().session.doc.body.get(doc_block).and_then(|b| b.as_para()).map(|p| p.len() / 2).unwrap_or(0);
|
|
// a char boundary at or before the middle
|
|
let mid = {
|
|
let p = h.state().session.doc.body.get(doc_block).and_then(|b| b.as_para()).map(|p| p.plain_text()).unwrap_or_default();
|
|
(0..=mid.min(p.len())).rev().find(|i| p.is_char_boundary(*i)).unwrap_or(0)
|
|
};
|
|
out["typing_long_para"] = typing(&mut h, json!({"story": "body", "path": [doc_block], "off": mid}));
|
|
out["typing_para_len"] = json!(h.state().session.doc.body.get(doc_block).and_then(|b| b.as_para()).map(|p| p.len()).unwrap_or(0));
|
|
let table = h.state().session.doc.body.iter().position(|b| matches!(&**b, wordcraft_doc::Block::Table(_)));
|
|
if let Some(t) = table {
|
|
out["typing_table"] = typing(&mut h, json!({"story": "body", "path": [t, 0, 0, 0], "off": 0}));
|
|
}
|
|
|
|
// Scrolling: mouse wheel (120 pt notches) and trackpad (20 pt gestures) over the canvas.
|
|
let scroll = |h: &mut egui_kittest::Harness<'_, WordApp>, dy: f32, n: usize| -> serde_json::Value {
|
|
h.state_mut().session.run("caret.docStart", &json!({})).ok();
|
|
for _ in 0..3 {
|
|
step(h);
|
|
}
|
|
let mut f = Vec::new();
|
|
for _ in 0..n {
|
|
h.input_mut().events.push(egui::Event::PointerMoved(egui::pos2(720.0, 500.0)));
|
|
h.input_mut().events.push(egui::Event::MouseWheel {
|
|
unit: egui::MouseWheelUnit::Point,
|
|
delta: egui::vec2(0.0, dy),
|
|
phase: egui::TouchPhase::Move,
|
|
modifiers: egui::Modifiers::NONE,
|
|
});
|
|
f.push(step(h));
|
|
}
|
|
json!({"frame_ms": stats(&f), "frames_over_33ms": f.iter().filter(|x| **x > 33.0).count(), "frames_over_16ms": f.iter().filter(|x| **x > 16.7).count()})
|
|
};
|
|
out["scroll_wheel"] = scroll(&mut h, -120.0, 120);
|
|
out["scroll_trackpad"] = scroll(&mut h, -20.0, 300);
|
|
|
|
// Zoom, ribbon tabs, resize: the frame after the change.
|
|
let mut zoom = Vec::new();
|
|
for z in [50, 75, 100, 150, 200, 100] {
|
|
h.state_mut().session.run("view.zoom", &json!({"value": z})).ok();
|
|
let a = step(&mut h);
|
|
let b = step(&mut h);
|
|
zoom.push(json!({"zoom": z, "frame_ms": (a * 10.0).round() / 10.0, "next_ms": (b * 10.0).round() / 10.0}));
|
|
}
|
|
out["zoom"] = json!(zoom);
|
|
let mut tabs = Vec::new();
|
|
for t in ["Insert", "Draw", "Design", "Layout", "References", "Mailings", "Review", "View", "Home"] {
|
|
h.state_mut().execute("ui.tab", json!({"tab": t})).ok();
|
|
tabs.push(step(&mut h));
|
|
}
|
|
out["ribbon_tab_ms"] = stats(&tabs);
|
|
let mut rs = Vec::new();
|
|
for w in (800..=1440).rev().step_by(40).chain((800..=1440).step_by(40)) {
|
|
h.set_size(egui::vec2(w as f32, 900.0));
|
|
rs.push(step(&mut h));
|
|
}
|
|
out["resize_ms"] = stats(&rs);
|
|
// Accept all (if the document has tracked changes).
|
|
if !h.state().session.doc.revisions.is_empty() {
|
|
let t = Instant::now();
|
|
h.state_mut().session.run("review.acceptAll", &json!({})).ok();
|
|
let cmd = t.elapsed().as_secs_f64() * 1000.0;
|
|
let f = step(&mut h);
|
|
out["accept_all"] = json!({"command_ms": (cmd * 10.0).round() / 10.0, "frame_ms": (f * 10.0).round() / 10.0});
|
|
}
|
|
println!("{out}");
|
|
}
|