Skip to main content

ftui_text/
vertical_metrics.rs

1//! Leading, baseline-grid, and paragraph spacing system.
2//!
3//! This module defines the vertical layout model for text rendering,
4//! providing deterministic control over:
5//!
6//! - **Leading**: extra vertical space distributed between lines within
7//!   a paragraph (analogous to CSS `line-height`).
8//! - **Baseline grid**: snap line positions to a regular grid to maintain
9//!   vertical rhythm across columns and pages.
10//! - **Paragraph spacing**: configurable space before/after paragraphs.
11//!
12//! # Design
13//!
14//! All measurements are in sub-pixel units (1/256 px), matching the
15//! fixed-point convention used by `ftui-render::fit_metrics`. Terminal
16//! renderers can convert to cell rows by dividing by cell height.
17//!
18//! # Policy tiers
19//!
20//! Three quality tiers provide progressive enhancement:
21//! - [`VerticalPolicy::Compact`]: zero leading, no baseline grid (terminal default).
22//! - [`VerticalPolicy::Readable`]: moderate leading, paragraph spacing.
23//! - [`VerticalPolicy::Typographic`]: baseline-grid alignment, fine-grained control.
24
25use std::fmt;
26
27/// Sub-pixel units per pixel (must match fit_metrics::SUBPX_SCALE).
28const SUBPX_SCALE: u32 = 256;
29
30// =========================================================================
31// LeadingSpec
32// =========================================================================
33
34/// How leading (inter-line spacing) is specified.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
36pub enum LeadingSpec {
37    /// No extra leading — lines are packed at cell height.
38    #[default]
39    None,
40    /// Fixed leading in sub-pixel units (1/256 px) added between lines.
41    Fixed(u32),
42    /// Leading as a fraction of line height (256 = 100% = double spacing).
43    ///
44    /// Common values:
45    /// - 0 = single spacing (no extra)
46    /// - 51 ≈ 20% extra (1.2x line height, CSS default for body text)
47    /// - 128 = 50% extra (1.5x line height)
48    /// - 256 = 100% extra (double spacing)
49    Proportional(u32),
50}
51
52impl LeadingSpec {
53    /// Compute the actual leading in sub-pixel units for a given line height.
54    #[must_use]
55    pub fn resolve(&self, line_height_subpx: u32) -> u32 {
56        match *self {
57            LeadingSpec::None => 0,
58            LeadingSpec::Fixed(v) => v,
59            LeadingSpec::Proportional(frac) => {
60                // leading = line_height * frac / SUBPX_SCALE
61                let product = (line_height_subpx as u64) * (frac as u64);
62                (product / SUBPX_SCALE as u64) as u32
63            }
64        }
65    }
66
67    /// CSS-style 1.2x line height (20% extra leading).
68    pub const CSS_DEFAULT: Self = Self::Proportional(51);
69
70    /// 1.5x line height.
71    pub const ONE_HALF: Self = Self::Proportional(128);
72
73    /// Double spacing.
74    pub const DOUBLE: Self = Self::Proportional(256);
75}
76
77impl fmt::Display for LeadingSpec {
78    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79        match self {
80            Self::None => write!(f, "none"),
81            Self::Fixed(v) => write!(f, "fixed({:.1}px)", *v as f64 / SUBPX_SCALE as f64),
82            Self::Proportional(frac) => {
83                write!(f, "{:.0}%", *frac as f64 / SUBPX_SCALE as f64 * 100.0)
84            }
85        }
86    }
87}
88
89// =========================================================================
90// ParagraphSpacing
91// =========================================================================
92
93/// Configurable space before and after paragraphs.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
95pub struct ParagraphSpacing {
96    /// Space before the first line of a paragraph (sub-pixel units).
97    pub before_subpx: u32,
98    /// Space after the last line of a paragraph (sub-pixel units).
99    pub after_subpx: u32,
100}
101
102impl ParagraphSpacing {
103    /// No extra paragraph spacing.
104    pub const NONE: Self = Self {
105        before_subpx: 0,
106        after_subpx: 0,
107    };
108
109    /// One full line of spacing between paragraphs (at given line height).
110    #[must_use]
111    pub fn one_line(line_height_subpx: u32) -> Self {
112        Self {
113            before_subpx: 0,
114            after_subpx: line_height_subpx,
115        }
116    }
117
118    /// Half-line spacing between paragraphs.
119    #[must_use]
120    pub fn half_line(line_height_subpx: u32) -> Self {
121        Self {
122            before_subpx: 0,
123            after_subpx: line_height_subpx / 2,
124        }
125    }
126
127    /// Custom spacing in sub-pixel units.
128    #[must_use]
129    pub const fn custom(before: u32, after: u32) -> Self {
130        Self {
131            before_subpx: before,
132            after_subpx: after,
133        }
134    }
135
136    /// Total paragraph overhead (before + after) in sub-pixel units.
137    #[must_use]
138    pub const fn total(&self) -> u32 {
139        self.before_subpx.saturating_add(self.after_subpx)
140    }
141}
142
143impl Default for ParagraphSpacing {
144    fn default() -> Self {
145        Self::NONE
146    }
147}
148
149impl fmt::Display for ParagraphSpacing {
150    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151        write!(
152            f,
153            "before={:.1}px after={:.1}px",
154            self.before_subpx as f64 / SUBPX_SCALE as f64,
155            self.after_subpx as f64 / SUBPX_SCALE as f64,
156        )
157    }
158}
159
160// =========================================================================
161// BaselineGrid
162// =========================================================================
163
164/// Baseline grid alignment configuration.
165///
166/// When active, line positions are snapped to a regular vertical grid
167/// to maintain consistent vertical rhythm. This is essential for
168/// multi-column layouts where lines should align across columns.
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
170pub struct BaselineGrid {
171    /// Grid interval in sub-pixel units.
172    ///
173    /// All line positions are rounded up to the nearest multiple of this value.
174    /// Typically set to the line height (line_height + leading).
175    pub interval_subpx: u32,
176    /// Offset from the top of the text area (sub-pixel units).
177    ///
178    /// Shifts the grid to align with an arbitrary starting position.
179    pub offset_subpx: u32,
180}
181
182impl BaselineGrid {
183    /// No baseline grid (disabled).
184    pub const NONE: Self = Self {
185        interval_subpx: 0,
186        offset_subpx: 0,
187    };
188
189    /// Create a grid from line height and leading.
190    #[must_use]
191    pub const fn from_line_height(line_height_subpx: u32, leading_subpx: u32) -> Self {
192        Self {
193            interval_subpx: line_height_subpx.saturating_add(leading_subpx),
194            offset_subpx: 0,
195        }
196    }
197
198    /// Whether the grid is active (non-zero interval).
199    #[must_use]
200    pub const fn is_active(&self) -> bool {
201        self.interval_subpx > 0
202    }
203
204    /// Snap a vertical position to the grid.
205    ///
206    /// Rounds up to the nearest grid line at or above `pos`.
207    #[must_use]
208    pub const fn snap(&self, pos_subpx: u32) -> u32 {
209        if self.interval_subpx == 0 {
210            return pos_subpx;
211        }
212        let adjusted = pos_subpx.saturating_sub(self.offset_subpx);
213        let remainder = adjusted % self.interval_subpx;
214        if remainder == 0 {
215            pos_subpx
216        } else {
217            pos_subpx.saturating_add(self.interval_subpx - remainder)
218        }
219    }
220}
221
222impl Default for BaselineGrid {
223    fn default() -> Self {
224        Self::NONE
225    }
226}
227
228// =========================================================================
229// VerticalPolicy
230// =========================================================================
231
232/// Pre-configured vertical layout policy tiers.
233///
234/// These provide progressive enhancement from compact terminal rendering
235/// to high-quality typographic output.
236#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
237pub enum VerticalPolicy {
238    /// Zero leading, no baseline grid, no paragraph spacing.
239    /// Suitable for terminal UIs where every row counts.
240    #[default]
241    Compact,
242    /// Moderate leading (20% of line height), half-line paragraph spacing.
243    /// Good for readable text content within a terminal.
244    Readable,
245    /// Baseline-grid alignment with configurable leading and paragraph spacing.
246    /// For high-quality proportional text rendering.
247    Typographic,
248}
249
250impl VerticalPolicy {
251    /// Resolve this policy into a concrete [`VerticalMetrics`] configuration
252    /// given the line height.
253    #[must_use]
254    pub fn resolve(&self, line_height_subpx: u32) -> VerticalMetrics {
255        match self {
256            Self::Compact => VerticalMetrics {
257                leading: LeadingSpec::None,
258                paragraph_spacing: ParagraphSpacing::NONE,
259                baseline_grid: BaselineGrid::NONE,
260                first_line_indent_subpx: 0,
261            },
262            Self::Readable => {
263                let leading = LeadingSpec::CSS_DEFAULT;
264                let leading_val = leading.resolve(line_height_subpx);
265                VerticalMetrics {
266                    leading,
267                    paragraph_spacing: ParagraphSpacing::half_line(
268                        line_height_subpx.saturating_add(leading_val),
269                    ),
270                    baseline_grid: BaselineGrid::NONE,
271                    first_line_indent_subpx: 0,
272                }
273            }
274            Self::Typographic => {
275                let leading = LeadingSpec::CSS_DEFAULT;
276                let leading_val = leading.resolve(line_height_subpx);
277                let total_line = line_height_subpx.saturating_add(leading_val);
278                VerticalMetrics {
279                    leading,
280                    paragraph_spacing: ParagraphSpacing::one_line(total_line),
281                    baseline_grid: BaselineGrid::from_line_height(line_height_subpx, leading_val),
282                    first_line_indent_subpx: 2 * SUBPX_SCALE, // 2px indent
283                }
284            }
285        }
286    }
287}
288
289impl fmt::Display for VerticalPolicy {
290    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
291        match self {
292            Self::Compact => write!(f, "compact"),
293            Self::Readable => write!(f, "readable"),
294            Self::Typographic => write!(f, "typographic"),
295        }
296    }
297}
298
299// =========================================================================
300// VerticalMetrics
301// =========================================================================
302
303/// Resolved vertical layout configuration.
304///
305/// All values are in sub-pixel units (1/256 px).
306#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
307pub struct VerticalMetrics {
308    /// Inter-line leading specification.
309    pub leading: LeadingSpec,
310    /// Paragraph spacing configuration.
311    pub paragraph_spacing: ParagraphSpacing,
312    /// Baseline grid alignment.
313    pub baseline_grid: BaselineGrid,
314    /// First-line indent in sub-pixel units.
315    pub first_line_indent_subpx: u32,
316}
317
318impl VerticalMetrics {
319    /// Compute the total height of a paragraph in sub-pixel units.
320    ///
321    /// Given `line_count` lines at `line_height_subpx` base height:
322    ///   total = before + sum(line_heights) + (line_count-1)*leading + after
323    ///
324    /// If baseline grid is active, the result is snapped to the grid.
325    #[must_use]
326    pub fn paragraph_height(&self, line_count: usize, line_height_subpx: u32) -> u32 {
327        if line_count == 0 {
328            return 0;
329        }
330
331        let leading_val = self.leading.resolve(line_height_subpx);
332        let lines_height = (line_count as u32) * line_height_subpx;
333        let inter_leading = if line_count > 1 {
334            ((line_count - 1) as u32) * leading_val
335        } else {
336            0
337        };
338
339        let content_height = lines_height.saturating_add(inter_leading);
340        let total = self
341            .paragraph_spacing
342            .before_subpx
343            .saturating_add(content_height)
344            .saturating_add(self.paragraph_spacing.after_subpx);
345
346        if self.baseline_grid.is_active() {
347            self.baseline_grid.snap(total)
348        } else {
349            total
350        }
351    }
352
353    /// Compute the Y position of line `n` (0-indexed) within a paragraph.
354    ///
355    /// Accounts for `before` spacing, leading, and baseline grid snapping.
356    #[must_use]
357    pub fn line_y(&self, line_index: usize, line_height_subpx: u32) -> u32 {
358        let leading_val = self.leading.resolve(line_height_subpx);
359        let line_step = line_height_subpx.saturating_add(leading_val);
360
361        let raw_y = self
362            .paragraph_spacing
363            .before_subpx
364            .saturating_add((line_index as u32) * line_step);
365
366        if self.baseline_grid.is_active() {
367            self.baseline_grid.snap(raw_y)
368        } else {
369            raw_y
370        }
371    }
372
373    /// Total height for a multi-paragraph document.
374    ///
375    /// `paragraphs` is a slice of line counts per paragraph.
376    #[must_use]
377    pub fn document_height(&self, paragraphs: &[usize], line_height_subpx: u32) -> u32 {
378        let mut total = 0u32;
379        for (idx, &line_count) in paragraphs.iter().enumerate() {
380            if idx > 0 {
381                // Collapse paragraph spacing: use max of previous after and current before.
382                // CSS-style margin collapsing.
383                let collapsed = self
384                    .paragraph_spacing
385                    .after_subpx
386                    .max(self.paragraph_spacing.before_subpx);
387                total = total.saturating_add(collapsed);
388            } else {
389                total = total.saturating_add(self.paragraph_spacing.before_subpx);
390            }
391
392            let leading_val = self.leading.resolve(line_height_subpx);
393            let lines_height = (line_count as u32) * line_height_subpx;
394            let inter_leading = if line_count > 1 {
395                ((line_count - 1) as u32) * leading_val
396            } else {
397                0
398            };
399            total = total
400                .saturating_add(lines_height)
401                .saturating_add(inter_leading);
402
403            // Add after spacing for last paragraph
404            if idx == paragraphs.len() - 1 {
405                total = total.saturating_add(self.paragraph_spacing.after_subpx);
406            }
407        }
408
409        if self.baseline_grid.is_active() {
410            self.baseline_grid.snap(total)
411        } else {
412            total
413        }
414    }
415
416    /// Convert total sub-pixel height to terminal cell rows.
417    ///
418    /// Rounds up: any partial row counts as a full row.
419    #[must_use]
420    pub fn to_cell_rows(height_subpx: u32, cell_height_subpx: u32) -> u16 {
421        if cell_height_subpx == 0 {
422            return 0;
423        }
424        let rows = height_subpx.div_ceil(cell_height_subpx);
425        rows.min(u16::MAX as u32) as u16
426    }
427}
428
429// =========================================================================
430// Tests
431// =========================================================================
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436
437    const LINE_H: u32 = 16 * SUBPX_SCALE; // 16px line height
438
439    // ── LeadingSpec ───────────────────────────────────────────────────
440
441    #[test]
442    fn leading_none() {
443        assert_eq!(LeadingSpec::None.resolve(LINE_H), 0);
444    }
445
446    #[test]
447    fn leading_fixed() {
448        let spec = LeadingSpec::Fixed(2 * SUBPX_SCALE); // 2px
449        assert_eq!(spec.resolve(LINE_H), 2 * SUBPX_SCALE);
450    }
451
452    #[test]
453    fn leading_proportional_20_percent() {
454        let spec = LeadingSpec::CSS_DEFAULT; // ~20%
455        let leading = spec.resolve(LINE_H);
456        // 16px * 51/256 ≈ 3.18px → 3 * 256 = 816 subpx
457        assert_eq!(leading, 816);
458    }
459
460    #[test]
461    fn leading_proportional_50_percent() {
462        let leading = LeadingSpec::ONE_HALF.resolve(LINE_H);
463        // 16px * 128/256 = 8px = 8 * 256 = 2048 subpx
464        assert_eq!(leading, 2048);
465    }
466
467    #[test]
468    fn leading_proportional_double() {
469        let leading = LeadingSpec::DOUBLE.resolve(LINE_H);
470        // 16px * 256/256 = 16px = 16 * 256 = 4096 subpx
471        assert_eq!(leading, LINE_H);
472    }
473
474    #[test]
475    fn leading_display() {
476        assert_eq!(format!("{}", LeadingSpec::None), "none");
477        let fixed = LeadingSpec::Fixed(2 * SUBPX_SCALE);
478        assert!(format!("{fixed}").contains("2.0px"));
479    }
480
481    // ── ParagraphSpacing ──────────────────────────────────────────────
482
483    #[test]
484    fn spacing_none() {
485        assert_eq!(ParagraphSpacing::NONE.total(), 0);
486    }
487
488    #[test]
489    fn spacing_one_line() {
490        let sp = ParagraphSpacing::one_line(LINE_H);
491        assert_eq!(sp.before_subpx, 0);
492        assert_eq!(sp.after_subpx, LINE_H);
493        assert_eq!(sp.total(), LINE_H);
494    }
495
496    #[test]
497    fn spacing_half_line() {
498        let sp = ParagraphSpacing::half_line(LINE_H);
499        assert_eq!(sp.after_subpx, LINE_H / 2);
500    }
501
502    #[test]
503    fn spacing_custom() {
504        let sp = ParagraphSpacing::custom(100, 200);
505        assert_eq!(sp.before_subpx, 100);
506        assert_eq!(sp.after_subpx, 200);
507        assert_eq!(sp.total(), 300);
508    }
509
510    #[test]
511    fn spacing_display() {
512        let s = format!("{}", ParagraphSpacing::NONE);
513        assert!(s.contains("0.0px"));
514    }
515
516    // ── BaselineGrid ──────────────────────────────────────────────────
517
518    #[test]
519    fn grid_none_is_inactive() {
520        assert!(!BaselineGrid::NONE.is_active());
521    }
522
523    #[test]
524    fn grid_from_line_height() {
525        let grid = BaselineGrid::from_line_height(LINE_H, 2 * SUBPX_SCALE);
526        assert!(grid.is_active());
527        assert_eq!(grid.interval_subpx, LINE_H + 2 * SUBPX_SCALE);
528    }
529
530    #[test]
531    fn grid_snap_exact() {
532        let grid = BaselineGrid {
533            interval_subpx: 1000,
534            offset_subpx: 0,
535        };
536        assert_eq!(grid.snap(2000), 2000);
537        assert_eq!(grid.snap(3000), 3000);
538    }
539
540    #[test]
541    fn grid_snap_rounds_up() {
542        let grid = BaselineGrid {
543            interval_subpx: 1000,
544            offset_subpx: 0,
545        };
546        assert_eq!(grid.snap(1), 1000);
547        assert_eq!(grid.snap(999), 1000);
548        assert_eq!(grid.snap(1001), 2000);
549    }
550
551    #[test]
552    fn grid_snap_with_offset() {
553        let grid = BaselineGrid {
554            interval_subpx: 1000,
555            offset_subpx: 200,
556        };
557        // pos=200 → adjusted=0 → remainder=0 → stays at 200
558        assert_eq!(grid.snap(200), 200);
559        // pos=500 → adjusted=300 → remainder=300 → snap to 500 + (1000-300) = 1200
560        assert_eq!(grid.snap(500), 1200);
561    }
562
563    #[test]
564    fn grid_snap_disabled() {
565        assert_eq!(BaselineGrid::NONE.snap(42), 42);
566    }
567
568    // ── VerticalPolicy ────────────────────────────────────────────────
569
570    #[test]
571    fn policy_compact() {
572        let m = VerticalPolicy::Compact.resolve(LINE_H);
573        assert_eq!(m.leading, LeadingSpec::None);
574        assert_eq!(m.paragraph_spacing, ParagraphSpacing::NONE);
575        assert!(!m.baseline_grid.is_active());
576        assert_eq!(m.first_line_indent_subpx, 0);
577    }
578
579    #[test]
580    fn policy_readable() {
581        let m = VerticalPolicy::Readable.resolve(LINE_H);
582        assert_eq!(m.leading, LeadingSpec::CSS_DEFAULT);
583        assert!(!m.baseline_grid.is_active());
584        assert!(m.paragraph_spacing.after_subpx > 0);
585    }
586
587    #[test]
588    fn policy_typographic() {
589        let m = VerticalPolicy::Typographic.resolve(LINE_H);
590        assert_eq!(m.leading, LeadingSpec::CSS_DEFAULT);
591        assert!(m.baseline_grid.is_active());
592        assert!(m.paragraph_spacing.after_subpx > 0);
593        assert!(m.first_line_indent_subpx > 0);
594    }
595
596    #[test]
597    fn policy_display() {
598        assert_eq!(format!("{}", VerticalPolicy::Compact), "compact");
599        assert_eq!(format!("{}", VerticalPolicy::Readable), "readable");
600        assert_eq!(format!("{}", VerticalPolicy::Typographic), "typographic");
601    }
602
603    #[test]
604    fn policy_default_is_compact() {
605        assert_eq!(VerticalPolicy::default(), VerticalPolicy::Compact);
606    }
607
608    // ── VerticalMetrics ───────────────────────────────────────────────
609
610    #[test]
611    fn paragraph_height_zero_lines() {
612        let m = VerticalPolicy::Compact.resolve(LINE_H);
613        assert_eq!(m.paragraph_height(0, LINE_H), 0);
614    }
615
616    #[test]
617    fn paragraph_height_single_line_compact() {
618        let m = VerticalPolicy::Compact.resolve(LINE_H);
619        assert_eq!(m.paragraph_height(1, LINE_H), LINE_H);
620    }
621
622    #[test]
623    fn paragraph_height_multi_line_compact() {
624        let m = VerticalPolicy::Compact.resolve(LINE_H);
625        // 3 lines, no leading: 3 * 16px = 48px
626        assert_eq!(m.paragraph_height(3, LINE_H), 3 * LINE_H);
627    }
628
629    #[test]
630    fn paragraph_height_with_leading() {
631        let mut m = VerticalPolicy::Compact.resolve(LINE_H);
632        m.leading = LeadingSpec::Fixed(2 * SUBPX_SCALE); // 2px between lines
633        // 3 lines: 3*16 + 2*2 = 52px = 52 * 256 = 13312 subpx
634        assert_eq!(
635            m.paragraph_height(3, LINE_H),
636            3 * LINE_H + 2 * 2 * SUBPX_SCALE
637        );
638    }
639
640    #[test]
641    fn paragraph_height_with_spacing() {
642        let mut m = VerticalPolicy::Compact.resolve(LINE_H);
643        m.paragraph_spacing = ParagraphSpacing::custom(SUBPX_SCALE, SUBPX_SCALE);
644        // 1 line + 1px before + 1px after = 18px
645        assert_eq!(m.paragraph_height(1, LINE_H), LINE_H + 2 * SUBPX_SCALE);
646    }
647
648    #[test]
649    fn line_y_compact() {
650        let m = VerticalPolicy::Compact.resolve(LINE_H);
651        assert_eq!(m.line_y(0, LINE_H), 0);
652        assert_eq!(m.line_y(1, LINE_H), LINE_H);
653        assert_eq!(m.line_y(2, LINE_H), 2 * LINE_H);
654    }
655
656    #[test]
657    fn line_y_with_leading() {
658        let mut m = VerticalPolicy::Compact.resolve(LINE_H);
659        m.leading = LeadingSpec::Fixed(SUBPX_SCALE); // 1px
660        assert_eq!(m.line_y(0, LINE_H), 0);
661        assert_eq!(m.line_y(1, LINE_H), LINE_H + SUBPX_SCALE);
662        assert_eq!(m.line_y(2, LINE_H), 2 * (LINE_H + SUBPX_SCALE));
663    }
664
665    #[test]
666    fn line_y_with_before_spacing() {
667        let mut m = VerticalPolicy::Compact.resolve(LINE_H);
668        m.paragraph_spacing.before_subpx = SUBPX_SCALE; // 1px before
669        assert_eq!(m.line_y(0, LINE_H), SUBPX_SCALE);
670        assert_eq!(m.line_y(1, LINE_H), SUBPX_SCALE + LINE_H);
671    }
672
673    #[test]
674    fn document_height_single_paragraph() {
675        let m = VerticalPolicy::Compact.resolve(LINE_H);
676        assert_eq!(m.document_height(&[3], LINE_H), 3 * LINE_H);
677    }
678
679    #[test]
680    fn document_height_multi_paragraph() {
681        let m = VerticalPolicy::Compact.resolve(LINE_H);
682        // No spacing: 3+2 = 5 lines = 5 * 16px
683        assert_eq!(m.document_height(&[3, 2], LINE_H), 5 * LINE_H);
684    }
685
686    #[test]
687    fn document_height_with_spacing() {
688        let mut m = VerticalPolicy::Compact.resolve(LINE_H);
689        m.paragraph_spacing = ParagraphSpacing::custom(0, SUBPX_SCALE);
690        // Two paragraphs: [3, 2]
691        // Para 0: 0 before + 3*LINE_H + after folded into collapse
692        // Between: collapsed = max(after=256, before=0) = 256
693        // Para 1: 2*LINE_H + 256 after
694        // Total: 3*LINE_H + 256 + 2*LINE_H + 256 = 5*LINE_H + 512
695        assert_eq!(
696            m.document_height(&[3, 2], LINE_H),
697            5 * LINE_H + 2 * SUBPX_SCALE
698        );
699    }
700
701    #[test]
702    fn document_height_empty() {
703        let m = VerticalPolicy::Compact.resolve(LINE_H);
704        assert_eq!(m.document_height(&[], LINE_H), 0);
705    }
706
707    #[test]
708    fn to_cell_rows_exact() {
709        assert_eq!(VerticalMetrics::to_cell_rows(LINE_H * 3, LINE_H), 3);
710    }
711
712    #[test]
713    fn to_cell_rows_rounds_up() {
714        assert_eq!(VerticalMetrics::to_cell_rows(LINE_H * 3 + 1, LINE_H), 4);
715    }
716
717    #[test]
718    fn to_cell_rows_zero_height() {
719        assert_eq!(VerticalMetrics::to_cell_rows(0, LINE_H), 0);
720    }
721
722    #[test]
723    fn to_cell_rows_zero_cell_height() {
724        assert_eq!(VerticalMetrics::to_cell_rows(LINE_H, 0), 0);
725    }
726
727    // ── Determinism ────────────────────────────────────────────────────
728
729    #[test]
730    fn same_inputs_same_outputs() {
731        let m1 = VerticalPolicy::Typographic.resolve(LINE_H);
732        let m2 = VerticalPolicy::Typographic.resolve(LINE_H);
733        assert_eq!(
734            m1.paragraph_height(5, LINE_H),
735            m2.paragraph_height(5, LINE_H)
736        );
737        assert_eq!(m1.line_y(3, LINE_H), m2.line_y(3, LINE_H));
738    }
739
740    #[test]
741    fn baseline_grid_deterministic() {
742        let grid = BaselineGrid::from_line_height(LINE_H, SUBPX_SCALE);
743        let a = grid.snap(1234);
744        let b = grid.snap(1234);
745        assert_eq!(a, b);
746    }
747}