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