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