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