Skip to main content

eui_core/
measure.rs

1//! The host MEASURE contract. The VAPP-4 verdict: never one FFI upcall per
2//! taffy measure call (taffy asks a leaf 10–15 times per pass). The core
3//! batches: ONE upcall for the intrinsic widths of every dirty leaf (with the
4//! resolved text style, so Compose can pick the font before its first pass),
5//! taffy runs against a Rust-side memo, ONE upcall for the heights at the
6//! widths taffy decided, and a third round only when a height changed a width
7//! decision (counted in `LayoutOutput::measure_rounds`). In-process painters
8//! (gpui) may answer per request through [`DirectMeasure`]; nothing crosses
9//! an FFI there.
10
11use std::collections::HashMap;
12
13use serde::{Deserialize, Serialize};
14use serde_json::{Map, Value};
15
16/// Typography a measured leaf is laid out with (the theme's resolved recipe).
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
18pub struct TextStyle {
19    pub font_size: f32,
20    pub font_weight: u16,
21    pub line_height: f32,
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub font_family: Option<String>,
24    /// `fontStyle` (`italic`), `textTransform` and `letterSpacing` (px):
25    /// they change the shaped width, so the measurer needs them too.
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub font_style: Option<String>,
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub text_transform: Option<String>,
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub letter_spacing: Option<f32>,
32}
33
34impl Default for TextStyle {
35    fn default() -> Self {
36        TextStyle { font_size: 14.0, font_weight: 400, line_height: 20.0, font_family: None, font_style: None, text_transform: None, letter_spacing: None }
37    }
38}
39
40/// The control box a leaf's recipe fixes (padding inside the measured size,
41/// definite sizes taffy already knows). The measurer adds the padding around
42/// the content it shapes and honours `min_*`. `padding_horizontal` /
43/// `padding_vertical` are the AVERAGES of the two sides (a measurer adding
44/// `2 × padding` gets the exact total); `padding` has every side.
45#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
46pub struct ControlBox {
47    pub padding_horizontal: f32,
48    pub padding_vertical: f32,
49    /// `[top, right, bottom, left]`, logical keys resolved by the direction.
50    #[serde(default)]
51    pub padding: [f32; 4],
52    pub border_width: f32,
53    pub gap: f32,
54    pub min_width: Option<f32>,
55    pub min_height: Option<f32>,
56    pub width: Option<f32>,
57    pub height: Option<f32>,
58}
59
60/// One leaf the host must measure. `index` names the layout node; the host
61/// keys its own caches on it. `lines` = the `Text` prop (truncate to N lines).
62#[derive(Debug, Clone)]
63pub struct LeafRequest<'a> {
64    pub index: u32,
65    pub id: &'a str,
66    /// The native kind (`Text`, `Button`, `Input`, `Extension`…).
67    pub component: &'a str,
68    /// The synthetic part of a native when the leaf is one (`Tabs/tab`).
69    pub part: Option<&'a str>,
70    /// The component that owns the part (`Tabs` for `Tabs/tab`; `None` for
71    /// a plain node): a part's chrome (an icon, a check) depends on it.
72    pub owner_component: Option<&'a str>,
73    pub props: &'a Map<String, Value>,
74    pub text_style: &'a TextStyle,
75    pub control: ControlBox,
76    pub lines: Option<u32>,
77}
78
79impl LeafRequest<'_> {
80    /// The text a textual leaf shows.
81    pub fn text(&self) -> &str {
82        self.props
83            .get("text")
84            .or_else(|| self.props.get("label"))
85            .or_else(|| self.props.get("title"))
86            .or_else(|| self.props.get("content"))
87            .or_else(|| self.props.get("placeholder"))
88            .and_then(Value::as_str)
89            .unwrap_or("")
90    }
91
92    /// [`Self::text`] for a painter: the same prop, and a number or boolean
93    /// in it shows as its [`crate::format::display_string`] (`412`, `1.5`,
94    /// `true`), the rule the build applies to bound text props. An FFI host
95    /// gets this as `FfiLeaf.text`.
96    pub fn display_text(&self) -> std::borrow::Cow<'_, str> {
97        let v = ["text", "label", "title", "content", "placeholder"].iter().find_map(|k| self.props.get(*k));
98        match v {
99            Some(Value::String(s)) => std::borrow::Cow::Borrowed(s.as_str()),
100            Some(v @ (Value::Number(_) | Value::Bool(_))) => std::borrow::Cow::Owned(crate::format::display_string(v)),
101            _ => std::borrow::Cow::Borrowed(""),
102        }
103    }
104}
105
106/// What the host answers for one leaf: the width it needs with every line
107/// break taken (`min_content`), with none (`max_content`), the height at
108/// `max_content` width (one line for text) and the FIRST BASELINE from the
109/// top of the border box (`alignItems: baseline`; it does not move with the
110/// width: the first line stays the first line). `None` = no baseline (an
111/// icon, a box): the bottom edge stands in, like CSS. Controls with a fixed
112/// box return it in both widths.
113#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
114pub struct Intrinsics {
115    pub min_content_width: f32,
116    pub max_content_width: f32,
117    pub height_at_max_content: f32,
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub baseline: Option<f32>,
120}
121
122impl Intrinsics {
123    /// R8 F53: a host measurer's answer with every NaN or infinite number
124    /// replaced (0; a baseline that is not finite = none) and the widths
125    /// ordered (`min_content_width <= max_content_width`): the memo clamps
126    /// against them (`f32::clamp` panics on NaN) and taffy sizes by them.
127    /// Non-negative too: a negative width or height is 0.
128    pub fn sanitised(self) -> Intrinsics {
129        let min = finite_or_zero(self.min_content_width);
130        let max = finite_or_zero(self.max_content_width);
131        Intrinsics {
132            min_content_width: min.min(max),
133            max_content_width: max.max(min),
134            height_at_max_content: finite_or_zero(self.height_at_max_content),
135            baseline: self.baseline.filter(|b| b.is_finite()),
136        }
137    }
138}
139
140/// `v` when finite and non-negative, else 0 (R8 F53: a host's NaN, ±inf or
141/// negative size never reaches the memo or taffy).
142pub fn finite_or_zero(v: f32) -> f32 {
143    if v.is_finite() && v > 0.0 {
144        v
145    } else {
146        0.0
147    }
148}
149
150/// Second phase: the height of leaf `index` when laid out `width` wide.
151#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
152pub struct HeightRequest {
153    pub index: u32,
154    pub width: f32,
155}
156
157/// Implemented by the host. Both calls receive batches; the core never calls
158/// them per taffy request.
159pub trait Measure {
160    /// Identity of this measurer (font scale, Dynamic Type state…). Two
161    /// consecutive passes with different ids invalidate every memo.
162    fn measure_id(&self) -> u64 {
163        2
164    }
165    fn measure_intrinsics(&mut self, leaves: &[LeafRequest]) -> Vec<Intrinsics>;
166    fn measure_heights(&mut self, leaves: &[LeafRequest], requests: &[HeightRequest]) -> Vec<f32>;
167}
168
169/// The per-request form for in-process painters (gpui): one closure that
170/// answers `(leaf, wrap_width)` with a size. `None` = unconstrained
171/// (max-content); `Some(0.0)` = min-content. No baselines (see
172/// [`DirectMeasureWithBaseline`]).
173pub struct DirectMeasure<F>(pub F, pub u64);
174
175/// [`DirectMeasure`] whose closure also answers the first baseline:
176/// `(leaf, wrap_width) → (width, height, baseline)`.
177pub struct DirectMeasureWithBaseline<F>(pub F, pub u64);
178
179impl<F: FnMut(&LeafRequest, Option<f32>) -> (f32, f32, Option<f32>)> Measure for DirectMeasureWithBaseline<F> {
180    fn measure_id(&self) -> u64 {
181        self.1
182    }
183
184    fn measure_intrinsics(&mut self, leaves: &[LeafRequest]) -> Vec<Intrinsics> {
185        leaves
186            .iter()
187            .map(|leaf| {
188                let (max_w, h, baseline) = (self.0)(leaf, None);
189                let (min_w, _, _) = (self.0)(leaf, Some(0.0));
190                Intrinsics { min_content_width: min_w, max_content_width: max_w, height_at_max_content: h, baseline }
191            })
192            .collect()
193    }
194
195    fn measure_heights(&mut self, leaves: &[LeafRequest], requests: &[HeightRequest]) -> Vec<f32> {
196        requests.iter().map(|r| leaves.iter().find(|l| l.index == r.index).map(|leaf| (self.0)(leaf, Some(r.width)).1).unwrap_or(0.0)).collect()
197    }
198}
199
200impl<F: FnMut(&LeafRequest, Option<f32>) -> (f32, f32)> Measure for DirectMeasure<F> {
201    fn measure_id(&self) -> u64 {
202        self.1
203    }
204
205    fn measure_intrinsics(&mut self, leaves: &[LeafRequest]) -> Vec<Intrinsics> {
206        leaves
207            .iter()
208            .map(|leaf| {
209                let (max_w, h) = (self.0)(leaf, None);
210                let (min_w, _) = (self.0)(leaf, Some(0.0));
211                Intrinsics { min_content_width: min_w, max_content_width: max_w, height_at_max_content: h, baseline: None }
212            })
213            .collect()
214    }
215
216    fn measure_heights(&mut self, leaves: &[LeafRequest], requests: &[HeightRequest]) -> Vec<f32> {
217        requests
218            .iter()
219            .map(|r| leaves.iter().find(|l| l.index == r.index).map(|leaf| (self.0)(leaf, Some(r.width)).1).unwrap_or(0.0))
220            .collect()
221    }
222}
223
224pub const FIXED_CHAR_WIDTH: f32 = 8.0;
225pub const FIXED_LINE_HEIGHT: f32 = 20.0;
226/// The fixed measure's ascent: a 20 px line's baseline sits 15 px down.
227pub const FIXED_ASCENT: f32 = 15.0;
228
229/// The geometry-test measure every platform reproduces exactly: 8 px per
230/// character on ONE 20 px line (no wrapping), fixed boxes per control, or an
231/// explicit size per node id (`fixtures/layout-geometry.json` `measures`).
232#[derive(Debug, Clone, Default)]
233pub struct FixedMeasure {
234    pub sizes: HashMap<String, (f32, f32)>,
235    /// When true, text leaves WRAP at 8 px per character (for the list and
236    /// overlay tests); the geometry fixture keeps it off.
237    pub wrap: bool,
238}
239
240impl FixedMeasure {
241    pub fn with_sizes(sizes: HashMap<String, (f32, f32)>) -> FixedMeasure {
242        FixedMeasure { sizes, wrap: false }
243    }
244
245    fn chars(s: &str) -> f32 {
246        s.chars().count() as f32
247    }
248
249    /// The intrinsic size of a leaf by kind (the spike's table).
250    pub fn intrinsic(&self, leaf: &LeafRequest) -> (f32, f32) {
251        if let Some(size) = self.sizes.get(leaf.id) {
252            return *size;
253        }
254        let text = leaf.text();
255        let str_prop = |k: &str| leaf.props.get(k).and_then(Value::as_str).unwrap_or("");
256        let c = leaf.control;
257        let (w, h) = match leaf.component {
258            // A host text field inside a control (NumberField/ChipInput
259            // `input`, Select `search`): one line of its text or placeholder.
260            _ if matches!(leaf.part, Some("input") | Some("search")) => {
261                let shown = Self::chars(text).max(Self::chars(str_prop("placeholder")));
262                (FIXED_CHAR_WIDTH * shown, c.height.unwrap_or(FIXED_LINE_HEIGHT))
263            }
264            "Text" | "Link" if text.contains('\n') => {
265                let lines = text.split('\n').count() as f32;
266                let widest = text.split('\n').map(Self::chars).fold(0.0, f32::max);
267                (FIXED_CHAR_WIDTH * widest, leaf.text_style.line_height.max(FIXED_LINE_HEIGHT) * lines)
268            }
269            "Text" | "Link" => (FIXED_CHAR_WIDTH * Self::chars(text), leaf.text_style.line_height.max(FIXED_LINE_HEIGHT)),
270            "Markdown" => {
271                let lines = text.lines().count().max(1) as f32;
272                let widest = text.lines().map(Self::chars).fold(0.0, f32::max);
273                (FIXED_CHAR_WIDTH * widest, FIXED_LINE_HEIGHT * lines)
274            }
275            // EXP-1259: a loading Button never shrinks below its idle width
276            // and grows to fit `pendingLabel`.
277            "Button" if leaf.props.get("loading").and_then(Value::as_bool) == Some(true) => {
278                let chars = Self::chars(str_prop("label")).max(Self::chars(str_prop("pendingLabel")));
279                (FIXED_CHAR_WIDTH * chars + 24.0, c.height.unwrap_or(36.0))
280            }
281            "Button" | "Toggle" => (FIXED_CHAR_WIDTH * Self::chars(str_prop("label")) + 24.0, c.height.unwrap_or(36.0)),
282            "SearchList" if leaf.part.is_none() => (240.0, 40.0 + FIXED_LINE_HEIGHT * leaf.props.get("items").and_then(Value::as_array).map_or(0, Vec::len).min(8) as f32),
283            "Input" | "Select" | "DatePicker" => (160.0, c.height.unwrap_or(36.0)),
284            "Textarea" if leaf.part.is_none() || leaf.part == Some("field") => (160.0, c.height.unwrap_or_else(|| Self::textarea_height(leaf))),
285            "Textarea" | "Composer" => (160.0, c.height.unwrap_or(72.0)),
286            "NumberField" | "TimePicker" | "DateRangePicker" | "ChipInput" if leaf.part.is_none() => (160.0, c.height.unwrap_or(36.0)),
287            "CodeBlock" if leaf.part.is_none() => {
288                let code = str_prop("code");
289                let lines = code.split('\n').count().max(1) as f32;
290                let widest = code.split('\n').map(Self::chars).fold(0.0, f32::max);
291                (FIXED_CHAR_WIDTH * widest, FIXED_LINE_HEIGHT * lines)
292            }
293            "Chart" => (320.0, leaf.props.get("height").and_then(Value::as_f64).unwrap_or(200.0) as f32),
294            // EXP-1280: an unexpanded ChoiceTiles (geometry mode) = its grid
295            // of tiles at 320 px: per row a picture (when any option has
296            // one, 1.6 : 1) over two lines, 8 px padding, 8 px gaps.
297            "ChoiceTiles" if leaf.part.is_none() => {
298                let options = leaf.props.get("options").and_then(Value::as_array);
299                let n = options.map_or(0, Vec::len) as f32;
300                let cols = leaf.props.get("columns").and_then(Value::as_f64).unwrap_or(2.0).round().clamp(1.0, 4.0) as f32;
301                let rows = (n / cols).ceil();
302                let pictures = options.is_some_and(|o| o.iter().any(|o| o.get("image").and_then(Value::as_str).is_some_and(|s| !s.is_empty())));
303                let tile_w = (320.0 - 8.0 * (cols - 1.0)) / cols - 18.0;
304                let tile_h = if pictures { tile_w / 1.6 + 4.0 } else { 0.0 } + 2.0 * FIXED_LINE_HEIGHT + 4.0 + 18.0;
305                (320.0, (rows * tile_h + (rows - 1.0).max(0.0) * 8.0).max(0.0))
306            }
307            "Switch" => (44.0, 24.0),
308            "Checkbox" | "Radio" => (16.0, 16.0),
309            "Avatar" => {
310                let s = c.width.unwrap_or(32.0);
311                (s, s)
312            }
313            "Icon" => (c.width.unwrap_or(16.0), c.height.unwrap_or(16.0)),
314            "Image" | "Video" => (320.0, 180.0),
315            // EXP-1604: the composition's px size (a `src` animation: none).
316            "Motion" => crate::layout_tree::motion_size(&leaf.props).map_or((0.0, 0.0), |(w, h)| (w as f32, h as f32)),
317            "Slider" => (160.0, 16.0),
318            "Spinner" | "Ring" => (c.width.unwrap_or(20.0), c.height.unwrap_or(20.0)),
319            "Skeleton" => (
320                leaf.props.get("width").and_then(Value::as_f64).unwrap_or(120.0) as f32,
321                leaf.props.get("height").and_then(Value::as_f64).unwrap_or(16.0) as f32,
322            ),
323            _ => (c.width.unwrap_or(0.0), c.height.unwrap_or(0.0)),
324        };
325        (c.width.unwrap_or(w).max(c.min_width.unwrap_or(0.0)), c.height.unwrap_or(h).max(c.min_height.unwrap_or(0.0)))
326    }
327
328    /// `rows` lines (autosize: the value's lines clamped to `rows..maxRows`)
329    /// of 20 px plus the vertical padding.
330    fn textarea_height(leaf: &LeafRequest) -> f32 {
331        let rows = leaf.props.get("rows").and_then(Value::as_f64).unwrap_or(3.0).max(1.0) as f32;
332        let mut lines = rows;
333        if leaf.props.get("autosize").and_then(Value::as_bool) == Some(true) {
334            let value = leaf.props.get("value").and_then(Value::as_str).unwrap_or("");
335            let typed = value.split('\n').count() as f32;
336            let max = leaf.props.get("maxRows").and_then(Value::as_f64).map(|m| m as f32).unwrap_or(f32::INFINITY);
337            lines = typed.max(rows).min(max.max(rows));
338        }
339        lines * FIXED_LINE_HEIGHT + leaf.control.padding[0] + leaf.control.padding[2]
340    }
341
342    /// The fixed baseline: the first 20 px line's ascent, centred in a
343    /// control's box; none for boxes without text.
344    pub fn baseline(&self, leaf: &LeafRequest, height: f32) -> Option<f32> {
345        match leaf.component {
346            "Text" | "Link" | "Markdown" | "CodeBlock" => Some(leaf.control.padding[0] + FIXED_ASCENT),
347            "Checkbox" | "Radio" | "Switch" | "Avatar" | "Icon" | "Image" | "Video" | "Slider" | "Spinner" | "Ring" | "Skeleton" | "Chart" | "Motion" => None,
348            _ if leaf.text().is_empty() => None,
349            _ => Some(((height - FIXED_LINE_HEIGHT) / 2.0).max(0.0) + FIXED_ASCENT),
350        }
351    }
352
353    fn wraps(&self, leaf: &LeafRequest) -> bool {
354        self.wrap && !self.sizes.contains_key(leaf.id) && matches!(leaf.component, "Text" | "Markdown")
355    }
356}
357
358impl Measure for FixedMeasure {
359    fn measure_id(&self) -> u64 {
360        1
361    }
362
363    fn measure_intrinsics(&mut self, leaves: &[LeafRequest]) -> Vec<Intrinsics> {
364        leaves
365            .iter()
366            .map(|leaf| {
367                let (w, h) = self.intrinsic(leaf);
368                let min = if self.wraps(leaf) {
369                    leaf.text().split_whitespace().map(Self::chars).fold(0.0, f32::max) * FIXED_CHAR_WIDTH
370                } else {
371                    w
372                };
373                let baseline = if self.sizes.contains_key(leaf.id) { None } else { self.baseline(leaf, h) };
374                Intrinsics { min_content_width: min, max_content_width: w, height_at_max_content: h, baseline }
375            })
376            .collect()
377    }
378
379    fn measure_heights(&mut self, leaves: &[LeafRequest], requests: &[HeightRequest]) -> Vec<f32> {
380        requests
381            .iter()
382            .map(|r| {
383                let Some(leaf) = leaves.iter().find(|l| l.index == r.index) else { return 0.0 };
384                let (w, h) = self.intrinsic(leaf);
385                if !self.wraps(leaf) || r.width >= w || r.width <= 0.0 {
386                    return h;
387                }
388                let per_line = (r.width / FIXED_CHAR_WIDTH).floor().max(1.0);
389                let lines = (Self::chars(leaf.text()) / per_line).ceil().max(1.0);
390                let lines = match leaf.lines {
391                    Some(n) if n > 0 => lines.min(n as f32),
392                    _ => lines,
393                };
394                lines * FIXED_LINE_HEIGHT
395            })
396            .collect()
397    }
398}
399
400/// The Rust-side memo taffy's measure closure answers from. Keyed by layout
401/// node index; `content_version` bumps clear a leaf's entries.
402#[derive(Debug, Default, Clone)]
403pub struct MeasureMemo {
404    pub intrinsics: HashMap<u32, Intrinsics>,
405    /// index → (width in 1/64 px, height) pairs. VAPP-132: by index, so
406    /// forgetting a leaf costs its own entries, not every leaf's (a
407    /// 10,000-item rebind forgot 10,000 leaves one full scan each).
408    heights: HashMap<u32, Vec<(i64, f32)>>,
409}
410
411impl MeasureMemo {
412    pub fn width_key(width: f32) -> i64 {
413        (width * 64.0).round() as i64
414    }
415
416    pub fn clear(&mut self) {
417        self.intrinsics.clear();
418        self.heights.clear();
419    }
420
421    pub fn forget(&mut self, index: u32) {
422        self.intrinsics.remove(&index);
423        self.heights.remove(&index);
424    }
425
426    pub fn height_at(&self, index: u32, width: f32) -> Option<f32> {
427        let key = Self::width_key(width);
428        self.heights.get(&index)?.iter().find(|(w, _)| *w == key).map(|(_, h)| *h)
429    }
430
431    /// Remember the height at a width key ([`Self::width_key`]).
432    pub fn insert_height(&mut self, index: u32, width_key: i64, height: f32) {
433        let at = self.heights.entry(index).or_default();
434        match at.iter_mut().find(|(w, _)| *w == width_key) {
435            Some(entry) => entry.1 = height,
436            None => at.push((width_key, height)),
437        }
438    }
439
440    /// Every (width key, height) remembered for a leaf.
441    pub fn heights_of(&self, index: u32) -> &[(i64, f32)] {
442        self.heights.get(&index).map(Vec::as_slice).unwrap_or(&[])
443    }
444}
445
446/// A measured leaf's answer for one taffy request, from the memo. `width`
447/// taffy already knows wins; else the available space picks min/max content;
448/// a definite available width clamps max-content (text wraps).
449#[derive(Debug, Clone, Copy)]
450pub struct MeasureRequest {
451    pub index: u32,
452    pub known_width: Option<f32>,
453    pub known_height: Option<f32>,
454    pub available_width: taffy::AvailableSpace,
455    pub available_height: taffy::AvailableSpace,
456}
457
458impl MeasureMemo {
459    /// Answer from the memo. The second value says whether the HEIGHT was a
460    /// guess (the max-content height at a narrower width), i.e. a second
461    /// phase must ask the host for it; the third is the leaf's first
462    /// baseline (`Intrinsics::baseline`).
463    pub fn answer(&self, req: &MeasureRequest) -> (taffy::geometry::Size<f32>, bool, Option<f32>) {
464        let Some(i) = self.intrinsics.get(&req.index) else {
465            return (taffy::geometry::Size { width: req.known_width.unwrap_or(0.0), height: req.known_height.unwrap_or(0.0) }, false, None);
466        };
467        let width = req.known_width.unwrap_or(match req.available_width {
468            // CSS: a definite available width shrinks the content down to its
469            // min-content and no further (the box overflows instead).
470            taffy::AvailableSpace::Definite(w) => w.clamp(i.min_content_width.min(i.max_content_width), i.max_content_width),
471            taffy::AvailableSpace::MinContent => i.min_content_width,
472            taffy::AvailableSpace::MaxContent => i.max_content_width,
473        });
474        if let Some(h) = req.known_height {
475            return (taffy::geometry::Size { width, height: h }, false, i.baseline);
476        }
477        // No line-break opportunity (min == max) = the height never changes
478        // with the width: no second phase.
479        if width + 0.01 >= i.max_content_width || (i.max_content_width - i.min_content_width).abs() < 0.01 {
480            return (taffy::geometry::Size { width, height: i.height_at_max_content }, false, i.baseline);
481        }
482        match self.height_at(req.index, width) {
483            Some(h) => (taffy::geometry::Size { width, height: h }, false, i.baseline),
484            None => (taffy::geometry::Size { width, height: i.height_at_max_content }, true, i.baseline),
485        }
486    }
487}