makeover_geometry/lib.rs
1//! The invariant half of the make-family design system.
2//!
3//! <!-- wiki: makeover-geometry -->
4//!
5//! [`makeover`] resolves colour, which varies by theme. This crate carries
6//! everything that does not: spacing, radius, border width and the type scale.
7//! The split is the same one Balanced Breakfast's theme contract has always
8//! drawn — *a theme overrides colour tokens only* — moved out of two app
9//! stylesheets so the three consumers stop maintaining three copies of it.
10//!
11//! # Spacing is relational, not numeric
12//!
13//! The Mac OS 8 Human Interface Guidelines specify white space by *what two
14//! things are being separated*, never by a size name, and define no base grid
15//! unit. A control and its satellite pop-up are set 4 pixels apart; peers
16//! stacked in a list get 6; a group box's inner margin is 10; separated groups
17//! and rows of push buttons get 12.
18//!
19//! That vocabulary is the primary interface here. [`Gap`] names the
20//! relationship and the size follows from it, exactly as `surface-raised`
21//! names an intent and the hex follows from it. The raw [`Step`] scale exists
22//! underneath for distances a relationship does not describe, but reaching for
23//! it is a smell worth a second look.
24//!
25//! Naming the relationship is what makes the rule reviewable. Whether a gap
26//! should be 6px or 8px is unanswerable in isolation; whether two things are
27//! peers is not.
28//!
29//! # Ratios, not pixel counts
30//!
31//! This is the deliberate departure from the HIG, which is specified in hard
32//! device pixels because in 1997 there was one pixel density and one text
33//! size. Every [`Step`] here is a [`Ratio`] of a single base unit, so the
34//! whole system scales from one knob: `--geometry-base`, `1rem` by default.
35//!
36//! At the default base the ratios land exactly on the HIG's numbers — `Snug`
37//! is three eighths of 16px, which is 6px — so nothing is lost in the
38//! translation. What is gained is that the layout tracks the user's text size
39//! instead of fighting it, an accessibility setting becomes one value rather
40//! than a sweep, and the scale means the same thing at any display density.
41//!
42//! # Density presets
43//!
44//! Naming relationships instead of sizes is what makes a density preset
45//! possible at all. [`Density`] changes what each relationship resolves to
46//! without touching a single call site, because no call site names a size.
47//! The mobile and desktop builds of a Tauri app should differ mostly by which
48//! preset they emit, not by a parallel set of hand-written mode-scoped rules.
49//!
50//! The two presets are not one scaled copy of the other, and the asymmetry is
51//! the point — it is also why a base scalar alone cannot express this. Touch
52//! needs *more* room between things you tap, because a fingertip is coarser
53//! than a pointer, and *less* room around the edges, because the screen is
54//! small and outer margin is screen you do not get to use. So `Peer` and
55//! `Section` open up under [`Density::Touch`] while `Pane` and `Page` tighten.
56//! `Bound` never moves: it is the one relationship that says "these are one
57//! object", and separating them on touch would say the opposite.
58//!
59//! # Surfaces, and why a TUI is not a third density
60//!
61//! [`Surface`] is the third axis and the one that carries this to alloy_tui. A
62//! terminal is not a density preset; it is a surface whose smallest
63//! representable step is one cell rather than one pixel. Give
64//! [`Ratio::quanta`] a quantum and it answers in whole units of it, so the
65//! relational vocabulary crosses to a character grid with nothing added.
66//!
67//! Quantising is not a terminal special case either — a display quantises to
68//! the pixel. It is only that rounding 6.0 to the nearest pixel is
69//! uninteresting, while rounding three eighths of a cell to the nearest cell
70//! decides the layout.
71//!
72//! On [`Surface::terminal`] the pointer preset resolves to 0, 0, 1, 1, 2, 2
73//! cells. `Bound` and `Peer` collapsing to nothing is correct rather than lossy:
74//! in a grid that dense, both relationships are expressed by adjacency. A
75//! coarse surface genuinely has fewer distinctions available, and the model
76//! should say so instead of inventing a gap to keep six names distinct.
77//!
78//! So the three axes are: [`Gap`] is what is being separated, [`Density`] is
79//! who is operating it, [`Surface`] is what it is drawn on.
80
81#![forbid(unsafe_code)]
82
83use std::fmt::Write as _;
84
85/// The default base unit in CSS pixels, at a 16px root font size.
86pub const DEFAULT_BASE_PX: u16 = 16;
87
88/// The CSS custom property every ratio scales from.
89pub const BASE_TOKEN: &str = "geometry-base";
90
91/// A fraction of the base unit.
92///
93/// Rational rather than floating point so the scale is exact, comparable and
94/// usable in a `const`. At the default base every ratio below divides evenly.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
96pub struct Ratio {
97 /// Top of the fraction.
98 pub numerator: u16,
99 /// Bottom of the fraction. Never zero for any ratio this crate defines.
100 pub denominator: u16,
101}
102
103impl Ratio {
104 /// Resolve against a base measured in whole pixels, rounding to nearest.
105 ///
106 /// Integer maths throughout, and exact for every [`Step`] at
107 /// [`DEFAULT_BASE_PX`] because the scale is eighths. This is
108 /// [`Self::quanta`] with a quantum of one pixel, kept separate only so the
109 /// common case stays `const`.
110 #[must_use]
111 pub const fn px_at(self, base_px: u16) -> u16 {
112 let (n, d) = (self.numerator as u32, self.denominator as u32);
113 let scaled = base_px as u32 * n;
114 // Round half away from zero without leaving integer arithmetic.
115 ((scaled * 2 + d) / (d * 2)) as u16
116 }
117
118 /// Resolve against an arbitrary base, keeping the fraction.
119 ///
120 /// The exact value, before any surface gets a say. Prefer
121 /// [`Surface::resolve`] unless you specifically want the unsnapped number.
122 #[must_use]
123 pub fn scale(self, base: f32) -> f32 {
124 base * f32::from(self.numerator) / f32::from(self.denominator)
125 }
126
127 /// How many whole quanta this ratio is worth on a surface whose smallest
128 /// representable step is `quantum`.
129 ///
130 /// The generalisation of "round to a pixel". A display quantises to one
131 /// pixel and the answer is usually uninteresting; a terminal quantises to
132 /// one cell and the answer is the whole design. Rounds to nearest, and
133 /// does not floor at one: a gap that lands below half a quantum should
134 /// collapse to nothing, because on that surface it *is* nothing.
135 ///
136 /// A `quantum` that is zero, negative or not finite yields `0` rather than
137 /// panicking or returning infinity — a surface with no smallest step is a
138 /// caller error, not a layout to guess at.
139 #[must_use]
140 pub fn quanta(self, base: f32, quantum: f32) -> u32 {
141 if !quantum.is_finite() || quantum <= 0.0 || !base.is_finite() {
142 return 0;
143 }
144 let exact = self.scale(base) / quantum;
145 if exact <= 0.0 {
146 0
147 } else {
148 // `as` saturates at the integer bound, so a wild base cannot wrap.
149 exact.round() as u32
150 }
151 }
152
153 /// Resolve against a base and snap to a whole number of `quantum`.
154 ///
155 /// The value [`Self::quanta`] counts, back in the surface's own units.
156 /// Guards the degenerate quantum in its own right rather than leaning on
157 /// [`Self::quanta`]: a count of zero times a non-finite quantum is NaN,
158 /// not zero.
159 #[must_use]
160 pub fn quantize(self, base: f32, quantum: f32) -> f32 {
161 if !quantum.is_finite() || quantum <= 0.0 || !base.is_finite() {
162 return 0.0;
163 }
164 self.quanta(base, quantum) as f32 * quantum
165 }
166
167 /// The CSS value, as an expression over [`BASE_TOKEN`].
168 ///
169 /// A whole multiple of the base emits without a division, and 1:1 emits
170 /// the bare `var()`, because `calc(var(--geometry-base) * 1 / 1)` is noise.
171 #[must_use]
172 pub fn css(self) -> String {
173 match (self.numerator, self.denominator) {
174 (n, d) if n == d => format!("var(--{BASE_TOKEN})"),
175 (n, 1) => format!("calc(var(--{BASE_TOKEN}) * {n})"),
176 (n, d) => format!("calc(var(--{BASE_TOKEN}) * {n} / {d})"),
177 }
178 }
179}
180
181/// What the layout is being drawn on: a base unit, and the smallest step the
182/// surface can actually represent.
183///
184/// Both are in the surface's own units, and the crate never assumes those are
185/// pixels. A display measures in pixels and can represent one of them; a
186/// terminal measures in cells and cannot represent less than one. That single
187/// difference is the whole of the terminal story — a TUI is not a density, it
188/// is a surface with a coarse quantum, and the relational vocabulary above
189/// crosses over untouched.
190///
191/// Quantising is not a terminal special case. A display does it too; it is
192/// just that rounding 6.0 to the nearest pixel is uninteresting, whereas
193/// rounding three eighths of a cell to the nearest cell is a design decision
194/// the surface makes for you.
195#[derive(Debug, Clone, Copy, PartialEq)]
196pub struct Surface {
197 /// The base unit, in this surface's units.
198 pub base: f32,
199 /// The smallest step this surface can represent, in the same units.
200 pub quantum: f32,
201}
202
203impl Surface {
204 /// A display measuring in CSS pixels: a 16px base, one-pixel quantum.
205 #[must_use]
206 pub fn web() -> Self {
207 Self {
208 base: f32::from(DEFAULT_BASE_PX),
209 quantum: 1.0,
210 }
211 }
212
213 /// A terminal measuring in cells: a one-cell base, one-cell quantum.
214 ///
215 /// The coarsest surface in the family, and the one that proves the
216 /// vocabulary. `bound` and `peer` collapse to no cells at all, which is
217 /// correct — in a grid this dense, "belongs to" and "is a peer of" are
218 /// both expressed by adjacency, not by a gap.
219 #[must_use]
220 pub fn terminal() -> Self {
221 Self {
222 base: 1.0,
223 quantum: 1.0,
224 }
225 }
226
227 /// Resolve a ratio on this surface, snapped to its quantum.
228 #[must_use]
229 pub fn resolve(self, ratio: Ratio) -> f32 {
230 ratio.quantize(self.base, self.quantum)
231 }
232
233 /// How many whole quanta a ratio is worth here.
234 ///
235 /// What a cell-addressed layout actually wants: the count, not the size.
236 #[must_use]
237 pub fn quanta(self, ratio: Ratio) -> u32 {
238 ratio.quanta(self.base, self.quantum)
239 }
240
241 /// Resolve a relationship on this surface at a given density, in quanta.
242 ///
243 /// The whole model in one call: *what* is being separated, *who* is
244 /// operating it, *what* it is drawn on.
245 #[must_use]
246 pub fn gap(self, gap: Gap, density: Density) -> u32 {
247 self.quanta(gap.step_at(density).ratio())
248 }
249}
250
251/// Which input the layout is being sized for.
252///
253/// A preset, not a breakpoint, and orthogonal to [`Surface`]: density decides
254/// which step a relationship picks, the surface decides how that step lands.
255/// Which density applies is the app's call — GoingsOn and Balanced Breakfast
256/// already decide it once and hang a `ui-mode-*` class off the result.
257#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
258pub enum Density {
259 /// Mouse or trackpad. Resolves to the Mac OS 8 HIG's own proportions.
260 #[default]
261 Pointer,
262 /// Finger. Targets separate, shells tighten.
263 Touch,
264}
265
266/// A named separation between two things.
267///
268/// Pick by relationship. The size is a consequence of the name, not the other
269/// way round, and callers should never care what it is.
270#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
271pub enum Gap {
272 /// A control and the thing it belongs to: an edit field and its pop-up, a
273 /// checkbox and its label, an icon and the text it labels. Reads as one
274 /// object.
275 Bound,
276 /// Items of the same kind in a list: stacked checkboxes, radio buttons,
277 /// rows, chips in a row. Reads as a set.
278 Peer,
279 /// A container's inner margin, and the distance between sibling groups
280 /// side by side. Reads as "inside this box".
281 Group,
282 /// Separated groups, and rows of actions. The first gap that reads as a
283 /// deliberate break rather than as breathing room.
284 Section,
285 /// Panel padding and content shells. Layout, not controls.
286 Pane,
287 /// The outermost shell margin. One per screen, usually.
288 Page,
289}
290
291/// A raw step on the underlying scale.
292///
293/// Present because not every distance is a relationship between two controls —
294/// an optical nudge inside a badge is not a `Gap`. Prefer [`Gap`] wherever one
295/// fits: a step name says how big, a gap name says why.
296#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
297pub enum Step {
298 /// An eighth of the base. Optical nudges inside small inline elements.
299 Hair,
300 /// A quarter of the base.
301 Tight,
302 /// Three eighths of the base.
303 Snug,
304 /// Half the base.
305 Base,
306 /// Five eighths of the base.
307 Roomy,
308 /// Three quarters of the base.
309 Wide,
310 /// The base itself.
311 Loose,
312 /// One and a half times the base.
313 Broad,
314 /// Twice the base.
315 Vast,
316 /// Three times the base.
317 Colossal,
318}
319
320impl Step {
321 /// This step as a fraction of the base unit.
322 #[must_use]
323 pub const fn ratio(self) -> Ratio {
324 let (numerator, denominator) = match self {
325 Self::Hair => (1, 8),
326 Self::Tight => (1, 4),
327 Self::Snug => (3, 8),
328 Self::Base => (1, 2),
329 Self::Roomy => (5, 8),
330 Self::Wide => (3, 4),
331 Self::Loose => (1, 1),
332 Self::Broad => (3, 2),
333 Self::Vast => (2, 1),
334 Self::Colossal => (3, 1),
335 };
336 Ratio {
337 numerator,
338 denominator,
339 }
340 }
341
342 /// Size in CSS pixels at the default base.
343 #[must_use]
344 pub const fn px(self) -> u16 {
345 self.ratio().px_at(DEFAULT_BASE_PX)
346 }
347
348 /// The CSS custom-property name, without the leading `--`.
349 #[must_use]
350 pub const fn token(self) -> &'static str {
351 match self {
352 Self::Hair => "step-hair",
353 Self::Tight => "step-tight",
354 Self::Snug => "step-snug",
355 Self::Base => "step-base",
356 Self::Roomy => "step-roomy",
357 Self::Wide => "step-wide",
358 Self::Loose => "step-loose",
359 Self::Broad => "step-broad",
360 Self::Vast => "step-vast",
361 Self::Colossal => "step-colossal",
362 }
363 }
364
365 /// Every step, smallest first.
366 #[must_use]
367 pub const fn all() -> [Self; 10] {
368 [
369 Self::Hair,
370 Self::Tight,
371 Self::Snug,
372 Self::Base,
373 Self::Roomy,
374 Self::Wide,
375 Self::Loose,
376 Self::Broad,
377 Self::Vast,
378 Self::Colossal,
379 ]
380 }
381}
382
383impl Gap {
384 /// The step this relationship resolves to at a given density.
385 #[must_use]
386 pub const fn step_at(self, density: Density) -> Step {
387 match (self, density) {
388 // Binding is not a separation, so it does not open up on touch.
389 (Self::Bound, _) => Step::Tight,
390
391 (Self::Peer, Density::Pointer) => Step::Snug,
392 (Self::Peer, Density::Touch) => Step::Roomy,
393
394 (Self::Group, Density::Pointer) => Step::Roomy,
395 (Self::Group, Density::Touch) => Step::Wide,
396
397 (Self::Section, Density::Pointer) => Step::Wide,
398 (Self::Section, Density::Touch) => Step::Loose,
399
400 // Shells tighten on touch: outer margin is screen you don't get.
401 (Self::Pane, Density::Pointer) => Step::Broad,
402 (Self::Pane, Density::Touch) => Step::Loose,
403
404 (Self::Page, Density::Pointer) => Step::Vast,
405 (Self::Page, Density::Touch) => Step::Broad,
406 }
407 }
408
409 /// The step this relationship resolves to at the default density.
410 #[must_use]
411 pub const fn step(self) -> Step {
412 self.step_at(Density::Pointer)
413 }
414
415 /// Size in CSS pixels at the default base, at a given density.
416 #[must_use]
417 pub const fn px_at(self, density: Density) -> u16 {
418 self.step_at(density).px()
419 }
420
421 /// Size in CSS pixels at the default base and density.
422 #[must_use]
423 pub const fn px(self) -> u16 {
424 self.step().px()
425 }
426
427 /// The CSS custom-property name, without the leading `--`.
428 #[must_use]
429 pub const fn token(self) -> &'static str {
430 match self {
431 Self::Bound => "gap-bound",
432 Self::Peer => "gap-peer",
433 Self::Group => "gap-group",
434 Self::Section => "gap-section",
435 Self::Pane => "gap-pane",
436 Self::Page => "gap-page",
437 }
438 }
439
440 /// Every relationship, tightest first.
441 #[must_use]
442 pub const fn all() -> [Self; 6] {
443 [
444 Self::Bound,
445 Self::Peer,
446 Self::Group,
447 Self::Section,
448 Self::Pane,
449 Self::Page,
450 ]
451 }
452}
453
454/// Emit the base unit and the raw scale as CSS declarations, no selector.
455///
456/// Density-invariant: the steps are the vocabulary, and only which step a
457/// relationship picks changes between presets.
458#[must_use]
459pub fn scale_css_declarations() -> String {
460 let mut out = String::new();
461 let _ = writeln!(
462 out,
463 " /* Every size below is a ratio of this. Scale it and the whole\n \
464 layout scales with it, including for a user who has asked for\n \
465 larger text. */\n --{BASE_TOKEN}: 1rem;\n"
466 );
467 out.push_str(" /* Raw scale. Prefer a --gap-* below; reach here only when\n");
468 out.push_str(" no relationship describes the distance. */\n");
469 for step in Step::all() {
470 let _ = writeln!(out, " --{}: {};", step.token(), step.ratio().css());
471 }
472 out
473}
474
475/// Emit the relational layer for one density as CSS declarations, no selector.
476///
477/// Gaps reference their step rather than repeating a value, so the scale has
478/// exactly one definition and a reader can see which relationship maps where.
479#[must_use]
480pub fn gap_css_declarations(density: Density) -> String {
481 let mut out = String::new();
482 for gap in Gap::all() {
483 let _ = writeln!(
484 out,
485 " --{}: var(--{});",
486 gap.token(),
487 gap.step_at(density).token()
488 );
489 }
490 out
491}
492
493/// Emit the whole geometry layer as a `:root { … }` block at one density.
494///
495/// Mirrors `makeover::intent_css_vars`. Unlike the colour layer this is
496/// constant, so a web consumer should bake it in at build time rather than
497/// apply it from JS on every load.
498#[must_use]
499pub fn geometry_css_vars(density: Density) -> String {
500 format!(
501 ":root {{\n{}\n{}}}\n",
502 scale_css_declarations(),
503 gap_css_declarations(density)
504 )
505}
506
507/// The whole spacing layer with the canonical density selection, as CSS.
508///
509/// **Density is a capability, not a device and not a width.** A narrow window
510/// on a desktop still has a pointer in it and a tablet at full width still has
511/// a finger, so the touch preset hangs off `(hover: none), (pointer: coarse)`
512/// rather than off a breakpoint or a user-agent string. That is the question
513/// the platform actually answers, and it is the one [`Density`] is asking.
514///
515/// `explicit_touch` names a selector an app sets when the *user* has chosen.
516/// It is emitted last and therefore wins at equal specificity, because
517/// detection is a default rather than a verdict: a touchscreen laptop and
518/// someone who simply wants roomier targets are both real, and neither is
519/// visible to a media query.
520///
521/// Settles a policy three consumers previously answered three ways. GoingsOn
522/// sniffed the user agent behind a mode class, Balanced Breakfast used
523/// `(hover: none)` alone, and audiofiles had no switch at all; the first of
524/// those asked what device this is as a proxy for a capability already
525/// reported.
526#[must_use]
527pub fn density_css(explicit_touch: Option<&str>) -> String {
528 let mut css = geometry_css_vars(Density::Pointer);
529 css.push_str("\n@media (hover: none), (pointer: coarse) {\n");
530 for line in gap_css_overrides(":root", Density::Touch).lines() {
531 css.push_str(" ");
532 css.push_str(line);
533 css.push('\n');
534 }
535 css.push_str("}\n");
536 if let Some(selector) = explicit_touch {
537 css.push_str("\n/* An explicit user choice, last so it wins over detection. */\n");
538 css.push_str(&gap_css_overrides(selector, Density::Touch));
539 }
540 css
541}
542
543/// Emit a density preset as a scoped override block.
544///
545/// Only the relational layer is emitted: the scale and the base do not change
546/// between presets, so an app ships [`geometry_css_vars`] at its default
547/// density and one of these per mode class it supports.
548///
549/// ```
550/// # use makeover_geometry::{Density, gap_css_overrides};
551/// let css = gap_css_overrides(".ui-mode-mobile", Density::Touch);
552/// assert!(css.starts_with(".ui-mode-mobile {\n"));
553/// ```
554#[must_use]
555pub fn gap_css_overrides(selector: &str, density: Density) -> String {
556 format!("{selector} {{\n{}}}\n", gap_css_declarations(density))
557}
558
559#[cfg(test)]
560mod tests {
561 use super::*;
562
563 #[test]
564 fn density_is_selected_by_capability_not_by_width_or_agent() {
565 let css = density_css(None);
566 assert!(css.contains("@media (hover: none), (pointer: coarse)"));
567 // The three things density must never be selected by.
568 assert!(!css.contains("max-width"), "a breakpoint crept in");
569 assert!(!css.contains("min-width"), "a breakpoint crept in");
570 assert!(!css.contains("ui-mode"), "a device mode crept in");
571 }
572
573 #[test]
574 fn an_explicit_choice_is_emitted_after_the_detection() {
575 let css = density_css(Some(".ui-mode-mobile"));
576 let media = css.find("@media").expect("media query");
577 let explicit = css.find(".ui-mode-mobile").expect("explicit selector");
578 // Equal specificity, so order is the whole mechanism: the user's
579 // choice has to come last or detection quietly overrides it.
580 assert!(explicit > media, "the explicit selector must come last");
581 }
582
583 #[test]
584 fn without_an_explicit_selector_there_are_exactly_two_presets() {
585 assert_eq!(density_css(None).matches("--gap-peer").count(), 2);
586 }
587
588 #[test]
589 fn the_hig_relationships_land_on_the_hig_values() {
590 // Mac OS 8 HIG, Control Layout Guidelines. The ratios are ours, but at
591 // the default base they must resolve to the numbers the HIG specifies,
592 // or the departure has cost us the thing it was translating.
593 assert_eq!(Gap::Bound.px(), 4);
594 assert_eq!(Gap::Peer.px(), 6);
595 assert_eq!(Gap::Group.px(), 10);
596 assert_eq!(Gap::Section.px(), 12);
597 }
598
599 #[test]
600 fn every_ratio_divides_the_default_base_exactly() {
601 for step in Step::all() {
602 let r = step.ratio();
603 assert_eq!(
604 u32::from(DEFAULT_BASE_PX) * u32::from(r.numerator) % u32::from(r.denominator),
605 0,
606 "{step:?} is fractional at the default base"
607 );
608 }
609 }
610
611 #[test]
612 fn ratios_scale_linearly() {
613 for step in Step::all() {
614 assert_eq!(
615 step.ratio().px_at(DEFAULT_BASE_PX * 2),
616 step.px() * 2,
617 "{step:?} does not double with the base"
618 );
619 }
620 }
621
622 #[test]
623 fn steps_ascend_and_never_repeat() {
624 let px: Vec<u16> = Step::all().iter().map(|s| s.px()).collect();
625 let mut sorted = px.clone();
626 sorted.sort_unstable();
627 sorted.dedup();
628 assert_eq!(px, sorted, "steps must be strictly ascending");
629 }
630
631 #[test]
632 fn gaps_ascend_with_their_relationships_at_every_density() {
633 for density in [Density::Pointer, Density::Touch] {
634 let px: Vec<u16> = Gap::all().iter().map(|g| g.px_at(density)).collect();
635 let mut sorted = px.clone();
636 sorted.sort_unstable();
637 assert_eq!(px, sorted, "{density:?}: a looser relationship is tighter");
638 }
639 }
640
641 #[test]
642 fn touch_separates_targets_and_tightens_shells() {
643 // The asymmetry is the whole reason a base scalar would not do.
644 for gap in [Gap::Peer, Gap::Section] {
645 assert!(
646 gap.px_at(Density::Touch) > gap.px_at(Density::Pointer),
647 "{gap:?} must open up for a fingertip"
648 );
649 }
650 for gap in [Gap::Pane, Gap::Page] {
651 assert!(
652 gap.px_at(Density::Touch) < gap.px_at(Density::Pointer),
653 "{gap:?} must tighten on a small screen"
654 );
655 }
656 assert_eq!(
657 Gap::Bound.px_at(Density::Touch),
658 Gap::Bound.px_at(Density::Pointer),
659 "bound things stay bound"
660 );
661 }
662
663 #[test]
664 fn a_terminal_resolves_the_vocabulary_to_whole_cells() {
665 let t = Surface::terminal();
666 let cells: Vec<u32> = Gap::all()
667 .iter()
668 .map(|g| t.gap(*g, Density::Pointer))
669 .collect();
670 // bound, peer | group, section | pane, page
671 assert_eq!(cells, vec![0, 0, 1, 1, 2, 2]);
672 }
673
674 #[test]
675 fn collapsing_is_allowed_but_inverting_is_not() {
676 // A coarse surface has fewer distinctions, so neighbouring gaps may
677 // land on the same quantum. What must never happen is a looser
678 // relationship coming out tighter than a closer one.
679 for quantum in [0.5_f32, 1.0, 2.0, 3.0, 7.0] {
680 for density in [Density::Pointer, Density::Touch] {
681 let s = Surface {
682 base: 16.0,
683 quantum,
684 };
685 let v: Vec<u32> = Gap::all().iter().map(|g| s.gap(*g, density)).collect();
686 let mut sorted = v.clone();
687 sorted.sort_unstable();
688 assert_eq!(v, sorted, "quantum {quantum} {density:?} inverted: {v:?}");
689 }
690 }
691 }
692
693 #[test]
694 fn the_web_surface_agrees_with_the_pixel_helper() {
695 let w = Surface::web();
696 for step in Step::all() {
697 assert_eq!(
698 w.resolve(step.ratio()) as u16,
699 step.px(),
700 "{step:?} disagrees between surface and px_at"
701 );
702 }
703 }
704
705 #[test]
706 fn quantising_is_monotonic_in_the_ratio() {
707 let (base, quantum) = (16.0, 1.0);
708 let mut previous = 0;
709 for step in Step::all() {
710 let q = step.ratio().quanta(base, quantum);
711 assert!(q >= previous, "{step:?} went backwards");
712 previous = q;
713 }
714 }
715
716 #[test]
717 fn a_degenerate_quantum_yields_nothing_rather_than_panicking() {
718 let r = Step::Loose.ratio();
719 for bad in [0.0_f32, -1.0, f32::NAN] {
720 assert_eq!(r.quanta(16.0, bad), 0);
721 assert!(r.quantize(16.0, bad).abs() < f32::EPSILON);
722 }
723 assert_eq!(r.quanta(f32::INFINITY, 1.0), 0);
724 }
725
726 #[test]
727 fn px_at_rounds_rather_than_truncating() {
728 // Eighths divide 16 exactly, so the rounding only shows on a base
729 // that does not: 3/8 of 15 is 5.625, which is 6px, not 5.
730 assert_eq!(Step::Snug.ratio().px_at(15), 6);
731 assert_eq!(Step::Snug.ratio().px_at(DEFAULT_BASE_PX), 6);
732 }
733
734 #[test]
735 fn tokens_are_unique() {
736 let mut names: Vec<&str> = Step::all().iter().map(|s| s.token()).collect();
737 names.extend(Gap::all().iter().map(|g| g.token()));
738 let count = names.len();
739 names.sort_unstable();
740 names.dedup();
741 assert_eq!(names.len(), count, "token names collide");
742 }
743
744 #[test]
745 fn css_is_expressed_over_the_base_never_in_pixels() {
746 let css = geometry_css_vars(Density::Pointer);
747 assert!(css.starts_with(":root {\n"));
748 assert!(css.trim_end().ends_with('}'));
749 assert!(css.contains("--geometry-base: 1rem;"));
750 for step in Step::all() {
751 let line = format!("--{}: {}", step.token(), step.ratio().css());
752 assert!(css.contains(&line), "missing or wrong: {line}");
753 }
754 // A hard pixel count anywhere in the scale defeats the point.
755 let scale = scale_css_declarations();
756 assert!(
757 !scale.contains("px;"),
758 "the scale must not emit pixel literals:\n{scale}"
759 );
760 }
761
762 #[test]
763 fn ratio_css_drops_redundant_arithmetic() {
764 assert_eq!(Step::Loose.ratio().css(), "var(--geometry-base)");
765 assert_eq!(Step::Vast.ratio().css(), "calc(var(--geometry-base) * 2)");
766 assert_eq!(
767 Step::Snug.ratio().css(),
768 "calc(var(--geometry-base) * 3 / 8)"
769 );
770 }
771
772 #[test]
773 fn gaps_reference_steps_rather_than_repeating_values() {
774 let css = geometry_css_vars(Density::Pointer);
775 assert!(css.contains("--gap-peer: var(--step-snug);"));
776 assert!(!css.contains("--gap-peer: calc"));
777 }
778
779 #[test]
780 fn a_density_override_emits_only_the_relational_layer() {
781 let css = gap_css_overrides(".ui-mode-mobile", Density::Touch);
782 assert!(css.contains("--gap-peer: var(--step-roomy);"));
783 // Referencing a step is the point; re-declaring one would fork the
784 // scale, so the check is on declarations, not on mentions.
785 let declared: Vec<&str> = css
786 .lines()
787 .filter_map(|l| l.trim().strip_prefix("--"))
788 .filter_map(|l| l.split(':').next())
789 .collect();
790 assert!(
791 declared.iter().all(|t| t.starts_with("gap-")),
792 "only the relational layer may be overridden, got {declared:?}"
793 );
794 }
795}