Skip to main content

text_typeset/
document_flow.rs

1//! Per-widget document flow state.
2//!
3//! A [`DocumentFlow`] is everything that describes **what a specific
4//! widget is showing** — viewport, zoom, scroll offset, wrap mode,
5//! the laid-out flow (blocks / tables / frames), the rendered frame
6//! cache, the cursor(s), and the selection / caret / text colors.
7//!
8//! Flows do not own font data. Every layout and render call takes a
9//! [`TextFontService`] by reference and reads the font registry,
10//! glyph atlas, and glyph cache through it. This split lets many
11//! widgets in the same window share one atlas (and one GPU upload
12//! per frame) while each owns an independent view onto its own
13//! document.
14//!
15//! # Lifecycle
16//!
17//! ```rust,no_run
18//! use text_typeset::{DocumentFlow, TextFontService};
19//!
20//! let mut service = TextFontService::new();
21//! let face = service.register_font(include_bytes!("../test-fonts/NotoSans-Variable.ttf"));
22//! service.set_default_font(face, 16.0);
23//!
24//! let mut flow = DocumentFlow::new();
25//! flow.set_viewport(800.0, 600.0);
26//!
27//! # #[cfg(feature = "text-document")]
28//! # {
29//! let doc = text_document::TextDocument::new();
30//! doc.set_plain_text("Hello, world!").unwrap();
31//! flow.layout_full(&service, &doc.snapshot_flow());
32//! # }
33//!
34//! let frame = flow.render(&mut service);
35//! // frame.glyphs     -> glyph quads (textured rects from the shared atlas)
36//! // frame.decorations -> cursor, selection, underlines, borders
37//! ```
38//!
39//! The caller's pattern for a multi-widget UI is the same, plus one
40//! rule: each widget owns its own `DocumentFlow` and must re-push
41//! its view state (viewport, zoom, scroll, cursor, colors) before
42//! its own `layout_*` / `render` call, because those fields live on
43//! the flow itself, not on the shared service.
44
45use crate::TextFontService;
46use crate::font::resolve::resolve_font;
47use crate::layout::block::BlockLayoutParams;
48use crate::layout::flow::{FlowItem, FlowLayout};
49use crate::layout::frame::FrameLayoutParams;
50use crate::layout::inline_markup::{InlineAttrs, InlineMarkup};
51use crate::layout::paragraph::{Alignment, Hyphenator, break_into_lines};
52use crate::layout::table::TableLayoutParams;
53use crate::shaping::run::{ShapedGlyph, ShapedRun};
54use crate::shaping::shaper::{
55    bidi_runs, font_metrics_px, shape_text, shape_text_with_fallback, to_harfrust_features,
56};
57use crate::types::{
58    BlockVisualInfo, CharacterGeometry, CursorDisplay, DecorationKind, DecorationRect, GlyphQuad,
59    HitTestResult, LaidOutSpan, LaidOutSpanKind, ParagraphResult, RenderFrame, SingleLineResult,
60    TextFormat,
61};
62
63/// Reasons [`DocumentFlow::relayout_block`] may refuse an
64/// incremental update.
65///
66/// Both variants describe invariant violations the caller can
67/// detect structurally ahead of time by asking
68/// [`DocumentFlow::has_layout`] and
69/// [`DocumentFlow::layout_dirty_for_scale`]. Returned as a
70/// `Result` rather than panicking so a misbehaving caller
71/// produces a recoverable error at the exact call site instead
72/// of corrupting the flow with a partial relayout.
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum RelayoutError {
75    /// No `layout_*` method has been called on this flow yet.
76    /// The caller must run [`DocumentFlow::layout_full`] or
77    /// [`DocumentFlow::layout_blocks`] first to establish a
78    /// baseline layout before incremental updates make sense.
79    NoLayout,
80    /// The backing [`TextFontService`] has had its HiDPI scale
81    /// factor changed since this flow was last laid out, so the
82    /// existing block layouts hold advances at the old ppem.
83    /// Re-shaping a single block would leave it at the new ppem
84    /// while neighbors stay at the old, producing an inconsistent
85    /// flow. The caller must re-run `layout_full` /
86    /// `layout_blocks` to rebuild everything at the new scale.
87    ScaleDirty,
88}
89
90impl std::fmt::Display for RelayoutError {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        match self {
93            RelayoutError::NoLayout => {
94                f.write_str("relayout_block called before any layout_* method")
95            }
96            RelayoutError::ScaleDirty => f.write_str(
97                "relayout_block called after a scale-factor change without a fresh layout_*",
98            ),
99        }
100    }
101}
102
103impl std::error::Error for RelayoutError {}
104
105/// How the content (layout) width is determined.
106///
107/// Controls whether text reflows when the viewport resizes (web or
108/// editor style) or wraps at a fixed width (page / WYSIWYG style).
109#[derive(Debug, Clone, Copy, Default)]
110pub enum ContentWidthMode {
111    /// Content width equals viewport width (divided by zoom). Text
112    /// reflows on window resize — the default, typical for editors
113    /// and web layout.
114    #[default]
115    Auto,
116    /// Content width is fixed at a specific value, independent of
117    /// the viewport. Useful for page-like WYSIWYG layout, print
118    /// preview, or side panels with their own column width.
119    Fixed(f32),
120}
121
122/// Per-widget document flow state.
123///
124/// See the module-level docs for the shape of the split and for
125/// lifecycle examples. Every layout/render method here takes a
126/// [`TextFontService`] reference so flows can share one atlas across
127/// an entire window.
128/// Whether the render window changed enough since the last full render that the
129/// incremental paths — which reuse the culled cache — must fall back to a full
130/// re-render. A `None`↔`Some` transition always drifts; two `Some`s drift on a
131/// >0.001px change in either endpoint (so a scroll re-renders, a still view does not).
132fn render_window_drifted(now: Option<(f32, f32)>, then: Option<(f32, f32)>) -> bool {
133    match (now, then) {
134        (None, None) => false,
135        (Some((t0, h0)), Some((t1, h1))) => (t0 - t1).abs() > 0.001 || (h0 - h1).abs() > 0.001,
136        _ => true,
137    }
138}
139
140pub struct DocumentFlow {
141    flow_layout: FlowLayout,
142    render_frame: RenderFrame,
143    scroll_offset: f32,
144    rendered_scroll_offset: f32,
145    /// When `Some((top, height))`, render culling uses this content-space band
146    /// instead of `[scroll_offset, scroll_offset + viewport_height]`. Positioning
147    /// (glyph screen y, hit-testing) is unaffected. See
148    /// [`DocumentFlow::set_render_window`].
149    render_window: Option<(f32, f32)>,
150    /// The `render_window` in effect at the last full `render()`, so the
151    /// incremental paths can fall back when the visible band scrolls.
152    rendered_window: Option<(f32, f32)>,
153    viewport_width: f32,
154    viewport_height: f32,
155    content_width_mode: ContentWidthMode,
156    selection_color: [f32; 4],
157    cursor_color: [f32; 4],
158    text_color: [f32; 4],
159    /// Background used by the text-document bridge when a code block
160    /// carries no explicit `background_color`. Overrides the bridge's
161    /// historical light-grey default. Threaded into every
162    /// `convert_flow_with` / `convert_block_with` call kicked off
163    /// from `layout_full`. See [`Self::set_code_block_background`].
164    code_block_background: [f32; 4],
165    /// Foreground used by the text-document bridge for monospaced runs
166    /// (markdown inline `code`, fenced code blocks) that carry no
167    /// explicit `foreground_color`. `None` keeps the engine's default
168    /// `text_color`. See [`Self::set_code_block_foreground`].
169    code_block_foreground: Option<[f32; 4]>,
170    /// Echo / masking character for secure (password) fields. When
171    /// `Some(c)`, every character laid out by `layout_full` is replaced
172    /// with `c` before shaping, so the real text never reaches the
173    /// shaper or the glyph atlas. `None` (default) lays text out
174    /// verbatim. Threaded into the bridge via [`crate::bridge::BridgeOptions::echo_char`]
175    /// from `layout_full`. See [`set_echo_char`](Self::set_echo_char).
176    echo_char: Option<char>,
177    /// Auto-hyphenate justified blocks that don't set `hyphenate`
178    /// explicitly. Threaded into the bridge via
179    /// [`crate::bridge::BridgeOptions::hyphenate_justified`] from
180    /// `layout_full`. Enable on prose surfaces only. `false` by default.
181    /// See [`set_hyphenate_justified`](Self::set_hyphenate_justified).
182    hyphenate_justified: bool,
183    cursors: Vec<CursorDisplay>,
184    zoom: f32,
185    rendered_zoom: f32,
186    /// Per-document logical text-magnification factor (`1.0` = none). Unlike
187    /// `zoom` (a post-layout *display* transform that leaves font metrics
188    /// untouched) and `raster_scale`/`scale_factor` (raster density only),
189    /// `font_scale` multiplies the resolved logical font size *before* shaping,
190    /// so glyph advances, line heights, and `content_height` all grow and text
191    /// re-wraps. This is the accessibility "grow all text" knob. Set via
192    /// [`set_font_scale`](Self::set_font_scale); pushed into `flow_layout` at
193    /// every `layout_*` call alongside `scale_factor`.
194    font_scale: f32,
195    /// Raster densification for content drawn under a scale transform.
196    /// `1.0` = unscaled UI. Rasterization happens at
197    /// `size × scale_factor × raster_scale` physical pixels while layout
198    /// and glyph `screen` rects stay in logical pixels. Unlike `zoom`
199    /// (a post-layout coordinate transform) this changes which bitmaps
200    /// the quads sample, so the incremental `render_block_only` path
201    /// falls back to a full render when it changed. See
202    /// [`set_raster_scale`](Self::set_raster_scale).
203    raster_scale: f32,
204    rendered_raster_scale: f32,
205    /// `TextFontService::scale_generation` at the time of the last
206    /// `layout_*` call. Used by
207    /// [`layout_dirty_for_scale`](DocumentFlow::layout_dirty_for_scale)
208    /// so the framework can detect HiDPI transitions and re-run
209    /// layout without having to track them itself.
210    layout_scale_generation: u64,
211    /// Whether any `layout_*` call has been made at least once.
212    has_layout: bool,
213}
214
215impl DocumentFlow {
216    /// Create an empty flow with no content.
217    ///
218    /// After construction the caller typically calls
219    /// [`set_viewport`](Self::set_viewport) and one of the
220    /// `layout_*` methods before the first render.
221    pub fn new() -> Self {
222        Self {
223            flow_layout: FlowLayout::new(),
224            render_frame: RenderFrame::new(),
225            scroll_offset: 0.0,
226            rendered_scroll_offset: f32::NAN,
227            render_window: None,
228            rendered_window: None,
229            viewport_width: 0.0,
230            viewport_height: 0.0,
231            content_width_mode: ContentWidthMode::Auto,
232            selection_color: [0.26, 0.52, 0.96, 0.3],
233            cursor_color: [0.0, 0.0, 0.0, 1.0],
234            text_color: [0.0, 0.0, 0.0, 1.0],
235            code_block_background: [0.95, 0.95, 0.95, 1.0],
236            code_block_foreground: None,
237            echo_char: None,
238            hyphenate_justified: false,
239            cursors: Vec::new(),
240            zoom: 1.0,
241            rendered_zoom: f32::NAN,
242            font_scale: 1.0,
243            raster_scale: 1.0,
244            rendered_raster_scale: f32::NAN,
245            layout_scale_generation: 0,
246            has_layout: false,
247        }
248    }
249
250    // ── Viewport & content width ───────────────────────────────
251
252    /// Set the visible area dimensions in logical pixels.
253    ///
254    /// The viewport controls:
255    ///
256    /// - **Culling**: only blocks within the viewport are rendered.
257    /// - **Selection highlight**: multi-line selection extends to
258    ///   the viewport width.
259    /// - **Layout width** (in [`ContentWidthMode::Auto`]): text
260    ///   wraps at `viewport_width / zoom`.
261    ///
262    /// Call this when the widget's container resizes. A resize by
263    /// itself does not relayout — re-run `layout_full` /
264    /// `layout_blocks` if the wrap width changed.
265    pub fn set_viewport(&mut self, width: f32, height: f32) {
266        self.viewport_width = width;
267        self.viewport_height = height;
268        self.flow_layout.viewport_width = width;
269        self.flow_layout.viewport_height = height;
270    }
271
272    /// Current viewport width in logical pixels.
273    pub fn viewport_width(&self) -> f32 {
274        self.viewport_width
275    }
276
277    /// Current viewport height in logical pixels.
278    pub fn viewport_height(&self) -> f32 {
279        self.viewport_height
280    }
281
282    /// Pin content width at a fixed value, independent of viewport.
283    ///
284    /// Text wraps at this width regardless of how wide the viewport
285    /// is. Use for page-like (WYSIWYG) layout or documents with an
286    /// explicit column width. Pass `f32::INFINITY` for no-wrap mode.
287    pub fn set_content_width(&mut self, width: f32) {
288        self.content_width_mode = ContentWidthMode::Fixed(width);
289    }
290
291    /// Reflow content width to follow the viewport (the default).
292    ///
293    /// Text re-wraps on every viewport resize. Standard editor and
294    /// web-style layout.
295    pub fn set_content_width_auto(&mut self) {
296        self.content_width_mode = ContentWidthMode::Auto;
297    }
298
299    /// The effective width used for text layout (line wrapping,
300    /// table columns, etc.).
301    ///
302    /// In [`ContentWidthMode::Auto`], equals `viewport_width / zoom`
303    /// so that text reflows to fit the zoomed viewport. In
304    /// [`ContentWidthMode::Fixed`], equals the set value (zoom only
305    /// magnifies the rendered output).
306    pub fn layout_width(&self) -> f32 {
307        match self.content_width_mode {
308            ContentWidthMode::Auto => self.viewport_width / self.zoom,
309            ContentWidthMode::Fixed(w) => w,
310        }
311    }
312
313    /// The currently configured content-width mode.
314    pub fn content_width_mode(&self) -> ContentWidthMode {
315        self.content_width_mode
316    }
317
318    /// Set the vertical scroll offset in logical pixels from the
319    /// top of the document. Affects culling and screen-space `y`
320    /// coordinates in the rendered frame.
321    pub fn set_scroll_offset(&mut self, offset: f32) {
322        self.scroll_offset = offset;
323    }
324
325    /// Current vertical scroll offset.
326    pub fn scroll_offset(&self) -> f32 {
327        self.scroll_offset
328    }
329
330    /// Restrict render **culling** to the content-space band `[top, top + height]`
331    /// instead of the default `[scroll_offset, scroll_offset + viewport_height]`.
332    ///
333    /// This affects *only* which blocks / lines / decorations are emitted into the
334    /// frame — glyph screen positions, hit-testing and caret geometry all still key
335    /// off `scroll_offset` and are unchanged. It exists for an editor laid out at its
336    /// full document height inside an outer `ScrollArea` ("bastard mode"): its own
337    /// viewport spans the whole document (so the viewport-derived window culls
338    /// nothing) and `scroll_offset` stays `0` (the ancestor scrolls it by
339    /// translation), so the true visible band must be supplied from the ancestor
340    /// clip. Pass `None` (the default) to restore the viewport-derived window.
341    pub fn set_render_window(&mut self, window: Option<(f32, f32)>) {
342        self.render_window = window;
343    }
344
345    /// The active render window, if any. See [`set_render_window`](Self::set_render_window).
346    pub fn render_window(&self) -> Option<(f32, f32)> {
347        self.render_window
348    }
349
350    /// Total content height after layout, in logical pixels.
351    pub fn content_height(&self) -> f32 {
352        self.flow_layout.content_height
353    }
354
355    /// Maximum content width across all laid-out lines, in logical
356    /// pixels. Used for horizontal scrollbar range when wrapping
357    /// is disabled.
358    pub fn max_content_width(&self) -> f32 {
359        self.flow_layout.cached_max_content_width
360    }
361
362    // ── Zoom ────────────────────────────────────────────────────
363
364    /// Set the display zoom level (PDF-style, no reflow).
365    ///
366    /// Zoom is a pure display transform: layout stays at base size
367    /// and all screen-space output (glyph quads, decorations, caret
368    /// rects) is scaled by this factor. Hit-test inputs are
369    /// inversely scaled.
370    ///
371    /// For browser-style zoom that reflows text, combine with
372    /// `set_content_width(viewport_width / zoom)`.
373    ///
374    /// Clamped to `0.1..=10.0`. Default is `1.0`.
375    pub fn set_zoom(&mut self, zoom: f32) {
376        self.zoom = zoom.clamp(0.1, 10.0);
377    }
378
379    /// Current display zoom level.
380    pub fn zoom(&self) -> f32 {
381        self.zoom
382    }
383
384    // ── Font scale (logical text magnification) ──────────────────
385
386    /// Set the per-document logical font-scale factor (`1.0` = none).
387    ///
388    /// Unlike [`set_zoom`](Self::set_zoom) (a post-layout display transform
389    /// that does **not** change font metrics), `font_scale` multiplies the
390    /// resolved logical font size *before* shaping. Glyph advances, line
391    /// heights, and `content_height` all grow, and text re-wraps — true text
392    /// magnification, the mechanism behind an app-wide "grow all text"
393    /// accessibility setting. Takes effect on the next `layout_*` call.
394    /// Clamped to `0.1..=10.0`.
395    pub fn set_font_scale(&mut self, font_scale: f32) {
396        self.font_scale = font_scale.clamp(0.1, 10.0);
397    }
398
399    /// Current logical font-scale factor.
400    pub fn font_scale(&self) -> f32 {
401        self.font_scale
402    }
403
404    /// Set the raster densification scale for content drawn under a
405    /// scale transform (a zoomed scene viewport).
406    ///
407    /// Orthogonal to [`set_zoom`](Self::set_zoom): zoom multiplies the
408    /// emitted screen coordinates, `raster_scale` only changes the
409    /// physical ppem glyphs are rasterized at — layout, metrics, and
410    /// `screen` rects are identical at every raster scale, so no
411    /// relayout is needed after changing it. The next [`render`](Self::render)
412    /// rasterizes missing glyphs at the new density;
413    /// old-density entries age out of the atlas via the normal LRU.
414    /// Scaled rasters (`!= 1.0`) are unhinted.
415    ///
416    /// Clamped to `0.1..=16.0`. Default is `1.0`.
417    pub fn set_raster_scale(&mut self, raster_scale: f32) {
418        self.raster_scale = raster_scale.clamp(0.1, 16.0);
419    }
420
421    /// Current raster densification scale.
422    pub fn raster_scale(&self) -> f32 {
423        self.raster_scale
424    }
425
426    // ── Scale factor sync ───────────────────────────────────────
427
428    /// Whether any `layout_*` method has run on this flow at least
429    /// once. Callers that need to distinguish "never laid out"
430    /// from "laid out against a stale scale factor" read this
431    /// alongside [`layout_dirty_for_scale`](Self::layout_dirty_for_scale).
432    pub fn has_layout(&self) -> bool {
433        self.has_layout
434    }
435
436    /// Returns `true` when the backing [`TextFontService`] has had
437    /// its HiDPI scale factor changed since this flow was last laid
438    /// out, meaning stored shaped advances and cached ppem values
439    /// are stale.
440    ///
441    /// Call after every `service.set_scale_factor(...)` to decide
442    /// whether to re-run `layout_full` / `layout_blocks` before the
443    /// next render. Returns `false` for flows that have never been
444    /// laid out at all (nothing to invalidate).
445    pub fn layout_dirty_for_scale(&self, service: &TextFontService) -> bool {
446        self.has_layout && self.layout_scale_generation != service.scale_generation()
447    }
448
449    // ── Layout ──────────────────────────────────────────────────
450
451    /// Full layout from a text-document `FlowSnapshot`.
452    ///
453    /// Clears any existing flow state and lays out every element
454    /// (blocks, tables, frames) from the snapshot in flow order.
455    /// Call on document load or `DocumentReset`. For single-block
456    /// edits prefer [`relayout_block`](Self::relayout_block).
457    #[cfg(feature = "text-document")]
458    pub fn layout_full(&mut self, service: &TextFontService, flow: &text_document::FlowSnapshot) {
459        use crate::bridge::{BridgeOptions, convert_flow_with};
460
461        let opts = BridgeOptions {
462            code_block_background: self.code_block_background,
463            code_block_foreground: self.code_block_foreground,
464            echo_char: self.echo_char,
465            hyphenate_justified: self.hyphenate_justified,
466        };
467        let converted = convert_flow_with(flow, &opts);
468
469        // Merge all elements by flow index and process in order.
470        let mut all_items: Vec<(usize, FlowItemKind)> = Vec::new();
471        for (idx, params) in converted.blocks {
472            all_items.push((idx, FlowItemKind::Block(params)));
473        }
474        for (idx, params) in converted.tables {
475            all_items.push((idx, FlowItemKind::Table(params)));
476        }
477        for (idx, params) in converted.frames {
478            all_items.push((idx, FlowItemKind::Frame(params)));
479        }
480        all_items.sort_by_key(|(idx, _)| *idx);
481
482        let lw = self.layout_width();
483        self.flow_layout.clear();
484        self.flow_layout.viewport_width = self.viewport_width;
485        self.flow_layout.viewport_height = self.viewport_height;
486        self.flow_layout.scale_factor = service.scale_factor;
487        self.flow_layout.font_scale = self.font_scale;
488
489        for (_idx, kind) in all_items {
490            match kind {
491                FlowItemKind::Block(params) => {
492                    self.flow_layout
493                        .add_block(&service.font_registry, &params, lw);
494                }
495                FlowItemKind::Table(params) => {
496                    self.flow_layout
497                        .add_table(&service.font_registry, &params, lw);
498                }
499                FlowItemKind::Frame(params) => {
500                    self.flow_layout
501                        .add_frame(&service.font_registry, &params, lw);
502                }
503            }
504        }
505
506        // Capture the freshly-shaped blocks as the paint-overlay base. The
507        // engine applies paint spans afterward (recolor without reshape).
508        self.flow_layout.refresh_base_blocks();
509
510        self.note_layout_done(service);
511    }
512
513    /// Lay out a list of blocks from scratch.
514    ///
515    /// Framework-agnostic entry point — the caller assembles
516    /// [`BlockLayoutParams`] directly without going through
517    /// text-document. Replaces any existing flow state.
518    pub fn layout_blocks(
519        &mut self,
520        service: &TextFontService,
521        block_params: Vec<BlockLayoutParams>,
522    ) {
523        self.flow_layout.scale_factor = service.scale_factor;
524        self.flow_layout.font_scale = self.font_scale;
525        self.flow_layout
526            .layout_blocks(&service.font_registry, block_params, self.layout_width());
527        self.note_layout_done(service);
528    }
529
530    /// Append a block to the current flow, in O(1).
531    ///
532    /// The block counterpart of [`add_frame`](Self::add_frame) /
533    /// [`add_table`](Self::add_table), and the incremental alternative to
534    /// re-running [`layout_blocks`](Self::layout_blocks) after content grows.
535    ///
536    /// Streaming consumers (a log/console view tailing output) need this: a
537    /// full re-layout is O(N) in the whole document, so appending one line to
538    /// a 100 000-line buffer costs over a second, while this stays flat at the
539    /// cost of shaping the one new line, whatever the buffer already holds.
540    /// See `docs/streaming-baseline.md` for the measurements.
541    ///
542    /// Appends at the tail: the new block takes the current `content_height`
543    /// as its `y` (margin-collapsed against the previous block, exactly as a
544    /// bulk layout would place it), so an append-only sequence produces a flow
545    /// identical to laying the same blocks out in one call.
546    ///
547    /// # Invariants
548    ///
549    /// Like [`relayout_block`](Self::relayout_block), this is an incremental
550    /// operation, so it must not run against a layout shaped at a different
551    /// HiDPI scale: appending at the current scale while every existing block
552    /// sits at the old one would leave the flow permanently mixed-scale — and
553    /// worse, stamping the flow as freshly laid out would clear the very
554    /// staleness flag ([`layout_dirty_for_scale`](Self::layout_dirty_for_scale))
555    /// the caller relies on to know it must re-layout. Returns
556    /// [`RelayoutError::ScaleDirty`] instead; the caller re-runs
557    /// [`layout_full`](Self::layout_full) / [`layout_blocks`](Self::layout_blocks).
558    ///
559    /// Unlike `relayout_block` there is no `NoLayout` error: appending to an
560    /// empty flow is how an append-only buffer legitimately starts.
561    pub fn add_block(
562        &mut self,
563        service: &TextFontService,
564        params: &BlockLayoutParams,
565    ) -> Result<(), RelayoutError> {
566        // Only meaningful once a layout exists; an empty flow has no
567        // established scale to conflict with.
568        if self.has_layout && self.layout_scale_generation != service.scale_generation() {
569            return Err(RelayoutError::ScaleDirty);
570        }
571        self.flow_layout.scale_factor = service.scale_factor;
572        self.flow_layout.font_scale = self.font_scale;
573        self.flow_layout
574            .append_block(&service.font_registry, params, self.layout_width());
575        self.note_layout_done(service);
576        Ok(())
577    }
578
579    /// Drop the first `n` blocks of the flow, returning how many were removed.
580    ///
581    /// The eviction half of a bounded streaming buffer: pair it with
582    /// [`add_block`](Self::add_block) to hold a scrollback cap. Usually O(n)
583    /// plus one `Vec` memmove of the survivors — nothing is reshaped. The
584    /// return value is the count actually evicted, which is less than `n` when
585    /// the flow holds fewer leading blocks than that, or a table/frame stops
586    /// the walk.
587    ///
588    /// Survivors keep their absolute `y`, so the vacated band at the top
589    /// becomes empty and `content_height` does not change: content below never
590    /// moves, and the viewport stays where the user put it. Callers that want
591    /// the freed space reclaimed re-run a full [`layout_blocks`](Self::layout_blocks).
592    ///
593    /// Only leading top-level blocks are evicted; a leading table or frame
594    /// stops the walk. Evicting the widest block re-derives
595    /// [`max_content_width`](Self::max_content_width) from the survivors, so
596    /// the horizontal scroll range stops describing content that is gone.
597    pub fn remove_leading(&mut self, n: usize) -> usize {
598        self.flow_layout.remove_leading(n)
599    }
600
601    /// Shape only `window` — a slice of a much larger uniform-row-height
602    /// document — placing each row at `y = index * row_height`.
603    ///
604    /// The memory counterpart of [`add_block`](Self::add_block): `add_block`
605    /// makes *growing* a buffer cheap, this makes *holding* a large one cheap.
606    /// A resident shaped line costs ~6.5 KB, so a fully laid-out 100 000-line
607    /// buffer costs ~623 MB, against ~1 MB for a viewport-sized window; render
608    /// already culls to the viewport, so shaping the rest buys nothing. See
609    /// `docs/streaming-baseline.md`.
610    ///
611    /// `content_height` is derived from `total_rows`, so the scrollbar spans
612    /// the whole document even though almost none of it is shaped. Re-call this
613    /// when the visible range moves; append at the tail with
614    /// [`add_block`](Self::add_block) and trim the front with
615    /// [`remove_leading`](Self::remove_leading) while following output, which
616    /// avoids re-shaping the window on every line.
617    ///
618    /// # Invariants
619    ///
620    /// Correct only for genuinely uniform rows: **one row = one visual line of
621    /// exactly `row_height`** — no wrapping, no embedded newlines, no per-row
622    /// margins, one font size throughout (log/console output, monospaced
623    /// code). Variable-height or wrapped content must use
624    /// [`layout_blocks`](Self::layout_blocks) / [`layout_full`](Self::layout_full).
625    /// `window` must be sorted ascending by index. Both are checked in debug
626    /// builds.
627    ///
628    /// Rows outside the window are not laid out, so
629    /// [`block_visual_info`](Self::block_visual_info) and hit-testing answer
630    /// only for resident rows; derive off-window geometry arithmetically from
631    /// `row_height`.
632    ///
633    /// # Behaviour worth knowing
634    ///
635    /// Like [`layout_blocks`](Self::layout_blocks), this drops any paint
636    /// overlay — re-apply spans after re-windowing or the rows render in base
637    /// colours. Since re-windowing happens on every visible-range change, that
638    /// re-apply belongs on the scroll path, not in one-off setup.
639    ///
640    /// [`max_content_width`](Self::max_content_width) reports the widest row
641    /// *seen so far* in this session: not the document's widest (unknowable
642    /// without shaping all of it), and deliberately not the window's widest,
643    /// which would make the horizontal scrollbar jump on every vertical scroll.
644    ///
645    /// `f32` places rows exactly only to 2^24, so past ~840 000 rows at a 20 px
646    /// row height positions begin quantizing — far beyond the target sizes, but
647    /// not unbounded.
648    pub fn layout_window(
649        &mut self,
650        service: &TextFontService,
651        window: &[(usize, BlockLayoutParams)],
652        total_rows: usize,
653        row_height: f32,
654    ) {
655        self.flow_layout.scale_factor = service.scale_factor;
656        self.flow_layout.font_scale = self.font_scale;
657        self.flow_layout.layout_window(
658            &service.font_registry,
659            window,
660            total_rows,
661            row_height,
662            self.layout_width(),
663        );
664        self.note_layout_done(service);
665    }
666
667    /// Declare the total extent of a uniform-row-height document without
668    /// shaping anything.
669    ///
670    /// Keeps the scrollbar honest when the row count changes outside the shaped
671    /// window — a line appended while the user is scrolled away from the tail,
672    /// where [`add_block`](Self::add_block) would wrongly shape a row nowhere
673    /// near the window. Leaves the shaped window untouched.
674    ///
675    /// Only meaningful for a flow driven by [`layout_window`](Self::layout_window).
676    /// On a normally laid-out flow this overwrites the accumulated
677    /// `content_height` with a fabricated `total_rows * row_height` that bears
678    /// no relation to the real content, so the scroll range goes wrong; nothing
679    /// in the type distinguishes the two, so this is the caller's contract.
680    pub fn set_uniform_extent(&mut self, total_rows: usize, row_height: f32) {
681        self.flow_layout.set_uniform_extent(total_rows, row_height);
682    }
683
684    /// Convert one document block snapshot into layout params using this flow's
685    /// own bridge options — the per-block half of [`layout_full`](Self::layout_full)'s
686    /// conversion, exposed for the windowed streaming path.
687    ///
688    /// [`layout_window`](Self::layout_window) takes already-built
689    /// [`BlockLayoutParams`], but only
690    /// this flow knows the code-block colours, echo char, and
691    /// justified-hyphenation policy that `layout_full` folds in through
692    /// [`BridgeOptions`](crate::bridge::BridgeOptions). A streaming consumer
693    /// building a window of rows from document snapshots calls this per row, so
694    /// the windowed and full paths shape a given block identically. The result
695    /// is a plain value the caller may tint (set a fragment's
696    /// `foreground_color`) before handing the window to `layout_window`.
697    pub fn block_params_for(
698        &self,
699        block: &text_document::BlockSnapshot,
700    ) -> crate::layout::block::BlockLayoutParams {
701        let opts = crate::bridge::BridgeOptions {
702            code_block_background: self.code_block_background,
703            code_block_foreground: self.code_block_foreground,
704            echo_char: self.echo_char,
705            hyphenate_justified: self.hyphenate_justified,
706        };
707        crate::bridge::convert_block_with(block, &opts)
708    }
709
710    /// Append a frame to the current flow. The frame's position
711    /// (inline, float, absolute) is carried in `params`.
712    pub fn add_frame(&mut self, service: &TextFontService, params: &FrameLayoutParams) {
713        self.flow_layout.scale_factor = service.scale_factor;
714        self.flow_layout.font_scale = self.font_scale;
715        self.flow_layout
716            .add_frame(&service.font_registry, params, self.layout_width());
717        self.note_layout_done(service);
718    }
719
720    /// Append a table to the current flow.
721    pub fn add_table(&mut self, service: &TextFontService, params: &TableLayoutParams) {
722        self.flow_layout.scale_factor = service.scale_factor;
723        self.flow_layout.font_scale = self.font_scale;
724        self.flow_layout
725            .add_table(&service.font_registry, params, self.layout_width());
726        self.note_layout_done(service);
727    }
728
729    /// Relayout a single block after its content or formatting
730    /// changed.
731    ///
732    /// Re-shapes and re-wraps just that block, then shifts
733    /// subsequent items if the height changed. Much cheaper than a
734    /// full layout for single-block edits (typing, format toggles).
735    /// If the block lives inside a table cell, the row height is
736    /// re-measured and content below the table shifts.
737    ///
738    /// # Invariants
739    ///
740    /// This is an incremental operation and only makes sense when
741    /// a valid layout is already installed on this flow, laid out
742    /// against the same HiDPI scale factor the service currently
743    /// reports. Violations produce a [`RelayoutError`]:
744    ///
745    /// - [`RelayoutError::NoLayout`] if no `layout_*` method has
746    ///   run on this flow yet — there is nothing to update.
747    /// - [`RelayoutError::ScaleDirty`] if the service's scale
748    ///   factor has changed since the last layout — reshaping a
749    ///   single block would leave neighbors at the old ppem and
750    ///   produce an inconsistent flow. The caller must re-run
751    ///   [`layout_full`](Self::layout_full) / [`layout_blocks`](Self::layout_blocks)
752    ///   first.
753    ///
754    /// Both conditions are detected structurally from
755    /// [`has_layout`](Self::has_layout) and
756    /// [`layout_dirty_for_scale`](Self::layout_dirty_for_scale),
757    /// so callers that already guard those don't need to handle
758    /// the error.
759    pub fn relayout_block(
760        &mut self,
761        service: &TextFontService,
762        params: &BlockLayoutParams,
763    ) -> Result<(), RelayoutError> {
764        if !self.has_layout {
765            return Err(RelayoutError::NoLayout);
766        }
767        if self.layout_scale_generation != service.scale_generation() {
768            return Err(RelayoutError::ScaleDirty);
769        }
770        self.flow_layout.scale_factor = service.scale_factor;
771        self.flow_layout.font_scale = self.font_scale;
772        self.flow_layout
773            .relayout_block(&service.font_registry, params, self.layout_width());
774        self.note_layout_done(service);
775        Ok(())
776    }
777
778    /// Replace the paint-only color overlay for the whole flow, re-derived from
779    /// the captured base layout. Recolors without reshaping or reflowing — the
780    /// fast path for search / spell / paint-only syntax highlights. Call
781    /// `render` afterward to refresh the GPU frame.
782    pub fn apply_paint_spans_for(
783        &mut self,
784        spans_by_block: std::collections::HashMap<usize, Vec<crate::layout::block::PaintSpan>>,
785    ) {
786        self.flow_layout.apply_paint_spans_for(spans_by_block);
787    }
788
789    /// Apply (or clear) the paint overlay for a single block. Returns `false`
790    /// if the block has no captured base (no full layout yet).
791    pub fn apply_block_paint_spans(
792        &mut self,
793        block_id: usize,
794        spans: &[crate::layout::block::PaintSpan],
795    ) -> bool {
796        self.flow_layout.apply_block_paint_spans(block_id, spans)
797    }
798
799    fn note_layout_done(&mut self, service: &TextFontService) {
800        self.has_layout = true;
801        self.layout_scale_generation = service.scale_generation();
802    }
803
804    // ── Rendering ──────────────────────────────────────────────
805
806    /// Render the visible viewport and return the produced frame.
807    ///
808    /// Performs viewport culling, rasterizes any glyphs missing
809    /// from the atlas into it, and emits glyph quads, image quads,
810    /// and decoration rectangles. The returned reference borrows
811    /// both `self` and `service`; drop it before the next mutation.
812    ///
813    /// On every call, stale glyphs (unused for ~120 frames) are
814    /// evicted from the atlas to reclaim slot space.
815    pub fn render(&mut self, service: &mut TextFontService) -> &RenderFrame {
816        let effective_vw = self.viewport_width / self.zoom;
817        let effective_vh = self.viewport_height / self.zoom;
818        crate::render::frame::build_render_frame(
819            &self.flow_layout,
820            &service.font_registry,
821            &mut service.atlas,
822            &mut service.glyph_cache,
823            &mut service.scale_context,
824            self.scroll_offset,
825            effective_vw,
826            effective_vh,
827            self.render_window,
828            &self.cursors,
829            self.cursor_color,
830            self.selection_color,
831            self.text_color,
832            self.raster_scale,
833            &mut self.render_frame,
834            &mut service.eviction_epoch,
835        );
836        self.rendered_scroll_offset = self.scroll_offset;
837        self.rendered_window = self.render_window;
838        self.rendered_zoom = self.zoom;
839        self.rendered_raster_scale = self.raster_scale;
840        apply_zoom(&mut self.render_frame, self.zoom);
841        &self.render_frame
842    }
843
844    /// Incremental render that only re-renders one block's glyphs.
845    ///
846    /// Reuses cached glyph / decoration data for all other blocks
847    /// from the last full `render()`. Call after
848    /// [`relayout_block`](Self::relayout_block) when only one block's
849    /// text changed.
850    ///
851    /// Falls back to a full [`render`](Self::render) if the block's
852    /// height changed (subsequent glyph positions would be stale),
853    /// if scroll offset or zoom changed since the last full render,
854    /// or if the block lives inside a table / frame (those are
855    /// cached with a different key).
856    pub fn render_block_only(
857        &mut self,
858        service: &mut TextFontService,
859        block_id: usize,
860    ) -> &RenderFrame {
861        if (self.scroll_offset - self.rendered_scroll_offset).abs() > 0.001
862            || render_window_drifted(self.render_window, self.rendered_window)
863            || (self.zoom - self.rendered_zoom).abs() > 0.001
864            || (self.raster_scale - self.rendered_raster_scale).abs() > 0.001
865        {
866            return self.render(service);
867        }
868
869        // Defensive: if the atlas has dropped any entry since the last
870        // full render, our cached per-block glyph quads may now point
871        // at slots owned by unrelated glyphs. Fall back to a full
872        // re-render — `touch_glyphs` in `rebuild_flat_frame` is the
873        // primary keep-alive mechanism, this is the safety net.
874        if service.eviction_epoch != self.render_frame.atlas_eviction_epoch {
875            return self.render(service);
876        }
877
878        if !self.flow_layout.blocks.contains_key(&block_id) {
879            let in_table = self.flow_layout.tables.values().any(|table| {
880                table
881                    .cell_layouts
882                    .iter()
883                    .any(|c| c.blocks.iter().any(|b| b.block_id == block_id))
884            });
885            if in_table {
886                return self.render(service);
887            }
888            let in_frame = self
889                .flow_layout
890                .frames
891                .values()
892                .any(|frame| crate::layout::flow::frame_contains_block(frame, block_id));
893            if in_frame {
894                return self.render(service);
895            }
896        }
897
898        if let Some(block) = self.flow_layout.blocks.get(&block_id) {
899            let old_height = self
900                .render_frame
901                .block_heights
902                .get(&block_id)
903                .copied()
904                .unwrap_or(block.height);
905            if (block.height - old_height).abs() > 0.001 {
906                return self.render(service);
907            }
908        }
909
910        let effective_vw = self.viewport_width / self.zoom;
911        let effective_vh = self.viewport_height / self.zoom;
912        let scale_factor = service.scale_factor;
913        let mut new_glyphs = Vec::new();
914        let mut new_images = Vec::new();
915        let mut new_keys: Vec<crate::atlas::cache::GlyphCacheKey> = Vec::new();
916        if let Some(block) = self.flow_layout.blocks.get(&block_id) {
917            let mut tmp = RenderFrame::new();
918            crate::render::frame::render_block_at_offset(
919                block,
920                0.0,
921                0.0,
922                &service.font_registry,
923                &mut service.atlas,
924                &mut service.glyph_cache,
925                &mut service.scale_context,
926                self.scroll_offset,
927                effective_vh,
928                self.render_window,
929                self.text_color,
930                scale_factor,
931                self.raster_scale,
932                &mut tmp,
933                &mut new_keys,
934                &mut service.eviction_epoch,
935            );
936            new_glyphs = tmp.glyphs;
937            new_images = tmp.images;
938        }
939
940        let new_decos = if let Some(block) = self.flow_layout.blocks.get(&block_id) {
941            crate::render::decoration::generate_block_decorations(
942                block,
943                &service.font_registry,
944                self.scroll_offset,
945                effective_vh,
946                self.render_window,
947                0.0,
948                0.0,
949                effective_vw,
950                self.text_color,
951                scale_factor,
952            )
953        } else {
954            Vec::new()
955        };
956
957        if let Some(entry) = self
958            .render_frame
959            .block_glyphs
960            .iter_mut()
961            .find(|(id, _)| *id == block_id)
962        {
963            entry.1 = new_glyphs;
964        }
965        if let Some(entry) = self
966            .render_frame
967            .block_images
968            .iter_mut()
969            .find(|(id, _)| *id == block_id)
970        {
971            entry.1 = new_images;
972        }
973        if let Some(entry) = self
974            .render_frame
975            .block_decorations
976            .iter_mut()
977            .find(|(id, _)| *id == block_id)
978        {
979            entry.1 = new_decos;
980        }
981        if let Some(entry) = self
982            .render_frame
983            .block_glyph_keys
984            .iter_mut()
985            .find(|(id, _)| *id == block_id)
986        {
987            entry.1 = new_keys;
988        }
989
990        self.rebuild_flat_frame(service);
991        apply_zoom(&mut self.render_frame, self.zoom);
992        &self.render_frame
993    }
994
995    /// Lightweight render that only updates cursor/selection
996    /// decorations.
997    ///
998    /// Reuses the existing glyph quads and images from the last
999    /// full `render()`. Use when only the cursor blinked or the
1000    /// selection changed. Falls back to a full [`render`](Self::render)
1001    /// if the scroll offset or zoom changed in the meantime.
1002    pub fn render_cursor_only(&mut self, service: &mut TextFontService) -> &RenderFrame {
1003        if (self.scroll_offset - self.rendered_scroll_offset).abs() > 0.001
1004            || render_window_drifted(self.render_window, self.rendered_window)
1005            || (self.zoom - self.rendered_zoom).abs() > 0.001
1006        {
1007            return self.render(service);
1008        }
1009
1010        // Defensive: if any atlas eviction has happened since the last
1011        // full render, our cached glyph quads' atlas coordinates may
1012        // refer to slots reallocated for unrelated glyphs. Fall back
1013        // to a fresh render rather than painting garbled text.
1014        if service.eviction_epoch != self.render_frame.atlas_eviction_epoch {
1015            return self.render(service);
1016        }
1017
1018        // Keep cached glyphs alive in the shared atlas. Cursor blinks
1019        // and selection-only changes paint the cached `render_frame.glyphs`
1020        // without ever calling `cache.get`, so the LRU sees those
1021        // glyphs as idle and ages them out under sustained activity
1022        // in *other* widgets sharing the atlas. Touching here closes
1023        // that gap so the eviction fallback above stays a safety net
1024        // rather than a hot path.
1025        service.touch_glyphs(&self.render_frame.glyph_keys);
1026
1027        self.render_frame.decorations.retain(|d| {
1028            !matches!(
1029                d.kind,
1030                DecorationKind::Cursor | DecorationKind::Selection | DecorationKind::CellSelection
1031            )
1032        });
1033
1034        let effective_vw = self.viewport_width / self.zoom;
1035        let effective_vh = self.viewport_height / self.zoom;
1036        let mut cursor_decos = crate::render::cursor::generate_cursor_decorations(
1037            &self.flow_layout,
1038            &self.cursors,
1039            self.scroll_offset,
1040            self.cursor_color,
1041            self.selection_color,
1042            effective_vw,
1043            effective_vh,
1044        );
1045        apply_zoom_decorations(&mut cursor_decos, self.zoom);
1046        self.render_frame.decorations.extend(cursor_decos);
1047
1048        &self.render_frame
1049    }
1050
1051    fn rebuild_flat_frame(&mut self, service: &mut TextFontService) {
1052        self.render_frame.glyphs.clear();
1053        self.render_frame.images.clear();
1054        self.render_frame.decorations.clear();
1055        self.render_frame.glyph_keys.clear();
1056        for (_, glyphs) in &self.render_frame.block_glyphs {
1057            self.render_frame.glyphs.extend_from_slice(glyphs);
1058        }
1059        for (_, images) in &self.render_frame.block_images {
1060            self.render_frame.images.extend_from_slice(images);
1061        }
1062        for (_, decos) in &self.render_frame.block_decorations {
1063            self.render_frame.decorations.extend_from_slice(decos);
1064        }
1065        for (_, keys) in &self.render_frame.block_glyph_keys {
1066            self.render_frame.glyph_keys.extend_from_slice(keys);
1067        }
1068        // Keep the cached glyphs alive in the shared atlas. Without
1069        // this, blocks that are still visible through cached quads
1070        // but never re-rasterized this frame would age out under the
1071        // 120-generation LRU and have their atlas slots reallocated
1072        // to unrelated glyphs — corrupting every paint that reuses
1073        // these quads (the editor-and-viewer-mangled-together bug).
1074        service.touch_glyphs(&self.render_frame.glyph_keys);
1075
1076        for item in &self.flow_layout.flow_order {
1077            match item {
1078                FlowItem::Table { table_id, .. } => {
1079                    if let Some(table) = self.flow_layout.tables.get(table_id) {
1080                        let decos = crate::layout::table::generate_table_decorations(
1081                            table,
1082                            self.scroll_offset,
1083                        );
1084                        self.render_frame.decorations.extend(decos);
1085                    }
1086                }
1087                FlowItem::Frame { frame_id, .. } => {
1088                    if let Some(frame) = self.flow_layout.frames.get(frame_id) {
1089                        crate::render::frame::append_frame_table_decorations(
1090                            frame,
1091                            0.0,
1092                            0.0,
1093                            self.scroll_offset,
1094                            &mut self.render_frame.decorations,
1095                        );
1096                        crate::render::frame::append_frame_border_decorations(
1097                            frame,
1098                            self.scroll_offset,
1099                            &mut self.render_frame.decorations,
1100                        );
1101                    }
1102                }
1103                FlowItem::Block { .. } => {}
1104            }
1105        }
1106
1107        let effective_vw = self.viewport_width / self.zoom;
1108        let effective_vh = self.viewport_height / self.zoom;
1109        let cursor_decos = crate::render::cursor::generate_cursor_decorations(
1110            &self.flow_layout,
1111            &self.cursors,
1112            self.scroll_offset,
1113            self.cursor_color,
1114            self.selection_color,
1115            effective_vw,
1116            effective_vh,
1117        );
1118        self.render_frame.decorations.extend(cursor_decos);
1119
1120        self.render_frame.atlas_dirty = service.atlas.dirty;
1121        self.render_frame.atlas_width = service.atlas.width;
1122        self.render_frame.atlas_height = service.atlas.height;
1123        if service.atlas.dirty {
1124            let pixels = &service.atlas.pixels;
1125            let needed = (service.atlas.width * service.atlas.height * 4) as usize;
1126            self.render_frame.atlas_pixels.resize(needed, 0);
1127            let copy_len = needed.min(pixels.len());
1128            self.render_frame.atlas_pixels[..copy_len].copy_from_slice(&pixels[..copy_len]);
1129            service.atlas.dirty = false;
1130        }
1131    }
1132
1133    // ── Single-line layout ──────────────────────────────────────
1134
1135    /// Lay out a single line of text and return GPU-ready glyph
1136    /// quads. Fast path for labels, tooltips, overlays — anything
1137    /// that doesn't need the full document pipeline.
1138    ///
1139    /// If `max_width` is set and the shaped text exceeds it, the
1140    /// output is truncated with an ellipsis character. Glyph quads
1141    /// are positioned with the top-left at `(0, 0)`.
1142    ///
1143    /// `raster_scale` densifies glyph bitmaps for content drawn under
1144    /// a scale transform (pass `1.0` for unscaled UI): rasterization
1145    /// happens at `size × scale_factor × raster_scale` physical pixels
1146    /// while every returned metric and `screen` rect stays in logical
1147    /// pixels — layout is identical at every raster scale.
1148    pub fn layout_single_line(
1149        &mut self,
1150        service: &mut TextFontService,
1151        text: &str,
1152        format: &TextFormat,
1153        max_width: Option<f32>,
1154        raster_scale: f32,
1155    ) -> SingleLineResult {
1156        let empty = SingleLineResult {
1157            width: 0.0,
1158            height: 0.0,
1159            baseline: 0.0,
1160            underline_offset: 0.0,
1161            underline_thickness: 0.0,
1162            glyphs: Vec::new(),
1163            glyph_keys: Vec::new(),
1164            spans: Vec::new(),
1165        };
1166
1167        if text.is_empty() {
1168            return empty;
1169        }
1170
1171        let font_point_size = format.font_size.map(|s| s as u32);
1172        let resolved = match resolve_font(
1173            &service.font_registry,
1174            format.font_family.as_deref(),
1175            format.font_weight,
1176            format.font_bold,
1177            format.font_italic,
1178            font_point_size,
1179            service.scale_factor,
1180            1.0, // standalone shaper: caller's explicit size is already theme-scaled
1181        ) {
1182            Some(r) => r,
1183            None => return empty,
1184        };
1185
1186        let metrics = match font_metrics_px(&service.font_registry, &resolved) {
1187            Some(m) => m,
1188            None => return empty,
1189        };
1190        let line_height = metrics.ascent + metrics.descent + metrics.leading;
1191        let baseline = metrics.ascent;
1192
1193        let features = to_harfrust_features(&format.features);
1194        let runs: Vec<_> = bidi_runs(text)
1195            .into_iter()
1196            .filter_map(|br| {
1197                let slice = text.get(br.byte_range.clone())?;
1198                shape_text_with_fallback(
1199                    &service.font_registry,
1200                    &resolved,
1201                    slice,
1202                    br.byte_range.start,
1203                    br.direction,
1204                    &features,
1205                )
1206            })
1207            .collect();
1208
1209        if runs.is_empty() {
1210            return empty;
1211        }
1212
1213        let total_advance: f32 = runs.iter().map(|r| r.advance_width).sum();
1214
1215        let (truncate_at_visual_index, final_width, ellipsis_run) = if let Some(max_w) = max_width
1216            && total_advance > max_w
1217        {
1218            let ellipsis_run = shape_text(&service.font_registry, &resolved, "\u{2026}", 0);
1219            let ellipsis_width = ellipsis_run
1220                .as_ref()
1221                .map(|r| r.advance_width)
1222                .unwrap_or(0.0);
1223            let budget = (max_w - ellipsis_width).max(0.0);
1224
1225            let mut used = 0.0f32;
1226            let mut count = 0usize;
1227            'outer: for run in &runs {
1228                for g in &run.glyphs {
1229                    if used + g.x_advance > budget {
1230                        break 'outer;
1231                    }
1232                    used += g.x_advance;
1233                    count += 1;
1234                }
1235            }
1236
1237            (Some(count), used + ellipsis_width, ellipsis_run)
1238        } else {
1239            (None, total_advance, None)
1240        };
1241
1242        let text_color = format.color.unwrap_or(self.text_color);
1243        let glyph_capacity: usize = runs.iter().map(|r| r.glyphs.len()).sum();
1244        let mut quads = Vec::with_capacity(glyph_capacity + 1);
1245        let mut keys = Vec::with_capacity(glyph_capacity + 1);
1246        let mut pen_x = 0.0f32;
1247        let mut emitted = 0usize;
1248
1249        'emit: for run in &runs {
1250            for glyph in &run.glyphs {
1251                if let Some(limit) = truncate_at_visual_index
1252                    && emitted >= limit
1253                {
1254                    break 'emit;
1255                }
1256                rasterize_glyph_quad(
1257                    service,
1258                    glyph,
1259                    run,
1260                    pen_x,
1261                    baseline,
1262                    text_color,
1263                    raster_scale,
1264                    &mut quads,
1265                    &mut keys,
1266                );
1267                pen_x += glyph.x_advance;
1268                emitted += 1;
1269            }
1270        }
1271
1272        if let Some(ref e_run) = ellipsis_run {
1273            for glyph in &e_run.glyphs {
1274                rasterize_glyph_quad(
1275                    service,
1276                    glyph,
1277                    e_run,
1278                    pen_x,
1279                    baseline,
1280                    text_color,
1281                    raster_scale,
1282                    &mut quads,
1283                    &mut keys,
1284                );
1285                pen_x += glyph.x_advance;
1286            }
1287        }
1288
1289        SingleLineResult {
1290            width: final_width,
1291            height: line_height,
1292            baseline,
1293            underline_offset: metrics.underline_offset,
1294            underline_thickness: metrics.stroke_size,
1295            glyphs: quads,
1296            glyph_keys: keys,
1297            spans: Vec::new(),
1298        }
1299    }
1300
1301    /// Lay out a multi-line paragraph by wrapping text at `max_width`.
1302    ///
1303    /// Multi-line counterpart to
1304    /// [`layout_single_line`](Self::layout_single_line). Shapes the
1305    /// input, breaks it at Unicode line-break opportunities
1306    /// (greedy, left-aligned), and rasterizes each line's glyphs
1307    /// into paragraph-local coordinates starting at `(0, 0)`.
1308    ///
1309    /// If `max_lines` is `Some(n)`, at most `n` lines are emitted
1310    /// and any remainder is silently dropped.
1311    ///
1312    /// See [`layout_single_line`](Self::layout_single_line) for the
1313    /// `raster_scale` contract (pass `1.0` for unscaled UI).
1314    pub fn layout_paragraph(
1315        &mut self,
1316        service: &mut TextFontService,
1317        text: &str,
1318        format: &TextFormat,
1319        max_width: f32,
1320        max_lines: Option<usize>,
1321        raster_scale: f32,
1322    ) -> ParagraphResult {
1323        let empty = ParagraphResult {
1324            width: 0.0,
1325            height: 0.0,
1326            baseline_first: 0.0,
1327            line_count: 0,
1328            line_height: 0.0,
1329            underline_offset: 0.0,
1330            underline_thickness: 0.0,
1331            glyphs: Vec::new(),
1332            glyph_keys: Vec::new(),
1333            spans: Vec::new(),
1334        };
1335
1336        if text.is_empty() || max_width <= 0.0 {
1337            return empty;
1338        }
1339
1340        let font_point_size = format.font_size.map(|s| s as u32);
1341        let resolved = match resolve_font(
1342            &service.font_registry,
1343            format.font_family.as_deref(),
1344            format.font_weight,
1345            format.font_bold,
1346            format.font_italic,
1347            font_point_size,
1348            service.scale_factor,
1349            1.0, // standalone shaper: caller's explicit size is already theme-scaled
1350        ) {
1351            Some(r) => r,
1352            None => return empty,
1353        };
1354
1355        let metrics = match font_metrics_px(&service.font_registry, &resolved) {
1356            Some(m) => m,
1357            None => return empty,
1358        };
1359
1360        let features = to_harfrust_features(&format.features);
1361        let runs: Vec<_> = bidi_runs(text)
1362            .into_iter()
1363            .filter_map(|br| {
1364                let slice = text.get(br.byte_range.clone())?;
1365                shape_text_with_fallback(
1366                    &service.font_registry,
1367                    &resolved,
1368                    slice,
1369                    br.byte_range.start,
1370                    br.direction,
1371                    &features,
1372                )
1373            })
1374            .collect();
1375
1376        if runs.is_empty() {
1377            return empty;
1378        }
1379
1380        let hyphenator = format.hyphenation.and_then(|h| {
1381            shape_text(&service.font_registry, &resolved, "-", 0)
1382                .and_then(|r| r.glyphs.into_iter().next())
1383                .map(|glyph| Hyphenator {
1384                    glyph,
1385                    language: h.language,
1386                })
1387        });
1388        let lines = break_into_lines(
1389            runs,
1390            text,
1391            max_width,
1392            Alignment::Left,
1393            0.0,
1394            &metrics,
1395            hyphenator,
1396        );
1397
1398        let line_count = match max_lines {
1399            Some(n) => lines.len().min(n),
1400            None => lines.len(),
1401        };
1402
1403        let text_color = format.color.unwrap_or(self.text_color);
1404        let mut quads: Vec<GlyphQuad> = Vec::new();
1405        let mut keys: Vec<crate::atlas::cache::GlyphCacheKey> = Vec::new();
1406        let mut y_top = 0.0f32;
1407        let mut max_line_width = 0.0f32;
1408        let baseline_first = metrics.ascent;
1409
1410        for line in lines.iter().take(line_count) {
1411            if line.width > max_line_width {
1412                max_line_width = line.width;
1413            }
1414            let baseline_y = y_top + metrics.ascent;
1415            for run in &line.runs {
1416                let mut pen_x = run.x;
1417                let run_copy = run.shaped_run.clone();
1418                for glyph in &run_copy.glyphs {
1419                    rasterize_glyph_quad(
1420                        service,
1421                        glyph,
1422                        &run_copy,
1423                        pen_x,
1424                        baseline_y,
1425                        text_color,
1426                        raster_scale,
1427                        &mut quads,
1428                        &mut keys,
1429                    );
1430                    pen_x += glyph.x_advance;
1431                }
1432            }
1433            y_top += metrics.ascent + metrics.descent + metrics.leading;
1434        }
1435
1436        let line_height = metrics.ascent + metrics.descent + metrics.leading;
1437        ParagraphResult {
1438            width: max_line_width,
1439            height: y_top,
1440            baseline_first,
1441            line_count,
1442            line_height,
1443            underline_offset: metrics.underline_offset,
1444            underline_thickness: metrics.stroke_size,
1445            glyphs: quads,
1446            glyph_keys: keys,
1447            spans: Vec::new(),
1448        }
1449    }
1450
1451    /// Single-line layout with inline markup. See
1452    /// [`layout_single_line`](Self::layout_single_line) for the plain
1453    /// variant. Accepts parsed `[label](url)`, `*italic*`, and
1454    /// `**bold**` spans and annotates the output with per-span
1455    /// bounding rectangles for hit-testing.
1456    pub fn layout_single_line_markup(
1457        &mut self,
1458        service: &mut TextFontService,
1459        markup: &InlineMarkup,
1460        format: &TextFormat,
1461        max_width: Option<f32>,
1462        raster_scale: f32,
1463    ) -> SingleLineResult {
1464        if markup.spans.is_empty() {
1465            return SingleLineResult {
1466                width: 0.0,
1467                height: 0.0,
1468                baseline: 0.0,
1469                underline_offset: 0.0,
1470                underline_thickness: 0.0,
1471                glyphs: Vec::new(),
1472                glyph_keys: Vec::new(),
1473                spans: Vec::new(),
1474            };
1475        }
1476
1477        let per_span: Vec<(SingleLineResult, &crate::layout::inline_markup::InlineSpan)> = markup
1478            .spans
1479            .iter()
1480            .map(|sp| {
1481                let fmt = merge_format(format, sp.attrs);
1482                let r = if sp.text.is_empty() {
1483                    SingleLineResult {
1484                        width: 0.0,
1485                        height: 0.0,
1486                        baseline: 0.0,
1487                        underline_offset: 0.0,
1488                        underline_thickness: 0.0,
1489                        glyphs: Vec::new(),
1490                        glyph_keys: Vec::new(),
1491                        spans: Vec::new(),
1492                    }
1493                } else {
1494                    self.layout_single_line(service, &sp.text, &fmt, None, raster_scale)
1495                };
1496                (r, sp)
1497            })
1498            .collect();
1499
1500        let total_width: f32 = per_span.iter().map(|(r, _)| r.width).sum();
1501        let line_height = per_span
1502            .iter()
1503            .map(|(r, _)| r.height)
1504            .fold(0.0f32, f32::max);
1505        let baseline = per_span
1506            .iter()
1507            .map(|(r, _)| r.baseline)
1508            .fold(0.0f32, f32::max);
1509        // Carry underline metrics from the first non-empty span. Spans may
1510        // use different fonts but a single line only has one underline, so
1511        // the first span wins.
1512        let (underline_offset, underline_thickness) = per_span
1513            .iter()
1514            .map(|(r, _)| (r.underline_offset, r.underline_thickness))
1515            .find(|(_, t)| *t > 0.0)
1516            .unwrap_or((0.0, 0.0));
1517
1518        let truncate = match max_width {
1519            Some(mw) if total_width > mw => Some(mw),
1520            _ => None,
1521        };
1522
1523        let mut glyphs: Vec<GlyphQuad> = Vec::new();
1524        let mut all_keys: Vec<crate::atlas::cache::GlyphCacheKey> = Vec::new();
1525        let mut spans_out: Vec<LaidOutSpan> = Vec::new();
1526        let mut pen_x: f32 = 0.0;
1527        let effective_width = truncate.unwrap_or(total_width);
1528
1529        for (r, sp) in &per_span {
1530            let remaining = (effective_width - pen_x).max(0.0);
1531            let span_visible_width = r.width.min(remaining);
1532            if span_visible_width <= 0.0 && r.width > 0.0 {
1533                spans_out.push(LaidOutSpan {
1534                    kind: if let Some(url) = sp.link_url.clone() {
1535                        LaidOutSpanKind::Link { url }
1536                    } else {
1537                        LaidOutSpanKind::Text
1538                    },
1539                    line_index: 0,
1540                    rect: [pen_x, 0.0, 0.0, line_height],
1541                    byte_range: sp.byte_range.clone(),
1542                });
1543                continue;
1544            }
1545
1546            for (gi, g) in r.glyphs.iter().enumerate() {
1547                let g_right = pen_x + g.screen[0] + g.screen[2];
1548                if g_right > effective_width + 0.5 {
1549                    break;
1550                }
1551                let mut gq = g.clone();
1552                gq.screen[0] += pen_x;
1553                glyphs.push(gq);
1554                if let Some(k) = r.glyph_keys.get(gi) {
1555                    all_keys.push(*k);
1556                }
1557            }
1558
1559            spans_out.push(LaidOutSpan {
1560                kind: if let Some(url) = sp.link_url.clone() {
1561                    LaidOutSpanKind::Link { url }
1562                } else {
1563                    LaidOutSpanKind::Text
1564                },
1565                line_index: 0,
1566                rect: [pen_x, 0.0, span_visible_width, line_height],
1567                byte_range: sp.byte_range.clone(),
1568            });
1569
1570            pen_x += r.width;
1571            if truncate.is_some() && pen_x >= effective_width {
1572                break;
1573            }
1574        }
1575
1576        SingleLineResult {
1577            width: effective_width,
1578            height: line_height,
1579            baseline,
1580            underline_offset,
1581            underline_thickness,
1582            glyphs,
1583            glyph_keys: all_keys,
1584            spans: spans_out,
1585        }
1586    }
1587
1588    /// Paragraph layout with inline markup. Multi-line counterpart
1589    /// to [`layout_single_line_markup`](Self::layout_single_line_markup).
1590    /// Emits a [`LaidOutSpan`] for every link segment so the caller
1591    /// can hit-test against wrapped links.
1592    pub fn layout_paragraph_markup(
1593        &mut self,
1594        service: &mut TextFontService,
1595        markup: &InlineMarkup,
1596        format: &TextFormat,
1597        max_width: f32,
1598        max_lines: Option<usize>,
1599        raster_scale: f32,
1600    ) -> ParagraphResult {
1601        let empty = ParagraphResult {
1602            width: 0.0,
1603            height: 0.0,
1604            baseline_first: 0.0,
1605            line_count: 0,
1606            line_height: 0.0,
1607            underline_offset: 0.0,
1608            underline_thickness: 0.0,
1609            glyphs: Vec::new(),
1610            glyph_keys: Vec::new(),
1611            spans: Vec::new(),
1612        };
1613
1614        if markup.spans.is_empty() || max_width <= 0.0 {
1615            return empty;
1616        }
1617
1618        let mut flat = String::new();
1619        let mut span_flat_offsets: Vec<usize> = Vec::with_capacity(markup.spans.len());
1620        for sp in &markup.spans {
1621            span_flat_offsets.push(flat.len());
1622            flat.push_str(&sp.text);
1623        }
1624        if flat.is_empty() {
1625            return empty;
1626        }
1627
1628        let base_point_size = format.font_size.map(|s| s as u32);
1629        let base_resolved = match resolve_font(
1630            &service.font_registry,
1631            format.font_family.as_deref(),
1632            format.font_weight,
1633            format.font_bold,
1634            format.font_italic,
1635            base_point_size,
1636            service.scale_factor,
1637            1.0, // standalone shaper: caller's explicit size is already theme-scaled
1638        ) {
1639            Some(r) => r,
1640            None => return empty,
1641        };
1642        let metrics = match font_metrics_px(&service.font_registry, &base_resolved) {
1643            Some(m) => m,
1644            None => return empty,
1645        };
1646
1647        let mut all_runs: Vec<ShapedRun> = Vec::new();
1648        for (span_idx, sp) in markup.spans.iter().enumerate() {
1649            if sp.text.is_empty() {
1650                continue;
1651            }
1652            let fmt = merge_format(format, sp.attrs);
1653            let span_point_size = fmt.font_size.map(|s| s as u32);
1654            let Some(resolved) = resolve_font(
1655                &service.font_registry,
1656                fmt.font_family.as_deref(),
1657                fmt.font_weight,
1658                fmt.font_bold,
1659                fmt.font_italic,
1660                span_point_size,
1661                service.scale_factor,
1662                1.0, // standalone shaper: caller's explicit size is already theme-scaled
1663            ) else {
1664                continue;
1665            };
1666
1667            let flat_start = span_flat_offsets[span_idx];
1668            let features = to_harfrust_features(&fmt.features);
1669            for br in bidi_runs(&sp.text) {
1670                let slice = match sp.text.get(br.byte_range.clone()) {
1671                    Some(s) => s,
1672                    None => continue,
1673                };
1674                let Some(mut run) = shape_text_with_fallback(
1675                    &service.font_registry,
1676                    &resolved,
1677                    slice,
1678                    flat_start + br.byte_range.start,
1679                    br.direction,
1680                    &features,
1681                ) else {
1682                    continue;
1683                };
1684                if let Some(url) = sp.link_url.as_ref() {
1685                    run.is_link = true;
1686                    run.anchor_href = Some(url.clone());
1687                }
1688                all_runs.push(run);
1689            }
1690        }
1691
1692        if all_runs.is_empty() {
1693            return empty;
1694        }
1695
1696        let hyphenator = format.hyphenation.and_then(|h| {
1697            shape_text(&service.font_registry, &base_resolved, "-", 0)
1698                .and_then(|r| r.glyphs.into_iter().next())
1699                .map(|glyph| Hyphenator {
1700                    glyph,
1701                    language: h.language,
1702                })
1703        });
1704        let lines = break_into_lines(
1705            all_runs,
1706            &flat,
1707            max_width,
1708            Alignment::Left,
1709            0.0,
1710            &metrics,
1711            hyphenator,
1712        );
1713
1714        let line_count = match max_lines {
1715            Some(n) => lines.len().min(n),
1716            None => lines.len(),
1717        };
1718
1719        let text_color = format.color.unwrap_or(self.text_color);
1720        let mut glyphs_out: Vec<GlyphQuad> = Vec::new();
1721        let mut keys_out: Vec<crate::atlas::cache::GlyphCacheKey> = Vec::new();
1722        let mut spans_out: Vec<LaidOutSpan> = Vec::new();
1723        let line_height = metrics.ascent + metrics.descent + metrics.leading;
1724        let mut y_top: f32 = 0.0;
1725        let mut max_line_width: f32 = 0.0;
1726        let baseline_first = metrics.ascent;
1727
1728        for (line_idx, line) in lines.iter().take(line_count).enumerate() {
1729            if line.width > max_line_width {
1730                max_line_width = line.width;
1731            }
1732            let baseline_y = y_top + metrics.ascent;
1733
1734            for pr in &line.runs {
1735                let run_copy = pr.shaped_run.clone();
1736                let mut pen_x = pr.x;
1737                for glyph in &run_copy.glyphs {
1738                    rasterize_glyph_quad(
1739                        service,
1740                        glyph,
1741                        &run_copy,
1742                        pen_x,
1743                        baseline_y,
1744                        text_color,
1745                        raster_scale,
1746                        &mut glyphs_out,
1747                        &mut keys_out,
1748                    );
1749                    pen_x += glyph.x_advance;
1750                }
1751
1752                if pr.decorations.is_link
1753                    && let Some(url) = pr.decorations.anchor_href.clone()
1754                {
1755                    let width = pr.shaped_run.advance_width;
1756                    spans_out.push(LaidOutSpan {
1757                        kind: LaidOutSpanKind::Link { url },
1758                        line_index: line_idx,
1759                        rect: [pr.x, y_top, width, line_height],
1760                        byte_range: pr.shaped_run.text_range.clone(),
1761                    });
1762                }
1763            }
1764
1765            y_top += line_height;
1766        }
1767
1768        ParagraphResult {
1769            width: max_line_width,
1770            height: y_top,
1771            baseline_first,
1772            line_count,
1773            line_height,
1774            underline_offset: metrics.underline_offset,
1775            underline_thickness: metrics.stroke_size,
1776            glyphs: glyphs_out,
1777            glyph_keys: keys_out,
1778            spans: spans_out,
1779        }
1780    }
1781
1782    // ── Hit testing & character geometry ───────────────────────
1783
1784    /// Map a screen-space point to a document position. Coordinates
1785    /// are relative to the widget's top-left corner; the scroll
1786    /// offset is applied internally. Returns `None` when the flow
1787    /// has no content.
1788    pub fn hit_test(&self, x: f32, y: f32) -> Option<HitTestResult> {
1789        crate::render::hit_test::hit_test(
1790            &self.flow_layout,
1791            self.scroll_offset,
1792            x / self.zoom,
1793            y / self.zoom,
1794        )
1795    }
1796
1797    /// Per-character advance geometry within a laid-out block.
1798    ///
1799    /// Used by accessibility layers that need to expose character
1800    /// positions to screen readers (AccessKit's `character_positions`
1801    /// / `character_widths` on `Role::TextRun`). `char_start` and
1802    /// `char_end` are block-relative character offsets. Returns one
1803    /// entry per character in the range, with `position` measured
1804    /// in run-local coordinates (the first character sits at `0`).
1805    pub fn character_geometry(
1806        &self,
1807        block_id: usize,
1808        char_start: usize,
1809        char_end: usize,
1810    ) -> Vec<CharacterGeometry> {
1811        // x for `offset` against a sorted, offset-deduped stop list: exact match,
1812        // else the nearer of the two bracketing stops (lower offset wins a tie) —
1813        // the same rule `LayoutLine::x_for_offset` applies, but O(log n) against a
1814        // shared build instead of an O(n) rebuild-and-scan per character.
1815        fn x_in_sorted_stops(stops: &[(usize, f32)], offset: usize) -> f32 {
1816            if stops.is_empty() {
1817                return 0.0;
1818            }
1819            match stops.binary_search_by_key(&offset, |(o, _)| *o) {
1820                Ok(i) => stops[i].1,
1821                Err(i) => {
1822                    let left = i.checked_sub(1).map(|j| stops[j]);
1823                    let right = stops.get(i).copied();
1824                    match (left, right) {
1825                        (Some((lo, lx)), Some((ro, rx))) => {
1826                            if offset.abs_diff(lo) <= ro.abs_diff(offset) {
1827                                lx
1828                            } else {
1829                                rx
1830                            }
1831                        }
1832                        (Some((_, lx)), None) => lx,
1833                        (None, Some((_, rx))) => rx,
1834                        (None, None) => 0.0,
1835                    }
1836                }
1837            }
1838        }
1839
1840        if char_start >= char_end {
1841            return Vec::new();
1842        }
1843        let block = match self.flow_layout.blocks.get(&block_id) {
1844            Some(b) => b,
1845            None => return Vec::new(),
1846        };
1847
1848        let mut absolute: Vec<(usize, f32)> = Vec::with_capacity(char_end - char_start);
1849        for line in &block.lines {
1850            if line.char_range.end <= char_start || line.char_range.start >= char_end {
1851                continue;
1852            }
1853            let local_start = char_start.max(line.char_range.start);
1854            let local_end = char_end.min(line.char_range.end);
1855            // Build the line's caret stops ONCE and index into them, rather than
1856            // calling `x_for_offset` per character — which rebuilt the stop list
1857            // (O(runs+glyphs), one allocation) every call. The paint pass splits
1858            // a spell-checked line into a run per range, so `x_for_offset`-per-char
1859            // is O(chars × runs) per line and turns quadratic on a dense document
1860            // (this dominated the accessibility rebuild on a Lorem scene with tens
1861            // of thousands of ranges). Stable-sort by offset then dedup keeps the
1862            // first (leftmost) x per offset — the same tie-break `x_for_offset`
1863            // makes with its find-first — so the geometry is unchanged.
1864            let mut stops = line.caret_stops();
1865            stops.sort_by_key(|(o, _)| *o);
1866            stops.dedup_by_key(|(o, _)| *o);
1867            for c in local_start..local_end {
1868                absolute.push((c, x_in_sorted_stops(&stops, c)));
1869            }
1870            if local_end == char_end {
1871                absolute.push((local_end, x_in_sorted_stops(&stops, local_end)));
1872            }
1873        }
1874
1875        if absolute.is_empty() {
1876            return Vec::new();
1877        }
1878
1879        absolute.sort_by_key(|(c, _)| *c);
1880
1881        let base_x = absolute.first().map(|(_, x)| *x).unwrap_or(0.0);
1882        let mut out: Vec<CharacterGeometry> = Vec::with_capacity(absolute.len());
1883        for window in absolute.windows(2) {
1884            let (c, x) = window[0];
1885            let (_, x_next) = window[1];
1886            if c >= char_end {
1887                break;
1888            }
1889            out.push(CharacterGeometry {
1890                position: x - base_x,
1891                width: (x_next - x).max(0.0),
1892            });
1893        }
1894        out
1895    }
1896
1897    /// Screen-space caret rectangle at a document position with the
1898    /// given affinity, as `[x, y, width, height]`. Feed this to the
1899    /// platform IME for composition window placement. For drawing the
1900    /// caret itself, use the `DecorationKind::Cursor` entry in
1901    /// [`RenderFrame::decorations`] instead.
1902    ///
1903    /// Affinity only changes the result at soft-wrap boundaries; at
1904    /// every other position the two affinities return the same rect.
1905    /// `CursorAffinity::Downstream` matches the pre-affinity behavior.
1906    pub fn caret_rect(&self, position: usize, affinity: crate::types::CursorAffinity) -> [f32; 4] {
1907        let mut rect = crate::render::hit_test::caret_rect(
1908            &self.flow_layout,
1909            self.scroll_offset,
1910            position,
1911            affinity,
1912        );
1913        rect[0] *= self.zoom;
1914        rect[1] *= self.zoom;
1915        rect[2] *= self.zoom;
1916        rect[3] *= self.zoom;
1917        rect
1918    }
1919
1920    // ── Cursor & colors ────────────────────────────────────────
1921
1922    /// Replace the cursor display with a single cursor.
1923    pub fn set_cursor(&mut self, cursor: &CursorDisplay) {
1924        self.cursors = vec![CursorDisplay {
1925            position: cursor.position,
1926            anchor: cursor.anchor,
1927            affinity: cursor.affinity,
1928            visible: cursor.visible,
1929            selected_cells: cursor.selected_cells.clone(),
1930        }];
1931    }
1932
1933    /// Replace the cursor display with multiple cursors (multi-caret
1934    /// editing). Each cursor independently generates a caret and
1935    /// optional selection highlight.
1936    pub fn set_cursors(&mut self, cursors: &[CursorDisplay]) {
1937        self.cursors = cursors
1938            .iter()
1939            .map(|c| CursorDisplay {
1940                position: c.position,
1941                anchor: c.anchor,
1942                affinity: c.affinity,
1943                visible: c.visible,
1944                selected_cells: c.selected_cells.clone(),
1945            })
1946            .collect();
1947    }
1948
1949    /// Set the selection highlight color `[r, g, b, a]` in 0..=1
1950    /// space. Default: `[0.26, 0.52, 0.96, 0.3]` (translucent blue).
1951    pub fn set_selection_color(&mut self, color: [f32; 4]) {
1952        self.selection_color = color;
1953    }
1954
1955    /// Set the caret color `[r, g, b, a]`. Default: black.
1956    pub fn set_cursor_color(&mut self, color: [f32; 4]) {
1957        self.cursor_color = color;
1958    }
1959
1960    /// Set the default text color `[r, g, b, a]`, used when a
1961    /// fragment has no explicit `foreground_color`. Default: black.
1962    pub fn set_text_color(&mut self, color: [f32; 4]) {
1963        self.text_color = color;
1964    }
1965
1966    /// Current default text color.
1967    pub fn text_color(&self) -> [f32; 4] {
1968        self.text_color
1969    }
1970
1971    /// Set the background painted behind fenced code blocks when the
1972    /// block carries no explicit `background_color`. Hosts wire this
1973    /// from the active theme so dark / light swaps reach the cards.
1974    /// Default `[0.95, 0.95, 0.95, 1.0]` (light grey). Affects future
1975    /// `layout_full` / `relayout_block` calls; existing layouts keep
1976    /// their already-converted background until they next re-shape.
1977    pub fn set_code_block_background(&mut self, color: [f32; 4]) {
1978        self.code_block_background = color;
1979    }
1980
1981    /// Current code-block background default.
1982    pub fn code_block_background(&self) -> [f32; 4] {
1983        self.code_block_background
1984    }
1985
1986    /// Auto-hyphenate justified blocks (that don't set `hyphenate`
1987    /// explicitly) on future `layout_full` / `relayout_block` calls.
1988    /// Enable on prose/rich-text surfaces; leave off for single-line or
1989    /// label widgets. Default `false`.
1990    pub fn set_hyphenate_justified(&mut self, enabled: bool) {
1991        self.hyphenate_justified = enabled;
1992    }
1993
1994    /// Whether justified blocks are auto-hyphenated.
1995    pub fn hyphenate_justified(&self) -> bool {
1996        self.hyphenate_justified
1997    }
1998
1999    /// Set the foreground used for monospaced runs (inline `code`,
2000    /// fenced code blocks) that carry no explicit `foreground_color`.
2001    /// `None` (default) keeps the engine's `text_color`. Hosts wire
2002    /// this from the active theme alongside `set_code_block_background`.
2003    pub fn set_code_block_foreground(&mut self, color: Option<[f32; 4]>) {
2004        self.code_block_foreground = color;
2005    }
2006
2007    /// Current code-block foreground override.
2008    pub fn code_block_foreground(&self) -> Option<[f32; 4]> {
2009        self.code_block_foreground
2010    }
2011
2012    /// Set the echo / masking character for secure (password) fields.
2013    ///
2014    /// When `Some(c)`, every character laid out by future `layout_full`
2015    /// calls is replaced with `c` before shaping, so the real text never
2016    /// reaches the shaper or the glyph atlas. `None` (default) lays text
2017    /// out verbatim. One echo char is emitted per source `char`,
2018    /// preserving char counts so caret / selection / hit-test (all
2019    /// char-indexed) stay aligned with the host document's positions.
2020    ///
2021    /// Affects future `layout_full` calls; existing layouts keep their
2022    /// already-converted glyphs until they next re-shape. The incremental
2023    /// `relayout_block` path takes pre-converted [`BlockLayoutParams`], so
2024    /// hosts driving that path must thread the same echo char through
2025    /// their own [`crate::bridge::BridgeOptions`].
2026    pub fn set_echo_char(&mut self, echo: Option<char>) {
2027        self.echo_char = echo;
2028    }
2029
2030    /// Current echo / masking character, if any.
2031    pub fn echo_char(&self) -> Option<char> {
2032        self.echo_char
2033    }
2034
2035    // ── Scrolling helpers ──────────────────────────────────────
2036
2037    /// Visual position and height of a laid-out block. Returns
2038    /// `None` if `block_id` is not in the current layout.
2039    pub fn block_visual_info(&self, block_id: usize) -> Option<BlockVisualInfo> {
2040        let block = self.flow_layout.blocks.get(&block_id)?;
2041        Some(BlockVisualInfo {
2042            block_id,
2043            y: block.y,
2044            height: block.height,
2045        })
2046    }
2047
2048    /// Whether a block lives inside any table cell.
2049    pub fn is_block_in_table(&self, block_id: usize) -> bool {
2050        self.flow_layout.tables.values().any(|table| {
2051            table
2052                .cell_layouts
2053                .iter()
2054                .any(|cell| cell.blocks.iter().any(|b| b.block_id == block_id))
2055        })
2056    }
2057
2058    /// Scroll so that `position` is visible, placing it roughly one
2059    /// third from the top of the viewport. Returns the new offset.
2060    /// Affinity defaults to `Downstream` since scroll targeting picks
2061    /// any acceptable line for the position.
2062    pub fn scroll_to_position(&mut self, position: usize) -> f32 {
2063        let rect = crate::render::hit_test::caret_rect(
2064            &self.flow_layout,
2065            self.scroll_offset,
2066            position,
2067            crate::types::CursorAffinity::Downstream,
2068        );
2069        let target_y = rect[1] + self.scroll_offset - self.viewport_height / (3.0 * self.zoom);
2070        self.scroll_offset = target_y.max(0.0);
2071        self.scroll_offset
2072    }
2073
2074    /// Scroll the minimum amount needed to make the current caret
2075    /// visible. Call after arrow-key / click / typing. Returns
2076    /// `Some(new_offset)` if the scroll moved, `None` otherwise.
2077    pub fn ensure_caret_visible(&mut self) -> Option<f32> {
2078        if self.cursors.is_empty() {
2079            return None;
2080        }
2081        let pos = self.cursors[0].position;
2082        let affinity = self.cursors[0].affinity;
2083        let rect = crate::render::hit_test::caret_rect(
2084            &self.flow_layout,
2085            self.scroll_offset,
2086            pos,
2087            affinity,
2088        );
2089        let caret_screen_y = rect[1];
2090        let caret_screen_bottom = caret_screen_y + rect[3];
2091        let effective_vh = self.viewport_height / self.zoom;
2092        let margin = 10.0 / self.zoom;
2093        let old_offset = self.scroll_offset;
2094
2095        if caret_screen_y < 0.0 {
2096            self.scroll_offset += caret_screen_y - margin;
2097            self.scroll_offset = self.scroll_offset.max(0.0);
2098        } else if caret_screen_bottom > effective_vh {
2099            self.scroll_offset += caret_screen_bottom - effective_vh + margin;
2100        }
2101
2102        if (self.scroll_offset - old_offset).abs() > 0.001 {
2103            Some(self.scroll_offset)
2104        } else {
2105            None
2106        }
2107    }
2108}
2109
2110impl Default for DocumentFlow {
2111    fn default() -> Self {
2112        Self::new()
2113    }
2114}
2115
2116#[cfg(feature = "text-document")]
2117enum FlowItemKind {
2118    Block(BlockLayoutParams),
2119    Table(TableLayoutParams),
2120    Frame(FrameLayoutParams),
2121}
2122
2123/// Rasterize a single glyph into the service's atlas and append a
2124/// `GlyphQuad` to the output vec. Shared between
2125/// [`DocumentFlow::layout_single_line`] and
2126/// [`DocumentFlow::layout_paragraph`] (plus the markup variants).
2127///
2128/// `raster_scale` densifies the bitmap without touching layout: the
2129/// glyph is rasterized at `size × scale_factor × raster_scale`
2130/// physical pixels while the emitted `screen` rect stays in logical
2131/// pixels (divided by the *total* scale), so content drawn under a
2132/// scale transform (scene zoom) samples a matching-resolution bitmap
2133/// instead of stretching a 1× raster. Scaled rasters are unhinted —
2134/// glyph positions come from shaping at the logical ppem.
2135#[allow(clippy::too_many_arguments)]
2136fn rasterize_glyph_quad(
2137    service: &mut TextFontService,
2138    glyph: &ShapedGlyph,
2139    run: &ShapedRun,
2140    pen_x: f32,
2141    baseline: f32,
2142    text_color: [f32; 4],
2143    raster_scale: f32,
2144    quads: &mut Vec<GlyphQuad>,
2145    glyph_keys: &mut Vec<crate::atlas::cache::GlyphCacheKey>,
2146) {
2147    use crate::atlas::cache::GlyphCacheKey;
2148    use crate::atlas::rasterizer::rasterize_glyph;
2149
2150    if glyph.glyph_id == 0 {
2151        return;
2152    }
2153
2154    let entry = match service.font_registry.get(glyph.font_face_id) {
2155        Some(e) => e,
2156        None => return,
2157    };
2158
2159    let raster_scale = if raster_scale > 0.0 {
2160        raster_scale
2161    } else {
2162        1.0
2163    };
2164    let hinted = raster_scale == 1.0;
2165    let sf = service.scale_factor.max(f32::MIN_POSITIVE);
2166    let inv_total = 1.0 / (sf * raster_scale);
2167    let physical_size_px = run.size_px * sf * raster_scale;
2168    let cache_key = GlyphCacheKey::with_weight(
2169        glyph.font_face_id,
2170        glyph.glyph_id,
2171        physical_size_px,
2172        run.weight as u32,
2173        hinted,
2174    );
2175
2176    if service.glyph_cache.peek(&cache_key).is_none()
2177        && let Some(image) = rasterize_glyph(
2178            &mut service.scale_context,
2179            entry.bytes(),
2180            entry.face_index,
2181            entry.swash_cache_key,
2182            glyph.glyph_id,
2183            physical_size_px,
2184            run.weight as u32,
2185            hinted,
2186        )
2187        && image.width > 0
2188        && image.height > 0
2189    {
2190        let (alloc, evicted) = crate::atlas::allocate_or_evict(
2191            &mut service.atlas,
2192            &mut service.glyph_cache,
2193            image.width,
2194            image.height,
2195        );
2196        if evicted {
2197            service.eviction_epoch = service.eviction_epoch.wrapping_add(1);
2198        }
2199        if let Some(alloc) = alloc {
2200            let rect = alloc.rectangle;
2201            let atlas_x = rect.min.x as u32;
2202            let atlas_y = rect.min.y as u32;
2203            if image.is_color {
2204                service
2205                    .atlas
2206                    .blit_rgba(atlas_x, atlas_y, image.width, image.height, &image.data);
2207            } else {
2208                service
2209                    .atlas
2210                    .blit_mask(atlas_x, atlas_y, image.width, image.height, &image.data);
2211            }
2212            service.glyph_cache.insert(
2213                cache_key,
2214                crate::atlas::cache::CachedGlyph {
2215                    alloc_id: alloc.id,
2216                    atlas_x,
2217                    atlas_y,
2218                    width: image.width,
2219                    height: image.height,
2220                    placement_left: image.placement_left,
2221                    placement_top: image.placement_top,
2222                    is_color: image.is_color,
2223                    last_used: 0,
2224                },
2225            );
2226        }
2227    }
2228
2229    if let Some(cached) = service.glyph_cache.get(&cache_key) {
2230        let logical_w = cached.width as f32 * inv_total;
2231        let logical_h = cached.height as f32 * inv_total;
2232        let logical_left = cached.placement_left as f32 * inv_total;
2233        let logical_top = cached.placement_top as f32 * inv_total;
2234        let screen_x = pen_x + glyph.x_offset + logical_left;
2235        let screen_y = baseline - glyph.y_offset - logical_top;
2236        let color = if cached.is_color {
2237            [1.0, 1.0, 1.0, 1.0]
2238        } else {
2239            text_color
2240        };
2241        quads.push(GlyphQuad {
2242            screen: [screen_x, screen_y, logical_w, logical_h],
2243            atlas: [
2244                cached.atlas_x as f32,
2245                cached.atlas_y as f32,
2246                cached.width as f32,
2247                cached.height as f32,
2248            ],
2249            color,
2250            is_color: cached.is_color,
2251        });
2252        glyph_keys.push(cache_key);
2253    }
2254}
2255
2256/// Scale all screen-space coordinates in a RenderFrame by `zoom`.
2257fn apply_zoom(frame: &mut RenderFrame, zoom: f32) {
2258    if (zoom - 1.0).abs() <= f32::EPSILON {
2259        return;
2260    }
2261    for q in &mut frame.glyphs {
2262        q.screen[0] *= zoom;
2263        q.screen[1] *= zoom;
2264        q.screen[2] *= zoom;
2265        q.screen[3] *= zoom;
2266    }
2267    for q in &mut frame.images {
2268        q.screen[0] *= zoom;
2269        q.screen[1] *= zoom;
2270        q.screen[2] *= zoom;
2271        q.screen[3] *= zoom;
2272    }
2273    apply_zoom_decorations(&mut frame.decorations, zoom);
2274}
2275
2276/// Scale all screen-space coordinates in decoration rects by `zoom`.
2277fn apply_zoom_decorations(decorations: &mut [DecorationRect], zoom: f32) {
2278    if (zoom - 1.0).abs() <= f32::EPSILON {
2279        return;
2280    }
2281    for d in decorations.iter_mut() {
2282        d.rect[0] *= zoom;
2283        d.rect[1] *= zoom;
2284        d.rect[2] *= zoom;
2285        d.rect[3] *= zoom;
2286    }
2287}
2288
2289/// Derive a per-span [`TextFormat`] from a base format and inline
2290/// markup attributes (bold / italic).
2291fn merge_format(base: &TextFormat, attrs: InlineAttrs) -> TextFormat {
2292    let mut fmt = base.clone();
2293    if attrs.is_bold() {
2294        fmt.font_bold = Some(true);
2295        if let Some(w) = fmt.font_weight
2296            && w < 600
2297        {
2298            fmt.font_weight = Some(700);
2299        } else if fmt.font_weight.is_none() {
2300            fmt.font_weight = Some(700);
2301        }
2302    }
2303    if attrs.is_italic() {
2304        fmt.font_italic = Some(true);
2305    }
2306    fmt
2307}
2308
2309#[cfg(test)]
2310mod tests {
2311    use super::*;
2312    use crate::layout::block::{BlockLayoutParams, FragmentParams};
2313    use crate::layout::paragraph::Alignment;
2314    use crate::types::{UnderlineStyle, VerticalAlignment};
2315
2316    const NOTO_SANS: &[u8] = include_bytes!("../test-fonts/NotoSans-Variable.ttf");
2317
2318    fn service() -> TextFontService {
2319        // Hermetic: don't pull in the host machine's fonts.
2320        let mut s = TextFontService::new_without_system_fonts();
2321        let face = s.register_font(NOTO_SANS);
2322        s.set_default_font(face, 16.0);
2323        s
2324    }
2325
2326    fn block(id: usize, text: &str) -> BlockLayoutParams {
2327        BlockLayoutParams {
2328            block_id: id,
2329            position: 0,
2330            text: text.to_string(),
2331            fragments: vec![FragmentParams {
2332                text: text.to_string(),
2333                offset: 0,
2334                length: text.len(),
2335                font_family: None,
2336                font_weight: None,
2337                font_bold: None,
2338                font_italic: None,
2339                font_point_size: None,
2340                underline_style: UnderlineStyle::None,
2341                overline: false,
2342                strikeout: false,
2343                is_link: false,
2344                letter_spacing: 0.0,
2345                word_spacing: 0.0,
2346                foreground_color: None,
2347                underline_color: None,
2348                background_color: None,
2349                anchor_href: None,
2350                tooltip: None,
2351                vertical_alignment: VerticalAlignment::Normal,
2352                image_name: None,
2353                image_width: 0.0,
2354                image_height: 0.0,
2355                features: Vec::new(),
2356            }],
2357            alignment: Alignment::Left,
2358            top_margin: 0.0,
2359            bottom_margin: 0.0,
2360            left_margin: 0.0,
2361            right_margin: 0.0,
2362            text_indent: 0.0,
2363            list_marker: String::new(),
2364            list_indent: 0.0,
2365            tab_positions: vec![],
2366            line_height_multiplier: None,
2367            non_breakable_lines: false,
2368            hyphenation: None,
2369            checkbox: None,
2370            background_color: None,
2371        }
2372    }
2373
2374    #[test]
2375    fn relayout_block_returns_no_layout_when_never_laid_out() {
2376        let svc = service();
2377        let mut flow = DocumentFlow::new();
2378        flow.set_viewport(400.0, 200.0);
2379        let err = flow.relayout_block(&svc, &block(1, "Hello")).unwrap_err();
2380        assert_eq!(err, RelayoutError::NoLayout);
2381    }
2382
2383    #[test]
2384    fn relayout_block_returns_scale_dirty_after_scale_factor_change() {
2385        let mut svc = service();
2386        let mut flow = DocumentFlow::new();
2387        flow.set_viewport(400.0, 200.0);
2388        flow.layout_blocks(&svc, vec![block(1, "Hello")]);
2389        assert!(flow.has_layout());
2390
2391        // Simulate a HiDPI transition on the shared service.
2392        svc.set_scale_factor(2.0);
2393        assert!(flow.layout_dirty_for_scale(&svc));
2394
2395        let err = flow
2396            .relayout_block(&svc, &block(1, "Hello world"))
2397            .unwrap_err();
2398        assert_eq!(err, RelayoutError::ScaleDirty);
2399    }
2400
2401    #[test]
2402    fn relayout_block_succeeds_after_fresh_layout_post_scale_change() {
2403        let mut svc = service();
2404        let mut flow = DocumentFlow::new();
2405        flow.set_viewport(400.0, 200.0);
2406        flow.layout_blocks(&svc, vec![block(1, "Hello")]);
2407
2408        svc.set_scale_factor(2.0);
2409        // Caller is expected to re-run a full layout at the new
2410        // scale before issuing incremental updates.
2411        flow.layout_blocks(&svc, vec![block(1, "Hello")]);
2412        assert!(!flow.layout_dirty_for_scale(&svc));
2413
2414        // Now the incremental path succeeds.
2415        flow.relayout_block(&svc, &block(1, "Hello world"))
2416            .expect("relayout_block must succeed after a fresh post-scale layout");
2417    }
2418
2419    /// `block_params_for` converts a document block snapshot into layout params
2420    /// — the per-block seam the windowed streaming path is built on. The text
2421    /// must round-trip so the shaped row matches the document.
2422    #[test]
2423    fn block_params_for_converts_a_document_snapshot() {
2424        let flow = DocumentFlow::new();
2425        let doc = text_document::TextDocument::new();
2426        doc.set_plain_text("alpha\nbeta").unwrap();
2427
2428        // Second block ("beta") starts after "alpha\n" — position 6.
2429        let snap = doc.snapshot_block_at_position(6).expect("block snapshot");
2430        let params = flow.block_params_for(&snap);
2431
2432        assert_eq!(params.text, "beta", "the block text must round-trip");
2433        assert!(
2434            !params.fragments.is_empty(),
2435            "a non-empty block must convert to at least one fragment"
2436        );
2437    }
2438
2439    /// It must use *this flow's* bridge options, not defaults — otherwise the
2440    /// windowed path would shape a block differently from `layout_full`. The
2441    /// echo char is the cheapest observable: with it set, the conversion masks
2442    /// the text.
2443    #[test]
2444    fn block_params_for_honours_the_flow_echo_char() {
2445        let mut flow = DocumentFlow::new();
2446        flow.set_echo_char(Some('•'));
2447        let doc = text_document::TextDocument::new();
2448        doc.set_plain_text("secret").unwrap();
2449
2450        let snap = doc.snapshot_block_at_position(0).expect("block snapshot");
2451        let params = flow.block_params_for(&snap);
2452
2453        assert!(
2454            params.fragments.iter().all(|f| !f.text.contains("secret")),
2455            "the flow's echo char must mask the plaintext, proving its own \
2456             bridge options are used"
2457        );
2458    }
2459}