#![forbid(unsafe_code)]
pub use makeover_geometry::{Density, SizeClass};
pub use makeover_layout::Priority;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Affordance {
Hover,
Hint,
Ancillary,
Detail,
Anchored,
Overflow,
Gesture,
Haptic,
}
impl Affordance {
#[must_use]
pub const fn available(self, density: Density, size: SizeClass) -> bool {
match self {
Self::Hover | Self::Hint => matches!(density, Density::Pointer),
Self::Gesture | Self::Haptic => matches!(density, Density::Touch),
Self::Ancillary => matches!(size, SizeClass::Expanded),
Self::Detail => matches!(size, SizeClass::Medium | SizeClass::Expanded),
Self::Anchored | Self::Overflow => matches!(size, SizeClass::Compact),
}
}
#[must_use]
pub const fn gained_by(self) -> Option<Density> {
match self {
Self::Hover | Self::Hint => Some(Density::Pointer),
Self::Gesture | Self::Haptic => Some(Density::Touch),
Self::Ancillary | Self::Detail | Self::Anchored | Self::Overflow => None,
}
}
#[must_use]
pub const fn reads_density(self) -> bool {
self.gained_by().is_some()
}
#[must_use]
pub const fn reads_size(self) -> bool {
!self.reads_density()
}
#[must_use]
pub const fn all() -> [Self; 8] {
[
Self::Hover,
Self::Hint,
Self::Ancillary,
Self::Detail,
Self::Anchored,
Self::Overflow,
Self::Gesture,
Self::Haptic,
]
}
#[must_use]
pub const fn token(self) -> &'static str {
match self {
Self::Hover => "offers-hover",
Self::Hint => "offers-hint",
Self::Ancillary => "offers-ancillary",
Self::Detail => "offers-detail",
Self::Anchored => "offers-anchored",
Self::Overflow => "offers-overflow",
Self::Gesture => "offers-gesture",
Self::Haptic => "offers-haptic",
}
}
}
#[must_use]
pub const fn column_cutoff(size: SizeClass) -> Priority {
match size {
SizeClass::Compact => Priority::Essential,
SizeClass::Medium => Priority::Secondary,
SizeClass::Expanded => Priority::Optional,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn surfaces() -> Vec<(Density, SizeClass)> {
let mut out = Vec::new();
for d in [Density::Pointer, Density::Touch] {
for s in SizeClass::all() {
out.push((d, s));
}
}
out
}
#[test]
fn density_gates_only_what_the_contact_patch_touches() {
for a in Affordance::all() {
let varies_by_density = SizeClass::all()
.iter()
.any(|&s| a.available(Density::Pointer, s) != a.available(Density::Touch, s));
assert_eq!(
varies_by_density,
a.reads_density(),
"{a:?} disagrees with its own reads_density()"
);
}
}
#[test]
fn size_gates_only_what_screen_budget_touches() {
for a in Affordance::all() {
let varies_by_size = [Density::Pointer, Density::Touch].iter().any(|&d| {
SizeClass::all()
.iter()
.any(|&s| a.available(d, s) != a.available(d, SizeClass::Compact))
});
assert_eq!(
varies_by_size,
a.reads_size(),
"{a:?} disagrees with its own reads_size()"
);
}
}
#[test]
fn no_member_reads_both_axes() {
for a in Affordance::all() {
assert!(
a.reads_density() != a.reads_size(),
"{a:?} reads both axes; update this test and say why"
);
}
}
#[test]
fn every_member_is_an_adaptation() {
for a in Affordance::all() {
let yes = surfaces()
.iter()
.filter(|&&(d, s)| a.available(d, s))
.count();
assert!(yes > 0, "{a:?} exists on no surface");
assert!(yes < surfaces().len(), "{a:?} exists on every surface");
}
}
#[test]
fn availability_is_contiguous_across_the_size_ladder() {
for a in Affordance::all() {
for d in [Density::Pointer, Density::Touch] {
let run: Vec<bool> = SizeClass::all()
.iter()
.map(|&s| a.available(d, s))
.collect();
let transitions = run.windows(2).filter(|w| w[0] != w[1]).count();
assert!(
transitions <= 1,
"{a:?} at {d:?} is available in a broken run: {run:?}"
);
}
}
}
#[test]
fn compact_compensates_rather_than_only_losing() {
for d in [Density::Pointer, Density::Touch] {
assert!(!Affordance::Detail.available(d, SizeClass::Compact));
assert!(Affordance::Anchored.available(d, SizeClass::Compact));
}
}
#[test]
fn hover_and_hint_collapse_and_that_is_allowed() {
for (d, s) in surfaces() {
assert_eq!(
Affordance::Hover.available(d, s),
Affordance::Hint.available(d, s)
);
}
assert_ne!(Affordance::Hover.token(), Affordance::Hint.token());
}
fn opposite(d: Density) -> Density {
match d {
Density::Pointer => Density::Touch,
Density::Touch => Density::Pointer,
}
}
#[test]
fn a_density_member_is_available_on_exactly_the_density_it_declares() {
for a in Affordance::all() {
match a.gained_by() {
Some(gained) => {
assert!(
a.reads_density(),
"{a:?} declares a density but denies reading one"
);
for s in SizeClass::all() {
assert!(
a.available(gained, s),
"{a:?} declares {gained:?} but is unavailable there at {s:?}"
);
assert!(
!a.available(opposite(gained), s),
"{a:?} declares {gained:?} but is also available on the other density at {s:?}"
);
}
}
None => assert!(
!a.reads_density(),
"{a:?} reads density but declares no side"
),
}
}
}
#[test]
fn both_densities_gain_something() {
for d in [Density::Pointer, Density::Touch] {
assert!(
Affordance::all().iter().any(|a| a.gained_by() == Some(d)),
"no member is gained by {d:?}"
);
}
}
#[test]
fn the_density_axis_compensates_rather_than_only_losing() {
for s in SizeClass::all() {
assert!(!Affordance::Hover.available(Density::Touch, s));
assert!(Affordance::Gesture.available(Density::Touch, s));
}
}
#[test]
fn tokens_are_distinct() {
let mut seen: Vec<&str> = Affordance::all().iter().map(|a| a.token()).collect();
seen.sort_unstable();
let before = seen.len();
seen.dedup();
assert_eq!(seen.len(), before);
}
#[test]
fn the_column_cutoff_relaxes_as_the_window_widens() {
assert!(column_cutoff(SizeClass::Compact) > column_cutoff(SizeClass::Medium));
assert!(column_cutoff(SizeClass::Medium) > column_cutoff(SizeClass::Expanded));
}
#[test]
fn the_column_cutoff_replaces_the_ordinal() {
use makeover_layout::{Column, Priority as P, Width};
let before = [
Column {
name: "Title",
width: Width::Fill,
priority: P::Essential,
},
Column {
name: "Due",
width: Width::Fixed,
priority: P::Secondary,
},
Column {
name: "Estimate",
width: Width::Fixed,
priority: P::Optional,
},
];
let after = [
Column {
name: "Title",
width: Width::Fill,
priority: P::Essential,
},
Column {
name: "Project",
width: Width::Fill,
priority: P::Secondary,
},
Column {
name: "Due",
width: Width::Fixed,
priority: P::Secondary,
},
Column {
name: "Estimate",
width: Width::Fixed,
priority: P::Optional,
},
];
let cutoff = column_cutoff(SizeClass::Compact);
let kept: Vec<&str> = before
.iter()
.filter(|c| c.kept_at(cutoff))
.map(|c| c.name)
.collect();
assert_eq!(kept, ["Title"]);
let kept: Vec<&str> = after
.iter()
.filter(|c| c.kept_at(cutoff))
.map(|c| c.name)
.collect();
assert_eq!(kept, ["Title"]);
}
#[test]
fn the_axes_are_borrowed_not_redefined() {
assert_eq!(SizeClass::Medium.min_px(), 600);
assert_eq!(SizeClass::Expanded.min_px(), 840);
assert_eq!(SizeClass::at_width(599), SizeClass::Compact);
}
}