lattice_cells/row_weights.rs
1//! IM.1 — how much vertical space a source line's display rows occupy,
2//! measured in **line-heights**.
3//!
4//! ## Why this exists
5//!
6//! Scroll arithmetic counts display rows and assumes every row is one unit
7//! tall. That holds for the TUI forever — a terminal cell has one size — and
8//! it held for the GPUI peer until rows started differing in height (scaled
9//! markdown headings today, inline media blocks at IM.3).
10//!
11//! The IM.0 audit found the damage is narrow but real: `Editor::scroll` is a
12//! row *index* and survives untouched, but `Editor::viewport_height` is a row
13//! *count*, computed as `available_px / row_px` against a uniform `row_px`.
14//! Once rows differ, "how many rows fit" is not a constant — it depends on
15//! which rows — so the seven functions that spend that number as a budget are
16//! wrong. See `docs/dev/architecture/inline-media.md` §4.0.
17//!
18//! ## Why line-heights and not pixels
19//!
20//! Pixels are a concept the TUI does not have, and this rides in shared host
21//! state that both peers read. A line-height is a unit both peers have: the
22//! TUI's rows are all exactly 1.0, and GPUI's are `row_scale`. Keeping the
23//! core in line-heights is what stops a renderer concern leaking into
24//! renderer-neutral types.
25//!
26//! ## Why keyed by source line
27//!
28//! The scroll walks (`bottom_anchored_scroll` and friends) step by source
29//! line and ask "how many display rows does this line cost". Keying the
30//! override the same way lets the weight replace that answer directly,
31//! rather than forcing the walk to first resolve each line to a range of
32//! display-row indices.
33//!
34//! ## The empty case is the important one
35//!
36//! Almost every line is ordinary, so the map is sparse and
37//! [`RowWeights::is_uniform`] is true in the overwhelming majority of
38//! buffers — including every TUI buffer, always. When it is, [`cost`] returns
39//! exactly `default_rows as f32`, so the converted arithmetic reduces to the
40//! integer arithmetic it replaced. That property is the regression guard for
41//! the whole slice: the TUI must behave identically before and after.
42//!
43//! [`cost`]: RowWeights::cost
44
45use std::collections::HashMap;
46
47/// Per-source-line vertical cost overrides, in line-heights.
48///
49/// Absent line ⇒ the line costs its display-row count, unchanged.
50#[derive(Debug, Clone, Default, PartialEq)]
51pub struct RowWeights {
52 /// Source line → total height of that line's display rows, in
53 /// line-heights. Only lines that differ from their row count appear.
54 overrides: HashMap<u32, f32>,
55}
56
57impl RowWeights {
58 /// The uniform map: every line costs its display-row count. What the TUI
59 /// publishes, always, and what GPUI publishes for a buffer with no scaled
60 /// or media rows.
61 pub fn uniform() -> Self {
62 Self::default()
63 }
64
65 /// True when nothing overrides the default. Callers use it to keep the
66 /// common path free of float work entirely.
67 pub fn is_uniform(&self) -> bool {
68 self.overrides.is_empty()
69 }
70
71 /// Declare that `line`'s display rows together occupy `line_heights`.
72 ///
73 /// Non-finite and negative values are ignored rather than stored: a bad
74 /// weight would poison a scroll budget and wedge the viewport, and a
75 /// silently-uniform line is a far better failure than a stuck buffer.
76 pub fn set(&mut self, line: u32, line_heights: f32) {
77 if line_heights.is_finite() && line_heights >= 0.0 {
78 self.overrides.insert(line, line_heights);
79 }
80 }
81
82 /// The vertical cost of `line`, given the display-row count the caller
83 /// already computed (wrap segments + virtual rows).
84 ///
85 /// Returns exactly `default_rows as f32` when unoverridden, which is what
86 /// makes the uniform case bit-identical to the integer arithmetic this
87 /// replaced.
88 pub fn cost(&self, line: u32, default_rows: u32) -> f32 {
89 match self.overrides.get(&line) {
90 Some(w) => *w,
91 None => default_rows as f32,
92 }
93 }
94
95 /// How many lines carry an override. Diagnostics and tests.
96 pub fn len(&self) -> usize {
97 self.overrides.len()
98 }
99
100 /// True when no line carries an override — the alias of
101 /// [`is_uniform`](Self::is_uniform) that clippy expects beside `len`.
102 pub fn is_empty(&self) -> bool {
103 self.overrides.is_empty()
104 }
105}
106
107#[cfg(test)]
108mod tests {
109 use super::*;
110
111 #[test]
112 fn the_uniform_map_returns_the_row_count_untouched() {
113 let w = RowWeights::uniform();
114 assert!(w.is_uniform());
115 // The property the whole slice rests on: with no overrides the
116 // converted arithmetic is the integer arithmetic it replaced. Small
117 // integers are exact in f32, so this is equality, not approximation.
118 for rows in [0u32, 1, 2, 7, 40] {
119 assert_eq!(w.cost(0, rows), rows as f32);
120 }
121 }
122
123 #[test]
124 fn an_override_replaces_the_row_count_for_that_line_only() {
125 let mut w = RowWeights::uniform();
126 w.set(4, 8.25);
127 assert!(!w.is_uniform());
128 assert_eq!(w.cost(4, 1), 8.25, "the overridden line");
129 assert_eq!(w.cost(3, 1), 1.0, "its neighbour is untouched");
130 assert_eq!(w.cost(5, 2), 2.0);
131 }
132
133 /// A NaN or negative weight would poison a budget subtraction and could
134 /// wedge the viewport. Dropping it degrades that line to uniform, which
135 /// is a visible-but-harmless wrong height rather than a stuck buffer.
136 #[test]
137 fn a_nonsense_weight_is_refused_rather_than_stored() {
138 let mut w = RowWeights::uniform();
139 w.set(1, f32::NAN);
140 w.set(2, f32::INFINITY);
141 w.set(3, -4.0);
142 assert!(w.is_uniform(), "none of those were stored");
143 assert_eq!(w.cost(1, 1), 1.0);
144
145 // Zero IS legal — a collapsed / concealed row really can occupy no
146 // vertical space, and refusing it would be the wrong call.
147 w.set(5, 0.0);
148 assert_eq!(w.cost(5, 3), 0.0);
149 }
150}