|
1 |
+ |
//! The adaptation layer of the make-family design system.
|
|
2 |
+ |
//!
|
|
3 |
+ |
//! <!-- wiki: makeover-touch -->
|
|
4 |
+ |
//!
|
|
5 |
+ |
//! `makeover` answers *what colour*. `makeover-geometry` answers *how much
|
|
6 |
+ |
//! space*, and owns the two axes an adaptation is stated against:
|
|
7 |
+ |
//! [`Density`] (pointer or touch) and [`SizeClass`] (compact, medium,
|
|
8 |
+ |
//! expanded). `makeover-layout` answers *what the thing is*. This crate
|
|
9 |
+ |
//! answers one question and no other:
|
|
10 |
+ |
//!
|
|
11 |
+ |
//! > Does this affordance exist here?
|
|
12 |
+ |
//!
|
|
13 |
+ |
//! Like `makeover-layout` it emits nothing. It is a description, rendered to
|
|
14 |
+ |
//! CSS by `makeover-webview` and to whatever the other renderers can express.
|
|
15 |
+ |
//!
|
|
16 |
+ |
//! # Why this is a crate and not a density preset
|
|
17 |
+ |
//!
|
|
18 |
+ |
//! Measured across the MNW server's `@media` blocks (137) and goingson's
|
|
19 |
+ |
//! `ui-mode-*` blocks (192), bucketed by what the declarations inside actually
|
|
20 |
+ |
//! change:
|
|
21 |
+ |
//!
|
|
22 |
+ |
//! | bucket | MNW | GO | retired by |
|
|
23 |
+ |
//! |---|---|---|---|
|
|
24 |
+ |
//! | density | 32% | 27% | a `makeover-geometry` preset |
|
|
25 |
+ |
//! | type | 23% | 15% | the type scale |
|
|
26 |
+ |
//! | columns | 20% | 12% | `makeover_layout::Column` |
|
|
27 |
+ |
//! | reflow | 16% | 17% | `makeover_layout::Arrangement` |
|
|
28 |
+ |
//! | **show/hide** | **12%** | **12%** | **this crate** |
|
|
29 |
+ |
//! | **reposition** | **6%** | **20%** | **this crate** |
|
|
30 |
+ |
//! | **appearance** | **1%** | **17%** | **this crate** |
|
|
31 |
+ |
//!
|
|
32 |
+ |
//! The bottom three are the roughly 43% that no spacing scale can retire, and
|
|
33 |
+ |
//! trying is the mistake this whole family already made once. `display: none`
|
|
34 |
+ |
//! on a keyboard hint says *the affordance does not exist on touch*. No amount
|
|
35 |
+ |
//! of gap retuning expresses that, and a scale that tried would be smuggling a
|
|
36 |
+ |
//! product claim onto a measurement axis — which is exactly what the 2026-07-29
|
|
37 |
+ |
//! Touch demolition was for.
|
|
38 |
+ |
//!
|
|
39 |
+ |
//! # The two axes are borrowed, never redefined
|
|
40 |
+ |
//!
|
|
41 |
+ |
//! Boundaries are not this crate's job. `makeover-geometry` quotes Material 3's
|
|
42 |
+ |
//! window size classes at 600 and 840 and carries [`Density`]; this crate names
|
|
43 |
+ |
//! affordances *against* those two and adds no third axis, no fourth class and
|
|
44 |
+ |
//! no breakpoint of its own. If a rule here wants a boundary that does not
|
|
45 |
+ |
//! exist, that is a conversation with `makeover-geometry`, not a constant.
|
|
46 |
+ |
//!
|
|
47 |
+ |
//! # What density is allowed to gate
|
|
48 |
+ |
//!
|
|
49 |
+ |
//! Density is a claim about **the contact patch and nothing else**. So it gates
|
|
50 |
+ |
//! affordances that depend on an interaction a fingertip cannot perform —
|
|
51 |
+ |
//! hovering, and the keyboard chrome that documents shortcuts a touch surface
|
|
52 |
+ |
//! has no way to send. It does not gate anything that is really about how much
|
|
53 |
+ |
//! screen there is. A phone is small *and* touch; a tablet is big *and* touch.
|
|
54 |
+ |
//!
|
|
55 |
+ |
//! That separation is asserted, not merely intended, by
|
|
56 |
+ |
//! `density_gates_only_what_the_contact_patch_touches`. Putting a screen-budget
|
|
57 |
+ |
//! claim on the input device is the specific failure that produced this crate,
|
|
58 |
+ |
//! and re-introducing it should have to come to the test and say so.
|
|
59 |
+ |
//!
|
|
60 |
+ |
//! # Collapsing is allowed, inverting is not
|
|
61 |
+ |
//!
|
|
62 |
+ |
//! Borrowed verbatim from `makeover-geometry`, where two gap relationships both
|
|
63 |
+ |
//! resolve to zero cells on a terminal and stay two members regardless. Two
|
|
64 |
+ |
//! affordances here may have identical availability today — [`Affordance::Hover`]
|
|
65 |
+ |
//! and [`Affordance::Hint`] do — and are still two members, because the call
|
|
66 |
+ |
//! site names *what is being gated*, not the rule. What must never happen is
|
|
67 |
+ |
//! one of them becoming available where the other is not for a reason that is
|
|
68 |
+ |
//! really the same reason.
|
|
69 |
+ |
//!
|
|
70 |
+ |
//! # Deliberately absent
|
|
71 |
+ |
//!
|
|
72 |
+ |
//! **A navigation shell fork.** goingson currently carries two: 12 forked
|
|
73 |
+ |
//! selectors and 10 desktop-only rules concentrated in `.app-header`, `.tab`,
|
|
74 |
+ |
//! `.tab-navigation`, `.pill-nav`, `.saved-views-sidebar` and
|
|
75 |
+ |
//! `.modal-container`. That is not one shell adapting, it is two shells, and
|
|
76 |
+ |
//! choosing to build two is a product decision rather than an adaptation. This
|
|
77 |
+ |
//! crate will not describe it, and goingson's own restructure is the way it
|
|
78 |
+ |
//! stops being true. Named here the way `makeover-layout` names validation
|
|
79 |
+ |
//! absent, so nobody has to discover it.
|
|
80 |
+ |
//!
|
|
81 |
+ |
//! **Which class applies.** The app decides, from a measured width via
|
|
82 |
+ |
//! [`SizeClass::at_width`] and from whatever it already knows about the input.
|
|
83 |
+ |
//! This crate takes both as arguments and never sniffs.
|
|
84 |
+ |
//!
|
|
85 |
+ |
//! **What a renderer does when an affordance is unavailable.** Hiding it,
|
|
86 |
+ |
//! substituting it, or showing it unconditionally anyway is renderer policy.
|
|
87 |
+ |
//! `makeover-layout` already deleted `Fill::fallback` for being exactly that.
|
|
88 |
+ |
|
|
89 |
+ |
#![forbid(unsafe_code)]
|
|
90 |
+ |
|
|
91 |
+ |
pub use makeover_geometry::{Density, SizeClass};
|
|
92 |
+ |
pub use makeover_layout::Priority;
|
|
93 |
+ |
|
|
94 |
+ |
/// An affordance whose existence depends on the surface it is offered on.
|
|
95 |
+ |
///
|
|
96 |
+ |
/// Six members, drawn from what the two measured apps already gate by hand
|
|
97 |
+ |
/// 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.
|
|
100 |
+ |
///
|
|
101 |
+ |
/// Each answers [`Self::available`] against the two axes and nothing else. An
|
|
102 |
+ |
/// affordance that is always available is not an affordance this crate has
|
|
103 |
+ |
/// anything to say about, and `every_member_is_an_adaptation` asserts none has
|
|
104 |
+ |
/// snuck in.
|
|
105 |
+ |
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
106 |
+ |
#[non_exhaustive]
|
|
107 |
+ |
pub enum Affordance {
|
|
108 |
+ |
/// Anything a consumer reveals on hover: a row's action cluster, a
|
|
109 |
+ |
/// hover toolbar, a preview popover.
|
|
110 |
+ |
///
|
|
111 |
+ |
/// Both webview apps arrived at hover-revealed row actions independently
|
|
112 |
+ |
/// (goingson `.task-row-action`, Balanced Breakfast `.row-actions`), which
|
|
113 |
+ |
/// is why `makeover-layout` records the reveal as behaviour of
|
|
114 |
+ |
/// `RowPart::Actions` rather than as app policy. What neither app can say
|
|
115 |
+ |
/// is that a fingertip has no hover state at all, so the affordance is not
|
|
116 |
+ |
/// hidden on touch — it does not exist there, and something else has to
|
|
117 |
+ |
/// carry the same actions.
|
|
118 |
+ |
Hover,
|
|
119 |
+ |
/// Chrome documenting a keyboard interaction: shortcut badges, key hints,
|
|
120 |
+ |
/// a "press / to search" line.
|
|
121 |
+ |
///
|
|
122 |
+ |
/// goingson hides `.kbd-hint` on touch. Strictly this is a claim about
|
|
123 |
+ |
/// having a keyboard rather than about the contact patch, and [`Density`]
|
|
124 |
+ |
/// is the closest honest proxy the family carries. Stated rather than
|
|
125 |
+ |
/// hidden, because a detachable-keyboard tablet is where the proxy breaks
|
|
126 |
+ |
/// and a third axis is what fixing it would cost.
|
|
127 |
+ |
Hint,
|
|
128 |
+ |
/// A secondary panel standing beside the primary content: a saved-views
|
|
129 |
+ |
/// rail, a filter sidebar, an inspector.
|
|
130 |
+ |
///
|
|
131 |
+ |
/// goingson hides `.saved-views-sidebar` below its widest layout. Purely a
|
|
132 |
+ |
/// screen-budget claim — a touchscreen laptop should keep it — so this
|
|
133 |
+ |
/// reads [`SizeClass`] alone.
|
|
134 |
+ |
Ancillary,
|
|
135 |
+ |
/// The detail half of a list-detail split, shown *alongside* the list
|
|
136 |
+ |
/// rather than navigated to.
|
|
137 |
+ |
///
|
|
138 |
+ |
/// goingson's `.main-content` and Balanced Breakfast's `.detail-panel`.
|
|
139 |
+ |
/// Unavailable is not the same as absent: the detail still exists, it is
|
|
140 |
+ |
/// reached by navigation instead of by adjacency, and which of the two a
|
|
141 |
+ |
/// screen gets is what `makeover-layout`'s `Arrangement` is describing.
|
|
142 |
+ |
Detail,
|
|
143 |
+ |
/// Navigation or a primary action cluster pinned to a fixed screen edge
|
|
144 |
+ |
/// instead of sitting in the flow of the page.
|
|
145 |
+ |
///
|
|
146 |
+ |
/// The reposition bucket, and the largest single one in goingson at 20%.
|
|
147 |
+ |
/// It exists to compensate for what a compact window cannot hold in flow,
|
|
148 |
+ |
/// so unlike the two above it is available at the *narrow* end and not the
|
|
149 |
+ |
/// wide one. That inversion is the point: an adaptation that only ever
|
|
150 |
+ |
/// removes things describes a degraded layout rather than a different one.
|
|
151 |
+ |
Anchored,
|
|
152 |
+ |
/// An action cluster collapsed behind one control rather than laid out
|
|
153 |
+ |
/// inline.
|
|
154 |
+ |
///
|
|
155 |
+ |
/// The other compensating member. `makeover-layout`'s `Column::kept_at`
|
|
156 |
+ |
/// already handles a *table* narrowing by dropping columns; this is the
|
|
157 |
+ |
/// same pressure on a cluster of controls, which cannot drop any of them
|
|
158 |
+ |
/// and folds instead.
|
|
159 |
+ |
Overflow,
|
|
160 |
+ |
}
|
|
161 |
+ |
|
|
162 |
+ |
impl Affordance {
|
|
163 |
+ |
/// Whether this affordance exists on a surface with the given input class
|
|
164 |
+ |
/// and screen budget.
|
|
165 |
+ |
///
|
|
166 |
+ |
/// The whole crate in one call. A renderer asks per affordance and never
|
|
167 |
+ |
/// branches on a width.
|
|
168 |
+ |
#[must_use]
|
|
169 |
+ |
pub const fn available(self, density: Density, size: SizeClass) -> bool {
|
|
170 |
+ |
match self {
|
|
171 |
+ |
// Contact patch. Screen budget has no opinion about either.
|
|
172 |
+ |
Self::Hover | Self::Hint => matches!(density, Density::Pointer),
|
|
173 |
+ |
// Screen budget. The input device has no opinion about any of them.
|
|
174 |
+ |
Self::Ancillary => matches!(size, SizeClass::Expanded),
|
|
175 |
+ |
Self::Detail => matches!(size, SizeClass::Medium | SizeClass::Expanded),
|
|
176 |
+ |
Self::Anchored | Self::Overflow => matches!(size, SizeClass::Compact),
|
|
177 |
+ |
}
|
|
178 |
+ |
}
|
|
179 |
+ |
|
|
180 |
+ |
/// Whether this affordance's availability reads [`Density`] at all.
|
|
181 |
+ |
///
|
|
182 |
+ |
/// Exposed rather than kept private because it is the crate's own claim
|
|
183 |
+ |
/// about itself: exactly the members gating a contact-patch interaction say
|
|
184 |
+ |
/// yes. A renderer with one density can skip the rest entirely.
|
|
185 |
+ |
#[must_use]
|
|
186 |
+ |
pub const fn reads_density(self) -> bool {
|
|
187 |
+ |
matches!(self, Self::Hover | Self::Hint)
|
|
188 |
+ |
}
|
|
189 |
+ |
|
|
190 |
+ |
/// Whether this affordance's availability reads [`SizeClass`] at all.
|
|
191 |
+ |
#[must_use]
|
|
192 |
+ |
pub const fn reads_size(self) -> bool {
|
|
193 |
+ |
!self.reads_density()
|
|
194 |
+ |
}
|
|
195 |
+ |
|
|
196 |
+ |
/// Every member, in declaration order.
|
|
197 |
+ |
#[must_use]
|
|
198 |
+ |
pub const fn all() -> [Self; 6] {
|
|
199 |
+ |
[
|
|
200 |
+ |
Self::Hover,
|
|
201 |
+ |
Self::Hint,
|
|
202 |
+ |
Self::Ancillary,
|
|
203 |
+ |
Self::Detail,
|
|
204 |
+ |
Self::Anchored,
|
|
205 |
+ |
Self::Overflow,
|
|
206 |
+ |
]
|
|
207 |
+ |
}
|
|
208 |
+ |
|
|
209 |
+ |
/// The CSS class name an app may hang off this, without the leading dot.
|
|
210 |
+ |
///
|
|
211 |
+ |
/// Present for the same reason [`SizeClass::token`] is: a webview renderer
|
|
212 |
+ |
/// needs a stable name, and minting it per app is how two apps end up with
|
|
213 |
+ |
/// `has-hover` and `hover-capable`.
|
|
214 |
+ |
#[must_use]
|
|
215 |
+ |
pub const fn token(self) -> &'static str {
|
|
216 |
+ |
match self {
|
|
217 |
+ |
Self::Hover => "offers-hover",
|
|
218 |
+ |
Self::Hint => "offers-hint",
|
|
219 |
+ |
Self::Ancillary => "offers-ancillary",
|
|
220 |
+ |
Self::Detail => "offers-detail",
|
|
221 |
+ |
Self::Anchored => "offers-anchored",
|
|
222 |
+ |
Self::Overflow => "offers-overflow",
|
|
223 |
+ |
}
|
|
224 |
+ |
}
|
|
225 |
+ |
}
|
|
226 |
+ |
|
|
227 |
+ |
/// The column-drop cutoff a window of this size class asks for.
|
|
228 |
+ |
///
|
|
229 |
+ |
/// The seam between `makeover-layout` and `makeover-geometry` that neither
|
|
230 |
+ |
/// crate could close. Layout defines the priority ladder and `Column::kept_at`;
|
|
231 |
+ |
/// geometry defines the boundaries. Nothing said *which* cutoff a compact
|
|
232 |
+ |
/// window uses, so both webview apps answered it with `nth-child` on an ordinal
|
|
233 |
+ |
/// and inserting a column silently hid the wrong one.
|
|
234 |
+ |
///
|
|
235 |
+ |
/// A free function rather than an [`Affordance`] member because a column is not
|
|
236 |
+ |
/// gated, it is ranked: the question is which cutoff to raise to, not whether
|
|
237 |
+ |
/// the table exists.
|
|
238 |
+ |
#[must_use]
|
|
239 |
+ |
pub const fn column_cutoff(size: SizeClass) -> Priority {
|
|
240 |
+ |
match size {
|
|
241 |
+ |
// Only what identifies the row.
|
|
242 |
+ |
SizeClass::Compact => Priority::Essential,
|
|
243 |
+ |
// The optional columns go first.
|
|
244 |
+ |
SizeClass::Medium => Priority::Secondary,
|
|
245 |
+ |
// Everything survives.
|
|
246 |
+ |
SizeClass::Expanded => Priority::Optional,
|
|
247 |
+ |
}
|
|
248 |
+ |
}
|
|
249 |
+ |
|
|
250 |
+ |
#[cfg(test)]
|
|
251 |
+ |
mod tests {
|
|
252 |
+ |
use super::*;
|
|
253 |
+ |
|
|
254 |
+ |
/// Every combination of the two axes, narrowest and coarsest first.
|
|
255 |
+ |
fn surfaces() -> Vec<(Density, SizeClass)> {
|
|
256 |
+ |
let mut out = Vec::new();
|
|
257 |
+ |
for d in [Density::Pointer, Density::Touch] {
|
|
258 |
+ |
for s in SizeClass::all() {
|
|
259 |
+ |
out.push((d, s));
|
|
260 |
+ |
}
|
|
261 |
+ |
}
|
|
262 |
+ |
out
|
|
263 |
+ |
}
|
|
264 |
+ |
|
|
265 |
+ |
#[test]
|
|
266 |
+ |
fn density_gates_only_what_the_contact_patch_touches() {
|
|
267 |
+ |
// The failure this crate exists to avoid: a screen-budget claim
|
|
268 |
+ |
// smuggled onto the input axis, which is what let the old Touch gap
|
|
269 |
+ |
// preset set a floor under a preset quoted from the HIG. Adding a
|
|
270 |
+ |
// density dependency to a screen-budget affordance has to come here
|
|
271 |
+ |
// and say so.
|
|
272 |
+ |
for a in Affordance::all() {
|
|
273 |
+ |
let varies_by_density = SizeClass::all()
|
|
274 |
+ |
.iter()
|
|
275 |
+ |
.any(|&s| a.available(Density::Pointer, s) != a.available(Density::Touch, s));
|
|
276 |
+ |
assert_eq!(
|
|
277 |
+ |
varies_by_density,
|
|
278 |
+ |
a.reads_density(),
|
|
279 |
+ |
"{a:?} disagrees with its own reads_density()"
|
|
280 |
+ |
);
|
|
281 |
+ |
}
|
|
282 |
+ |
}
|
|
283 |
+ |
|
|
284 |
+ |
#[test]
|
|
285 |
+ |
fn size_gates_only_what_screen_budget_touches() {
|
|
286 |
+ |
for a in Affordance::all() {
|
|
287 |
+ |
let varies_by_size = [Density::Pointer, Density::Touch].iter().any(|&d| {
|
|
288 |
+ |
SizeClass::all()
|
|
289 |
+ |
.iter()
|
|
290 |
+ |
.any(|&s| a.available(d, s) != a.available(d, SizeClass::Compact))
|
|
291 |
+ |
});
|
|
292 |
+ |
assert_eq!(
|
|
293 |
+ |
varies_by_size,
|
|
294 |
+ |
a.reads_size(),
|
|
295 |
+ |
"{a:?} disagrees with its own reads_size()"
|
|
296 |
+ |
);
|
|
297 |
+ |
}
|
|
298 |
+ |
}
|
|
299 |
+ |
|
|
300 |
+ |
#[test]
|
|
301 |
+ |
fn no_member_reads_both_axes() {
|
|
302 |
+ |
// Not a law of adaptation, a statement about the six that exist. A
|
|
303 |
+ |
// seventh reading both is allowed, and this test is where the claim
|
|
304 |
+ |
// gets withdrawn rather than quietly falsified.
|
|
305 |
+ |
for a in Affordance::all() {
|
|
306 |
+ |
assert!(
|
|
307 |
+ |
a.reads_density() != a.reads_size(),
|
|
308 |
+ |
"{a:?} reads both axes; update this test and say why"
|
|
309 |
+ |
);
|
|
310 |
+ |
}
|
|
311 |
+ |
}
|
|
312 |
+ |
|
|
313 |
+ |
#[test]
|
|
314 |
+ |
fn every_member_is_an_adaptation() {
|
|
315 |
+ |
// A member available everywhere, or nowhere, is not describing an
|
|
316 |
+ |
// adaptation and does not belong in this crate.
|
|
317 |
+ |
for a in Affordance::all() {
|
|
318 |
+ |
let yes = surfaces()
|
|
319 |
+ |
.iter()
|
|
320 |
+ |
.filter(|&&(d, s)| a.available(d, s))
|
|
321 |
+ |
.count();
|
|
322 |
+ |
assert!(yes > 0, "{a:?} exists on no surface");
|
|
323 |
+ |
assert!(yes < surfaces().len(), "{a:?} exists on every surface");
|
|
324 |
+ |
}
|
|
325 |
+ |
}
|
|
326 |
+ |
|
|
327 |
+ |
#[test]
|
|
328 |
+ |
fn availability_is_contiguous_across_the_size_ladder() {
|
|
329 |
+ |
// No member may exist at compact and expanded but not medium. A hole
|
|
330 |
+ |
// in the middle is always an off-by-one, never a design.
|
|
331 |
+ |
for a in Affordance::all() {
|
|
332 |
+ |
for d in [Density::Pointer, Density::Touch] {
|
|
333 |
+ |
let run: Vec<bool> = SizeClass::all()
|
|
334 |
+ |
.iter()
|
|
335 |
+ |
.map(|&s| a.available(d, s))
|
|
336 |
+ |
.collect();
|
|
337 |
+ |
let transitions = run.windows(2).filter(|w| w[0] != w[1]).count();
|
|
338 |
+ |
assert!(
|
|
339 |
+ |
transitions <= 1,
|
|
340 |
+ |
"{a:?} at {d:?} is available in a broken run: {run:?}"
|
|
341 |
+ |
);
|
|
342 |
+ |
}
|
|
343 |
+ |
}
|
|
344 |
+ |
}
|
|
345 |
+ |
|
|
346 |
+ |
#[test]
|
|
347 |
+ |
fn compact_compensates_rather_than_only_losing() {
|
|
348 |
+ |
// The reposition bucket is 20% of goingson's adaptation rules and the
|
|
349 |
+ |
// reason this crate is not just a hide-list. Whatever compact takes
|
|
350 |
+ |
// away, something has to give back.
|
|
351 |
+ |
for d in [Density::Pointer, Density::Touch] {
|
|
352 |
+ |
assert!(!Affordance::Detail.available(d, SizeClass::Compact));
|
|
353 |
+ |
assert!(Affordance::Anchored.available(d, SizeClass::Compact));
|
|
354 |
+ |
}
|
|
355 |
+ |
}
|
|
356 |
+ |
|
|
357 |
+ |
#[test]
|
|
358 |
+ |
fn hover_and_hint_collapse_and_that_is_allowed() {
|
|
359 |
+ |
// Borrowed from makeover-geometry, where bound and peer both resolve to
|
|
360 |
+ |
// zero cells on a terminal and stay two members. Identical rules are
|
|
361 |
+ |
// not a duplicate; the call site names what is gated.
|
|
362 |
+ |
for (d, s) in surfaces() {
|
|
363 |
+ |
assert_eq!(
|
|
364 |
+ |
Affordance::Hover.available(d, s),
|
|
365 |
+ |
Affordance::Hint.available(d, s)
|
|
366 |
+ |
);
|
|
367 |
+ |
}
|
|
368 |
+ |
assert_ne!(Affordance::Hover.token(), Affordance::Hint.token());
|
|
369 |
+ |
}
|
|
370 |
+ |
|
|
371 |
+ |
#[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) {
|
|
379 |
+ |
assert!(
|
|
380 |
+ |
a.available(Density::Pointer, s),
|
|
381 |
+ |
"{a:?} exists on touch at {s:?} but not on pointer"
|
|
382 |
+ |
);
|
|
383 |
+ |
}
|
|
384 |
+ |
}
|
|
385 |
+ |
}
|
|
386 |
+ |
}
|
|
387 |
+ |
|
|
388 |
+ |
#[test]
|
|
389 |
+ |
fn tokens_are_distinct() {
|
|
390 |
+ |
let mut seen: Vec<&str> = Affordance::all().iter().map(|a| a.token()).collect();
|
|
391 |
+ |
seen.sort_unstable();
|
|
392 |
+ |
let before = seen.len();
|
|
393 |
+ |
seen.dedup();
|
|
394 |
+ |
assert_eq!(seen.len(), before);
|
|
395 |
+ |
}
|
|
396 |
+ |
|
|
397 |
+ |
#[test]
|
|
398 |
+ |
fn the_column_cutoff_relaxes_as_the_window_widens() {
|
|
399 |
+ |
// Priority derives Ord with Optional lowest, so a narrower window is a
|
|
400 |
+ |
// higher cutoff. Asserted by comparison rather than by naming the three
|
|
401 |
+ |
// constants, so reordering the ladder in makeover-layout breaks here.
|
|
402 |
+ |
assert!(column_cutoff(SizeClass::Compact) > column_cutoff(SizeClass::Medium));
|
|
403 |
+ |
assert!(column_cutoff(SizeClass::Medium) > column_cutoff(SizeClass::Expanded));
|
|
404 |
+ |
}
|
|
405 |
+ |
|
|
406 |
+ |
#[test]
|
|
407 |
+ |
fn the_column_cutoff_replaces_the_ordinal() {
|
|
408 |
+ |
// goingson's bug, written against this crate's answer: inserting a
|
|
409 |
+ |
// column must not change which column drops.
|
|
410 |
+ |
use makeover_layout::{Column, Priority as P, Width};
|
|
411 |
+ |
|
|
412 |
+ |
let before = [
|
|
413 |
+ |
Column {
|
|
414 |
+ |
name: "Title",
|
|
415 |
+ |
width: Width::Fill,
|
|
416 |
+ |
priority: P::Essential,
|
|
417 |
+ |
},
|
|
418 |
+ |
Column {
|
|
419 |
+ |
name: "Due",
|
|
420 |
+ |
width: Width::Fixed,
|
|
421 |
+ |
priority: P::Secondary,
|
|
422 |
+ |
},
|
|
423 |
+ |
Column {
|
|
424 |
+ |
name: "Estimate",
|
|
425 |
+ |
width: Width::Fixed,
|
|
426 |
+ |
priority: P::Optional,
|
|
427 |
+ |
},
|
|
428 |
+ |
];
|
|
429 |
+ |
let after = [
|
|
430 |
+ |
Column {
|
|
431 |
+ |
name: "Title",
|
|
432 |
+ |
width: Width::Fill,
|
|
433 |
+ |
priority: P::Essential,
|
|
434 |
+ |
},
|
|
435 |
+ |
Column {
|
|
436 |
+ |
name: "Project",
|
|
437 |
+ |
width: Width::Fill,
|
|
438 |
+ |
priority: P::Secondary,
|
|
439 |
+ |
},
|
|
440 |
+ |
Column {
|
|
441 |
+ |
name: "Due",
|
|
442 |
+ |
width: Width::Fixed,
|
|
443 |
+ |
priority: P::Secondary,
|
|
444 |
+ |
},
|
|
445 |
+ |
Column {
|
|
446 |
+ |
name: "Estimate",
|
|
447 |
+ |
width: Width::Fixed,
|
|
448 |
+ |
priority: P::Optional,
|
|
449 |
+ |
},
|
|
450 |
+ |
];
|
|
451 |
+ |
|
|
452 |
+ |
let cutoff = column_cutoff(SizeClass::Compact);
|
|
453 |
+ |
let kept: Vec<&str> = before
|
|
454 |
+ |
.iter()
|
|
455 |
+ |
.filter(|c| c.kept_at(cutoff))
|
|
456 |
+ |
.map(|c| c.name)
|
|
457 |
+ |
.collect();
|
|
458 |
+ |
assert_eq!(kept, ["Title"]);
|
|
459 |
+ |
|
|
460 |
+ |
let kept: Vec<&str> = after
|
|
461 |
+ |
.iter()
|
|
462 |
+ |
.filter(|c| c.kept_at(cutoff))
|
|
463 |
+ |
.map(|c| c.name)
|
|
464 |
+ |
.collect();
|
|
465 |
+ |
assert_eq!(kept, ["Title"]);
|
|
466 |
+ |
}
|
|
467 |
+ |
|
|
468 |
+ |
#[test]
|
|
469 |
+ |
fn the_axes_are_borrowed_not_redefined() {
|
|
470 |
+ |
// Re-exported rather than mirrored, so there is exactly one definition
|
|
471 |
+ |
// of each in the family. A local copy is how two crates start
|
|
472 |
+ |
// disagreeing about where 600px is.
|
|
473 |
+ |
assert_eq!(SizeClass::Medium.min_px(), 600);
|
|
474 |
+ |
assert_eq!(SizeClass::Expanded.min_px(), 840);
|
|
475 |
+ |
assert_eq!(SizeClass::at_width(599), SizeClass::Compact);
|
|
476 |
+ |
}
|
|
477 |
+ |
}
|