Skip to main content

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::all().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::all() {
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::all() {
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::all() {
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::all() {
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}