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            glyphs: Vec::new(),
736            spans: Vec::new(),
737        };
738
739        if text.is_empty() {
740            return empty;
741        }
742
743        let font_point_size = format.font_size.map(|s| s as u32);
744        let resolved = match resolve_font(
745            &service.font_registry,
746            format.font_family.as_deref(),
747            format.font_weight,
748            format.font_bold,
749            format.font_italic,
750            font_point_size,
751            service.scale_factor,
752        ) {
753            Some(r) => r,
754            None => return empty,
755        };
756
757        let metrics = match font_metrics_px(&service.font_registry, &resolved) {
758            Some(m) => m,
759            None => return empty,
760        };
761        let line_height = metrics.ascent + metrics.descent + metrics.leading;
762        let baseline = metrics.ascent;
763
764        let runs: Vec<_> = bidi_runs(text)
765            .into_iter()
766            .filter_map(|br| {
767                let slice = text.get(br.byte_range.clone())?;
768                shape_text_with_fallback(
769                    &service.font_registry,
770                    &resolved,
771                    slice,
772                    br.byte_range.start,
773                    br.direction,
774                )
775            })
776            .collect();
777
778        if runs.is_empty() {
779            return empty;
780        }
781
782        let total_advance: f32 = runs.iter().map(|r| r.advance_width).sum();
783
784        let (truncate_at_visual_index, final_width, ellipsis_run) = if let Some(max_w) = max_width
785            && total_advance > max_w
786        {
787            let ellipsis_run = shape_text(&service.font_registry, &resolved, "\u{2026}", 0);
788            let ellipsis_width = ellipsis_run
789                .as_ref()
790                .map(|r| r.advance_width)
791                .unwrap_or(0.0);
792            let budget = (max_w - ellipsis_width).max(0.0);
793
794            let mut used = 0.0f32;
795            let mut count = 0usize;
796            'outer: for run in &runs {
797                for g in &run.glyphs {
798                    if used + g.x_advance > budget {
799                        break 'outer;
800                    }
801                    used += g.x_advance;
802                    count += 1;
803                }
804            }
805
806            (Some(count), used + ellipsis_width, ellipsis_run)
807        } else {
808            (None, total_advance, None)
809        };
810
811        let text_color = format.color.unwrap_or(self.text_color);
812        let glyph_capacity: usize = runs.iter().map(|r| r.glyphs.len()).sum();
813        let mut quads = Vec::with_capacity(glyph_capacity + 1);
814        let mut pen_x = 0.0f32;
815        let mut emitted = 0usize;
816
817        'emit: for run in &runs {
818            for glyph in &run.glyphs {
819                if let Some(limit) = truncate_at_visual_index
820                    && emitted >= limit
821                {
822                    break 'emit;
823                }
824                rasterize_glyph_quad(service, glyph, run, pen_x, baseline, text_color, &mut quads);
825                pen_x += glyph.x_advance;
826                emitted += 1;
827            }
828        }
829
830        if let Some(ref e_run) = ellipsis_run {
831            for glyph in &e_run.glyphs {
832                rasterize_glyph_quad(
833                    service, glyph, e_run, pen_x, baseline, text_color, &mut quads,
834                );
835                pen_x += glyph.x_advance;
836            }
837        }
838
839        SingleLineResult {
840            width: final_width,
841            height: line_height,
842            baseline,
843            glyphs: quads,
844            spans: Vec::new(),
845        }
846    }
847
848    /// Lay out a multi-line paragraph by wrapping text at `max_width`.
849    ///
850    /// Multi-line counterpart to
851    /// [`layout_single_line`](Self::layout_single_line). Shapes the
852    /// input, breaks it at Unicode line-break opportunities
853    /// (greedy, left-aligned), and rasterizes each line's glyphs
854    /// into paragraph-local coordinates starting at `(0, 0)`.
855    ///
856    /// If `max_lines` is `Some(n)`, at most `n` lines are emitted
857    /// and any remainder is silently dropped.
858    pub fn layout_paragraph(
859        &mut self,
860        service: &mut TextFontService,
861        text: &str,
862        format: &TextFormat,
863        max_width: f32,
864        max_lines: Option<usize>,
865    ) -> ParagraphResult {
866        let empty = ParagraphResult {
867            width: 0.0,
868            height: 0.0,
869            baseline_first: 0.0,
870            line_count: 0,
871            line_height: 0.0,
872            glyphs: Vec::new(),
873            spans: Vec::new(),
874        };
875
876        if text.is_empty() || max_width <= 0.0 {
877            return empty;
878        }
879
880        let font_point_size = format.font_size.map(|s| s as u32);
881        let resolved = match resolve_font(
882            &service.font_registry,
883            format.font_family.as_deref(),
884            format.font_weight,
885            format.font_bold,
886            format.font_italic,
887            font_point_size,
888            service.scale_factor,
889        ) {
890            Some(r) => r,
891            None => return empty,
892        };
893
894        let metrics = match font_metrics_px(&service.font_registry, &resolved) {
895            Some(m) => m,
896            None => return empty,
897        };
898
899        let runs: Vec<_> = bidi_runs(text)
900            .into_iter()
901            .filter_map(|br| {
902                let slice = text.get(br.byte_range.clone())?;
903                shape_text_with_fallback(
904                    &service.font_registry,
905                    &resolved,
906                    slice,
907                    br.byte_range.start,
908                    br.direction,
909                )
910            })
911            .collect();
912
913        if runs.is_empty() {
914            return empty;
915        }
916
917        let lines = break_into_lines(runs, text, max_width, Alignment::Left, 0.0, &metrics);
918
919        let line_count = match max_lines {
920            Some(n) => lines.len().min(n),
921            None => lines.len(),
922        };
923
924        let text_color = format.color.unwrap_or(self.text_color);
925        let mut quads: Vec<GlyphQuad> = Vec::new();
926        let mut y_top = 0.0f32;
927        let mut max_line_width = 0.0f32;
928        let baseline_first = metrics.ascent;
929
930        for line in lines.iter().take(line_count) {
931            if line.width > max_line_width {
932                max_line_width = line.width;
933            }
934            let baseline_y = y_top + metrics.ascent;
935            for run in &line.runs {
936                let mut pen_x = run.x;
937                let run_copy = run.shaped_run.clone();
938                for glyph in &run_copy.glyphs {
939                    rasterize_glyph_quad(
940                        service, glyph, &run_copy, pen_x, baseline_y, text_color, &mut quads,
941                    );
942                    pen_x += glyph.x_advance;
943                }
944            }
945            y_top += metrics.ascent + metrics.descent + metrics.leading;
946        }
947
948        let line_height = metrics.ascent + metrics.descent + metrics.leading;
949        ParagraphResult {
950            width: max_line_width,
951            height: y_top,
952            baseline_first,
953            line_count,
954            line_height,
955            glyphs: quads,
956            spans: Vec::new(),
957        }
958    }
959
960    /// Single-line layout with inline markup. See
961    /// [`layout_single_line`](Self::layout_single_line) for the plain
962    /// variant. Accepts parsed `[label](url)`, `*italic*`, and
963    /// `**bold**` spans and annotates the output with per-span
964    /// bounding rectangles for hit-testing.
965    pub fn layout_single_line_markup(
966        &mut self,
967        service: &mut TextFontService,
968        markup: &InlineMarkup,
969        format: &TextFormat,
970        max_width: Option<f32>,
971    ) -> SingleLineResult {
972        if markup.spans.is_empty() {
973            return SingleLineResult {
974                width: 0.0,
975                height: 0.0,
976                baseline: 0.0,
977                glyphs: Vec::new(),
978                spans: Vec::new(),
979            };
980        }
981
982        let per_span: Vec<(SingleLineResult, &crate::layout::inline_markup::InlineSpan)> = markup
983            .spans
984            .iter()
985            .map(|sp| {
986                let fmt = merge_format(format, sp.attrs);
987                let r = if sp.text.is_empty() {
988                    SingleLineResult {
989                        width: 0.0,
990                        height: 0.0,
991                        baseline: 0.0,
992                        glyphs: Vec::new(),
993                        spans: Vec::new(),
994                    }
995                } else {
996                    self.layout_single_line(service, &sp.text, &fmt, None)
997                };
998                (r, sp)
999            })
1000            .collect();
1001
1002        let total_width: f32 = per_span.iter().map(|(r, _)| r.width).sum();
1003        let line_height = per_span
1004            .iter()
1005            .map(|(r, _)| r.height)
1006            .fold(0.0f32, f32::max);
1007        let baseline = per_span
1008            .iter()
1009            .map(|(r, _)| r.baseline)
1010            .fold(0.0f32, f32::max);
1011
1012        let truncate = match max_width {
1013            Some(mw) if total_width > mw => Some(mw),
1014            _ => None,
1015        };
1016
1017        let mut glyphs: Vec<GlyphQuad> = Vec::new();
1018        let mut spans_out: Vec<LaidOutSpan> = Vec::new();
1019        let mut pen_x: f32 = 0.0;
1020        let effective_width = truncate.unwrap_or(total_width);
1021
1022        for (r, sp) in &per_span {
1023            let remaining = (effective_width - pen_x).max(0.0);
1024            let span_visible_width = r.width.min(remaining);
1025            if span_visible_width <= 0.0 && r.width > 0.0 {
1026                spans_out.push(LaidOutSpan {
1027                    kind: if let Some(url) = sp.link_url.clone() {
1028                        LaidOutSpanKind::Link { url }
1029                    } else {
1030                        LaidOutSpanKind::Text
1031                    },
1032                    line_index: 0,
1033                    rect: [pen_x, 0.0, 0.0, line_height],
1034                    byte_range: sp.byte_range.clone(),
1035                });
1036                continue;
1037            }
1038
1039            for g in &r.glyphs {
1040                let g_right = pen_x + g.screen[0] + g.screen[2];
1041                if g_right > effective_width + 0.5 {
1042                    break;
1043                }
1044                let mut gq = g.clone();
1045                gq.screen[0] += pen_x;
1046                glyphs.push(gq);
1047            }
1048
1049            spans_out.push(LaidOutSpan {
1050                kind: if let Some(url) = sp.link_url.clone() {
1051                    LaidOutSpanKind::Link { url }
1052                } else {
1053                    LaidOutSpanKind::Text
1054                },
1055                line_index: 0,
1056                rect: [pen_x, 0.0, span_visible_width, line_height],
1057                byte_range: sp.byte_range.clone(),
1058            });
1059
1060            pen_x += r.width;
1061            if truncate.is_some() && pen_x >= effective_width {
1062                break;
1063            }
1064        }
1065
1066        SingleLineResult {
1067            width: effective_width,
1068            height: line_height,
1069            baseline,
1070            glyphs,
1071            spans: spans_out,
1072        }
1073    }
1074
1075    /// Paragraph layout with inline markup. Multi-line counterpart
1076    /// to [`layout_single_line_markup`](Self::layout_single_line_markup).
1077    /// Emits a [`LaidOutSpan`] for every link segment so the caller
1078    /// can hit-test against wrapped links.
1079    pub fn layout_paragraph_markup(
1080        &mut self,
1081        service: &mut TextFontService,
1082        markup: &InlineMarkup,
1083        format: &TextFormat,
1084        max_width: f32,
1085        max_lines: Option<usize>,
1086    ) -> ParagraphResult {
1087        let empty = ParagraphResult {
1088            width: 0.0,
1089            height: 0.0,
1090            baseline_first: 0.0,
1091            line_count: 0,
1092            line_height: 0.0,
1093            glyphs: Vec::new(),
1094            spans: Vec::new(),
1095        };
1096
1097        if markup.spans.is_empty() || max_width <= 0.0 {
1098            return empty;
1099        }
1100
1101        let mut flat = String::new();
1102        let mut span_flat_offsets: Vec<usize> = Vec::with_capacity(markup.spans.len());
1103        for sp in &markup.spans {
1104            span_flat_offsets.push(flat.len());
1105            flat.push_str(&sp.text);
1106        }
1107        if flat.is_empty() {
1108            return empty;
1109        }
1110
1111        let base_point_size = format.font_size.map(|s| s as u32);
1112        let base_resolved = match resolve_font(
1113            &service.font_registry,
1114            format.font_family.as_deref(),
1115            format.font_weight,
1116            format.font_bold,
1117            format.font_italic,
1118            base_point_size,
1119            service.scale_factor,
1120        ) {
1121            Some(r) => r,
1122            None => return empty,
1123        };
1124        let metrics = match font_metrics_px(&service.font_registry, &base_resolved) {
1125            Some(m) => m,
1126            None => return empty,
1127        };
1128
1129        let mut all_runs: Vec<ShapedRun> = Vec::new();
1130        for (span_idx, sp) in markup.spans.iter().enumerate() {
1131            if sp.text.is_empty() {
1132                continue;
1133            }
1134            let fmt = merge_format(format, sp.attrs);
1135            let span_point_size = fmt.font_size.map(|s| s as u32);
1136            let Some(resolved) = resolve_font(
1137                &service.font_registry,
1138                fmt.font_family.as_deref(),
1139                fmt.font_weight,
1140                fmt.font_bold,
1141                fmt.font_italic,
1142                span_point_size,
1143                service.scale_factor,
1144            ) else {
1145                continue;
1146            };
1147
1148            let flat_start = span_flat_offsets[span_idx];
1149            for br in bidi_runs(&sp.text) {
1150                let slice = match sp.text.get(br.byte_range.clone()) {
1151                    Some(s) => s,
1152                    None => continue,
1153                };
1154                let Some(mut run) = shape_text_with_fallback(
1155                    &service.font_registry,
1156                    &resolved,
1157                    slice,
1158                    flat_start + br.byte_range.start,
1159                    br.direction,
1160                ) else {
1161                    continue;
1162                };
1163                if let Some(url) = sp.link_url.as_ref() {
1164                    run.is_link = true;
1165                    run.anchor_href = Some(url.clone());
1166                }
1167                all_runs.push(run);
1168            }
1169        }
1170
1171        if all_runs.is_empty() {
1172            return empty;
1173        }
1174
1175        let lines = break_into_lines(all_runs, &flat, max_width, Alignment::Left, 0.0, &metrics);
1176
1177        let line_count = match max_lines {
1178            Some(n) => lines.len().min(n),
1179            None => lines.len(),
1180        };
1181
1182        let text_color = format.color.unwrap_or(self.text_color);
1183        let mut glyphs_out: Vec<GlyphQuad> = Vec::new();
1184        let mut spans_out: Vec<LaidOutSpan> = Vec::new();
1185        let line_height = metrics.ascent + metrics.descent + metrics.leading;
1186        let mut y_top: f32 = 0.0;
1187        let mut max_line_width: f32 = 0.0;
1188        let baseline_first = metrics.ascent;
1189
1190        for (line_idx, line) in lines.iter().take(line_count).enumerate() {
1191            if line.width > max_line_width {
1192                max_line_width = line.width;
1193            }
1194            let baseline_y = y_top + metrics.ascent;
1195
1196            for pr in &line.runs {
1197                let run_copy = pr.shaped_run.clone();
1198                let mut pen_x = pr.x;
1199                for glyph in &run_copy.glyphs {
1200                    rasterize_glyph_quad(
1201                        service,
1202                        glyph,
1203                        &run_copy,
1204                        pen_x,
1205                        baseline_y,
1206                        text_color,
1207                        &mut glyphs_out,
1208                    );
1209                    pen_x += glyph.x_advance;
1210                }
1211
1212                if pr.decorations.is_link
1213                    && let Some(url) = pr.decorations.anchor_href.clone()
1214                {
1215                    let width = pr.shaped_run.advance_width;
1216                    spans_out.push(LaidOutSpan {
1217                        kind: LaidOutSpanKind::Link { url },
1218                        line_index: line_idx,
1219                        rect: [pr.x, y_top, width, line_height],
1220                        byte_range: pr.shaped_run.text_range.clone(),
1221                    });
1222                }
1223            }
1224
1225            y_top += line_height;
1226        }
1227
1228        ParagraphResult {
1229            width: max_line_width,
1230            height: y_top,
1231            baseline_first,
1232            line_count,
1233            line_height,
1234            glyphs: glyphs_out,
1235            spans: spans_out,
1236        }
1237    }
1238
1239    // ── Hit testing & character geometry ───────────────────────
1240
1241    /// Map a screen-space point to a document position. Coordinates
1242    /// are relative to the widget's top-left corner; the scroll
1243    /// offset is applied internally. Returns `None` when the flow
1244    /// has no content.
1245    pub fn hit_test(&self, x: f32, y: f32) -> Option<HitTestResult> {
1246        crate::render::hit_test::hit_test(
1247            &self.flow_layout,
1248            self.scroll_offset,
1249            x / self.zoom,
1250            y / self.zoom,
1251        )
1252    }
1253
1254    /// Per-character advance geometry within a laid-out block.
1255    ///
1256    /// Used by accessibility layers that need to expose character
1257    /// positions to screen readers (AccessKit's `character_positions`
1258    /// / `character_widths` on `Role::TextRun`). `char_start` and
1259    /// `char_end` are block-relative character offsets. Returns one
1260    /// entry per character in the range, with `position` measured
1261    /// in run-local coordinates (the first character sits at `0`).
1262    pub fn character_geometry(
1263        &self,
1264        block_id: usize,
1265        char_start: usize,
1266        char_end: usize,
1267    ) -> Vec<CharacterGeometry> {
1268        if char_start >= char_end {
1269            return Vec::new();
1270        }
1271        let block = match self.flow_layout.blocks.get(&block_id) {
1272            Some(b) => b,
1273            None => return Vec::new(),
1274        };
1275
1276        let mut absolute: Vec<(usize, f32)> = Vec::with_capacity(char_end - char_start);
1277        for line in &block.lines {
1278            if line.char_range.end <= char_start || line.char_range.start >= char_end {
1279                continue;
1280            }
1281            let local_start = char_start.max(line.char_range.start);
1282            let local_end = char_end.min(line.char_range.end);
1283            for c in local_start..local_end {
1284                let x = line.x_for_offset(c);
1285                absolute.push((c, x));
1286            }
1287            if local_end == char_end {
1288                let x_end = line.x_for_offset(local_end);
1289                absolute.push((local_end, x_end));
1290            }
1291        }
1292
1293        if absolute.is_empty() {
1294            return Vec::new();
1295        }
1296
1297        absolute.sort_by_key(|(c, _)| *c);
1298
1299        let base_x = absolute.first().map(|(_, x)| *x).unwrap_or(0.0);
1300        let mut out: Vec<CharacterGeometry> = Vec::with_capacity(absolute.len());
1301        for window in absolute.windows(2) {
1302            let (c, x) = window[0];
1303            let (_, x_next) = window[1];
1304            if c >= char_end {
1305                break;
1306            }
1307            out.push(CharacterGeometry {
1308                position: x - base_x,
1309                width: (x_next - x).max(0.0),
1310            });
1311        }
1312        out
1313    }
1314
1315    /// Screen-space caret rectangle at a document position, as
1316    /// `[x, y, width, height]`. Feed this to the platform IME for
1317    /// composition window placement. For drawing the caret itself,
1318    /// use the `DecorationKind::Cursor` entry in
1319    /// [`RenderFrame::decorations`] instead.
1320    pub fn caret_rect(&self, position: usize) -> [f32; 4] {
1321        let mut rect =
1322            crate::render::hit_test::caret_rect(&self.flow_layout, self.scroll_offset, position);
1323        rect[0] *= self.zoom;
1324        rect[1] *= self.zoom;
1325        rect[2] *= self.zoom;
1326        rect[3] *= self.zoom;
1327        rect
1328    }
1329
1330    // ── Cursor & colors ────────────────────────────────────────
1331
1332    /// Replace the cursor display with a single cursor.
1333    pub fn set_cursor(&mut self, cursor: &CursorDisplay) {
1334        self.cursors = vec![CursorDisplay {
1335            position: cursor.position,
1336            anchor: cursor.anchor,
1337            visible: cursor.visible,
1338            selected_cells: cursor.selected_cells.clone(),
1339        }];
1340    }
1341
1342    /// Replace the cursor display with multiple cursors (multi-caret
1343    /// editing). Each cursor independently generates a caret and
1344    /// optional selection highlight.
1345    pub fn set_cursors(&mut self, cursors: &[CursorDisplay]) {
1346        self.cursors = cursors
1347            .iter()
1348            .map(|c| CursorDisplay {
1349                position: c.position,
1350                anchor: c.anchor,
1351                visible: c.visible,
1352                selected_cells: c.selected_cells.clone(),
1353            })
1354            .collect();
1355    }
1356
1357    /// Set the selection highlight color `[r, g, b, a]` in 0..=1
1358    /// space. Default: `[0.26, 0.52, 0.96, 0.3]` (translucent blue).
1359    pub fn set_selection_color(&mut self, color: [f32; 4]) {
1360        self.selection_color = color;
1361    }
1362
1363    /// Set the caret color `[r, g, b, a]`. Default: black.
1364    pub fn set_cursor_color(&mut self, color: [f32; 4]) {
1365        self.cursor_color = color;
1366    }
1367
1368    /// Set the default text color `[r, g, b, a]`, used when a
1369    /// fragment has no explicit `foreground_color`. Default: black.
1370    pub fn set_text_color(&mut self, color: [f32; 4]) {
1371        self.text_color = color;
1372    }
1373
1374    /// Current default text color.
1375    pub fn text_color(&self) -> [f32; 4] {
1376        self.text_color
1377    }
1378
1379    // ── Scrolling helpers ──────────────────────────────────────
1380
1381    /// Visual position and height of a laid-out block. Returns
1382    /// `None` if `block_id` is not in the current layout.
1383    pub fn block_visual_info(&self, block_id: usize) -> Option<BlockVisualInfo> {
1384        let block = self.flow_layout.blocks.get(&block_id)?;
1385        Some(BlockVisualInfo {
1386            block_id,
1387            y: block.y,
1388            height: block.height,
1389        })
1390    }
1391
1392    /// Whether a block lives inside any table cell.
1393    pub fn is_block_in_table(&self, block_id: usize) -> bool {
1394        self.flow_layout.tables.values().any(|table| {
1395            table
1396                .cell_layouts
1397                .iter()
1398                .any(|cell| cell.blocks.iter().any(|b| b.block_id == block_id))
1399        })
1400    }
1401
1402    /// Scroll so that `position` is visible, placing it roughly one
1403    /// third from the top of the viewport. Returns the new offset.
1404    pub fn scroll_to_position(&mut self, position: usize) -> f32 {
1405        let rect =
1406            crate::render::hit_test::caret_rect(&self.flow_layout, self.scroll_offset, position);
1407        let target_y = rect[1] + self.scroll_offset - self.viewport_height / (3.0 * self.zoom);
1408        self.scroll_offset = target_y.max(0.0);
1409        self.scroll_offset
1410    }
1411
1412    /// Scroll the minimum amount needed to make the current caret
1413    /// visible. Call after arrow-key / click / typing. Returns
1414    /// `Some(new_offset)` if the scroll moved, `None` otherwise.
1415    pub fn ensure_caret_visible(&mut self) -> Option<f32> {
1416        if self.cursors.is_empty() {
1417            return None;
1418        }
1419        let pos = self.cursors[0].position;
1420        let rect = crate::render::hit_test::caret_rect(&self.flow_layout, self.scroll_offset, pos);
1421        let caret_screen_y = rect[1];
1422        let caret_screen_bottom = caret_screen_y + rect[3];
1423        let effective_vh = self.viewport_height / self.zoom;
1424        let margin = 10.0 / self.zoom;
1425        let old_offset = self.scroll_offset;
1426
1427        if caret_screen_y < 0.0 {
1428            self.scroll_offset += caret_screen_y - margin;
1429            self.scroll_offset = self.scroll_offset.max(0.0);
1430        } else if caret_screen_bottom > effective_vh {
1431            self.scroll_offset += caret_screen_bottom - effective_vh + margin;
1432        }
1433
1434        if (self.scroll_offset - old_offset).abs() > 0.001 {
1435            Some(self.scroll_offset)
1436        } else {
1437            None
1438        }
1439    }
1440}
1441
1442impl Default for DocumentFlow {
1443    fn default() -> Self {
1444        Self::new()
1445    }
1446}
1447
1448#[cfg(feature = "text-document")]
1449enum FlowItemKind {
1450    Block(BlockLayoutParams),
1451    Table(TableLayoutParams),
1452    Frame(FrameLayoutParams),
1453}
1454
1455/// Rasterize a single glyph into the service's atlas and append a
1456/// `GlyphQuad` to the output vec. Shared between
1457/// [`DocumentFlow::layout_single_line`] and
1458/// [`DocumentFlow::layout_paragraph`] (plus the markup variants).
1459fn rasterize_glyph_quad(
1460    service: &mut TextFontService,
1461    glyph: &ShapedGlyph,
1462    run: &ShapedRun,
1463    pen_x: f32,
1464    baseline: f32,
1465    text_color: [f32; 4],
1466    quads: &mut Vec<GlyphQuad>,
1467) {
1468    use crate::atlas::cache::GlyphCacheKey;
1469    use crate::atlas::rasterizer::rasterize_glyph;
1470
1471    if glyph.glyph_id == 0 {
1472        return;
1473    }
1474
1475    let entry = match service.font_registry.get(glyph.font_face_id) {
1476        Some(e) => e,
1477        None => return,
1478    };
1479
1480    let sf = service.scale_factor.max(f32::MIN_POSITIVE);
1481    let inv_sf = 1.0 / sf;
1482    let physical_size_px = run.size_px * sf;
1483    let cache_key = GlyphCacheKey::new(glyph.font_face_id, glyph.glyph_id, physical_size_px);
1484
1485    if service.glyph_cache.peek(&cache_key).is_none()
1486        && let Some(image) = rasterize_glyph(
1487            &mut service.scale_context,
1488            &entry.data,
1489            entry.face_index,
1490            entry.swash_cache_key,
1491            glyph.glyph_id,
1492            physical_size_px,
1493        )
1494        && image.width > 0
1495        && image.height > 0
1496        && let Some(alloc) = service.atlas.allocate(image.width, image.height)
1497    {
1498        let rect = alloc.rectangle;
1499        let atlas_x = rect.min.x as u32;
1500        let atlas_y = rect.min.y as u32;
1501        if image.is_color {
1502            service
1503                .atlas
1504                .blit_rgba(atlas_x, atlas_y, image.width, image.height, &image.data);
1505        } else {
1506            service
1507                .atlas
1508                .blit_mask(atlas_x, atlas_y, image.width, image.height, &image.data);
1509        }
1510        service.glyph_cache.insert(
1511            cache_key,
1512            crate::atlas::cache::CachedGlyph {
1513                alloc_id: alloc.id,
1514                atlas_x,
1515                atlas_y,
1516                width: image.width,
1517                height: image.height,
1518                placement_left: image.placement_left,
1519                placement_top: image.placement_top,
1520                is_color: image.is_color,
1521                last_used: 0,
1522            },
1523        );
1524    }
1525
1526    if let Some(cached) = service.glyph_cache.get(&cache_key) {
1527        let logical_w = cached.width as f32 * inv_sf;
1528        let logical_h = cached.height as f32 * inv_sf;
1529        let logical_left = cached.placement_left as f32 * inv_sf;
1530        let logical_top = cached.placement_top as f32 * inv_sf;
1531        let screen_x = pen_x + glyph.x_offset + logical_left;
1532        let screen_y = baseline - glyph.y_offset - logical_top;
1533        let color = if cached.is_color {
1534            [1.0, 1.0, 1.0, 1.0]
1535        } else {
1536            text_color
1537        };
1538        quads.push(GlyphQuad {
1539            screen: [screen_x, screen_y, logical_w, logical_h],
1540            atlas: [
1541                cached.atlas_x as f32,
1542                cached.atlas_y as f32,
1543                cached.width as f32,
1544                cached.height as f32,
1545            ],
1546            color,
1547        });
1548    }
1549}
1550
1551/// Scale all screen-space coordinates in a RenderFrame by `zoom`.
1552fn apply_zoom(frame: &mut RenderFrame, zoom: f32) {
1553    if (zoom - 1.0).abs() <= f32::EPSILON {
1554        return;
1555    }
1556    for q in &mut frame.glyphs {
1557        q.screen[0] *= zoom;
1558        q.screen[1] *= zoom;
1559        q.screen[2] *= zoom;
1560        q.screen[3] *= zoom;
1561    }
1562    for q in &mut frame.images {
1563        q.screen[0] *= zoom;
1564        q.screen[1] *= zoom;
1565        q.screen[2] *= zoom;
1566        q.screen[3] *= zoom;
1567    }
1568    apply_zoom_decorations(&mut frame.decorations, zoom);
1569}
1570
1571/// Scale all screen-space coordinates in decoration rects by `zoom`.
1572fn apply_zoom_decorations(decorations: &mut [DecorationRect], zoom: f32) {
1573    if (zoom - 1.0).abs() <= f32::EPSILON {
1574        return;
1575    }
1576    for d in decorations.iter_mut() {
1577        d.rect[0] *= zoom;
1578        d.rect[1] *= zoom;
1579        d.rect[2] *= zoom;
1580        d.rect[3] *= zoom;
1581    }
1582}
1583
1584/// Derive a per-span [`TextFormat`] from a base format and inline
1585/// markup attributes (bold / italic).
1586fn merge_format(base: &TextFormat, attrs: InlineAttrs) -> TextFormat {
1587    let mut fmt = base.clone();
1588    if attrs.is_bold() {
1589        fmt.font_bold = Some(true);
1590        if let Some(w) = fmt.font_weight
1591            && w < 600
1592        {
1593            fmt.font_weight = Some(700);
1594        } else if fmt.font_weight.is_none() {
1595            fmt.font_weight = Some(700);
1596        }
1597    }
1598    if attrs.is_italic() {
1599        fmt.font_italic = Some(true);
1600    }
1601    fmt
1602}
1603
1604#[cfg(test)]
1605mod tests {
1606    use super::*;
1607    use crate::layout::block::{BlockLayoutParams, FragmentParams};
1608    use crate::layout::paragraph::Alignment;
1609    use crate::types::{UnderlineStyle, VerticalAlignment};
1610
1611    const NOTO_SANS: &[u8] = include_bytes!("../test-fonts/NotoSans-Variable.ttf");
1612
1613    fn service() -> TextFontService {
1614        let mut s = TextFontService::new();
1615        let face = s.register_font(NOTO_SANS);
1616        s.set_default_font(face, 16.0);
1617        s
1618    }
1619
1620    fn block(id: usize, text: &str) -> BlockLayoutParams {
1621        BlockLayoutParams {
1622            block_id: id,
1623            position: 0,
1624            text: text.to_string(),
1625            fragments: vec![FragmentParams {
1626                text: text.to_string(),
1627                offset: 0,
1628                length: text.len(),
1629                font_family: None,
1630                font_weight: None,
1631                font_bold: None,
1632                font_italic: None,
1633                font_point_size: None,
1634                underline_style: UnderlineStyle::None,
1635                overline: false,
1636                strikeout: false,
1637                is_link: false,
1638                letter_spacing: 0.0,
1639                word_spacing: 0.0,
1640                foreground_color: None,
1641                underline_color: None,
1642                background_color: None,
1643                anchor_href: None,
1644                tooltip: None,
1645                vertical_alignment: VerticalAlignment::Normal,
1646                image_name: None,
1647                image_width: 0.0,
1648                image_height: 0.0,
1649            }],
1650            alignment: Alignment::Left,
1651            top_margin: 0.0,
1652            bottom_margin: 0.0,
1653            left_margin: 0.0,
1654            right_margin: 0.0,
1655            text_indent: 0.0,
1656            list_marker: String::new(),
1657            list_indent: 0.0,
1658            tab_positions: vec![],
1659            line_height_multiplier: None,
1660            non_breakable_lines: false,
1661            checkbox: None,
1662            background_color: None,
1663        }
1664    }
1665
1666    #[test]
1667    fn relayout_block_returns_no_layout_when_never_laid_out() {
1668        let svc = service();
1669        let mut flow = DocumentFlow::new();
1670        flow.set_viewport(400.0, 200.0);
1671        let err = flow.relayout_block(&svc, &block(1, "Hello")).unwrap_err();
1672        assert_eq!(err, RelayoutError::NoLayout);
1673    }
1674
1675    #[test]
1676    fn relayout_block_returns_scale_dirty_after_scale_factor_change() {
1677        let mut svc = service();
1678        let mut flow = DocumentFlow::new();
1679        flow.set_viewport(400.0, 200.0);
1680        flow.layout_blocks(&svc, vec![block(1, "Hello")]);
1681        assert!(flow.has_layout());
1682
1683        // Simulate a HiDPI transition on the shared service.
1684        svc.set_scale_factor(2.0);
1685        assert!(flow.layout_dirty_for_scale(&svc));
1686
1687        let err = flow
1688            .relayout_block(&svc, &block(1, "Hello world"))
1689            .unwrap_err();
1690        assert_eq!(err, RelayoutError::ScaleDirty);
1691    }
1692
1693    #[test]
1694    fn relayout_block_succeeds_after_fresh_layout_post_scale_change() {
1695        let mut svc = service();
1696        let mut flow = DocumentFlow::new();
1697        flow.set_viewport(400.0, 200.0);
1698        flow.layout_blocks(&svc, vec![block(1, "Hello")]);
1699
1700        svc.set_scale_factor(2.0);
1701        // Caller is expected to re-run a full layout at the new
1702        // scale before issuing incremental updates.
1703        flow.layout_blocks(&svc, vec![block(1, "Hello")]);
1704        assert!(!flow.layout_dirty_for_scale(&svc));
1705
1706        // Now the incremental path succeeds.
1707        flow.relayout_block(&svc, &block(1, "Hello world"))
1708            .expect("relayout_block must succeed after a fresh post-scale layout");
1709    }
1710}