makeover_layout/affordance.rs
1//! The adaptation members: which affordances exist at a given input class
2//! and window size class.
3//!
4//! <!-- wiki: makeover-touch -->
5//!
6//! `makeover` answers *what colour*. `makeover-geometry` answers *how much
7//! space*, and owns the two axes an adaptation is stated against:
8//! [`Density`] (pointer or touch) and [`SizeClass`] (compact, medium,
9//! expanded). The rest of this crate answers *what the thing is*. This
10//! module answers one question and no other:
11//!
12//! > Does this affordance exist here?
13//!
14//! Like the rest of the crate it emits nothing. It is a description, rendered
15//! to CSS by `makeover-webview` and to whatever the other renderers can express.
16//!
17//! # Why this is a description and not a density preset
18//!
19//! What an adaptive stylesheet changes falls into buckets, and each has an
20//! owner:
21//!
22//! | bucket | retired by |
23//! |---|---|
24//! | density | a `makeover-geometry` preset |
25//! | type | the type scale |
26//! | columns | [`ColumnRoom`](crate::ColumnRoom) |
27//! | reflow | [`Arrangement`](crate::Arrangement) |
28//! | **show/hide** | **[`Affordance`]** |
29//! | **reposition** | **[`Affordance`]** |
30//! | **appearance** | **[`Affordance`]** |
31//!
32//! The bottom three are what no spacing scale can retire. `display: none` on a
33//! keyboard hint says *the affordance does not exist on touch*. No amount of
34//! gap retuning expresses that, and a scale that tried would be putting a
35//! product claim on a measurement axis.
36//!
37//! [`Affordance::Gesture`] and [`Affordance::Haptic`] adapt *behaviour*, which
38//! no stylesheet contains. Without them the density question gets answered in
39//! script by sniffing (`'ontouchstart' in window`), where no stylesheet can see
40//! it.
41//!
42//! # The two axes are borrowed, never redefined
43//!
44//! Boundaries are not this module's job. `makeover-geometry` quotes Material 3's
45//! window size classes at 600 and 840 and carries [`Density`]; this module names
46//! affordances *against* those two and adds no third axis, no fourth class and
47//! no breakpoint of its own. If a rule here wants a boundary that does not
48//! exist, that is a conversation with `makeover-geometry`, not a constant.
49//!
50//! # What density is allowed to gate
51//!
52//! Density is a claim about **the contact patch and nothing else**. So it gates
53//! affordances that depend on an interaction a fingertip cannot perform:
54//! hovering, and the keyboard chrome that documents shortcuts a touch surface
55//! has no way to send. It does not gate anything that is really about how much
56//! screen there is. A phone is small *and* touch; a tablet is big *and* touch.
57//!
58//! `density_gates_only_what_the_contact_patch_touches` asserts that separation. Putting a screen-budget
59//! claim on the input device is the failure this module exists to prevent, and
60//! re-introducing it has to come to the test and say so.
61//!
62//! # Both densities gain something
63//!
64//! Touch is not pointer minus what a fingertip cannot do.
65//! [`Affordance::Hover`]'s own doc says a fingertip has no hover state *and that
66//! something else has to carry the same actions*, so something here has to be
67//! that something. [`Affordance::Anchored`] and [`Affordance::Overflow`]
68//! compensate on the size axis; [`Affordance::Gesture`] and
69//! [`Affordance::Haptic`] compensate on the density axis, and both are gained
70//! by touch rather than lost to it.
71//!
72//! [`Affordance::gained_by`] makes each member declare which density it belongs
73//! to, and
74//! `a_density_member_is_available_on_exactly_the_density_it_declares` checks the
75//! declaration against the rule. So a new member still cannot quietly invert:
76//! it has to say which way it goes, in code, and the test is where a wrong
77//! answer surfaces.
78//!
79//! # Collapsing is allowed, inverting is not
80//!
81//! Borrowed verbatim from `makeover-geometry`, where two gap relationships both
82//! resolve to zero cells on a terminal and stay two members regardless. Two
83//! affordances here may have identical availability ([`Affordance::Hover`] and
84//! [`Affordance::Hint`] do) and are still two members, because the call
85//! site names *what is being gated*, not the rule. What must never happen is
86//! one of them becoming available where the other is not for a reason that is
87//! really the same reason.
88//!
89//! # Deliberately absent
90//!
91//! **A navigation shell fork.** Two navigation shells behind one breakpoint are
92//! two shells rather than one shell adapting, and choosing to build two is a
93//! product decision. This module will not describe it. Named here the way
94//! the crate names validation absent, so nobody has to discover it.
95//!
96//! **Which columns a narrow window keeps.** A column is ranked rather than
97//! gated, so the cutoff for a size class lives with the ranking, in
98//! [`ColumnRoom`](crate::ColumnRoom).
99//!
100//! **Which class applies.** The app decides, from a measured width via
101//! [`SizeClass::at_width`] and from whatever it already knows about the input.
102//! This module takes both as arguments and never sniffs.
103//!
104//! **What a renderer does when an affordance is unavailable.** Hiding it,
105//! substituting it, or showing it unconditionally anyway is renderer policy.
106//! The crate already deleted `Fill::fallback` for being exactly that.
107
108use makeover_geometry::{Density, SizeClass};
109
110/// An affordance whose existence depends on the surface it is offered on.
111///
112/// Drawn from what apps already gate by hand rather than from a taxonomy.
113/// The crate's admission rule applies and is the reason each addition has to
114/// point at call sites rather than at a category:
115/// guessing is how a description becomes a framework.
116///
117/// Each answers [`Self::available`] against the two axes and nothing else. An
118/// affordance that is always available is not an affordance this module has
119/// anything to say about, and `every_member_is_an_adaptation` asserts none has
120/// snuck in.
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
122#[non_exhaustive]
123pub enum Affordance {
124 /// Anything a consumer reveals on hover: a row's action cluster, a
125 /// hover toolbar, a preview popover.
126 ///
127 /// The crate records hover-revealed row actions as behaviour of
128 /// [`RowPart::Actions`](crate::RowPart::Actions) rather than as app policy. A fingertip has no hover
129 /// state at all, so the affordance does not exist on touch, and something
130 /// else has to carry the same actions: [`Self::Gesture`].
131 Hover,
132 /// Chrome documenting a keyboard interaction: shortcut badges, key hints,
133 /// a "press / to search" line.
134 ///
135 /// Strictly this is a claim about
136 /// having a keyboard rather than about the contact patch, and [`Density`]
137 /// is the closest honest proxy the family carries. Stated rather than
138 /// hidden, because a detachable-keyboard tablet is where the proxy breaks
139 /// and a third axis is what fixing it would cost.
140 Hint,
141 /// A secondary panel standing beside the primary content: a saved-views
142 /// rail, a filter sidebar, an inspector.
143 ///
144 /// Purely a screen-budget claim (a touchscreen laptop should keep it), so
145 /// this reads [`SizeClass`] alone.
146 Ancillary,
147 /// The detail half of a list-detail split, shown *alongside* the list
148 /// rather than navigated to.
149 ///
150 /// Unavailable is not the same as absent: the detail still exists, it is
151 /// reached by navigation instead of by adjacency, and which of the two a
152 /// screen gets is what [`Arrangement`](crate::Arrangement) is describing.
153 Detail,
154 /// Navigation or a primary action cluster pinned to a fixed screen edge
155 /// instead of sitting in the flow of the page.
156 ///
157 /// The reposition bucket. It exists to compensate for what a compact window cannot hold in flow,
158 /// so unlike the two above it is available at the *narrow* end and not the
159 /// wide one. That inversion is the point: an adaptation that only ever
160 /// removes things describes a degraded layout rather than a different one.
161 Anchored,
162 /// An action cluster collapsed behind one control rather than laid out
163 /// inline.
164 ///
165 /// The other compensating member. [`ColumnRoom::kept_at`](crate::ColumnRoom::kept_at)
166 /// already handles a *table* narrowing by dropping columns; this is the
167 /// same pressure on a cluster of controls, which cannot drop any of them
168 /// and folds instead.
169 Overflow,
170 /// A direct-manipulation gesture on content: swipe-to-action, long-press to
171 /// select, pull to refresh, swipe to navigate, drag to dismiss.
172 ///
173 /// The density axis's compensating member, and the answer to the question
174 /// [`Self::Hover`] asks and cannot answer. A fingertip has no hover state,
175 /// so the row actions a pointer reveals by hovering have to arrive some
176 /// other way; on both webview apps that way is a swipe.
177 ///
178 /// **One member for five gestures, on purpose.** Apps gate all five behind
179 /// one fact about the surface, so five members would be minted from one.
180 /// If a surface ever offers swipe without long-press, splitting this is
181 /// additive and the call sites that named `Gesture` keep meaning what they
182 /// meant.
183 Gesture,
184 /// Confirmation delivered through the contact patch rather than the eye: the
185 /// tick as a drag crosses a threshold, the thump as a gesture fires.
186 ///
187 /// A contact-patch claim, which is what makes it this module's business
188 /// despite being the one member nothing on screen shows.
189 ///
190 /// The proxy breaks where the hardware has haptics and the user or the OS
191 /// has switched them off. That is neither [`Density`] nor [`SizeClass`], and
192 /// it is stated here rather than fixed for the same reason [`Self::Hint`]
193 /// states the detachable-keyboard case: a third axis is what fixing it would
194 /// cost. **This member says the surface can, never that the user wants.**
195 /// Asking the platform whether haptics are enabled is the renderer's job.
196 Haptic,
197}
198
199impl Affordance {
200 /// Whether this affordance exists on a surface with the given input class
201 /// and screen budget.
202 ///
203 /// The whole module in one call. A renderer asks per affordance and never
204 /// branches on a width.
205 #[must_use]
206 pub const fn available(self, density: Density, size: SizeClass) -> bool {
207 match self {
208 // Contact patch, lost to a fingertip.
209 Self::Hover | Self::Hint => matches!(density, Density::Pointer),
210 // Contact patch, gained by one. A mouse can neither swipe a row nor
211 // feel a confirmation, and a phone-sized window has nothing to do
212 // with either.
213 Self::Gesture | Self::Haptic => matches!(density, Density::Touch),
214 // Screen budget. The input device has no opinion about any of them.
215 Self::Ancillary => matches!(size, SizeClass::Expanded),
216 Self::Detail => matches!(size, SizeClass::Medium | SizeClass::Expanded),
217 Self::Anchored | Self::Overflow => matches!(size, SizeClass::Compact),
218 }
219 }
220
221 /// Which [`Density`] this affordance belongs to, or `None` when it reads the
222 /// screen budget instead.
223 ///
224 /// Each member declares its own direction, and
225 /// `a_density_member_is_available_on_exactly_the_density_it_declares` holds
226 /// the declaration to the rule. There is no module-wide one-directional
227 /// rule: see the module doc.
228 ///
229 /// Exposed rather than kept private for the same reason [`Self::reads_density`]
230 /// is: it is the module's claim about itself, and a renderer that has one
231 /// density can read it directly instead of probing [`Self::available`] twice.
232 #[must_use]
233 pub const fn gained_by(self) -> Option<Density> {
234 match self {
235 Self::Hover | Self::Hint => Some(Density::Pointer),
236 Self::Gesture | Self::Haptic => Some(Density::Touch),
237 Self::Ancillary | Self::Detail | Self::Anchored | Self::Overflow => None,
238 }
239 }
240
241 /// Which of the two axes this affordance's availability reads.
242 ///
243 /// Declared per member rather than derived, so the tests can hold each
244 /// declaration against what [`Self::available`] actually does.
245 #[must_use]
246 pub const fn axis(self) -> AffordanceAxis {
247 match self {
248 Self::Hover | Self::Hint | Self::Gesture | Self::Haptic => AffordanceAxis::Density,
249 Self::Ancillary | Self::Detail | Self::Anchored | Self::Overflow => {
250 AffordanceAxis::Size
251 }
252 }
253 }
254
255 /// Whether this affordance's availability reads [`Density`] at all.
256 ///
257 /// Exposed rather than kept private because it is the module's own claim
258 /// about itself: exactly the members gating a contact-patch interaction say
259 /// yes. A renderer with one density can skip the rest entirely.
260 #[must_use]
261 pub const fn reads_density(self) -> bool {
262 matches!(self.axis(), AffordanceAxis::Density)
263 }
264
265 /// Whether this affordance's availability reads [`SizeClass`] at all.
266 #[must_use]
267 pub const fn reads_size(self) -> bool {
268 matches!(self.axis(), AffordanceAxis::Size)
269 }
270
271 /// Every member, in declaration order.
272 ///
273 /// A slice rather than an array, so a new member changes a length and not
274 /// a type: `Affordance` is `#[non_exhaustive]` for exactly that reason.
275 #[must_use]
276 pub const fn all() -> &'static [Self] {
277 &[
278 Self::Hover,
279 Self::Hint,
280 Self::Ancillary,
281 Self::Detail,
282 Self::Anchored,
283 Self::Overflow,
284 Self::Gesture,
285 Self::Haptic,
286 ]
287 }
288
289 /// The CSS class name an app may hang off this, without the leading dot.
290 ///
291 /// Present for the same reason [`SizeClass::token`] is: a webview renderer
292 /// needs a stable name, and minting it per app is how two apps end up with
293 /// `has-hover` and `hover-capable`.
294 #[must_use]
295 pub const fn token(self) -> &'static str {
296 match self {
297 Self::Hover => "offers-hover",
298 Self::Hint => "offers-hint",
299 Self::Ancillary => "offers-ancillary",
300 Self::Detail => "offers-detail",
301 Self::Anchored => "offers-anchored",
302 Self::Overflow => "offers-overflow",
303 Self::Gesture => "offers-gesture",
304 Self::Haptic => "offers-haptic",
305 }
306 }
307}
308
309/// The axis an [`Affordance`] reads: the input class or the screen budget.
310///
311/// Every member reads exactly one. `#[non_exhaustive]` because a member that
312/// genuinely read both would be added here as a third answer, and
313/// `no_member_reads_both_axes` is where that claim would be withdrawn.
314#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
315#[non_exhaustive]
316pub enum AffordanceAxis {
317 /// [`Density`]: pointer or touch.
318 Density,
319 /// [`SizeClass`]: compact, medium or expanded.
320 Size,
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326
327 /// Whether `a`'s availability changes with density at any size.
328 fn varies_by_density(a: Affordance) -> bool {
329 SizeClass::all()
330 .iter()
331 .any(|&s| a.available(Density::Pointer, s) != a.available(Density::Touch, s))
332 }
333
334 /// Whether `a`'s availability changes with size at either density.
335 fn varies_by_size(a: Affordance) -> bool {
336 [Density::Pointer, Density::Touch].iter().any(|&d| {
337 SizeClass::all()
338 .iter()
339 .any(|&s| a.available(d, s) != a.available(d, SizeClass::Compact))
340 })
341 }
342
343 /// Every combination of the two axes, narrowest and coarsest first.
344 fn surfaces() -> Vec<(Density, SizeClass)> {
345 let mut out = Vec::new();
346 for d in [Density::Pointer, Density::Touch] {
347 for &s in SizeClass::all() {
348 out.push((d, s));
349 }
350 }
351 out
352 }
353
354 #[test]
355 fn density_gates_only_what_the_contact_patch_touches() {
356 // The failure this module exists to avoid: a screen-budget claim
357 // smuggled onto the input axis, which is what let the old Touch gap
358 // preset set a floor under a preset quoted from the HIG. Adding a
359 // density dependency to a screen-budget affordance has to come here
360 // and say so.
361 for &a in Affordance::all() {
362 assert_eq!(
363 varies_by_density(a),
364 a.reads_density(),
365 "{a:?} disagrees with its own reads_density()"
366 );
367 }
368 }
369
370 #[test]
371 fn size_gates_only_what_screen_budget_touches() {
372 for &a in Affordance::all() {
373 assert_eq!(
374 varies_by_size(a),
375 a.reads_size(),
376 "{a:?} disagrees with its own reads_size()"
377 );
378 }
379 }
380
381 #[test]
382 fn no_member_reads_both_axes() {
383 // Not a law of adaptation, a statement about the members that exist.
384 // Measured off `available`, so a member whose rule starts reading the
385 // second axis fails here whatever its `axis` declares. A member that
386 // genuinely reads both is allowed, and this test is where the claim
387 // gets withdrawn rather than quietly falsified.
388 for &a in Affordance::all() {
389 assert!(
390 !(varies_by_density(a) && varies_by_size(a)),
391 "{a:?} reads both axes; update this test and say why"
392 );
393 }
394 }
395
396 #[test]
397 fn every_member_is_an_adaptation() {
398 // A member available everywhere, or nowhere, is not describing an
399 // adaptation and does not belong in this module.
400 for &a in Affordance::all() {
401 let yes = surfaces()
402 .iter()
403 .filter(|&&(d, s)| a.available(d, s))
404 .count();
405 assert!(yes > 0, "{a:?} exists on no surface");
406 assert!(yes < surfaces().len(), "{a:?} exists on every surface");
407 }
408 }
409
410 #[test]
411 fn availability_is_contiguous_across_the_size_ladder() {
412 // No member may exist at compact and expanded but not medium. A hole
413 // in the middle is always an off-by-one, never a design.
414 for &a in Affordance::all() {
415 for d in [Density::Pointer, Density::Touch] {
416 let run: Vec<bool> = SizeClass::all()
417 .iter()
418 .map(|&s| a.available(d, s))
419 .collect();
420 let transitions = run.windows(2).filter(|w| w[0] != w[1]).count();
421 assert!(
422 transitions <= 1,
423 "{a:?} at {d:?} is available in a broken run: {run:?}"
424 );
425 }
426 }
427 }
428
429 #[test]
430 fn compact_compensates_rather_than_only_losing() {
431 // The reposition bucket is the reason this module is not just a
432 // hide-list. Whatever compact takes
433 // away, something has to give back.
434 for d in [Density::Pointer, Density::Touch] {
435 assert!(!Affordance::Detail.available(d, SizeClass::Compact));
436 assert!(Affordance::Anchored.available(d, SizeClass::Compact));
437 }
438 }
439
440 #[test]
441 fn hover_and_hint_collapse_and_that_is_allowed() {
442 // Borrowed from makeover-geometry, where bound and peer both resolve to
443 // zero cells on a terminal and stay two members. Identical rules are
444 // not a duplicate; the call site names what is gated.
445 for (d, s) in surfaces() {
446 assert_eq!(
447 Affordance::Hover.available(d, s),
448 Affordance::Hint.available(d, s)
449 );
450 }
451 assert_ne!(Affordance::Hover.token(), Affordance::Hint.token());
452 }
453
454 /// The other density. Two members, so this is total, and writing it here
455 /// rather than in `makeover-geometry` keeps the axis definition borrowed
456 /// rather than extended.
457 fn opposite(d: Density) -> Density {
458 match d {
459 Density::Pointer => Density::Touch,
460 Density::Touch => Density::Pointer,
461 }
462 }
463
464 #[test]
465 fn a_density_member_is_available_on_exactly_the_density_it_declares() {
466 // Direction is a property of each member rather than of the module:
467 // Gesture and Haptic are gained by touch. A member still cannot invert
468 // quietly. It declares its density in `gained_by`, and this is where a
469 // declaration that disagrees with the rule shows up.
470 for &a in Affordance::all() {
471 match a.gained_by() {
472 Some(gained) => {
473 assert!(
474 a.reads_density(),
475 "{a:?} declares a density but denies reading one"
476 );
477 for &s in SizeClass::all() {
478 assert!(
479 a.available(gained, s),
480 "{a:?} declares {gained:?} but is unavailable there at {s:?}"
481 );
482 assert!(
483 !a.available(opposite(gained), s),
484 "{a:?} declares {gained:?} but is also available on the other density at {s:?}"
485 );
486 }
487 }
488 None => assert!(
489 !a.reads_density(),
490 "{a:?} reads density but declares no side"
491 ),
492 }
493 }
494 }
495
496 #[test]
497 fn both_densities_gain_something() {
498 // A module where only pointer gains members describes touch as a lesser
499 // pointer. If the touch-gained members ever go, this fails rather than
500 // passing quietly on an empty set.
501 for d in [Density::Pointer, Density::Touch] {
502 assert!(
503 Affordance::all().iter().any(|a| a.gained_by() == Some(d)),
504 "no member is gained by {d:?}"
505 );
506 }
507 }
508
509 #[test]
510 fn the_density_axis_compensates_rather_than_only_losing() {
511 // The density-axis twin of `compact_compensates_rather_than_only_losing`:
512 // Hover's own doc says something else has to carry the actions a
513 // fingertip cannot hover to reveal, and Gesture is that something.
514 for &s in SizeClass::all() {
515 assert!(!Affordance::Hover.available(Density::Touch, s));
516 assert!(Affordance::Gesture.available(Density::Touch, s));
517 }
518 }
519
520 #[test]
521 fn tokens_are_distinct() {
522 let mut seen: Vec<&str> = Affordance::all().iter().map(|a| a.token()).collect();
523 seen.sort_unstable();
524 let before = seen.len();
525 seen.dedup();
526 assert_eq!(seen.len(), before);
527 }
528
529 #[test]
530 fn the_axes_are_borrowed_not_redefined() {
531 // Imported rather than mirrored, so there is exactly one definition
532 // of each in the family. A local copy is how two crates start
533 // disagreeing about where 600px is.
534 assert_eq!(SizeClass::Medium.min_px(), 600);
535 assert_eq!(SizeClass::Expanded.min_px(), 840);
536 assert_eq!(SizeClass::at_width(599), SizeClass::Compact);
537 }
538}