| 36 |
36 |
|
//! product claim onto a measurement axis — which is exactly what the 2026-07-29
|
| 37 |
37 |
|
//! Touch demolition was for.
|
| 38 |
38 |
|
//!
|
|
39 |
+ |
//! Two of the eight members are not in that census at all, and the exception is
|
|
40 |
+ |
//! worth stating rather than leaving to be noticed. [`Affordance::Gesture`] and
|
|
41 |
+ |
//! [`Affordance::Haptic`] adapt *behaviour*, which no stylesheet contains, so
|
|
42 |
+ |
//! counting `@media` blocks could never have found them. Their evidence has the
|
|
43 |
+ |
//! same shape in a different file: goingson `frontend/js/touch.js:12` and
|
|
44 |
+ |
//! Balanced Breakfast `frontend/js/touch.js:12` carry the identical line
|
|
45 |
+ |
//!
|
|
46 |
+ |
//! ```js
|
|
47 |
+ |
//! const isTouchDevice = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
|
|
48 |
+ |
//! ```
|
|
49 |
+ |
//!
|
|
50 |
+ |
//! and each hangs five gestures off it. Two apps, arrived at independently,
|
|
51 |
+ |
//! hand-rolling the density question this crate exists to answer.
|
|
52 |
+ |
//!
|
| 39 |
53 |
|
//! # The two axes are borrowed, never redefined
|
| 40 |
54 |
|
//!
|
| 41 |
55 |
|
//! Boundaries are not this crate's job. `makeover-geometry` quotes Material 3's
|
| 57 |
71 |
|
//! claim on the input device is the specific failure that produced this crate,
|
| 58 |
72 |
|
//! and re-introducing it should have to come to the test and say so.
|
| 59 |
73 |
|
//!
|
|
74 |
+ |
//! # Both densities gain something (0.3.0)
|
|
75 |
+ |
//!
|
|
76 |
+ |
//! Until 0.3.0 this crate asserted that touch is pointer minus what a fingertip
|
|
77 |
+ |
//! cannot do — availability only ever subtracted as you moved from
|
|
78 |
+ |
//! [`Density::Pointer`] to [`Density::Touch`], and a test called
|
|
79 |
+ |
//! `touch_never_gains_an_affordance_pointer_lacks` said so. That claim is
|
|
80 |
+ |
//! withdrawn, deliberately, because it left a hole the crate could not name:
|
|
81 |
+ |
//! [`Affordance::Hover`]'s own doc says a fingertip has no hover state *and that
|
|
82 |
+ |
//! something else has to carry the same actions*, and nothing here was allowed
|
|
83 |
+ |
//! to be that something. [`Affordance::Anchored`] and [`Affordance::Overflow`]
|
|
84 |
+ |
//! compensate on the size axis; nothing compensated on the density axis.
|
|
85 |
+ |
//!
|
|
86 |
+ |
//! [`Affordance::Gesture`] and [`Affordance::Haptic`] are that compensation, and
|
|
87 |
+ |
//! both are gained by touch rather than lost to it.
|
|
88 |
+ |
//!
|
|
89 |
+ |
//! The claim is replaced rather than dropped. [`Affordance::gained_by`] makes
|
|
90 |
+ |
//! each member declare which density it belongs to, and
|
|
91 |
+ |
//! `a_density_member_is_available_on_exactly_the_density_it_declares` checks the
|
|
92 |
+ |
//! declaration against the rule. So a new member still cannot quietly invert:
|
|
93 |
+ |
//! it has to say which way it goes, in code, and the test is where a wrong
|
|
94 |
+ |
//! answer surfaces.
|
|
95 |
+ |
//!
|
| 60 |
96 |
|
//! # Collapsing is allowed, inverting is not
|
| 61 |
97 |
|
//!
|
| 62 |
98 |
|
//! Borrowed verbatim from `makeover-geometry`, where two gap relationships both
|
| 93 |
129 |
|
|
| 94 |
130 |
|
/// An affordance whose existence depends on the surface it is offered on.
|
| 95 |
131 |
|
///
|
| 96 |
|
- |
/// Six members, drawn from what the two measured apps already gate by hand
|
|
132 |
+ |
/// Eight members, drawn from what the two measured apps already gate by hand
|
| 97 |
133 |
|
/// rather than from a taxonomy. `makeover-layout`'s warning applies and is the
|
| 98 |
|
- |
/// reason for stopping here: guessing at eight is how a description becomes a
|
| 99 |
|
- |
/// framework.
|
|
134 |
+ |
/// reason each addition has to point at call sites rather than at a category:
|
|
135 |
+ |
/// guessing is how a description becomes a framework.
|
| 100 |
136 |
|
///
|
| 101 |
137 |
|
/// Each answers [`Self::available`] against the two axes and nothing else. An
|
| 102 |
138 |
|
/// affordance that is always available is not an affordance this crate has
|
| 157 |
193 |
|
/// same pressure on a cluster of controls, which cannot drop any of them
|
| 158 |
194 |
|
/// and folds instead.
|
| 159 |
195 |
|
Overflow,
|
|
196 |
+ |
/// A direct-manipulation gesture on content: swipe-to-action, long-press to
|
|
197 |
+ |
/// select, pull to refresh, swipe to navigate, drag to dismiss.
|
|
198 |
+ |
///
|
|
199 |
+ |
/// The density axis's compensating member, and the answer to the question
|
|
200 |
+ |
/// [`Self::Hover`] asks and cannot answer. A fingertip has no hover state,
|
|
201 |
+ |
/// so the row actions a pointer reveals by hovering have to arrive some
|
|
202 |
+ |
/// other way; on both webview apps that way is a swipe.
|
|
203 |
+ |
///
|
|
204 |
+ |
/// **One member for five gestures, on purpose.** goingson and Balanced
|
|
205 |
+ |
/// Breakfast each gate all five behind a single boolean, so one member is
|
|
206 |
+ |
/// what is measured and five would be minted from one fact. If a surface
|
|
207 |
+ |
/// ever offers swipe without long-press, splitting this is additive and the
|
|
208 |
+ |
/// call sites that named `Gesture` keep meaning what they meant.
|
|
209 |
+ |
Gesture,
|
|
210 |
+ |
/// Confirmation delivered through the contact patch rather than the eye: the
|
|
211 |
+ |
/// tick as a drag crosses a threshold, the thump as a gesture fires.
|
|
212 |
+ |
///
|
|
213 |
+ |
/// A contact-patch claim, which is what makes it this crate's business
|
|
214 |
+ |
/// despite being the one member nothing on screen shows. goingson
|
|
215 |
+ |
/// `frontend/js/haptics.js`.
|
|
216 |
+ |
///
|
|
217 |
+ |
/// The proxy breaks where the hardware has haptics and the user or the OS
|
|
218 |
+ |
/// has switched them off. That is neither [`Density`] nor [`SizeClass`], and
|
|
219 |
+ |
/// it is stated here rather than fixed for the same reason [`Self::Hint`]
|
|
220 |
+ |
/// states the detachable-keyboard case: a third axis is what fixing it would
|
|
221 |
+ |
/// cost. **This member says the surface can, never that the user wants.**
|
|
222 |
+ |
/// Asking the platform whether haptics are enabled is the renderer's job.
|
|
223 |
+ |
Haptic,
|
| 160 |
224 |
|
}
|
| 161 |
225 |
|
|
| 162 |
226 |
|
impl Affordance {
|
| 168 |
232 |
|
#[must_use]
|
| 169 |
233 |
|
pub const fn available(self, density: Density, size: SizeClass) -> bool {
|
| 170 |
234 |
|
match self {
|
| 171 |
|
- |
// Contact patch. Screen budget has no opinion about either.
|
|
235 |
+ |
// Contact patch, lost to a fingertip.
|
| 172 |
236 |
|
Self::Hover | Self::Hint => matches!(density, Density::Pointer),
|
|
237 |
+ |
// Contact patch, gained by one. A mouse can neither swipe a row nor
|
|
238 |
+ |
// feel a confirmation, and a phone-sized window has nothing to do
|
|
239 |
+ |
// with either.
|
|
240 |
+ |
Self::Gesture | Self::Haptic => matches!(density, Density::Touch),
|
| 173 |
241 |
|
// Screen budget. The input device has no opinion about any of them.
|
| 174 |
242 |
|
Self::Ancillary => matches!(size, SizeClass::Expanded),
|
| 175 |
243 |
|
Self::Detail => matches!(size, SizeClass::Medium | SizeClass::Expanded),
|
| 177 |
245 |
|
}
|
| 178 |
246 |
|
}
|
| 179 |
247 |
|
|
|
248 |
+ |
/// Which [`Density`] this affordance belongs to, or `None` when it reads the
|
|
249 |
+ |
/// screen budget instead.
|
|
250 |
+ |
///
|
|
251 |
+ |
/// The replacement for the withdrawn one-directional rule (see the crate
|
|
252 |
+ |
/// doc). Until 0.3.0 the direction was a property of the whole crate and a
|
|
253 |
+ |
/// test enforced it globally; now each member declares its own, and
|
|
254 |
+ |
/// `a_density_member_is_available_on_exactly_the_density_it_declares` holds
|
|
255 |
+ |
/// the declaration to the rule.
|
|
256 |
+ |
///
|
|
257 |
+ |
/// Exposed rather than kept private for the same reason [`Self::reads_density`]
|
|
258 |
+ |
/// is: it is the crate's claim about itself, and a renderer that has one
|
|
259 |
+ |
/// density can read it directly instead of probing [`Self::available`] twice.
|
|
260 |
+ |
#[must_use]
|
|
261 |
+ |
pub const fn gained_by(self) -> Option<Density> {
|
|
262 |
+ |
match self {
|
|
263 |
+ |
Self::Hover | Self::Hint => Some(Density::Pointer),
|
|
264 |
+ |
Self::Gesture | Self::Haptic => Some(Density::Touch),
|
|
265 |
+ |
Self::Ancillary | Self::Detail | Self::Anchored | Self::Overflow => None,
|
|
266 |
+ |
}
|
|
267 |
+ |
}
|
|
268 |
+ |
|
| 180 |
269 |
|
/// Whether this affordance's availability reads [`Density`] at all.
|
| 181 |
270 |
|
///
|
| 182 |
271 |
|
/// Exposed rather than kept private because it is the crate's own claim
|
| 184 |
273 |
|
/// yes. A renderer with one density can skip the rest entirely.
|
| 185 |
274 |
|
#[must_use]
|
| 186 |
275 |
|
pub const fn reads_density(self) -> bool {
|
| 187 |
|
- |
matches!(self, Self::Hover | Self::Hint)
|
|
276 |
+ |
self.gained_by().is_some()
|
| 188 |
277 |
|
}
|
| 189 |
278 |
|
|
| 190 |
279 |
|
/// Whether this affordance's availability reads [`SizeClass`] at all.
|
| 195 |
284 |
|
|
| 196 |
285 |
|
/// Every member, in declaration order.
|
| 197 |
286 |
|
#[must_use]
|
| 198 |
|
- |
pub const fn all() -> [Self; 6] {
|
|
287 |
+ |
pub const fn all() -> [Self; 8] {
|
| 199 |
288 |
|
[
|
| 200 |
289 |
|
Self::Hover,
|
| 201 |
290 |
|
Self::Hint,
|
| 203 |
292 |
|
Self::Detail,
|
| 204 |
293 |
|
Self::Anchored,
|
| 205 |
294 |
|
Self::Overflow,
|
|
295 |
+ |
Self::Gesture,
|
|
296 |
+ |
Self::Haptic,
|
| 206 |
297 |
|
]
|
| 207 |
298 |
|
}
|
| 208 |
299 |
|
|
| 220 |
311 |
|
Self::Detail => "offers-detail",
|
| 221 |
312 |
|
Self::Anchored => "offers-anchored",
|
| 222 |
313 |
|
Self::Overflow => "offers-overflow",
|
|
314 |
+ |
Self::Gesture => "offers-gesture",
|
|
315 |
+ |
Self::Haptic => "offers-haptic",
|
| 223 |
316 |
|
}
|
| 224 |
317 |
|
}
|
| 225 |
318 |
|
}
|
| 368 |
461 |
|
assert_ne!(Affordance::Hover.token(), Affordance::Hint.token());
|
| 369 |
462 |
|
}
|
| 370 |
463 |
|
|
|
464 |
+ |
/// The other density. Two members, so this is total, and writing it here
|
|
465 |
+ |
/// rather than in `makeover-geometry` keeps the axis definition borrowed
|
|
466 |
+ |
/// rather than extended.
|
|
467 |
+ |
fn opposite(d: Density) -> Density {
|
|
468 |
+ |
match d {
|
|
469 |
+ |
Density::Pointer => Density::Touch,
|
|
470 |
+ |
Density::Touch => Density::Pointer,
|
|
471 |
+ |
}
|
|
472 |
+ |
}
|
|
473 |
+ |
|
| 371 |
474 |
|
#[test]
|
| 372 |
|
- |
fn touch_never_gains_an_affordance_pointer_lacks() {
|
| 373 |
|
- |
// Direction matters, same as makeover-geometry's cross-density rule.
|
| 374 |
|
- |
// Touch is derived from pointer by subtracting what a fingertip cannot
|
| 375 |
|
- |
// do, so it can lose members and never gain one.
|
| 376 |
|
- |
for s in SizeClass::all() {
|
| 377 |
|
- |
for a in Affordance::all() {
|
| 378 |
|
- |
if a.available(Density::Touch, s) {
|
|
475 |
+ |
fn a_density_member_is_available_on_exactly_the_density_it_declares() {
|
|
476 |
+ |
// Replaces `touch_never_gains_an_affordance_pointer_lacks`, withdrawn in
|
|
477 |
+ |
// 0.3.0. That test made direction a property of the whole crate: touch
|
|
478 |
+ |
// was pointer minus what a fingertip cannot do, and could never add.
|
|
479 |
+ |
// Gesture and Haptic add, so the global claim had to go.
|
|
480 |
+ |
//
|
|
481 |
+ |
// What survives is the part worth keeping. A member still cannot invert
|
|
482 |
+ |
// quietly -- it declares its density in `gained_by`, and this is where a
|
|
483 |
+ |
// declaration that disagrees with the rule shows up. The old test's job
|
|
484 |
+ |
// was to make a direction change deliberate; so is this one's.
|
|
485 |
+ |
for a in Affordance::all() {
|
|
486 |
+ |
match a.gained_by() {
|
|
487 |
+ |
Some(gained) => {
|
| 379 |
488 |
|
assert!(
|
| 380 |
|
- |
a.available(Density::Pointer, s),
|
| 381 |
|
- |
"{a:?} exists on touch at {s:?} but not on pointer"
|
|
489 |
+ |
a.reads_density(),
|
|
490 |
+ |
"{a:?} declares a density but denies reading one"
|
| 382 |
491 |
|
);
|
|
492 |
+ |
for s in SizeClass::all() {
|
|
493 |
+ |
assert!(
|
|
494 |
+ |
a.available(gained, s),
|
|
495 |
+ |
"{a:?} declares {gained:?} but is unavailable there at {s:?}"
|
|
496 |
+ |
);
|
|
497 |
+ |
assert!(
|
|
498 |
+ |
!a.available(opposite(gained), s),
|
|
499 |
+ |
"{a:?} declares {gained:?} but is also available on the other density at {s:?}"
|
|
500 |
+ |
);
|
|
501 |
+ |
}
|
| 383 |
502 |
|
}
|
|
503 |
+ |
None => assert!(
|
|
504 |
+ |
!a.reads_density(),
|
|
505 |
+ |
"{a:?} reads density but declares no side"
|
|
506 |
+ |
),
|
| 384 |
507 |
|
}
|
| 385 |
508 |
|
}
|
| 386 |
509 |
|
}
|
| 387 |
510 |
|
|
|
511 |
+ |
#[test]
|
|
512 |
+ |
fn both_densities_gain_something() {
|
|
513 |
+ |
// The withdrawn rule, inverted into a statement of what replaced it. A
|
|
514 |
+ |
// crate where only pointer gains members is the one this stopped being,
|
|
515 |
+ |
// and if a refactor ever takes the touch-gained members back out, the
|
|
516 |
+ |
// honest move is to restore the old one-directional test rather than
|
|
517 |
+ |
// let this one quietly pass on an empty set.
|
|
518 |
+ |
for d in [Density::Pointer, Density::Touch] {
|
|
519 |
+ |
assert!(
|
|
520 |
+ |
Affordance::all().iter().any(|a| a.gained_by() == Some(d)),
|
|
521 |
+ |
"no member is gained by {d:?}"
|
|
522 |
+ |
);
|
|
523 |
+ |
}
|
|
524 |
+ |
}
|
|
525 |
+ |
|
|
526 |
+ |
#[test]
|
|
527 |
+ |
fn the_density_axis_compensates_rather_than_only_losing() {
|
|
528 |
+ |
// The density-axis twin of `compact_compensates_rather_than_only_losing`,
|
|
529 |
+ |
// and the hole that motivated 0.3.0: Hover's own doc says something else
|
|
530 |
+ |
// has to carry the actions a fingertip cannot hover to reveal, and until
|
|
531 |
+ |
// Gesture existed nothing here could be that something.
|
|
532 |
+ |
for s in SizeClass::all() {
|
|
533 |
+ |
assert!(!Affordance::Hover.available(Density::Touch, s));
|
|
534 |
+ |
assert!(Affordance::Gesture.available(Density::Touch, s));
|
|
535 |
+ |
}
|
|
536 |
+ |
}
|
|
537 |
+ |
|
| 388 |
538 |
|
#[test]
|
| 389 |
539 |
|
fn tokens_are_distinct() {
|
| 390 |
540 |
|
let mut seen: Vec<&str> = Affordance::all().iter().map(|a| a.token()).collect();
|