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