Skip to main content

max / makeover-geometry

Generalise the terminal case to surface quantization A terminal is not a third density preset, which is how the seed commit framed it. It is a surface whose smallest representable step is one cell rather than one pixel, and that is the only difference. Ratio::quanta takes a quantum and answers in whole units of it, so the relational vocabulary reaches a character grid with nothing added to it. Quantizing is not a terminal special case either. A display quantizes to the pixel; it is only that rounding 6.0 to the nearest pixel is uninteresting, while rounding three eighths of a cell to the nearest cell decides the layout. px_at is now that same rounding, and rounds rather than truncating, which only shows on a base the eighths do not divide. Surface carries a base and a quantum, both in its own units, and Surface::gap resolves the whole model in one call: what is being separated, who is operating it, what it is drawn on. Three orthogonal axes instead of a special case. On a terminal the pointer preset gives 0, 0, 1, 1, 2, 2 cells. bound and peer collapsing is correct rather than lossy, because in a grid that dense both relationships are carried by adjacency. Collapsing is allowed and inverting is not, and there is a test across five quanta and both densities pinning exactly that. A degenerate quantum returns zero rather than panicking. quantize guards in its own right instead of leaning on quanta, because a count of zero times a NaN quantum is NaN, not zero. The test found that.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-27 01:15 UTC
Signed with PGP, not checked
Commit: ad194652901263e9eb63690b4694daf07efe6afc
Parent: 8199cfe
3 files changed, +263 insertions, -14 deletions
M README.md +32 -3
@@ -69,12 +69,41 @@
69 69 The override block emits only the relational layer. The base and the scale are
70 70 declared once.
71 71
72 + ## Surfaces, and why a terminal is not a third preset
73 +
74 + A terminal is not a density. It is a surface whose smallest representable step
75 + is one cell rather than one pixel, and that single difference is the whole of
76 + it. `Ratio::quanta` takes a quantum and answers in whole units of it, so the
77 + relational vocabulary reaches a character grid with nothing added.
78 +
79 + Quantising is not a terminal special case either. A display quantises to the
80 + pixel; it is only that rounding 6.0 to the nearest pixel is uninteresting, while
81 + rounding three eighths of a cell to the nearest cell decides the layout.
82 +
83 + ```rust
84 + use makeover_geometry::{Density, Gap, Surface};
85 +
86 + let t = Surface::terminal();
87 + assert_eq!(t.gap(Gap::Peer, Density::Pointer), 0); // cells
88 + assert_eq!(t.gap(Gap::Section, Density::Pointer), 1);
89 + assert_eq!(t.gap(Gap::Page, Density::Pointer), 2);
90 + ```
91 +
92 + `bound` and `peer` collapsing to zero cells is correct rather than lossy: in a
93 + grid that dense, both relationships are expressed by adjacency. A coarse surface
94 + genuinely has fewer distinctions available, and the model should say so instead
95 + of inventing a gap to keep six names apart. What it must never do is *invert*
96 + two relationships, and there is a test across several quanta pinning that.
97 +
98 + So three orthogonal axes: `Gap` is what is being separated, `Density` is who is
99 + operating it, `Surface` is what it is drawn on.
100 +
72 101 ## Consumers
73 102
74 103 Web surfaces bake the CSS in at build time; unlike colour, none of this changes
75 - at runtime, so there is nothing for JS to apply on load. egui surfaces read the
76 - same values through `Gap::px_at` and `Ratio::scale`, which is the reason this is
77 - a crate rather than a stylesheet.
104 + at runtime, so there is nothing for JS to apply on load. egui and ratatui
105 + surfaces resolve through `Surface` instead, which is the reason this is a crate
106 + rather than a stylesheet.
78 107
79 108 ## Licence
80 109
M examples/dump.rs +13 -2
@@ -1,9 +1,20 @@
1 - //! Print the geometry layer as an app would bake it in at build time.
1 + //! Print the geometry layer as an app would bake it in at build time, then
2 + //! the same vocabulary resolved for a terminal.
2 3
3 - use makeover_geometry::{Density, gap_css_overrides, geometry_css_vars};
4 + use makeover_geometry::{Density, Gap, Surface, gap_css_overrides, geometry_css_vars};
4 5
5 6 fn main() {
6 7 print!("{}", geometry_css_vars(Density::Pointer));
7 8 println!();
8 9 print!("{}", gap_css_overrides(".ui-mode-mobile", Density::Touch));
10 +
11 + println!("\n/* the same relationships on a terminal, in cells */");
12 + let t = Surface::terminal();
13 + for gap in Gap::all() {
14 + println!(
15 + "/* {:<8} {} */",
16 + gap.token().trim_start_matches("gap-"),
17 + t.gap(gap, Density::Pointer)
18 + );
19 + }
9 20 }
M src/lib.rs +218 -9
@@ -55,6 +55,28 @@
55 55 //! `Section` open up under [`Density::Touch`] while `Pane` and `Page` tighten.
56 56 //! `Bound` never moves: it is the one relationship that says "these are one
57 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.
58 80
59 81 #![forbid(unsafe_code)]
60 82
@@ -79,24 +101,69 @@
79 101 }
80 102
81 103 impl Ratio {
82 - /// Resolve against a base measured in pixels.
104 + /// Resolve against a base measured in whole pixels, rounding to nearest.
83 105 ///
84 - /// Integer maths, so this is exact for every ratio in [`Step`] at the
85 - /// default base. A base that does not divide evenly truncates, which is
86 - /// the right failure: a fractional CSS pixel is a blurry edge.
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`.
87 110 #[must_use]
88 111 pub const fn px_at(self, base_px: u16) -> u16 {
89 - base_px * self.numerator / self.denominator
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
90 116 }
91 117
92 118 /// Resolve against an arbitrary base, keeping the fraction.
93 119 ///
94 - /// For consumers that lay out in logical units rather than whole pixels.
120 + /// The exact value, before any surface gets a say. Prefer
121 + /// [`Surface::resolve`] unless you specifically want the unsnapped number.
95 122 #[must_use]
96 123 pub fn scale(self, base: f32) -> f32 {
97 124 base * f32::from(self.numerator) / f32::from(self.denominator)
98 125 }
99 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 +
100 167 /// The CSS value, as an expression over [`BASE_TOKEN`].
101 168 ///
102 169 /// A whole multiple of the base emits without a division, and 1:1 emits
@@ -111,11 +178,82 @@
111 178 }
112 179 }
113 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)]
196 + pub 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 +
203 + impl 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 +
114 251 /// Which input the layout is being sized for.
115 252 ///
116 - /// A preset, not a breakpoint. Which one applies is the app's call — GoingsOn
117 - /// and Balanced Breakfast already decide it once and hang a `ui-mode-*` class
118 - /// off the result.
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.
119 257 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
120 258 pub enum Density {
121 259 /// Mouse or trackpad. Resolves to the Mac OS 8 HIG's own proportions.
@@ -461,6 +599,77 @@
461 599 );
462 600 }
463 601
602 + #[test]
603 + fn a_terminal_resolves_the_vocabulary_to_whole_cells() {
604 + let t = Surface::terminal();
605 + let cells: Vec<u32> = Gap::all()
606 + .iter()
607 + .map(|g| t.gap(*g, Density::Pointer))
608 + .collect();
609 + // bound, peer | group, section | pane, page
610 + assert_eq!(cells, vec![0, 0, 1, 1, 2, 2]);
611 + }
612 +
613 + #[test]
614 + fn collapsing_is_allowed_but_inverting_is_not() {
615 + // A coarse surface has fewer distinctions, so neighbouring gaps may
616 + // land on the same quantum. What must never happen is a looser
617 + // relationship coming out tighter than a closer one.
618 + for quantum in [0.5_f32, 1.0, 2.0, 3.0, 7.0] {
619 + for density in [Density::Pointer, Density::Touch] {
620 + let s = Surface {
621 + base: 16.0,
622 + quantum,
623 + };
624 + let v: Vec<u32> = Gap::all().iter().map(|g| s.gap(*g, density)).collect();
625 + let mut sorted = v.clone();
626 + sorted.sort_unstable();
627 + assert_eq!(v, sorted, "quantum {quantum} {density:?} inverted: {v:?}");
628 + }
629 + }
630 + }
631 +
632 + #[test]
633 + fn the_web_surface_agrees_with_the_pixel_helper() {
634 + let w = Surface::web();
635 + for step in Step::all() {
636 + assert_eq!(
637 + w.resolve(step.ratio()) as u16,
638 + step.px(),
639 + "{step:?} disagrees between surface and px_at"
640 + );
641 + }
642 + }
643 +
644 + #[test]
645 + fn quantising_is_monotonic_in_the_ratio() {
646 + let (base, quantum) = (16.0, 1.0);
647 + let mut previous = 0;
648 + for step in Step::all() {
649 + let q = step.ratio().quanta(base, quantum);
650 + assert!(q >= previous, "{step:?} went backwards");
651 + previous = q;
652 + }
653 + }
654 +
655 + #[test]
656 + fn a_degenerate_quantum_yields_nothing_rather_than_panicking() {
657 + let r = Step::Loose.ratio();
658 + for bad in [0.0_f32, -1.0, f32::NAN] {
659 + assert_eq!(r.quanta(16.0, bad), 0);
660 + assert!(r.quantize(16.0, bad).abs() < f32::EPSILON);
661 + }
662 + assert_eq!(r.quanta(f32::INFINITY, 1.0), 0);
663 + }
664 +
665 + #[test]
666 + fn px_at_rounds_rather_than_truncating() {
667 + // Eighths divide 16 exactly, so the rounding only shows on a base
668 + // that does not: 3/8 of 15 is 5.625, which is 6px, not 5.
669 + assert_eq!(Step::Snug.ratio().px_at(15), 6);
670 + assert_eq!(Step::Snug.ratio().px_at(DEFAULT_BASE_PX), 6);
671 + }
672 +
464 673 #[test]
465 674 fn tokens_are_unique() {
466 675 let mut names: Vec<&str> = Step::all().iter().map(|s| s.token()).collect();