//! The built page, live: each package renders offline. use std::{path::Path, time::Duration}; use ely_gpui_component::theme::{ActiveTheme as _, Mode, Theme}; use ely_platform::AppId; use ely_web_content::{Event, WebView}; use gpui::{ App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, Styled as _, Window, div, }; /// What the page holds, each block scrolled in and mounted. const CHECK: &str = r#" (async () => { const view = document.querySelector('#document > div:not([hidden])') ?? document; const settle = () => new Promise((done) => setTimeout(done, 700)); const seen = (node) => { if (!node || !node.checkVisibility({ opacityProperty: true, visibilityProperty: true })) return false; node.scrollIntoView({ block: 'nearest' }); const box = node.getBoundingClientRect(); if (box.width < 1 || box.height < 1) return false; const top = document.elementFromPoint(box.x + box.width / 2, box.y + Math.min(box.height / 2, 8)); return Boolean(top && (node.contains(top) || top.contains(node))); }; const blocks = []; for (const block of view.querySelectorAll('.milkdown-code-block')) { block.scrollIntoView({ block: 'center' }); await settle(); const diagram = block.querySelector('.ely-diagram svg'); const shape = diagram?.querySelector('.node rect, .node polygon'); const box = diagram?.getBoundingClientRect(); blocks.push({ language: block.querySelector('.language-button')?.textContent.trim() ?? '', editors: block.querySelectorAll('.cm-editor').length, editable: block.querySelector('.cm-content')?.contentEditable ?? 'none', code: seen(block.querySelector('.cm-line')), math: seen(block.querySelector('.preview .katex')), diagram: box ? [Math.round(box.width), Math.round(box.height)] : [0, 0], drawn: seen(diagram), fill: shape ? getComputedStyle(shape).fill : '', first: window.firstFill ?? '', failure: seen(block.querySelector('.ely-failure')) ? block.querySelector('.ely-failure').textContent : '', }); } document.scrollingElement.scrollTop = 0; await settle(); const root = document.getElementById('document'); window.elyQuery({ request: JSON.stringify({ step: 'STEP', mode: root.dataset.mode, editable: view.querySelector('.ProseMirror')?.contentEditable ?? 'none', title: view.querySelector('.milkdown h1')?.textContent ?? '', heading: seen(view.querySelector('.milkdown h1')), inline: seen(view.querySelector('.milkdown p .katex')), display: seen(view.querySelector('.milkdown p .katex-display')), tasks: view.querySelectorAll('li[data-item-type="task"]').length, cells: view.querySelectorAll('.milkdown td').length, notes: view.querySelectorAll('[data-type="footnote_definition"]').length, blocks, font: [...document.fonts].some((f) => f.family === 'KaTeX_Main' && f.status === 'loaded'), source: seen(view.querySelector(':scope > .cm-editor .cm-line')), first: view.querySelector('.cm-line')?.textContent ?? '', editors: document.querySelectorAll('#document > div:not([hidden])').length, text: view.textContent.slice(0, 40), bg: getComputedStyle(document.documentElement).getPropertyValue('--ely-bg').trim(), }), onSuccess: () => {}, onFailure: () => {}, }); })(); "#; const TEXTS: &str = r#"{"searchLanguage":"s","noResult":"n","copy":"c","preview":"p","loading":"l","edit":"e","hide":"h"}"#; /// Steps in order; each reports once. const STEPS: [&str; 13] = [ "visual", "themed", "preview", "source", "race", "language", "midtheme", "release", "asked", "stale", "owner", "find", "look", ]; /// Empty and broken queries: errors, never CodeMirror's own panel. const FIND: &str = r#" (async () => { const report = {}; for (const mode of ['source', 'visual']) { await ely.open('a{b alpha\n', mode, TEXTS, 1, location.href); const empty = { search: '' }; ely.find(empty); ely.step({ spec: empty, forward: true }); ely.replace({ spec: empty, all: false }); for (const search of ['a{', '\\a']) ely.find({ search, regexp: true }); ely.unfind(); report[mode] = document.querySelectorAll('.cm-panels .cm-search').length; await ely.open('\u{1F600} alpha\n', mode, TEXTS, 1, location.href); ely.find({ search: '(?=.)', regexp: true }); ely.unfind(); } await new Promise((done) => setTimeout(done, 300)); window.elyQuery({ request: JSON.stringify({ step: 'find', panels: report }), onSuccess: () => {}, onFailure: () => {} }); })(); "#; /// The writing view: dim blocks, narrow serif column, centered caret. const LOOK: &str = r#" (async () => { const paragraphs = Array.from({ length: 80 }, (_, n) => `Paragraph ${n + 1} of the look.`) await ely.open(paragraphs.join('\n\n') + '\n', 'visual', TEXTS, 1, location.href) ely.look({ focus: true, typewriter: true, width: 'narrow', font: 'serif', size: 120 }) ely.reveal({ line: 159, query: '' }) await new Promise((done) => setTimeout(done, 600)) const blocks = [...document.querySelectorAll('.milkdown .ProseMirror > *')] const dim = blocks.filter((block) => getComputedStyle(block).opacity === '0.35').length const bright = document.querySelector('.ely-current') const caret = window.getSelection().getRangeAt(0).getBoundingClientRect() const page = document.getElementById('document') const report = { step: 'look', blocks: blocks.length, dim, bright: bright ? getComputedStyle(bright).opacity : 'none', centered: Math.abs(caret.top - window.innerHeight / 2) < window.innerHeight / 4, width: getComputedStyle(page).maxWidth, serif: getComputedStyle(page).fontFamily.includes('serif'), } ely.look({ focus: false, typewriter: false, width: 'medium', font: 'sans', size: 100 }) window.elyQuery({ request: JSON.stringify(report), onSuccess: () => {}, onFailure: () => {} }) })(); "#; /// Picks Rust in the first block's list, once mounted. const TO_RUST: &str = r#" (function pick() { const block = document.querySelector('#document > div:not([hidden]) .milkdown-code-block'); const button = block?.querySelector('.language-button'); if (!button) return requestAnimationFrame(pick); button.click(); setTimeout(() => [...block.querySelectorAll('.language-list-item')] .find((item) => item.textContent.trim() === 'Rust').click(), 30); })(); "#; /// Says when a diagram waits, drawn not yet. const PENDING: &str = r#" (function wait() { if (!document.querySelector('.ely-diagram:empty')) return requestAnimationFrame(wait); new MutationObserver((_, watch) => { const shape = document.querySelector('.ely-diagram svg .node rect'); if (!shape) return; window.firstFill = getComputedStyle(shape).fill; watch.disconnect(); }).observe(document.body, { childList: true, subtree: true }); window.elyQuery({ request: JSON.stringify({ kind: 'pending' }), onSuccess: () => {}, onFailure: () => {} }); })(); "#; /// A diagram slow enough to change things mid-draw. fn big() -> String { let mut source = String::from("```mermaid\ngraph TD\n"); for at in 0..150 { source += &format!(" N{at} --> N{}\n N{at} --> N{}\n", at + 1, (at * 7) % 151); } serde_json::to_string(&(source + "```\n")).expect("json") } /// Short sources, slow draws: blocks mount at once. fn chains(blocks: usize) -> String { let mut source = String::new(); for block in 0..blocks { let side = |name: &str| { let nodes: Vec = (0..20).map(|at| format!("{name}{block}x{at}")).collect(); nodes.join(" & ") }; source += &format!( "```mermaid\ngraph LR\n{} --> {}\n```\n\n", side("A"), side("B") ); } serde_json::to_string(&source).expect("json") } /// Leaves for source mid-draw; counts long stalls after. const RELEASE: &str = r#" (async () => { const sleep = (ms) => new Promise((done) => setTimeout(done, ms)); await ely.open(BIG, 'preview', TEXTS, 1, location.href); await sleep(50); await ely.open('x', 'source', TEXTS, 1, location.href); let last = performance.now(); let long = 0; const tick = setInterval(() => { const now = performance.now(); if (now - last > 500) long += 1; last = now; }, 20); await sleep(7000); clearInterval(tick); window.elyQuery({ request: JSON.stringify({ step: 'release', long }), onSuccess: () => {}, onFailure: () => {} }); })(); "#; fn flip(cx: &mut App) { let mode = match cx.theme().is_dark() { true => Mode::Light, false => Mode::Dark, }; Theme::set_mode_now(mode, cx); } struct Probe { view: Entity, step: usize, /// A fresh page: Mermaid's bundle not yet loaded. fresh: bool, /// Find reports that carried an error, and every total. errors: usize, totals: Vec, } impl Probe { /// Starts the current step; its check follows. fn begin(&mut self, cx: &mut Context) { let markdown = serde_json::to_string(include_str!("../../../tests/fixtures/mark/extended.md")) .expect("json"); let step = STEPS[self.step]; match step { "themed" => { flip(cx); self.check(step, 2500, cx); } // Two quick shows: only the last may stay. "race" => { self.view.read(cx).run_js(&format!( "ely.open('# OLD', 'visual', {TEXTS}, 1, location.href); \ ely.open('# NEW', 'source', {TEXTS}, 1, location.href)" )); self.check(step, 2500, cx); } // Two modes asked at once: the last one shows. "asked" => { self.view.read(cx).run_js(&format!( "ely.open('# M\\n', 'source', {TEXTS}, 1, location.href) \ .then(() => {{ ely.mode('visual'); ely.mode('preview') }})" )); self.check(step, 3000, cx); } // A request while visual's shown is unanswered wins. "stale" => { self.view.read(cx).run_js(&format!( "ely.open('# M\\n', 'source', {TEXTS}, 1, location.href) \ .then(() => {{ ely.mode('visual'); ely.mode('preview') }})" )); self.check(step, 3000, cx); } // An old answer leaves a newer build's request alone. "owner" => { self.view.read(cx).run_js(&format!( "ely.open('# M\\n', 'source', {TEXTS}, 1, location.href) \ .then(() => ely.mode('visual'))" )); self.check(step, 3000, cx); } "find" => self.view.read(cx).run_js(&FIND.replace("TEXTS", TEXTS)), "look" => self.view.read(cx).run_js(&LOOK.replace("TEXTS", TEXTS)), "release" => { let script = RELEASE.replace("BIG", &chains(3)).replace("TEXTS", TEXTS); self.view.read(cx).run_js(&script); } // Changes land while the bundle still loads. "language" | "midtheme" if !self.fresh => { self.fresh = true; self.view.read(cx).run_js("location.reload()"); } "language" | "midtheme" => { self.fresh = false; let show = format!("ely.open({}, 'visual', {TEXTS}, 1, location.href)", big()); self.view.read(cx).run_js(&show); } mode => self.view.read(cx).run_js(&format!( "ely.open({markdown}, '{mode}', {TEXTS}, 1, location.href)" )), } } /// Checks after `wait`, when drawing is done. fn check(&self, step: &'static str, wait: u64, cx: &mut Context) { let view = self.view.clone(); cx.spawn(async move |_, cx| { cx.background_executor() .timer(Duration::from_millis(wait)) .await; view.update(cx, |view, _| view.run_js(&CHECK.replace("STEP", step))); }) .detach(); } fn heard( &mut self, _: &Entity, event: &Event, _: &mut Window, cx: &mut Context, ) { match event { Event::Loaded => self.begin(cx), Event::Query(said, reply) => { let read: serde_json::Value = serde_json::from_str(said).expect("json"); // Some answers wait; meanwhile newer modes are asked. let (wait, then) = match (STEPS.get(self.step), read["mode"].as_str()) { (Some(&"stale"), Some("visual")) => (300, "ely.mode('source')"), (Some(&"owner"), Some("visual")) => { (300, "ely.mode('preview'); ely.mode('source')") } (Some(&"owner"), Some("preview")) => (600, ""), _ => (0, ""), }; if !then.is_empty() { self.view.read(cx).run_js(then); } let reply = reply.clone(); cx.spawn(async move |_, cx| { cx.background_executor() .timer(Duration::from_millis(wait)) .await; reply.ok(""); }) .detach(); if read["kind"] == "shown" { println!("mark: shown {}", read["mode"]); let Some(&step) = STEPS.get(self.step) else { return; }; // Mid-draw: a new language, or a new theme. match step { "language" => self.view.read(cx).run_js(TO_RUST), "midtheme" => return self.view.read(cx).run_js(PENDING), _ if step == read["mode"] => {} _ => return, } self.check(step, 5000, cx); return; } if read["kind"] == "found" { self.errors += read["error"].is_string() as usize; self.totals.extend(read["total"].as_u64()); return; } // Edits report too; the session tests them. if ["changed", "text", "outline", "place"] .contains(&read["kind"].as_str().unwrap_or_default()) { return; } if read["kind"] == "pending" { // Its draw takes seconds; flip once it has begun. println!("mark: pending"); cx.spawn(async move |this, cx| { cx.background_executor() .timer(Duration::from_millis(600)) .await; this.update(cx, |probe, cx| { flip(cx); probe.check("midtheme", 6000, cx); }) .ok(); }) .detach(); return; } let mut read = read; if read["step"] == "find" { read["errors"] = self.errors.into(); read["totals"] = self.totals.clone().into(); } println!("mark: {read}"); self.step += 1; if self.step == STEPS.len() { println!("mark: done"); return cx.quit(); } // The first view stays a while for the window's pixels. let wait = if self.step == 1 { 3000 } else { 0 }; cx.spawn(async move |this, cx| { cx.background_executor() .timer(Duration::from_millis(wait)) .await; this.update(cx, |probe, cx| probe.begin(cx)).ok(); }) .detach(); } Event::Leave(url) => println!("mark: leave {url}"), Event::Printed(path, ok) => println!("mark: printed {} {ok}", path.display()), Event::Failed(why) => { println!("mark: failed {why}"); cx.quit(); } } } } impl Render for Probe { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { div().size_full().child(self.view.clone()) } } fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity { let work = tempfile::tempdir().expect("a temp dir").keep(); ely_web_content::start(&work, cx).expect("CEF starts"); let content = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../engines/content/ely-mark"); let view = WebView::open(&content.join("index.html"), &content, window, cx).expect("a view"); let probe = cx.new(|cx| { cx.subscribe_in(&view, window, Probe::heard).detach(); Probe { view, step: 0, fresh: false, errors: 0, totals: Vec::new(), } }); ely_ui::root(app, Some(probe.into()), window, cx) } fn main() { if let Some(code) = ely_web_content::subprocess() { std::process::exit(code); } ely_ui::run(AppId::Mark, open); }