use std::time::Instant;
use twip::editing::Editor;
use twip::editing::commands::{Command, Dir, MoveBy};
use twip::editing::position::{Position, StoryId};
use twip::editing::selection::Selection;
use twip::font::Fonts;
use twip::layout::{ParaId, Unit};
struct Rng(u64);
impl Rng {
fn below(&mut self, n: usize) -> usize {
let mut x = self.0;
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
self.0 = x;
(x % n.max(1) as u64) as usize
}
}
fn percentiles(mut v: Vec<f64>) -> String {
v.sort_by(f64::total_cmp);
let at = |q: f64| v.get(((v.len().saturating_sub(1)) as f64 * q).round() as usize).copied().unwrap_or(0.0);
format!("p50 {:.2} p95 {:.2} max {:.2} ms (n {})", at(0.5), at(0.95), v.last().copied().unwrap_or(0.0), v.len())
}
fn text_paras(e: &Editor) -> Vec<(ParaId, u32)> {
fn walk(blocks: &[twip::docx::Block], out: &mut Vec<(ParaId, u32)>) {
for b in blocks {
match b {
twip::docx::Block::Para(p) => {
let n = p.inlines.iter().map(|i| i.units() as u32).sum();
if n > 0 {
out.push((ParaId(p.id), n));
}
}
twip::docx::Block::Table(t) => t.rows.iter().flat_map(|r| r.cells.iter()).for_each(|c| walk(&c.blocks, out)),
}
}
}
let mut out = Vec::new();
e.document().sections.iter().for_each(|s| walk(&s.blocks, &mut out));
out
}
fn paras_by_depth(e: &Editor) -> Vec<(ParaId, u32, usize)> {
fn walk(blocks: &[twip::docx::Block], depth: usize, out: &mut Vec<(ParaId, u32, usize)>) {
for b in blocks {
match b {
twip::docx::Block::Para(p) => out.push((ParaId(p.id), p.inlines.iter().map(|i| i.units() as u32).sum(), depth)),
twip::docx::Block::Table(t) => t.rows.iter().flat_map(|r| r.cells.iter()).for_each(|c| walk(&c.blocks, depth + 1, out)),
}
}
}
let mut out = Vec::new();
e.document().sections.iter().for_each(|s| walk(&s.blocks, 0, &mut out));
out
}
pub fn run_depth(path: &str, n: usize) -> Result<(), String> {
let fonts = Fonts::system();
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
let mut e = Editor::open(&bytes, &fonts)?;
e.complete(&fonts);
let paras = paras_by_depth(&e);
let deepest = paras.iter().map(|p| p.2).max().unwrap_or(0);
let p95 = |mut v: Vec<f64>| {
v.sort_by(f64::total_cmp);
v[((v.len() - 1) as f64 * 0.95).round() as usize]
};
let (mut ed, mut all) = (Vec::new(), Vec::new());
for depth in [1, deepest] {
let &(para, len, _) = paras.iter().find(|p| p.2 == depth).ok_or(format!("no paragraph at depth {depth}"))?;
e.set_caret_for_benchmark(Selection::Caret(Position::text(StoryId::Body, para, Unit(len))));
let (mut times, mut editor) = (Vec::new(), Vec::new());
for k in 0..n {
twip::prof::take();
let t = Instant::now();
e.execute(Command::InsertText("x".into()), &fonts);
let ms = t.elapsed().as_secs_f64() * 1e3;
let layout: f64 = twip::prof::take().iter().filter(|t| t.0 == "editor:layout").map(|t| t.1).sum();
times.push(ms);
editor.push(ms - layout);
while e.idle(&fonts) {}
if k % 10 == 9 {
e.execute(Command::Undo, &fonts);
e.set_caret_for_benchmark(Selection::Caret(Position::text(StoryId::Body, para, Unit(len))));
while e.idle(&fonts) {}
}
}
println!("depth {depth:<3} typing {}; editor alone {}", percentiles(times.clone()), percentiles(editor.clone()));
all.push(p95(times));
ed.push(p95(editor));
}
println!("with layout, p95 ratio depth {deepest} / depth 1: {:.2}", all[1] / all[0].max(1e-6));
let p95 = ed;
let ratio = p95[1] / p95[0].max(1e-6);
println!("editor time p95 ratio depth {deepest} / depth 1: {ratio:.2} (budget: at most 1.5)");
if ratio > 1.5 { Err(format!("depth budget fails: ratio {ratio:.2}")) } else { Ok(()) }
}
pub fn run(path: &str, n: usize) -> Result<(), String> {
let fonts = Fonts::system();
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
let t = Instant::now();
let mut e = Editor::open(&bytes, &fonts)?;
println!("open {:.1} ms, {} pages", t.elapsed().as_secs_f64() * 1e3, e.snapshot().pages().len());
let t = Instant::now();
while e.idle(&fonts) {}
println!("background layout after open {:.1} ms", t.elapsed().as_secs_f64() * 1e3);
let paras = text_paras(&e);
let mut rng = Rng(0x9e37_79b9_7f4a_7c15);
let (mut typing, mut back, mut enter, mut down, mut right, mut undo, mut idle) = (vec![], vec![], vec![], vec![], vec![], vec![], vec![]);
twip::prof::take();
let mut typing_phases: Vec<(&'static str, f64, u64)> = Vec::new();
let mut timed = |e: &mut Editor, cmd: Command, out: &mut Vec<f64>, idle: &mut Vec<f64>| {
let is_typing = matches!(cmd, Command::InsertText(_));
twip::prof::take();
let t = Instant::now();
e.execute(cmd, &fonts);
out.push(t.elapsed().as_secs_f64() * 1e3);
if is_typing {
for (name, ms, calls) in twip::prof::take() {
match typing_phases.iter_mut().find(|p| p.0 == name) {
Some(p) => {
p.1 += ms;
p.2 += calls;
}
None => typing_phases.push((name, ms, calls)),
}
}
}
let t = Instant::now();
while e.idle(&fonts) {}
idle.push(t.elapsed().as_secs_f64() * 1e3);
};
for k in 0..n {
let (p, len) = paras[rng.below(paras.len())];
let caret = Position::text(StoryId::Body, p, Unit(rng.below(len as usize + 1) as u32));
e.set_caret_for_benchmark(Selection::Caret(caret));
for _ in 0..5 {
timed(&mut e, Command::InsertText("x".into()), &mut typing, &mut idle);
}
timed(&mut e, Command::Delete { dir: Dir::Back, word: false }, &mut back, &mut idle);
timed(&mut e, Command::Move { by: MoveBy::Line, dir: Dir::Forward, extend: false }, &mut down, &mut idle);
timed(&mut e, Command::Move { by: MoveBy::Char, dir: Dir::Forward, extend: false }, &mut right, &mut idle);
if k % 10 == 0 {
timed(&mut e, Command::Enter, &mut enter, &mut idle);
}
if k % 5 == 0 {
timed(&mut e, Command::Undo, &mut undo, &mut idle);
}
}
let typed = typing.len().max(1) as f64;
println!("typing {}", percentiles(typing));
typing_phases.sort_by(|a, b| b.1.total_cmp(&a.1));
for (name, ms, calls) in &typing_phases {
println!(" typing phase {name:<16} {:.3} ms per keystroke ({calls} calls)", ms / typed);
}
println!("backspace {}", percentiles(back));
println!("enter {}", percentiles(enter));
println!("down {}", percentiles(down));
println!("right {}", percentiles(right));
println!("undo {}", percentiles(undo));
println!("idle {}", percentiles(idle));
let full = twip::layout::layout(e.document(), &fonts);
e.complete(&fonts);
let same = twip::layout::dump::page_text(&e.snapshot(), e.document()) == twip::layout::dump::page_text(&full, e.document());
println!("matches a full layout: {same}");
Ok(())
}
pub fn run_merge(path: &str, n: usize) -> Result<(), String> {
let fonts = Fonts::system();
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
let mut e = Editor::open(&bytes, &fonts)?;
e.complete(&fonts);
let mut seq = 0u64;
let mut send = |e: &mut Editor, mut v: serde_json::Value| -> (f64, serde_json::Value) {
seq += 1;
v["seq"] = seq.into();
let t = Instant::now();
let r = e.apply_json(&v.to_string(), &fonts);
let ms = t.elapsed().as_secs_f64() * 1e3;
while e.idle(&fonts) {}
(ms, serde_json::from_str(&r).unwrap_or_default())
};
let mut list = Vec::new();
let mut count = 0;
for _ in 0..20 {
let (ms, r) = send(&mut e, serde_json::json!({ "type": "query", "q": "elements" }));
count = r["answer"].as_array().map_or(0, Vec::len);
list.push(ms);
}
println!("elements {} over {count} elements (budget: under 200 ms)", percentiles(list.clone()));
let (_, r) = send(&mut e, serde_json::json!({ "type": "query", "q": "elements", "kind": "field" }));
let mut groups: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
for f in r["answer"].as_array().into_iter().flatten() {
let code = f["code"].as_str().unwrap_or("").trim();
if let Some(g) = code.strip_prefix("MERGEFIELD ").and_then(|c| c.split_once('.')).map(|(g, _)| g.to_string()) {
*groups.entry(g).or_default() += 1;
}
}
let (group, refs) = groups.into_iter().max_by_key(|(g, c)| (*c, std::cmp::Reverse(g.clone()))).ok_or("no MERGEFIELD codes")?;
let depth = e.history().undo_depth();
let (rename, r) = send(
&mut e,
serde_json::json!({ "type": "command", "id": "element.replace", "arg": { "find": format!("{group}."), "replace": format!("{group}Renamed."), "in": "code" } }),
);
let changed = r["answer"]["changed"].as_u64().unwrap_or(0);
let steps = e.history().undo_depth() - depth;
let (undo, _) = send(&mut e, serde_json::json!({ "type": "command", "id": "home.undo" }));
println!(
"element.replace {rename:.1} ms over {changed} of {refs} references in \"{group}.\", {steps} undo step; undo {undo:.1} ms (budget: under 500 ms, one step)"
);
let paras = text_paras(&e);
let mut rng = Rng(0x2545_f491_4f6c_dd1d);
let (mut insert, mut drop) = (Vec::new(), Vec::new());
for k in 0..n {
let (p, len) = paras[rng.below(paras.len())];
e.set_caret_for_benchmark(Selection::Caret(Position::text(StoryId::Body, p, Unit(rng.below(len as usize + 1) as u32))));
let code = format!("MERGEFIELD Timing.F{k}");
if k % 2 == 0 {
insert.push(send(&mut e, serde_json::json!({ "type": "command", "id": "field.insert", "arg": { "code": code } })).0);
} else {
let at = e.caret_view().and_then(|c| e.stack().to_doc(c.page, c.x, (c.top + c.bottom) / 2.0));
let Some((x, y)) = at else { continue };
drop.push(
send(
&mut e,
serde_json::json!({ "type": "command", "id": "field.insert", "arg": { "code": code, "target": { "at": { "x": x, "y": y } } } }),
)
.0,
);
}
}
println!("field.insert {} (budget: under 100 ms)", percentiles(insert.clone()));
println!("drop {} (budget: under 100 ms)", percentiles(drop.clone()));
let mut fails = Vec::new();
let p_max = |v: &[f64]| v.iter().copied().fold(0.0, f64::max);
if p_max(&insert) > 100.0 || p_max(&drop) > 100.0 {
fails.push("field insert and drop");
}
if p_max(&list) > 200.0 || count < 2000 {
fails.push("elements");
}
if rename > 500.0 || steps != 1 || changed < 500 {
fails.push("rename");
}
if fails.is_empty() { Ok(()) } else { Err(format!("{} not met", fails.join(", "))) }
}