Entropy's addon-facing UI kit already had real multi-line text (text_edit_multiline) and a line-numbered code_editor, but nothing that behaves like a word processor: fixed page dimensions, margins, content that flows and breaks across page boundaries, mixed fonts/sizes/colors inside one paragraph, a toolbar the addon builds itself rather than one baked into the widget. This post builds that as a new entropy_gui widget, DocEditor, with a specific target set before writing any of it: a keystroke's cost should not grow with total document length, and that claim needs a real number behind it, not a feeling.
What we're building
entropy_gui::DocEditor(src/entropy_gui/widgets_doc_editor.rs, new) - paragraphs of formatted runs, a per-paragraph shaped-line cache, and a page-assignment pass that buckets already-shaped lines into fixed-size pages with margins (or one continuous unbounded page - see "one toggle, one code path" below). A paragraph can span a page break at line granularity, same as a real word processor.- Real per-run formatting: bold, italic, a font family picked from the engine's existing ~60-font catalog, a point size, and a color - all independently settable and all mixed correctly within one wrapped line.
Painter::styled_glyphs(painter.rs, new method) - paints pre-shaped glyphs with a per-glyph bold/italic/color style, resolving each glyph back to a real loaded font by name.- A dedicated
doc_editorsmap onentropy_gui::Memory, not the smallWidgetStateenum every other widget's cursor/blink state lives in - see "who owns the document" below. - This widget draws only the page canvas. There is no built-in toolbar at all -
DocEditorCommands (toggle bold, set font, set size, set color, toggle pagination, load a sample) are queued by ops an addon's own buttons call, and applied once at the top ofshow()before input/layout/paint. - A new
UiWidget::DocEditorvariant,op_ui_widget_doc_editor, seven newop_doc_editor_*command ops, and matching wiring inaddon_engine.rs/addon_setup.js/addon.d.ts. doc_editor_demo_addon.ts("Document Editor Demo") - its entire toolbar (Bold/Italic buttons, a font dropdown, a size drag-value, a color swatch, a Paginated checkbox, a Load Sample button) is built from ordinary widgets and wired to those command functions, reachable viacargo run --bin example -- doc-editor-demo.src/bin/doc_editor_bench.rs- a headless benchmark exercising the exact sameDocEditorStatemethods the live widget calls every frame, no window or GPU needed.
Who owns the document
Every complex entropy_gui widget before this one - NodeGraphEditor, KeyframeTimeline, TrackView - follows the same contract: the addon rebuilds its whole data set from JS state every frame and hands it to show() as a plain slice; the widget never gets a persistent &mut into it. That's the right call for a few keyframes or clips. It's the wrong call for a document, because this whole render loop runs continuously at the window's frame rate - a JSON round-trip of the entire document through deno_core's serde path, 60 times a second, would itself become the bottleneck the rest of this post is trying to avoid, especially on a document with hundreds of paragraphs.
So DocEditor breaks the pattern on purpose. The document - paragraphs, per-paragraph layout cache, cursor, selection, active format - lives entirely Rust-side, keyed by the widget's id, the same way TextEditState's cursor/blink already does for text_edit_singleline. The op that draws the widget only ever carries page geometry:
DocEditor { id: String, page_width: f32, page_height: f32, margin: f32 },and everything the addon needs back - stats, and now the current active format - comes over the same ui_events string channel a button click already uses.
The one wrinkle this creates: Memory's existing WidgetState enum stores small copyable state and its get/set clone the whole value on every access - fine for a cursor position, not fine for a document. DocEditorState gets its own map and a take/put pair instead, moved in and out of Memory at the top and bottom of show() rather than cloned.
Who builds the toolbar
Every other widget in this kit draws its own chrome. DocEditor doesn't - it draws the page canvas and nothing else. Formatting is driven entirely by DocEditorCommand, applied once at the top of show():
pub enum DocEditorCommand {
ToggleBold,
ToggleItalic,
SetFontFamily(String),
SetFontSize(f32),
SetColor(Color32),
SetPaginated(bool),
LoadSample(usize),
}
pub fn show(self, ui: &mut Ui, page: PageConfig, commands: &[DocEditorCommand]) -> DocEditorResponse {
let mut state = ctx.memory_mut(|m| m.take_doc_editor(id));
for command in commands {
state.apply_command(command);
}
// ...input handling, layout, paint, as before...
}Each command mutates the current selection if there is one (single-paragraph only - see "v1 simplifications"), or the format that will be used for whatever's typed next otherwise, through one shared helper both the bold/italic toggle and the font/size/color setters route through:
fn apply_to_selection_or_active(&mut self, mutate: impl Fn(&mut RunFormat)) {
if let Some((a, b)) = self.selection_bounds() {
// split runs at the selection boundaries, mutate every run fully inside it, re-merge
} else {
mutate(&mut self.active_format);
}
}
pub fn set_font_size(&mut self, size: f32) {
self.apply_to_selection_or_active(move |f| f.size = size.clamp(4.0, 200.0));
}On the JS side, a command is a fire-and-forget op call, not a per-frame widget - the demo addon's toolbar is built entirely from existing generic widgets:
Entropy.UI.Widget.horizontal(win, () => {
Entropy.UI.Widget.button(win, { text: stats.bold ? "Bold [on]" : "Bold",
onClick: () => Entropy.UI.Widget.docEditorToggleBold(DOC_ID) });
Entropy.UI.Widget.checkbox(win, { label: "Paginated", value: stats.paginated,
onChange: (v) => Entropy.UI.Widget.docEditorSetPaginated(DOC_ID, v) });
});
Entropy.UI.Widget.dropdown(win, { label: "", options: fontNames, selectedIndex: fontIndex,
onChange: (i) => Entropy.UI.Widget.docEditorSetFontFamily(DOC_ID, fontNames[parseInt(i, 10)]) });onStats fires every frame with word/char/page counts and the current active format (bold, italic, font name, size, color as [r,g,b,a]), so the addon's own buttons/inputs can reflect it - the "Bold [on]" label and the dropdown's selected font above both come from that, not from any state the widget exposes directly.
Real pagination, not a scrolling column
A document is Vec<Paragraph>, each paragraph a Vec<Run> of (text, format). Enter creates a new paragraph rather than inserting a literal \n, so word-wrap only ever has to reason about one paragraph's plain text at a time.
Two passes run every frame:
Reshape. ensure_layout shapes one paragraph's runs and caches the result, keyed by paragraph index, invalidated only when that paragraph's text, format, or the page's content width actually changed:
fn ensure_layout(&mut self, ctx: &Context, page: PageConfig, idx: usize) {
let cw = page.content_width();
if (self.cached_content_width - cw).abs() > 0.5 {
for c in self.layout_cache.iter_mut() { *c = None; }
self.cached_content_width = cw;
}
if self.layout_cache[idx].is_none() {
self.layout_cache[idx] = Some(layout_paragraph(ctx, &self.paragraphs[idx], cw));
}
}A keystroke touches exactly one paragraph, so this costs O(that paragraph's length), not O(document length).
Paginate. paginate walks every already-shaped line's cached height and buckets lines into pages, merging consecutive lines from the same paragraph into one PageEntry:
pub fn paginate(&self, page: PageConfig) -> Vec<PageLayout> {
let content_h = if self.paginated { page.content_height() } else { f32::INFINITY };
let mut pages = Vec::new();
let mut cur = PageLayout::default();
for (pi, layout) in self.layout_cache.iter().enumerate() {
let Some(layout) = layout else { continue };
for (li, line) in layout.lines.iter().enumerate() {
if cur.used_height > 0.0 && cur.used_height + line.height > content_h {
pages.push(std::mem::take(&mut cur));
}
// append this line to the last entry if contiguous, else start a new one
cur.used_height += line.height;
}
cur.used_height += PARA_SPACING;
}
pages.push(cur);
pages
}This is O(total lines in the document), but it's pure arithmetic over cached line heights - no fontdue, no glyph atlas - so even at a few thousand lines it's microseconds (numbers below). Both passes are plain methods on DocEditorState, so doc_editor_bench can call the exact same code the live widget calls, headlessly. Painting only walks pages that intersect the current scroll viewport, so a keystroke's paint cost stays bounded by what's on screen too.
One toggle, one code path
The "Paginated" checkbox in the screenshots below doesn't branch the renderer - it changes one number. paginate treats content_h as f32::INFINITY when paginated is false, which means the cur.used_height + line.height > content_h check above can never trip, so every line lands in the single PageLayout that gets pushed at the end. Everything downstream - scrolling, click-to-cursor, visible-range culling - reads from page_metrics, a small Vec<(top, height)> computed once per frame that's either pages.len() uniform page.height steps (paginated) or one entry sized to its own content (continuous):
pub fn page_metrics(&self, pages: &[PageLayout], page: PageConfig) -> Vec<(f32, f32)> {
if self.paginated {
(0..pages.len()).map(|i| (i as f32 * (page.height + PAGE_GAP), page.height)).collect()
} else {
let h = pages.first().map(|p| p.used_height).unwrap_or(0.0) + 2.0 * page.margin;
vec![(0.0, h.max(page.height))]
}
}Neither show()'s scroll clamping nor its click-to-cursor mapping needed an if paginated branch anywhere - they just consume whatever page_metrics returns.
Fonts, sizes, and colors are per-run
This engine already embeds a ~60-font catalog (src/renderer_text/fonts.rs's FontManager, built for the old 3D-scene text renderer - Actor, Aleo, Bungee, Neuton, Martel, and around 55 more, each include_bytes!'d into the binary already). entropy_gui::fonts::FontRegistry now owns its own FontManager too and lazily parses a fontdue::Font per name the first time a document actually uses it, cached after that - so DocEditor's font picker offers the real catalog, not just the two UI faces (proportional/monospace) the rest of the GUI kit uses.
Mixing fonts and sizes within one wrapped line means shaping can't just call text_layout::shape_text once over a paragraph's plain text at a single size anymore - each run needs its own face and its own px. layout_paragraph drives a fontdue::layout::Layout directly instead: it collects the distinct font names a paragraph's runs use, resolves each to a real fontdue::Font, and appends each run's text as its own TextStyle { px: run.format.size, font_index, .. } into one continuous, still-word-wrapped layout:
for run in ¶.runs {
let primary_idx = face_names.iter().position(|n| n == &run.format.font_name).unwrap_or(0) as u8;
// split the run further by resolved face for emoji/symbol fallback, same idea
// `text_layout::shape_text` already uses for icon glyphs
append_sub(&mut layout, &faces, run.format.size, sub_text, face, base + sub_start, &mut glyphs);
}Wrapping still flows correctly across a run boundary in the middle of a line (a bold word followed by a plain one, or a size change mid-sentence, doesn't force an early break) because it's one Layout, not one shape call per run.
Each shaped ParagraphLayout carries its own face_names: Vec<String> alongside its lines - the ordered list ShapedGlyph::font_index indexes into for that paragraph. Painting happens in a different call (a later frame, potentially) than shaping, so Painter::styled_glyphs needs that same name-to-index mapping to resolve a glyph back to a real font:
pub fn styled_glyphs(&self, origin: Pos2, glyphs: &[ShapedGlyph], face_names: &[String],
style_at: impl Fn(usize) -> (bool, bool, Color32)) {
// ensure_named() for each, then resolve g.font_index back through the same list
}Mixed sizes on one line also means the line's own height has to be the tallest run touching it, not a fixed constant - ShapedLine::height is computed per line as max_size_in_range(para, start_byte, end_byte) + LINE_HEIGHT_EXTRA, and every place that used to multiply by a flat LINE_HEIGHT constant (paginate, hit_test, painting) now just sums each line's own height.
Formatting without a bold font
Even with ~60 real font files to choose from, most of those entries are one regular-weight file each - a handful genuinely ship a matching Bold or Italic (Amiko-Bold, Martel-Bold, Inria Sans's actual Bold/Italic/BoldItalic set), but DocEditor's Bold/Italic toggles don't yet look for those; they apply the same faux effect regardless of which family is active. Painter::styled_glyphs fakes both: bold is the glyph painted twice at a fraction-of-a-pixel offset, italic is a per-vertex shear (top corners pushed right relative to the bottom). Real visual effects - verified in the screenshots below - just not built from a real weight/style axis yet. See "what's next."
Wiring to the addon API
Same shape every other widget uses for the parts that do change every frame - a UiWidget variant, an op, a render arm - plus a second, much smaller mechanism for the parts that don't:
UiWidget::DocEditor { id: doc_id, page_width, page_height, margin } => {
let commands = context.doc_editor_commands.remove(doc_id).unwrap_or_default();
let resp = crate::entropy_gui::DocEditor::new(doc_id.as_str()).show(ui, page, &commands);
events_to_push.push(format!("DOCEDIT_STATS|{}|{}|{}|{}|{}|{}|{}|{}|{},{},{},{}|{}",
doc_id, resp.word_count, resp.char_count, resp.page_count, resp.paginated,
resp.active_bold, resp.active_italic, resp.active_font_size,
cr, cg, cb, ca, resp.active_font_name));
}op_doc_editor_toggle_bold/_toggle_italic/_set_font_family/_set_font_size/_set_color/_set_paginated/_load_sample each just push one DocEditorCommand onto a HashMap<String, Vec<DocEditorCommand>> in AddonContext, keyed by the widget's own id rather than a window id - drained once per frame, right before that specific widget's show() runs. op_doc_editor_font_names is the one op that doesn't need a DocEditor instance (or even reachable entropy_gui::Context) to exist at all: an op can't reach the render loop's Context/FontRegistry directly, so it just reads the same static FontManager catalog straight from src/renderer_text/fonts.rs.
Evidence
All screenshots below are real example.exe doc-editor-demo runs, driven by synthetic SetCursorPos/mouse_event/SendKeys input against the actual window, same verification method earlier Entropy posts use - not static renders.
The addon's own toolbar (Bold, Italic, a Paginated checkbox, Load Sample, a font dropdown, a size drag-value, a color swatch) above a blank US Letter page - none of this chrome exists inside DocEditor itself:

One paragraph mixing three sizes/fonts/colors after loading the sample and editing it: plain 15pt Figtree, a 34pt insertion typed after dragging the size control up, "Aleo" text typed after picking a different font from the dropdown (a visibly different, heavier typeface), and green text typed after cycling the color swatch:

Loading the 300-paragraph synthetic sample with Paginated checked - real discrete pages, 43 of them for 20,850 words:

The same document with Paginated unchecked - one continuous, unbounded page instead of 43 discrete ones:

Benchmark. cargo run --release --bin doc_editor_bench, on the hardware/toolchain in the frontmatter, re-run after the per-run font/size shaping rewrite above to confirm it didn't regress anything. Per-keystroke cost is insert_char + ensure_all_layout + paginate together, i.e. exactly what DocEditor::show runs after every typed character:
=== Per-keystroke cost vs. total document size (typing at the midpoint paragraph) ===
1 paragraphs ( 1 pages, 40 words): avg 0.0212 ms p50 0.0180 p95 0.0353 p99 0.0647 max 0.1472
10 paragraphs ( 2 pages, 655 words): avg 0.0276 ms p50 0.0269 p95 0.0349 p99 0.0395 max 0.1481
50 paragraphs ( 8 pages, 3435 words): avg 0.0350 ms p50 0.0346 p95 0.0425 p99 0.0503 max 0.1664
150 paragraphs ( 22 pages, 10365 words): avg 0.0352 ms p50 0.0351 p95 0.0407 p99 0.0437 max 0.0473
300 paragraphs ( 43 pages, 20850 words): avg 0.0338 ms p50 0.0339 p95 0.0399 p99 0.0438 max 0.1922
600 paragraphs ( 86 pages, 41700 words): avg 0.0333 ms p50 0.0325 p95 0.0397 p99 0.0459 max 0.1218
Going from a 1-paragraph, 1-page document to 600 paragraphs / 86 pages / 41,700 words leaves the average keystroke cost basically flat (0.021ms to 0.033ms) - well inside the noise of Instant-based measurement at this scale, and nowhere near the roughly-14x a document-length-proportional design would produce.
The second table isolates the part of the design that does not stay flat - reshaping is per-paragraph, not per-line, so a keystroke's cost scales with the length of the one paragraph it lands in:
=== Per-keystroke cost vs. the EDITED paragraph's own length (single paragraph doc) ===
paragraph ~ 100 chars: avg 0.0151 ms p50 0.0144 p95 0.0181 p99 0.0236 max 0.0831
paragraph ~ 1000 chars: avg 0.0564 ms p50 0.0491 p95 0.0854 p99 0.2322 max 0.2553
paragraph ~ 5000 chars: avg 0.2206 ms p50 0.2184 p95 0.2312 p99 0.2637 max 0.3231
paragraph ~ 20000 chars: avg 1.6315 ms p50 1.5579 p95 2.0536 p99 2.5806 max 2.6720
A normal prose paragraph (a few hundred characters) stays comfortably under a tenth of a millisecond. A 20,000-character single paragraph - not a realistic paragraph, but a real stress case - costs about 1.6ms per keystroke: still well inside frame budget, but a real, measured scaling wall, not a hypothetical one. See "what's next."
Pagination alone, over the 600-paragraph document, cache fully warm, no edits:
=== Paginate-only cost at a large document (no edits, cache fully warm) ===
paginate() over 600 paragraphs: avg 0.0127 ms p50 0.0127 p95 0.0132 p99 0.0164 max 0.0203
Confirms the design claim directly: bucketing ~2,600 already-shaped lines into pages costs about 13 microseconds, not milliseconds, because it never touches fontdue or the glyph atlas - and this number is unchanged from before the font-catalog rework, since paginate never got more expensive, only more capable (variable line heights instead of one constant).
Decision log
Document state lives in entropy_gui::Memory, not in the addon's JS data. The only real architectural deviation from every prior complex widget in this kit, made because a document is the one payload here plausibly large enough for a 60Hz JSON round-trip to actually matter.
The toolbar is entirely addon-owned; the widget draws only the canvas. DocEditorCommand plus a small per-widget-id command queue in AddonContext was worth the extra plumbing over a baked-in toolbar: it means an addon can build a toolbar that matches its own app's look, omit controls it doesn't want, or add ones this post never anticipated, without touching this widget's Rust code at all.
Reused the existing ~60-font catalog instead of shipping new font files. FontManager already existed for the old 3D-scene text renderer and already embeds real, varied typefaces - a proper font picker for zero new binary size, at the cost of entropy_gui::fonts::FontRegistry now owning a second FontManager instance (a startup-time byte-copy duplication, not a per-frame one - see its own module doc comment for the tradeoff).
Shaping drives fontdue::layout::Layout directly, not through text_layout::shape_text. shape_text only supports one size across a whole call; mixing sizes/fonts on one line needed per-run TextStyle.px, so layout_paragraph builds its own face array and appends run-by-run instead of reusing that shared helper. It reuses the same per-character emoji/symbol-fallback splitting idea, just parameterized per run.
Wrap-boundary detection by x-decrease, not y-change. Covered fully in failure notes below - x is monotonically non-decreasing within one real shaped line and only resets at an actual wrap; fontdue's per-glyph y is not a reliable same-line signal.
Faux bold/italic via styled_glyphs, not new font files. A real visual effect (verified in the screenshots above), not a placeholder - but a documented simplification, and now a slightly bigger one than before: a few catalog families genuinely ship a bold/italic file, and this doesn't use them yet. See "what's next."
Per-paragraph reshape granularity, not per-line. Simpler to build and reason about, and the benchmark shows it's the right tradeoff for anything that looks like real prose. It stops being the right tradeoff for a pathologically long single paragraph - measured, not asserted, in the second benchmark table.
Format commands are single-paragraph only. apply_to_selection_or_active bails out (a no-op) when a selection spans a paragraph boundary - a documented gap, not a silent one, and the common case (formatting a run of text within one paragraph) works end to end, per the screenshots.
Continuous mode is one number, not a second renderer. paginated: bool only changes what content_h is inside paginate (a real height, or f32::INFINITY); page_metrics gives show() one shape to consume either way, so scrolling/click-mapping/culling never branch on the mode at all.
Failure notes
A diagonal staircase instead of wrapped paragraphs, caused by trusting the wrong field for line detection. The first version detected a wrapped line boundary by watching ShapedGlyph::y change between consecutive glyphs. Loading a multi-paragraph sample and typing into it rendered every paragraph as a descending diagonal of one-to-four-character fragments - not a crash, not an error, just wrong, and only visible by actually looking at a screenshot rather than trusting that cargo build/cargo run succeeded. Per fontdue's own docs (docs.rs/fontdue/0.9.2, GlyphPosition::y), y is "the ymin of the glyph bounding box" in the coordinate system in use here (PositiveYDown, so ymin is the glyph's own top) - not a fixed per-line baseline or line-top. A glyph with a descender sits at a different bounding-box top than one without, even on the same visual line, so a small y-based epsilon produced a false "new line" every couple of characters, while those glyphs' x values (never reset, since it wasn't really a new line) kept climbing - painting each fake line at a full line-height step down while x kept increasing produced exactly the observed staircase. Fixed by detecting a wrap via x resetting backward instead and re-zeroing each line's glyph y relative to that line's own minimum before caching them. Verified by reloading the sample and confirming normal left-aligned wrapped paragraphs.
Typed text arriving scrambled, caused by re-running hit-testing on every frame instead of once per click. After fixing the line-detection bug, typing into a document still produced garbled, partially-reordered text. The cause: cursor-placement used Response::interact_pointer_pos(), which (per ui::interact's own implementation) returns Some(pointer_pos) on every frame the pointer merely hovers the rect, not only on the frame of an actual click. With the mouse left resting over the same screen position after the initial click, every subsequent frame re-ran hit-testing against that same pixel and reset the cursor there - and since the paragraph kept reflowing as new characters were inserted, "that same pixel" mapped to a different byte offset each frame, so each new character landed at a drifting position instead of advancing from the last one. Fixed by gating cursor placement on canvas_response.clicked(), an edge-triggered single-frame event, the same signal already used for setting keyboard focus.
fontdue::layout::Layout::append's byte_offset resets to 0 on every call - undocumented on docs.rs, confirmed against the actual 0.9.2 source. Shaping a paragraph's runs one at a time (needed for per-run size/font) meant multiple append calls into one Layout. The obvious assumption - that byte_offset keeps counting from where the previous append left off - is wrong: fontdue's source initializes byte_offset = 0 fresh inside append itself, with no cross-call accumulator. Every glyph from the second run onward would have reported a position as if it were the start of that run's own text, not the paragraph's. Caught before it ever produced a wrong cursor position, by checking the actual source rather than trusting the (silent-on-this-point) docs.rs page - append_sub rebases every glyph it collects by base_offset, the cumulative length of everything appended into that paragraph's layout so far.
What's next
- A font-weight/style axis for families that actually ship one. A few catalog entries (Amiko, Martel, Inria Sans, Neuton) have real Bold/Italic files; Bold/Italic toggles could look those up by name convention before falling back to the faux double-strike/shear.
- Real per-line incremental reshaping. Today a keystroke reshapes its whole paragraph; the benchmark's second table shows that's fine for normal prose and a real, if distant, wall for a pathologically long single paragraph.
- Cross-paragraph selection formatting -
apply_to_selection_or_activecurrently bails out at a paragraph boundary. - Mouse drag-to-select, copy/paste, undo - none of the three exist yet.
- Horizontal scroll or a shrink-to-fit zoom for a canvas narrower than the page - today a narrow window just clips the page's sides.