| 88 |
88 |
|
//! is the exact failure this replaces. Only a Pointer move upward can push
|
| 89 |
89 |
|
//! Touch, and that is the correct direction of authority.
|
| 90 |
90 |
|
//!
|
|
91 |
+ |
//! # Size class: the axis Density kept being asked to carry
|
|
92 |
+ |
//!
|
|
93 |
+ |
//! [`SizeClass`] answers **how much screen there is**, which is not the same
|
|
94 |
+ |
//! question as what is pointing at it. A phone is small and touch; a tablet and
|
|
95 |
+ |
//! a touchscreen laptop are big and touch; a half-width desktop window is small
|
|
96 |
+ |
//! and pointer. Four real combinations, and one axis cannot name them.
|
|
97 |
+ |
//!
|
|
98 |
+ |
//! This is the home for the claim the old Touch preset was thrown out for
|
|
99 |
+ |
//! making: *outer margin is screen you do not get*. That claim was never wrong,
|
|
100 |
+ |
//! it was on the wrong axis, and putting it on the input device is what let a
|
|
101 |
+ |
//! derived preset set a floor under a quoted one.
|
|
102 |
+ |
//!
|
|
103 |
+ |
//! Boundaries are **quoted** (Material 3 window size classes: 600 and 840)
|
|
104 |
+ |
//! rather than derived, for the same reason the [`Gap`] values are. This crate
|
|
105 |
+ |
//! carries the boundaries only; what appears or disappears at each is a product
|
|
106 |
+ |
//! decision and belongs to `makeover-touch`.
|
|
107 |
+ |
//!
|
|
108 |
+ |
//! Deliberately absent: size class does not feed [`Gap::step_at`] yet. Whether
|
|
109 |
+ |
//! shells tighten on a compact window is a look call, and deriving it a second
|
|
110 |
+ |
//! time is how the last one went wrong.
|
|
111 |
+ |
//!
|
| 91 |
112 |
|
//! # Surfaces, and why a TUI is not a third density
|
| 92 |
113 |
|
//!
|
| 93 |
|
- |
//! [`Surface`] is the third axis and the one that carries this to alloy_tui. A
|
|
114 |
+ |
//! [`Surface`] is the fourth axis and the one that carries this to alloy_tui. A
|
| 94 |
115 |
|
//! terminal is not a density preset; it is a surface whose smallest
|
| 95 |
116 |
|
//! representable step is one cell rather than one pixel. Give
|
| 96 |
117 |
|
//! [`Ratio::quanta`] a quantum and it answers in whole units of it, so the
|
| 298 |
319 |
|
Touch,
|
| 299 |
320 |
|
}
|
| 300 |
321 |
|
|
|
322 |
+ |
/// How much screen there is, independent of what is pointing at it.
|
|
323 |
+ |
///
|
|
324 |
+ |
/// The second axis, and the one [`Density`] kept being asked to carry. A phone
|
|
325 |
+ |
/// is small *and* touch; a tablet and a touchscreen laptop are big and touch; a
|
|
326 |
+ |
/// half-width window on a desktop is small and pointer. Those are four real
|
|
327 |
+ |
/// combinations and one axis cannot name them, which is what made the old Touch
|
|
328 |
+ |
/// preset tighten shells it had no business tightening.
|
|
329 |
+ |
///
|
|
330 |
+ |
/// **Boundaries are quoted, not derived**, for the same reason the [`Gap`]
|
|
331 |
+ |
/// values are: a derived boundary is one nobody can check. They are Material 3's
|
|
332 |
+ |
/// window size classes, the best-known three-tier split with published numbers.
|
|
333 |
+ |
/// Apple's size classes are two-tier and expressed as regular/compact per axis,
|
|
334 |
+ |
/// which does not give a middle to aim at.
|
|
335 |
+ |
///
|
|
336 |
+ |
/// Source: <https://m3.material.io/foundations/layout/applying-layout/window-size-classes>
|
|
337 |
+ |
///
|
|
338 |
+ |
/// This enum carries the **boundaries only**. What appears, disappears or
|
|
339 |
+ |
/// reflows at each is a product decision and belongs to `makeover-touch`, not
|
|
340 |
+ |
/// here. Deliberately absent for now: size class does not feed [`Gap::step_at`].
|
|
341 |
+ |
/// Whether shells should tighten on a compact window is a look call of exactly
|
|
342 |
+ |
/// the kind that produced the 2026-07-29 demolition, so it waits for an eyeball
|
|
343 |
+ |
/// rather than being derived a second time.
|
|
344 |
+ |
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
|
|
345 |
+ |
pub enum SizeClass {
|
|
346 |
+ |
/// Under 600px. Phones in either orientation, and any window narrowed to
|
|
347 |
+ |
/// phone width regardless of what is pointing at it.
|
|
348 |
+ |
Compact,
|
|
349 |
+ |
/// 600px to 839px. Small tablets, split-screen panes, half-width windows.
|
|
350 |
+ |
#[default]
|
|
351 |
+ |
Medium,
|
|
352 |
+ |
/// 840px and up. Laptops, desktops, tablets in landscape.
|
|
353 |
+ |
Expanded,
|
|
354 |
+ |
}
|
|
355 |
+ |
|
|
356 |
+ |
impl SizeClass {
|
|
357 |
+ |
/// Lower bound in CSS pixels, inclusive. [`Self::Compact`] starts at zero.
|
|
358 |
+ |
#[must_use]
|
|
359 |
+ |
pub const fn min_px(self) -> u16 {
|
|
360 |
+ |
match self {
|
|
361 |
+ |
Self::Compact => 0,
|
|
362 |
+ |
Self::Medium => 600,
|
|
363 |
+ |
Self::Expanded => 840,
|
|
364 |
+ |
}
|
|
365 |
+ |
}
|
|
366 |
+ |
|
|
367 |
+ |
/// The media condition selecting exactly this class, without the `@media`.
|
|
368 |
+ |
///
|
|
369 |
+ |
/// Bounded on both sides for the middle class, so the three are mutually
|
|
370 |
+ |
/// exclusive and a rule cannot land in two of them. `max-width` is one below
|
|
371 |
+ |
/// the next class's `min_px`, because CSS width ranges are inclusive.
|
|
372 |
+ |
#[must_use]
|
|
373 |
+ |
pub fn media_condition(self) -> String {
|
|
374 |
+ |
match self {
|
|
375 |
+ |
Self::Compact => format!("(max-width: {}px)", Self::Medium.min_px() - 1),
|
|
376 |
+ |
Self::Medium => format!(
|
|
377 |
+ |
"(min-width: {}px) and (max-width: {}px)",
|
|
378 |
+ |
Self::Medium.min_px(),
|
|
379 |
+ |
Self::Expanded.min_px() - 1
|
|
380 |
+ |
),
|
|
381 |
+ |
Self::Expanded => format!("(min-width: {}px)", Self::Expanded.min_px()),
|
|
382 |
+ |
}
|
|
383 |
+ |
}
|
|
384 |
+ |
|
|
385 |
+ |
/// The CSS class name an app may hang off this, without the leading dot.
|
|
386 |
+ |
#[must_use]
|
|
387 |
+ |
pub const fn token(self) -> &'static str {
|
|
388 |
+ |
match self {
|
|
389 |
+ |
Self::Compact => "size-compact",
|
|
390 |
+ |
Self::Medium => "size-medium",
|
|
391 |
+ |
Self::Expanded => "size-expanded",
|
|
392 |
+ |
}
|
|
393 |
+ |
}
|
|
394 |
+ |
|
|
395 |
+ |
/// Every class, narrowest first.
|
|
396 |
+ |
#[must_use]
|
|
397 |
+ |
pub const fn all() -> [Self; 3] {
|
|
398 |
+ |
[Self::Compact, Self::Medium, Self::Expanded]
|
|
399 |
+ |
}
|
|
400 |
+ |
|
|
401 |
+ |
/// The class a given viewport width falls in.
|
|
402 |
+ |
#[must_use]
|
|
403 |
+ |
pub const fn at_width(px: u16) -> Self {
|
|
404 |
+ |
if px >= Self::Expanded.min_px() {
|
|
405 |
+ |
Self::Expanded
|
|
406 |
+ |
} else if px >= Self::Medium.min_px() {
|
|
407 |
+ |
Self::Medium
|
|
408 |
+ |
} else {
|
|
409 |
+ |
Self::Compact
|
|
410 |
+ |
}
|
|
411 |
+ |
}
|
|
412 |
+ |
}
|
|
413 |
+ |
|
| 301 |
414 |
|
/// A named separation between two things.
|
| 302 |
415 |
|
///
|
| 303 |
416 |
|
/// Pick by relationship. The size is a consequence of the name, not the other
|
| 738 |
851 |
|
assert!(Gap::Section.px_at(Density::Touch) >= 16);
|
| 739 |
852 |
|
}
|
| 740 |
853 |
|
|
|
854 |
+ |
#[test]
|
|
855 |
+ |
fn size_classes_partition_every_width_exactly_once() {
|
|
856 |
+ |
// Mutually exclusive and exhaustive, or a rule lands in two classes and
|
|
857 |
+ |
// whichever is emitted last silently wins. Checked at every width up to
|
|
858 |
+ |
// well past the top boundary rather than at the boundaries alone.
|
|
859 |
+ |
for px in 0..=4000u16 {
|
|
860 |
+ |
let hits: Vec<SizeClass> = SizeClass::all()
|
|
861 |
+ |
.into_iter()
|
|
862 |
+ |
.filter(|c| {
|
|
863 |
+ |
let lo = c.min_px();
|
|
864 |
+ |
let hi = match c {
|
|
865 |
+ |
SizeClass::Compact => SizeClass::Medium.min_px() - 1,
|
|
866 |
+ |
SizeClass::Medium => SizeClass::Expanded.min_px() - 1,
|
|
867 |
+ |
SizeClass::Expanded => u16::MAX,
|
|
868 |
+ |
};
|
|
869 |
+ |
px >= lo && px <= hi
|
|
870 |
+ |
})
|
|
871 |
+ |
.collect();
|
|
872 |
+ |
assert_eq!(hits.len(), 1, "{px}px matched {hits:?}");
|
|
873 |
+ |
assert_eq!(hits[0], SizeClass::at_width(px), "{px}px disagrees");
|
|
874 |
+ |
}
|
|
875 |
+ |
}
|
|
876 |
+ |
|
|
877 |
+ |
#[test]
|
|
878 |
+ |
fn the_quoted_boundaries_are_the_ones_material_publishes() {
|
|
879 |
+ |
// Quoted, not derived. Changing these means departing from the source,
|
|
880 |
+ |
// which is a decision to record rather than a value to nudge.
|
|
881 |
+ |
assert_eq!(SizeClass::Compact.min_px(), 0);
|
|
882 |
+ |
assert_eq!(SizeClass::Medium.min_px(), 600);
|
|
883 |
+ |
assert_eq!(SizeClass::Expanded.min_px(), 840);
|
|
884 |
+ |
}
|
|
885 |
+ |
|
|
886 |
+ |
#[test]
|
|
887 |
+ |
fn the_media_conditions_do_not_overlap_at_the_boundary() {
|
|
888 |
+ |
// The off-by-one that makes CSS width ranges overlap: max-width is
|
|
889 |
+ |
// inclusive, so it must be one below the next class's min-width.
|
|
890 |
+ |
assert_eq!(
|
|
891 |
+ |
SizeClass::Compact.media_condition(),
|
|
892 |
+ |
"(max-width: 599px)",
|
|
893 |
+ |
"Compact must stop one pixel below Medium"
|
|
894 |
+ |
);
|
|
895 |
+ |
assert_eq!(
|
|
896 |
+ |
SizeClass::Medium.media_condition(),
|
|
897 |
+ |
"(min-width: 600px) and (max-width: 839px)"
|
|
898 |
+ |
);
|
|
899 |
+ |
assert_eq!(SizeClass::Expanded.media_condition(), "(min-width: 840px)");
|
|
900 |
+ |
}
|
|
901 |
+ |
|
|
902 |
+ |
#[test]
|
|
903 |
+ |
fn size_class_does_not_reach_the_gap_scale() {
|
|
904 |
+ |
// Deliberately absent, asserted so that wiring it in has to come here
|
|
905 |
+ |
// and say so. Whether a compact window tightens its shells is a look
|
|
906 |
+ |
// call; deriving it is what went wrong with Touch on 2026-07-29.
|
|
907 |
+ |
//
|
|
908 |
+ |
// This test does not check a value. It checks that the whole spacing
|
|
909 |
+ |
// layer is reachable without naming a size class at all.
|
|
910 |
+ |
let _ = geometry_css_vars(Density::Pointer);
|
|
911 |
+ |
let _ = Gap::Page.px_at(Density::Touch);
|
|
912 |
+ |
assert_eq!(SizeClass::all().len(), 3);
|
|
913 |
+ |
}
|
|
914 |
+ |
|
| 741 |
915 |
|
#[test]
|
| 742 |
916 |
|
fn a_terminal_resolves_the_vocabulary_to_whole_cells() {
|
| 743 |
917 |
|
let t = Surface::terminal();
|