| 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 |
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 |
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 |
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();
|