Skip to main content

text_typeset/
document_flow.rs

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