|
| 1 | +//! How wide a tab is (UI/UX v3 N-3a). |
| 2 | +//! |
| 3 | +//! The tab bar sizes a tab, records its click region, places its close and |
| 4 | +//! tear-out buttons, and scales its accent underline and progress bar from a |
| 5 | +//! single number. Until N-3 that number was `label.chars().count() * cell_w` — |
| 6 | +//! a count of *characters*, while `add_string_verts` advanced by *display |
| 7 | +//! width*. The two disagree by a factor of two for CJK, so a Japanese tab title |
| 8 | +//! drew past its own pill and the overhanging half of the label belonged, for |
| 9 | +//! click purposes, to the next tab. |
| 10 | +//! |
| 11 | +//! This module is where that number comes from now: [`tab_width`] measures the |
| 12 | +//! label the same way the drawing pass will, and [`fit_tab_width`] — pure, so |
| 13 | +//! the clamp and the floor are testable without a device — decides whether what |
| 14 | +//! is left of the strip can hold it. |
| 15 | +//! |
| 16 | +//! N-3a ships the two functions and their tests; the tab bar adopts them in |
| 17 | +//! N-3b, which is when the seven consumers of the width start following a |
| 18 | +//! correct one. |
| 19 | +// Every item here is called for the first time in N-3b, when |
| 20 | +// `ui_verts::build_tab_bar_verts` stops counting characters. The allowance is |
| 21 | +// this module's whole reason for shipping early, and it goes with that change — |
| 22 | +// P4b's identical markers outlived their adoption and had to be cleaned up in |
| 23 | +// P4c, so this one names the PR that removes it. |
| 24 | +#![allow(dead_code)] |
| 25 | + |
| 26 | +use crate::font::FontManager; |
| 27 | +use crate::vertex_util::measure_run; |
| 28 | + |
| 29 | +/// The glyph a truncated label ends with. |
| 30 | +/// |
| 31 | +/// A tab must always have room for this plus its padding, which is the floor |
| 32 | +/// the maintainer signed off on 2026-08-30 (spec §7): a tab too narrow to show |
| 33 | +/// even "there was more text here" is not worth the strip space, and the rule |
| 34 | +/// describes itself instead of naming a pixel count that a HiDPI display would |
| 35 | +/// falsify. |
| 36 | +pub(crate) const ELLIPSIS: &str = "…"; |
| 37 | + |
| 38 | +/// Narrowest tab worth drawing: an ellipsis plus the padding either side. |
| 39 | +/// |
| 40 | +/// Measured rather than assumed, for the same reason everything else in N-3 is: |
| 41 | +/// the ellipsis is a glyph like any other, and on a font that lacks it the |
| 42 | +/// advance is zero — in which case the floor is just the padding, and a tab |
| 43 | +/// that small still draws nothing legible but costs nothing either. |
| 44 | +pub(crate) fn min_tab_width( |
| 45 | + style: &nexterm_config::TypeStyle, |
| 46 | + padding: f32, |
| 47 | + font: &mut FontManager, |
| 48 | +) -> f32 { |
| 49 | + measure_run(ELLIPSIS, style, font) + padding * 2.0 |
| 50 | +} |
| 51 | + |
| 52 | +/// Fit a tab of `content_w` into the room left in the strip. |
| 53 | +/// |
| 54 | +/// Returns the width to draw, or `None` when the strip cannot hold even |
| 55 | +/// `min_w` — the caller stops laying out tabs at that point, which is what the |
| 56 | +/// old `label_w < cell_w * 2.0` break did. |
| 57 | +/// |
| 58 | +/// Pure: the caller measures. `content_w` is the measured label (plus any icon |
| 59 | +/// drawn beside it), `padding` is applied once on each side, and the result is |
| 60 | +/// clamped to `room_left` so a tab never spills out of the strip — the clamp |
| 61 | +/// the character-count formula also had, and the only part of it that was |
| 62 | +/// right. |
| 63 | +pub(crate) fn fit_tab_width( |
| 64 | + content_w: f32, |
| 65 | + padding: f32, |
| 66 | + room_left: f32, |
| 67 | + min_w: f32, |
| 68 | +) -> Option<f32> { |
| 69 | + let floor = min_w.max(0.0); |
| 70 | + if room_left < floor || room_left <= 0.0 { |
| 71 | + return None; |
| 72 | + } |
| 73 | + let wanted = content_w.max(0.0) + padding * 2.0; |
| 74 | + Some(wanted.clamp(floor, room_left)) |
| 75 | +} |
| 76 | + |
| 77 | +/// Width of one tab: its label measured at `style`, plus `icon_w` for a |
| 78 | +/// process icon drawn beside it, fitted to the room left. |
| 79 | +/// |
| 80 | +/// The measurement goes through [`measure_run`], the same function |
| 81 | +/// `truncate_run_to_width` and `add_run_verts` use, so the width a tab is sized |
| 82 | +/// by and the width its label is drawn at cannot disagree — whatever the font |
| 83 | +/// reports for any particular glyph (spec §4, `G-width`). |
| 84 | +pub(crate) fn tab_width( |
| 85 | + label: &str, |
| 86 | + style: &nexterm_config::TypeStyle, |
| 87 | + icon_w: f32, |
| 88 | + padding: f32, |
| 89 | + room_left: f32, |
| 90 | + font: &mut FontManager, |
| 91 | +) -> Option<f32> { |
| 92 | + let content_w = measure_run(label, style, font) + icon_w.max(0.0); |
| 93 | + let min_w = min_tab_width(style, padding, font); |
| 94 | + fit_tab_width(content_w, padding, room_left, min_w) |
| 95 | +} |
| 96 | + |
| 97 | +#[cfg(test)] |
| 98 | +mod tests { |
| 99 | + use super::*; |
| 100 | + |
| 101 | + const PADDING: f32 = 10.0; |
| 102 | + const MIN: f32 = 25.0; |
| 103 | + |
| 104 | + #[test] |
| 105 | + fn a_tab_is_its_content_plus_padding_on_both_sides() { |
| 106 | + assert_eq!(fit_tab_width(100.0, PADDING, 1000.0, MIN), Some(120.0)); |
| 107 | + } |
| 108 | + |
| 109 | + /// The clamp the character-count formula also had — the one part of it that |
| 110 | + /// was right. A tab never spills past the end of the strip. |
| 111 | + #[test] |
| 112 | + fn a_tab_never_exceeds_the_room_left() { |
| 113 | + assert_eq!(fit_tab_width(500.0, PADDING, 200.0, MIN), Some(200.0)); |
| 114 | + } |
| 115 | + |
| 116 | + /// The floor, as signed off in spec §7: a strip that cannot hold an |
| 117 | + /// ellipsis and its padding holds no more tabs. This is what replaces |
| 118 | + /// `label_w < cell_w * 2.0`, which had no meaning once text stopped being |
| 119 | + /// measured in cells. |
| 120 | + #[test] |
| 121 | + fn a_strip_too_narrow_for_the_floor_draws_no_further_tab() { |
| 122 | + assert_eq!(fit_tab_width(100.0, PADDING, MIN - 0.5, MIN), None); |
| 123 | + assert_eq!(fit_tab_width(100.0, PADDING, MIN, MIN), Some(MIN)); |
| 124 | + } |
| 125 | + |
| 126 | + /// A tab whose content is narrower than the floor still gets the floor, so |
| 127 | + /// a one-character title cannot produce a pill too small to click. |
| 128 | + #[test] |
| 129 | + fn a_tiny_label_is_widened_to_the_floor() { |
| 130 | + assert_eq!(fit_tab_width(0.0, 1.0, 1000.0, MIN), Some(MIN)); |
| 131 | + } |
| 132 | + |
| 133 | + /// Degenerate inputs must not produce a negative or reversed rect: a |
| 134 | + /// collapsed strip draws nothing, and a negative content width is treated |
| 135 | + /// as empty rather than eating the padding. |
| 136 | + #[test] |
| 137 | + fn degenerate_inputs_are_refused_rather_than_inverted() { |
| 138 | + assert_eq!(fit_tab_width(100.0, PADDING, 0.0, MIN), None); |
| 139 | + assert_eq!(fit_tab_width(100.0, PADDING, -10.0, MIN), None); |
| 140 | + assert_eq!(fit_tab_width(-100.0, PADDING, 1000.0, 0.0), Some(20.0)); |
| 141 | + } |
| 142 | + |
| 143 | + /// The floor is measured, not assumed. This runs on whatever font stack CI |
| 144 | + /// has — which answers the same advance for every character (spec §6) — so |
| 145 | + /// it asserts the shape of the rule, not a number: the floor is the |
| 146 | + /// padding plus something non-negative, and more padding moves it. |
| 147 | + #[test] |
| 148 | + fn the_floor_is_the_measured_ellipsis_plus_padding() { |
| 149 | + let mut font = FontManager::new("monospace", 14.0, &[], 1.0, true); |
| 150 | + let style = nexterm_config::MetricTokens::default().type_ramp.body; |
| 151 | + |
| 152 | + let narrow = min_tab_width(&style, 4.0, &mut font); |
| 153 | + let wide = min_tab_width(&style, 12.0, &mut font); |
| 154 | + |
| 155 | + assert!(narrow >= 8.0, "the floor includes its padding: {narrow}"); |
| 156 | + assert!( |
| 157 | + (wide - narrow - 16.0).abs() < 1e-3, |
| 158 | + "extra padding must move the floor by exactly twice: {narrow} → {wide}" |
| 159 | + ); |
| 160 | + } |
| 161 | + |
| 162 | + /// G-width in miniature: the width a tab is *sized* by comes from the same |
| 163 | + /// `measure_run` the drawing pass uses, so the two cannot disagree. Phrased |
| 164 | + /// as an equality between paths rather than as a claim about CJK metrics — |
| 165 | + /// CI's font stack has no real CJK face (spec §6), so the latter would pass |
| 166 | + /// for the wrong reason. |
| 167 | + #[test] |
| 168 | + fn a_tabs_width_contains_the_run_that_will_be_drawn_in_it() { |
| 169 | + let mut font = FontManager::new("monospace", 14.0, &[], 1.0, true); |
| 170 | + let style = nexterm_config::MetricTokens::default().type_ramp.body; |
| 171 | + |
| 172 | + for label in ["pane:1", "ビルド", "a very long tab title indeed"] { |
| 173 | + let drawn = measure_run(label, &style, &mut font); |
| 174 | + let width = tab_width(label, &style, 0.0, PADDING, 10_000.0, &mut font) |
| 175 | + .expect("a wide strip fits any tab"); |
| 176 | + assert!( |
| 177 | + width >= drawn, |
| 178 | + "{label:?} is drawn {drawn} wide in a {width}-wide tab" |
| 179 | + ); |
| 180 | + } |
| 181 | + } |
| 182 | + |
| 183 | + /// A process icon widens the tab it sits in — the icon is drawn beside the |
| 184 | + /// label (spec D3), so its width has to be in the tab's. |
| 185 | + #[test] |
| 186 | + fn a_process_icon_widens_its_tab() { |
| 187 | + let mut font = FontManager::new("monospace", 14.0, &[], 1.0, true); |
| 188 | + let style = nexterm_config::MetricTokens::default().type_ramp.body; |
| 189 | + |
| 190 | + let bare = tab_width("build", &style, 0.0, PADDING, 10_000.0, &mut font); |
| 191 | + let with_icon = tab_width("build", &style, 16.0, PADDING, 10_000.0, &mut font); |
| 192 | + assert_eq!( |
| 193 | + with_icon.zip(bare).map(|(a, b)| a - b), |
| 194 | + Some(16.0), |
| 195 | + "the icon's width lands in the tab's, unrounded" |
| 196 | + ); |
| 197 | + } |
| 198 | +} |
0 commit comments