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