Skip to main content

rhythm_gpui/
math.rs

1//! Vertical rhythm math, independent of gpui.
2//!
3//! # Model
4//!
5//! A renderer (CSS, gpui, and most UI toolkits) vertically centers a font's
6//! `ascent + descent` box inside the line box, placing the baseline at:
7//!
8//! ```text
9//! baseline_from_top = (line_height - ascent - descent) / 2 + ascent
10//! ```
11//!
12//! Given a line height that is an integer number of rhythm units, the baseline
13//! spacing functions compute padding/margin that lands each baseline exactly on
14//! a rhythm grid line. The cap-anchoring functions deliberately align the
15//! capitals' ink instead and preserve whole-row block height with a paired close.
16//!
17//! [`Rhythm`] is only the grid: unit size, spacing, whole-row heights, and
18//! snapping. Every calculation that also needs a typeface lives on
19//! [`FontRhythm`], and shaped-line geometry on
20//! [`RhythmLineMetrics`](crate::RhythmLineMetrics).
21//!
22//! All quantities are `f32` logical pixels; unit conversion (rems, device
23//! scale) belongs to the integration layer.
24//!
25//! The Plumber/rhythm-sass `baseline-ratio` is recoverable from font metrics:
26//! `ratio = (em + descent - ascent) / (2 * em)`. [`FontRhythm::from_baseline_ratio`]
27//! is provided for compatibility with existing measured values.
28
29/// The vertical rhythm grid: a stack of rows, each `size` logical pixels tall.
30#[derive(Debug, Clone, Copy, PartialEq)]
31pub struct Rhythm {
32    size: f32,
33}
34
35impl Rhythm {
36    /// Create a grid with a finite, positive rhythm-unit size.
37    ///
38    /// # Panics
39    ///
40    /// Panics when `size` is zero, negative, or non-finite.
41    pub const fn new(size: f32) -> Self {
42        assert!(
43            size.is_finite() && size > 0.0,
44            "rhythm unit size must be finite and greater than zero"
45        );
46        Self { size }
47    }
48
49    /// Height of one rhythm unit in logical pixels.
50    #[inline]
51    pub const fn size(&self) -> f32 {
52        self.size
53    }
54
55    /// Axis-neutral length of `n` rhythm units. Use this when the vertical grid
56    /// also supplies horizontal indents, gaps, or padding.
57    #[inline]
58    pub fn spacing(&self, n: i32) -> f32 {
59        self.size * n as f32
60    }
61
62    /// Total height of `n` rhythm units. Equivalent to rhythm-sass `rhythm($n)`;
63    /// apply offsets with plain addition: `grid.height(5) - 1.0`.
64    #[inline]
65    pub fn height(&self, n: i32) -> f32 {
66        self.spacing(n)
67    }
68
69    /// The smallest whole number of rhythm rows covering `height` — the pad
70    /// strategy for content whose height is not rhythm-controlled (images,
71    /// video, embeds): the block grows to the next grid line and the
72    /// remainder, always under one unit, becomes trailing whitespace.
73    ///
74    /// Heights within a few `f32` rounding steps of a whole-row height count
75    /// as exact, absorbing float error from measured sizes without allowing a
76    /// large rhythm unit to hide a visible remainder. Unlike [`snap`], which
77    /// rounds a final value to the nearest step of an arbitrary size, this
78    /// rounds heights outward to whole rhythm rows.
79    ///
80    /// # Panics
81    ///
82    /// Panics when `height` is negative or non-finite.
83    #[inline]
84    pub fn snap_up(&self, height: f32) -> f32 {
85        self.size * self.snap_rows(height, f32::ceil)
86    }
87
88    /// The largest whole number of rhythm rows within `height` — the crop
89    /// strategy: the block shrinks to the previous grid line, cutting less
90    /// than one unit and never scaling the content up. Same exactness
91    /// tolerance as [`Self::snap_up`].
92    ///
93    /// # Panics
94    ///
95    /// Panics when `height` is negative or non-finite.
96    #[inline]
97    pub fn snap_down(&self, height: f32) -> f32 {
98        self.size * self.snap_rows(height, f32::floor)
99    }
100
101    // Deliberately kept separate from `RhythmLineMetrics::minimum_line_rows`,
102    // which applies the same rule in `f64`. The two are calibrated to their
103    // inputs' precision, not merely duplicated: this path divides an `f32`
104    // height, whose own rounding error the tolerance absorbs, so widening it
105    // to `f64` changes results once the tolerance approaches half a unit
106    // (`snap_up_keeps_a_real_remainder_at_large_heights` pins one such case),
107    // and bounding the tolerance instead stops it absorbing the `f32` noise
108    // it exists for. Change one only with a differential sweep over both.
109    fn snap_rows(&self, height: f32, round_away: fn(f32) -> f32) -> f32 {
110        assert!(
111            height.is_finite() && height >= 0.0,
112            "height must be finite and non-negative"
113        );
114        let rows = height / self.size;
115        let nearest = rows.round();
116        let nearest_height = self.size * nearest;
117        // Division and multiplication can each move an exact multiple by a
118        // few ULPs. Scale by the measured height, not by the grid size, so the
119        // tolerance cannot hide a visible remainder on a large grid.
120        let tolerance = height * f32::EPSILON * 8.0;
121        if (height - nearest_height).abs() <= tolerance {
122            nearest
123        } else {
124            round_away(rows)
125        }
126    }
127}
128
129/// Vertical metrics of one text style participating in the rhythm grid.
130///
131/// `ascent` and `descent` are resolved at `font_size` (logical pixels, both
132/// positive). Line height is expressed in whole rhythm units, which is what keeps
133/// consecutive lines of the same style on the grid.
134#[derive(Debug, Clone, Copy, PartialEq)]
135pub struct FontRhythm {
136    font_size: f32,
137    line_rhythms: u32,
138    ascent: f32,
139    descent: f32,
140    cap_height: Option<f32>,
141    x_height: Option<f32>,
142}
143
144impl FontRhythm {
145    /// Build from real font metrics resolved at `font_size`.
146    ///
147    /// # Panics
148    ///
149    /// Panics when `font_size` or `ascent` is not finite and positive, when
150    /// `line_rhythms` is zero, or when `descent` is not finite and non-negative.
151    pub fn from_metrics(font_size: f32, line_rhythms: u32, ascent: f32, descent: f32) -> Self {
152        assert!(
153            font_size.is_finite() && font_size > 0.0,
154            "font_size must be finite and greater than zero"
155        );
156        assert!(line_rhythms > 0, "line_rhythms must be greater than zero");
157        assert!(
158            ascent.is_finite() && ascent > 0.0,
159            "ascent must be finite and greater than zero"
160        );
161        assert!(
162            descent.is_finite() && descent >= 0.0,
163            "descent must be finite and non-negative"
164        );
165        Self {
166            font_size,
167            line_rhythms,
168            ascent,
169            descent,
170            cap_height: None,
171            x_height: None,
172        }
173    }
174
175    /// Build from platform-reported metrics, normalizing conventions.
176    ///
177    /// Some platforms report table metrics in the OpenType sign convention where
178    /// descent is negative below the baseline (gpui on macOS does), so `ascent`
179    /// and `descent` are taken by magnitude. Non-finite or non-positive cap and
180    /// x heights are treated as unavailable and become `None`.
181    ///
182    /// # Panics
183    ///
184    /// Panics under the same conditions as [`Self::from_metrics`] after
185    /// normalization (e.g. a zero `ascent`).
186    pub fn from_platform_metrics(
187        font_size: f32,
188        line_rhythms: u32,
189        ascent: f32,
190        descent: f32,
191        cap_height: f32,
192        x_height: f32,
193    ) -> Self {
194        let mut font = Self::from_metrics(font_size, line_rhythms, ascent.abs(), descent.abs());
195        font.cap_height = usable_metric(cap_height);
196        font.x_height = usable_metric(x_height);
197        font
198    }
199
200    /// Compatibility constructor for a Plumber/rhythm-sass `baseline-ratio`
201    /// (`0 < ratio < 1`), using the em-box approximation the Sass library assumed:
202    /// `ascent = (1 - ratio) * font_size`, `descent = ratio * font_size`.
203    ///
204    /// Prefer [`Self::from_metrics`]: the ratio is itself derived from metrics
205    /// (`(em + descent - ascent) / (2 * em)`) and the em-box model slightly
206    /// misplaces the baseline for fonts whose `ascent + descent != em`.
207    ///
208    /// # Panics
209    ///
210    /// Panics when `baseline_ratio` is not strictly between 0 and 1, when
211    /// `font_size` is not finite and positive, or when `line_rhythms` is zero.
212    pub fn from_baseline_ratio(font_size: f32, line_rhythms: u32, baseline_ratio: f32) -> Self {
213        assert!(
214            baseline_ratio > 0.0 && baseline_ratio < 1.0,
215            "baseline ratio must be strictly between 0 and 1"
216        );
217        Self::from_metrics(
218            font_size,
219            line_rhythms,
220            font_size * (1.0 - baseline_ratio),
221            font_size * baseline_ratio,
222        )
223    }
224
225    /// Font size in logical pixels.
226    #[inline]
227    pub const fn font_size(&self) -> f32 {
228        self.font_size
229    }
230
231    /// Line height in whole rhythm units.
232    #[inline]
233    pub const fn line_rhythms(&self) -> u32 {
234        self.line_rhythms
235    }
236
237    /// Distance from the baseline up to the top of the `ascent` box, positive.
238    #[inline]
239    pub const fn ascent(&self) -> f32 {
240        self.ascent
241    }
242
243    /// Distance from the baseline down to the bottom of the `descent` box, positive.
244    #[inline]
245    pub const fn descent(&self) -> f32 {
246        self.descent
247    }
248
249    /// Height of capital letters above the baseline, if known.
250    #[inline]
251    pub const fn cap_height(&self) -> Option<f32> {
252        self.cap_height
253    }
254
255    /// Height of a lowercase x above the baseline, if known.
256    #[inline]
257    pub const fn x_height(&self) -> Option<f32> {
258        self.x_height
259    }
260
261    /// The Plumber-style baseline ratio implied by these metrics.
262    #[inline]
263    pub fn baseline_ratio(&self) -> f32 {
264        (self.font_size + self.descent - self.ascent) / (2.0 * self.font_size)
265    }
266
267    /// The line height on `grid`: [`line_rhythms`](Self::line_rhythms) whole rhythm units.
268    #[inline]
269    pub fn line_height(&self, grid: Rhythm) -> f32 {
270        self.line_metrics(grid).line_height()
271    }
272
273    /// Extra space split above and below the `ascent + descent` box.
274    #[inline]
275    pub fn half_leading(&self, grid: Rhythm) -> f32 {
276        self.line_metrics(grid).half_leading()
277    }
278
279    /// Distance from the top of the line box down to the baseline.
280    #[inline]
281    pub fn baseline_above(&self, grid: Rhythm) -> f32 {
282        self.line_metrics(grid).baseline_above()
283    }
284
285    /// Distance from the baseline down to the bottom of the line box.
286    #[inline]
287    pub fn baseline_below(&self, grid: Rhythm) -> f32 {
288        self.line_metrics(grid).baseline_below()
289    }
290
291    /// Invisible space between the top of the line box and the cap top: the amount
292    /// a leading-trim (CSS `text-box-trim`) would remove. Subtract it from a top
293    /// spacing (or apply as negative margin) to visually butt capitals against an
294    /// edge or grid line.
295    #[inline]
296    pub fn cap_trim_top(&self, grid: Rhythm) -> Option<f32> {
297        Some(self.baseline_above(grid) - self.cap_height?)
298    }
299
300    /// Like [`Self::cap_trim_top`] but trimming to the x-height.
301    #[inline]
302    pub fn x_trim_top(&self, grid: Rhythm) -> Option<f32> {
303        Some(self.baseline_above(grid) - self.x_height?)
304    }
305
306    /// Spacing from an element's top edge up to the nth grid line above the first
307    /// baseline. Equivalent to rhythm-sass `baseline-top()` / `rhythm-bottom()`.
308    ///
309    /// Applied as `padding-top` (or `margin-top`), it makes the first baseline land
310    /// exactly `n` rhythm units below the grid line at the element's padding edge.
311    ///
312    /// The result is negative when `n × size` is smaller than
313    /// [`baseline_above`](Self::baseline_above); a negative value is meaningful
314    /// as a margin but not as a padding, so pick `n` accordingly.
315    #[inline]
316    pub fn baseline_top(&self, grid: Rhythm, n: i32) -> f32 {
317        grid.height(n) - self.baseline_above(grid)
318    }
319
320    /// Spacing from an element's bottom edge down to the nth grid line below the
321    /// last baseline. Equivalent to rhythm-sass `baseline-bottom()` / `rhythm-top()`.
322    ///
323    /// The result is negative when `n × size` is smaller than
324    /// [`baseline_below`](Self::baseline_below); a negative value is meaningful
325    /// as a margin but not as a padding, so pick `n` accordingly.
326    #[inline]
327    pub fn baseline_bottom(&self, grid: Rhythm, n: i32) -> f32 {
328        grid.height(n) - self.baseline_below(grid)
329    }
330
331    /// Spacing from a block set in this style down to a following block set in
332    /// `below`, measured from the bottom of this line box to the top of the
333    /// below font's line box, such that the two adjacent baselines are exactly
334    /// `n` rhythm units apart. Equivalent to rhythm-sass `baseline-between()`.
335    ///
336    /// The result is negative when `n` rhythm units cannot fit both fonts'
337    /// baseline distances; negative values overlap the blocks when applied.
338    #[inline]
339    pub fn baseline_between(&self, grid: Rhythm, below: &FontRhythm, n: i32) -> f32 {
340        self.baseline_bottom(grid, n) - below.baseline_above(grid)
341    }
342
343    /// Top spacing that lands the **cap ink top** — not the baseline — on the
344    /// nth grid line: `n × size − cap_trim_top`. The grid-woven analog of CSS
345    /// `text-box-trim: trim-start` with `text-box-edge: cap`, for openings
346    /// where the eye measures ink to edge (heroes, cards, page tops).
347    ///
348    /// The block's baselines shift off the grid by `cap_height mod size`;
349    /// close the block with [`Self::cap_bottom`] — not
350    /// [`Self::baseline_bottom`] — so it still occupies a whole number of
351    /// rhythm rows and everything after it stays in rhythm.
352    ///
353    /// `None` when this style has no usable cap height. CJK faces report one
354    /// anyway, so on ideographic text this anchor returns `Some` while
355    /// trimming to the wrong ink: ideographs are not seated on the baseline,
356    /// and the envelope their ink is drawn to is the ideographic character
357    /// face, not a cap height. Anchor those with
358    /// [`RhythmBlockMetrics::ink_anchored`](crate::RhythmBlockMetrics::ink_anchored),
359    /// or with `RhythmIcfAnchor::span` under the `gpui` feature. Worse, the
360    /// reported value need not describe any glyph:
361    /// PingFang SC publishes `sCapHeight` 0.860 em, a copy of its
362    /// `sTypoAscender`, while its Latin `H` actually reaches 0.714 em
363    /// (Hiragino Sans GB, by contrast, reports its true 0.766 em). Treat a
364    /// CJK face's cap and x heights as unverified.
365    ///
366    /// # Examples
367    ///
368    /// ```
369    /// use rhythm_gpui::{FontRhythm, Rhythm};
370    ///
371    /// let grid = Rhythm::new(8.0);
372    /// // Georgia-like 28px heading on a 5-unit (40px) line.
373    /// let heading = FontRhythm::from_platform_metrics(28.0, 5, 25.68, -6.14, 19.40, 13.48);
374    ///
375    /// // Open: the capitals' ink starts exactly on the 3rd grid line…
376    /// let pt = heading.cap_top(grid, 3).unwrap();
377    /// assert!((pt + heading.cap_trim_top(grid).unwrap() - grid.height(3)).abs() < 1e-3);
378    ///
379    /// // …and the paired closer returns the trim, so the block spans whole rows.
380    /// let pb = heading.cap_bottom(grid, 0).unwrap();
381    /// let block = pt + heading.line_height(grid) + pb;
382    /// assert!((block - 64.0).abs() < 1e-3); // 8 whole rows: what follows stays in rhythm
383    /// ```
384    #[inline]
385    pub fn cap_top(&self, grid: Rhythm, n: i32) -> Option<f32> {
386        Some(grid.height(n) - self.cap_trim_top(grid)?)
387    }
388
389    /// Bottom spacing pairing [`Self::cap_top`]: `n × size + cap_trim_top`,
390    /// returning at the bottom exactly what `cap_top` trimmed at the top so
391    /// the block occupies a whole number of rhythm rows — for any number of
392    /// wrapped lines, since lines advance by whole rows. Equivalently, the
393    /// bottom edge lands `n + line_rhythms` units below the last line's cap
394    /// top.
395    ///
396    /// `None` when this style has no usable cap height.
397    #[inline]
398    pub fn cap_bottom(&self, grid: Rhythm, n: i32) -> Option<f32> {
399        Some(grid.height(n) + self.cap_trim_top(grid)?)
400    }
401
402    /// This style's line placement on `grid` as a
403    /// [`RhythmLineMetrics`](crate::RhythmLineMetrics) — the same value a
404    /// shaped line produces, so a custom renderer can place single-style
405    /// text (and empty lines) through one code path.
406    #[inline]
407    pub fn line_metrics(&self, grid: Rhythm) -> crate::RhythmLineMetrics {
408        crate::RhythmLineMetrics::new(self.ascent, self.descent, self.line_rhythms, grid)
409    }
410
411    /// Solve a drop cap sunk `lines` lines deep into text set in `self`.
412    ///
413    /// `cap` carries the cap face's metrics resolved at any font size (metrics
414    /// scale linearly, so the probe size is irrelevant); its `line_rhythms` is
415    /// ignored. The solved font size makes the cap face's capital span from the
416    /// first line's cap top down to the `lines`-th baseline, and
417    /// [`DropCapRhythm::top`] anchors the baseline there. A face without a
418    /// usable cap height falls back to the classic 0.7 em approximation, which
419    /// can only misplace the visual top — the baseline anchor stays exact.
420    ///
421    /// Drop caps are a Latin convention; CJK paragraphs conventionally open
422    /// with a first-line indent instead, so there is deliberately no
423    /// ideographic dual of this solver. A CJK face passed as `cap` is solved
424    /// from its reported (Latin-glyph) cap height like any other face.
425    ///
426    /// # Examples
427    ///
428    /// ```
429    /// use rhythm_gpui::{FontRhythm, Rhythm};
430    ///
431    /// let grid = Rhythm::new(8.0);
432    /// // Georgia-like metrics at 16px on a 3-unit (24px) line.
433    /// let body = FontRhythm::from_platform_metrics(16.0, 3, 14.67, -3.51, 11.09, 7.70);
434    /// let cap = body.drop_cap(grid, &body, 3);
435    ///
436    /// // The capital spans two body lines plus the body cap height…
437    /// let span = 2.0 * body.line_height(grid) + body.cap_height().unwrap();
438    /// assert!((cap.metrics().cap_height().unwrap() - span).abs() < 1e-3);
439    /// // …and `top` lands its baseline exactly on the third body baseline.
440    /// let target = body.baseline_above(grid) + 2.0 * body.line_height(grid);
441    /// assert!((cap.top() + cap.metrics().baseline_above(grid) - target).abs() < 1e-3);
442    /// ```
443    ///
444    /// # Panics
445    ///
446    /// Panics when `lines` is zero or `lines × line_rhythms` overflows `u32`.
447    pub fn drop_cap(&self, grid: Rhythm, cap: &FontRhythm, lines: u32) -> DropCapRhythm {
448        assert!(lines > 0, "a drop cap must sink at least one line");
449        let line_rhythms = self
450            .line_rhythms
451            .checked_mul(lines)
452            .expect("drop cap line rhythms overflow u32");
453        let body_cap = self.cap_height.unwrap_or(0.7 * self.font_size);
454        let cap_height = cap.cap_height;
455        let cap_ratio = cap_height.unwrap_or(0.7 * cap.font_size) / cap.font_size;
456        let span = (lines - 1) as f32 * self.line_height(grid) + body_cap;
457        let font_size = span / cap_ratio;
458        let scale = font_size / cap.font_size;
459        let mut metrics = FontRhythm::from_metrics(
460            font_size,
461            line_rhythms,
462            cap.ascent * scale,
463            cap.descent * scale,
464        );
465        metrics.cap_height = cap_height.and_then(|h| usable_metric(h * scale));
466        metrics.x_height = cap.x_height.and_then(|h| usable_metric(h * scale));
467        let target = self.baseline_above(grid) + (lines - 1) as f32 * self.line_height(grid);
468        DropCapRhythm {
469            top: target - metrics.baseline_above(grid),
470            metrics,
471        }
472    }
473}
474
475/// A drop cap solved by [`FontRhythm::drop_cap`]: the cap face's metrics at the
476/// solved size plus the offset anchoring its baseline.
477#[derive(Debug, Clone, Copy, PartialEq)]
478pub struct DropCapRhythm {
479    metrics: FontRhythm,
480    top: f32,
481}
482
483impl DropCapRhythm {
484    /// Cap-face metrics at the solved size. The line box spans the sunk lines
485    /// exactly: `line_rhythms` is `lines ×` the body's `line_rhythms`.
486    #[inline]
487    pub const fn metrics(&self) -> &FontRhythm {
488        &self.metrics
489    }
490
491    /// Offset from the paragraph's top edge down to the cap's line box top,
492    /// negative when the cap must sit above it. Apply it visually — in a flex
493    /// row as a relative inset, not a margin: cap-heavy faces (cap height
494    /// exceeding `ascent − descent`, e.g. Merriweather) yield a positive
495    /// offset, and as a margin it would grow the row's cross size and push
496    /// everything below off the grid.
497    #[inline]
498    pub const fn top(&self) -> f32 {
499        self.top
500    }
501}
502
503fn usable_metric(height: f32) -> Option<f32> {
504    (height.is_finite() && height > 0.0).then_some(height)
505}
506
507/// Round `value` to the nearest multiple of `step` (e.g. `1.0 / scale_factor` to
508/// snap a spacing to whole device pixels). The core functions never round, so
509/// baseline-anchored results stay exact; snap only final applied values.
510///
511/// # Panics
512///
513/// Panics when `step` is zero, negative, or non-finite.
514pub fn snap(value: f32, step: f32) -> f32 {
515    assert!(
516        step.is_finite() && step > 0.0,
517        "snap step must be finite and greater than zero"
518    );
519    (value / step).round() * step
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525
526    const GRID: Rhythm = Rhythm::new(8.0);
527    const NOTO_SERIF_RATIO: f32 = 0.112061;
528
529    // Mirrors __tests__/_font-maps.scss from rhythm-sass.
530    fn font_map_3() -> FontRhythm {
531        FontRhythm::from_baseline_ratio(17.0, 3, NOTO_SERIF_RATIO)
532    }
533
534    fn font_map_3_with_cap(cap_height: f32) -> FontRhythm {
535        let base = font_map_3();
536        FontRhythm::from_platform_metrics(
537            base.font_size(),
538            base.line_rhythms(),
539            base.ascent(),
540            base.descent(),
541            cap_height,
542            base.x_height().unwrap_or(0.0),
543        )
544    }
545
546    fn font_map_5() -> FontRhythm {
547        FontRhythm::from_baseline_ratio(12.0, 2, NOTO_SERIF_RATIO)
548    }
549
550    #[test]
551    fn rhythm_spacing_and_height() {
552        assert_eq!(GRID.spacing(5), 40.0);
553        assert_eq!(GRID.spacing(0), 0.0);
554        assert_eq!(GRID.spacing(-1), -8.0);
555        assert_eq!(GRID.height(5), 40.0);
556        assert_eq!(GRID.height(5), GRID.spacing(5));
557        assert_eq!(GRID.height(5) - 1.0, 39.0); // offsets are plain addition
558        assert_eq!(GRID.height(0), 0.0);
559        assert_eq!(GRID.height(-1), -8.0);
560    }
561
562    // rhythm-sass rounded baseline offsets to whole CSS px before subtracting
563    // (see calc-baseline-offset in _lib.scss). The math here stays exact, so the
564    // Sass parity tests apply the same rounding explicitly.
565    fn sass_baseline_bottom(font: &FontRhythm, n: i32) -> f32 {
566        GRID.height(n) - font.baseline_below(GRID).round()
567    }
568
569    fn sass_baseline_top(font: &FontRhythm, n: i32) -> f32 {
570        GRID.height(n) - font.baseline_above(GRID).round()
571    }
572
573    #[test]
574    fn sass_parity_rhythm_top_and_bottom() {
575        // Expected values from __tests__/rhythm.test.scss.
576        assert_eq!(sass_baseline_bottom(&font_map_3(), 3), 19.0); // rhythm-top
577        assert_eq!(sass_baseline_bottom(&font_map_3(), 0), -5.0);
578        assert_eq!(sass_baseline_bottom(&font_map_3(), -1), -13.0);
579        assert_eq!(sass_baseline_top(&font_map_3(), 3), 5.0); // rhythm-bottom
580        assert_eq!(sass_baseline_top(&font_map_3(), 0), -19.0);
581        assert_eq!(sass_baseline_top(&font_map_3(), -1), -27.0);
582    }
583
584    #[test]
585    fn sass_parity_baseline_between() {
586        let above = font_map_3();
587        let below = font_map_5();
588        let result = sass_baseline_bottom(&above, 3) - below.baseline_above(GRID).round();
589        assert_eq!(result, 6.0);
590    }
591
592    #[test]
593    fn exact_functions_land_baseline_on_grid() {
594        let font = font_map_3();
595        // padding_top + renderer's baseline placement = exact grid multiple
596        let padding_top = font.baseline_top(GRID, 3);
597        assert!((padding_top + font.baseline_above(GRID) - GRID.height(3)).abs() < 1e-4);
598
599        let padding_bottom = font.baseline_bottom(GRID, 3);
600        assert!((padding_bottom + font.baseline_below(GRID) - GRID.height(3)).abs() < 1e-4);
601
602        // Two stacked blocks: distance between adjacent baselines is n grid units.
603        let below = font_map_5();
604        let gap = font.baseline_between(GRID, &below, 3);
605        let baseline_distance = font.baseline_below(GRID) + gap + below.baseline_above(GRID);
606        assert!((baseline_distance - GRID.height(3)).abs() < 1e-4);
607    }
608
609    #[test]
610    fn baseline_ratio_roundtrip() {
611        let font = font_map_3();
612        assert!((font.baseline_ratio() - NOTO_SERIF_RATIO).abs() < 1e-6);
613    }
614
615    #[test]
616    fn baseline_ratio_from_real_metrics_matches_plumber() {
617        // Noto Serif: units_per_em 1000, hhea ascent 1069, descent 293.
618        // Plumber's published ratio for Noto Serif is 0.112061.
619        let em = 1000.0;
620        let font = FontRhythm::from_metrics(em, 1, 1069.0, 293.0);
621        assert!((font.baseline_ratio() - 0.112).abs() < 1e-3);
622    }
623
624    #[test]
625    fn platform_metrics_normalize_opentype_signs() {
626        // gpui's FontMetrics reports table descent as negative on macOS
627        // (OpenType sign convention); magnitudes must come out positive.
628        let font = FontRhythm::from_platform_metrics(16.0, 3, 14.75, -3.25, 11.2, 8.1);
629        assert_eq!(font.ascent(), 14.75);
630        assert_eq!(font.descent(), 3.25);
631        assert_eq!(font.cap_height(), Some(11.2));
632        assert_eq!(font.x_height(), Some(8.1));
633    }
634
635    #[test]
636    fn platform_metrics_filter_unusable_optional_heights() {
637        let font = FontRhythm::from_platform_metrics(16.0, 3, 14.75, 3.25, 0.0, f32::NAN);
638        assert_eq!(font.cap_height(), None);
639        assert_eq!(font.x_height(), None);
640
641        let font = FontRhythm::from_platform_metrics(16.0, 3, 14.75, 3.25, -1.0, 7.5);
642        assert_eq!(font.cap_height(), None);
643        assert_eq!(font.x_height(), Some(7.5));
644    }
645
646    /// Pins CHANGELOG.md's 0.2 migration recipe: the documented em-box
647    /// composition must stay bit-identical to `from_baseline_ratio`.
648    #[test]
649    fn changelog_ratio_recipe_matches_from_baseline_ratio() {
650        let composed = FontRhythm::from_platform_metrics(
651            17.0,
652            3,
653            17.0 * (1.0 - NOTO_SERIF_RATIO),
654            17.0 * NOTO_SERIF_RATIO,
655            11.9,
656            8.5,
657        );
658        let plain = font_map_3();
659
660        assert_eq!(composed.font_size(), plain.font_size());
661        assert_eq!(composed.line_rhythms(), plain.line_rhythms());
662        assert_eq!(composed.ascent(), plain.ascent());
663        assert_eq!(composed.descent(), plain.descent());
664        assert_eq!(composed.baseline_ratio(), plain.baseline_ratio());
665        // …while carrying the heights a bare ratio cannot express.
666        assert_eq!(composed.cap_height(), Some(11.9));
667        assert_eq!(composed.x_height(), Some(8.5));
668        assert_eq!(plain.cap_height(), None);
669    }
670
671    #[test]
672    #[should_panic(expected = "line_rhythms must be greater than zero")]
673    fn zero_line_rhythms_are_rejected() {
674        let _ = FontRhythm::from_metrics(16.0, 0, 14.75, 3.25);
675    }
676
677    #[test]
678    #[should_panic(expected = "baseline ratio must be strictly between 0 and 1")]
679    fn out_of_range_baseline_ratio_is_rejected() {
680        let _ = FontRhythm::from_baseline_ratio(16.0, 3, 1.5);
681    }
682
683    #[test]
684    fn cap_trim() {
685        let font = font_map_3_with_cap(0.7 * 17.0);
686        let trim = font.cap_trim_top(GRID).unwrap();
687        assert!((trim - (font.baseline_above(GRID) - 11.9)).abs() < 1e-4);
688        assert_eq!(font_map_3().cap_trim_top(GRID), None);
689    }
690
691    #[test]
692    fn snap_to_device_pixels() {
693        assert_eq!(snap(5.4, 0.5), 5.5); // 2x display
694        assert_eq!(snap(5.4, 1.0), 5.0);
695        assert_eq!(snap(-5.4, 0.5), -5.5);
696    }
697
698    #[test]
699    fn snap_heights_to_whole_rows() {
700        assert_eq!(GRID.snap_up(450.0), 456.0); // 800px wide at 16:9
701        assert_eq!(GRID.snap_down(450.0), 448.0);
702        assert_eq!(GRID.snap_up(0.0), 0.0);
703        assert_eq!(GRID.snap_down(7.9), 0.0); // under one row floors to zero
704                                              // Exact multiples pass through both directions.
705        assert_eq!(GRID.snap_up(448.0), 448.0);
706        assert_eq!(GRID.snap_down(448.0), 448.0);
707    }
708
709    #[test]
710    fn snap_heights_absorb_float_error_near_whole_rows() {
711        assert_eq!(GRID.snap_up(448.0002), 448.0);
712        assert_eq!(GRID.snap_down(447.9998), 448.0);
713    }
714
715    #[test]
716    fn snap_up_keeps_a_real_remainder_at_large_heights() {
717        // Guards the `f32` calibration of `snap_rows`: at this magnitude the
718        // proportional tolerance (~4.25) approaches half the 8.5 unit, and
719        // computing the same rule in `f64` instead swallows the genuine 4px
720        // remainder and returns the row below. 4460404.5 / 8.5 is
721        // 524753.47..., so snapping up must reach row 524754.
722        let grid = Rhythm::new(8.5);
723        assert_eq!(grid.snap_up(4_460_404.5), 8.5 * 524_754.0);
724        assert_eq!(grid.snap_down(4_460_404.5), 8.5 * 524_753.0);
725    }
726
727    #[test]
728    fn snap_tolerance_does_not_scale_with_the_grid_size() {
729        let large_grid = Rhythm::new(1_000_000.0);
730        assert_eq!(large_grid.snap_up(50.0), 1_000_000.0);
731        assert_eq!(large_grid.snap_down(50.0), 0.0);
732    }
733
734    #[test]
735    #[should_panic(expected = "height must be finite and non-negative")]
736    fn snap_heights_reject_negative_values() {
737        let _ = GRID.snap_up(-1.0);
738    }
739
740    // Georgia on macOS: upem 2048, hhea 1878/-449, cap 1419, x 986.
741    fn georgia_16() -> FontRhythm {
742        FontRhythm::from_platform_metrics(
743            16.0,
744            3,
745            16.0 * 1878.0 / 2048.0,
746            -16.0 * 449.0 / 2048.0,
747            16.0 * 1419.0 / 2048.0,
748            16.0 * 986.0 / 2048.0,
749        )
750    }
751
752    #[test]
753    fn drop_cap_baseline_lands_on_the_sunk_line() {
754        let body = georgia_16();
755        let cap = body.drop_cap(GRID, &body, 3);
756        assert_eq!(cap.metrics().line_rhythms(), 9);
757        // The capital spans two body lines plus the body cap height…
758        let span = 2.0 * body.line_height(GRID) + body.cap_height().unwrap();
759        assert!((cap.metrics().cap_height().unwrap() - span).abs() < 1e-3);
760        // …and its baseline lands exactly on the third body baseline.
761        let target = body.baseline_above(GRID) + 2.0 * body.line_height(GRID);
762        assert!((cap.top() + cap.metrics().baseline_above(GRID) - target).abs() < 1e-3);
763    }
764
765    #[test]
766    fn drop_cap_sizes_by_the_cap_faces_own_metrics() {
767        let body = georgia_16();
768        // A display face with taller capitals: cap height 0.8 em.
769        let display = FontRhythm::from_platform_metrics(12.0, 1, 11.0, -3.0, 9.6, 6.0);
770        let cap = body.drop_cap(GRID, &display, 3);
771        let span = 2.0 * body.line_height(GRID) + body.cap_height().unwrap();
772        // Solved with the cap face's own ratio, not the body's.
773        assert!((cap.metrics().font_size() - span / 0.8).abs() < 1e-3);
774        assert!((cap.metrics().cap_height().unwrap() - span).abs() < 1e-3);
775    }
776
777    #[test]
778    fn cap_heavy_faces_need_a_positive_top_offset() {
779        // Merriweather: upem 2000, hhea 1968/-546, cap 1486. Cap height
780        // (0.743 em) exceeds ascent − descent (0.711 em), which flips the
781        // anchor offset positive — the reason the offset must be applied as a
782        // relative inset, not a margin (a positive margin grows the flex row's
783        // cross size and pushes everything below off the grid).
784        let body = FontRhythm::from_platform_metrics(
785            16.0,
786            3,
787            16.0 * 1968.0 / 2000.0,
788            -16.0 * 546.0 / 2000.0,
789            16.0 * 1486.0 / 2000.0,
790            16.0 * 1111.0 / 2000.0,
791        );
792        let cap = body.drop_cap(GRID, &body, 3);
793        assert!(
794            cap.top() > 1.0 && cap.top() < 1.1,
795            "expected ≈ +1.03, got {}",
796            cap.top()
797        );
798        let target = body.baseline_above(GRID) + 2.0 * body.line_height(GRID);
799        assert!((cap.top() + cap.metrics().baseline_above(GRID) - target).abs() < 1e-3);
800    }
801
802    #[test]
803    fn drop_cap_falls_back_to_the_em_approximation() {
804        let body = font_map_3(); // baseline-ratio construction: no cap height
805        let cap = body.drop_cap(GRID, &body, 2);
806        let expected_size = (body.line_height(GRID) + 0.7 * body.font_size()) / 0.7;
807        assert!((cap.metrics().font_size() - expected_size).abs() < 1e-3);
808        // The fallback is not fabricated into the solved metrics.
809        assert_eq!(cap.metrics().cap_height(), None);
810        // The baseline anchor holds regardless.
811        let target = body.baseline_above(GRID) + body.line_height(GRID);
812        assert!((cap.top() + cap.metrics().baseline_above(GRID) - target).abs() < 1e-3);
813    }
814
815    #[test]
816    #[should_panic(expected = "at least one line")]
817    fn drop_cap_rejects_zero_lines() {
818        let _ = font_map_3().drop_cap(GRID, &font_map_3(), 0);
819    }
820
821    #[test]
822    fn cap_pair_opens_on_ink_and_closes_on_whole_rows() {
823        // Georgia-like 28px heading on a 5-unit line.
824        let heading = FontRhythm::from_platform_metrics(
825            28.0,
826            5,
827            28.0 * 1878.0 / 2048.0,
828            -28.0 * 449.0 / 2048.0,
829            28.0 * 1419.0 / 2048.0,
830            28.0 * 986.0 / 2048.0,
831        );
832        let pt = heading.cap_top(GRID, 3).unwrap();
833        // The capitals' ink starts exactly on the 3rd grid line…
834        assert!((pt + heading.cap_trim_top(GRID).unwrap() - GRID.height(3)).abs() < 1e-3);
835        // …and the paired closer makes the block a whole number of rows.
836        let pb = heading.cap_bottom(GRID, 0).unwrap();
837        let block = pt + heading.line_height(GRID) + pb;
838        assert!((block - 64.0).abs() < 1e-3);
839        // Closing with baseline_bottom instead would leave the fractional
840        // cap-height phase inside the block and push everything below it
841        // off the grid.
842        let mixed = pt + heading.line_height(GRID) + heading.baseline_bottom(GRID, 1);
843        let rows = mixed / GRID.size();
844        assert!((rows - rows.round()).abs() > 0.01);
845    }
846
847    #[test]
848    fn cap_anchors_need_a_usable_cap_height() {
849        let no_cap = font_map_3(); // baseline-ratio construction: no cap height
850        assert_eq!(no_cap.cap_top(GRID, 3), None);
851        assert_eq!(no_cap.cap_bottom(GRID, 1), None);
852    }
853
854    // PingFang SC Regular, read from the font file: upem 1000, hhea
855    // 1060/-340, BASE icfb -102 / icft +822, OS/2 sCapHeight 860 and sxHeight
856    // 600. Those last two are placeholders, not measurements — sCapHeight
857    // simply repeats sTypoAscender, while the face's real H reaches 0.714 em
858    // and its x 0.517 em. The fixtures keep the reported values because they
859    // are what a text system hands back.
860    const PINGFANG_ICFT: f32 = 0.822;
861
862    fn pingfang(size: f32, rows: u32) -> FontRhythm {
863        FontRhythm::from_platform_metrics(
864            size,
865            rows,
866            1.060 * size,
867            -0.340 * size,
868            0.860 * size,
869            0.600 * size,
870        )
871    }
872
873    /// The math layer needs no ideographic anchor of its own:
874    /// [`RhythmBlockMetrics::ink_anchored`] takes the anchored ink height, so
875    /// passing a character-face ascent lands ideographic ink and still spans
876    /// whole rows.
877    #[test]
878    fn ideographic_ink_anchors_through_the_generic_block_metrics() {
879        let font = pingfang(16.0, 3);
880        let icf = PINGFANG_ICFT * 16.0;
881        let line = font.line_metrics(GRID);
882        let block = crate::RhythmBlockMetrics::ink_anchored(line, icf, 3, 0);
883
884        // The character face's top edge starts exactly on the 3rd grid line…
885        let trim = font.baseline_above(GRID) - icf;
886        assert!((block.opening() + trim - GRID.height(3)).abs() < 1e-3);
887        // …and the paired closer makes the block a whole number of rows.
888        assert!((block.height(1) - 48.0).abs() < 1e-3);
889        assert_eq!(block.rows(1), 6);
890
891        // Closing with a baseline anchor instead would leave the fractional
892        // character-face phase inside the block.
893        let mixed = block.opening() + font.line_height(GRID) + font.baseline_bottom(GRID, 1);
894        let rows = mixed / GRID.size();
895        assert!((rows - rows.round()).abs() > 0.01);
896    }
897
898    #[test]
899    fn a_cjk_faces_cap_height_is_the_wrong_ink() {
900        // Two hazards at once. The reported cap height anchors Latin ink that
901        // ideographs do not share, and PingFang's reported value (0.860 em,
902        // a copy of sTypoAscender) describes no glyph at all — its real H is
903        // 0.714 em. Meanwhile the em box top, 0.86 em, sits above every
904        // ideograph's ink (字 reaches +0.825 em), so anchoring the box leaves
905        // a visible gap where the character face does not.
906        let font = pingfang(16.0, 3);
907        let icf = PINGFANG_ICFT * 16.0;
908        let ink_top = 0.825 * 16.0;
909
910        let cap_anchor = font.cap_top(GRID, 3).unwrap();
911        let face_anchor =
912            crate::RhythmBlockMetrics::ink_anchored(font.line_metrics(GRID), icf, 3, 0).opening();
913        assert!(
914            face_anchor < cap_anchor,
915            "the character face sits above the reported cap height"
916        );
917
918        // The face anchor puts real ink within a fraction of a pixel of the
919        // grid line; an em-box anchor is a visible distance short of it.
920        assert!((icf - ink_top).abs() < 0.06);
921        assert!(0.86 * 16.0 - ink_top > 0.5);
922    }
923
924    /// Pins the worked example in README.md's CJK section.
925    #[test]
926    fn readme_heading_flush_to_a_card_edge() {
927        // PingFang SC 24px on a 5-unit (40px) line, 8px grid.
928        let heading = pingfang(24.0, 5);
929        let icf = PINGFANG_ICFT * 24.0;
930        let trim = heading.baseline_above(GRID) - icf;
931        assert!((trim - 8.912).abs() < 1e-3, "invisible space, got {trim}");
932
933        // The naive padding overshoots by that whole invisible band.
934        assert!((16.0 + trim - 24.912).abs() < 1e-3);
935
936        // The anchored pair lands the ink and still spans whole rows.
937        let block = crate::RhythmBlockMetrics::ink_anchored(heading.line_metrics(GRID), icf, 2, 0);
938        assert!((block.opening() - 7.088).abs() < 1e-3);
939        assert!((block.opening() + trim - 16.0).abs() < 1e-3);
940        assert!((block.height(1) - 56.0).abs() < 1e-3);
941        assert_eq!(block.rows(1), 7);
942
943        // The cap anchor misses by PingFang's reported cap height.
944        assert!((heading.cap_top(GRID, 2).unwrap() + trim - 16.912).abs() < 1e-3);
945    }
946}