Skip to main content

retroglyph_ui/interact/
density.rs

1//! [`Density`]: touch vs. mouse sizing for interactive widgets.
2
3use retroglyph_core::grid::Size;
4
5/// How much room an interactive widget's hit target should claim.
6///
7/// It exists so an app choosing between a phone-sized and a desktop-sized
8/// layout has one place to ask "how big should this button/row/slider be",
9/// rather than inventing its own ad hoc breakpoint constants per widget. An
10/// [`InteractiveWidget`](crate::InteractiveWidget) reads
11/// [`min_target_size`](Self::min_target_size) the same way it reads
12/// [`sense`](crate::InteractiveWidget::sense); [`for_width`](Self::for_width) is the other
13/// half, turning a terminal width into a `Density` in the first place.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
16#[non_exhaustive]
17pub enum Density {
18    /// Larger interactive targets, for a fingertip on a phone-width terminal
19    /// or other touch input, at the cost of showing fewer rows at once.
20    Touch,
21    /// Dense, single-line interactive targets, for a mouse on a normal
22    /// desktop-sized terminal, where precise clicking doesn't need extra
23    /// height.
24    Mouse,
25}
26
27impl Density {
28    /// Below this width, in cells, [`for_width`](Self::for_width) picks [`Density::Touch`]; at
29    /// or above it, [`Density::Mouse`]. An app that wants a different breakpoint states it
30    /// relative to this default (e.g. `if width < Density::DEFAULT_BREAKPOINT_WIDTH - 4 { .. }`)
31    /// rather than inventing an unrelated constant from scratch.
32    ///
33    /// `64` sits between a phone-width terminal (roughly 40 to 50 columns, where fingertip sizing
34    /// wins) and the classic 80-column desktop width (where a mouse and dense rows win). Lower
35    /// keeps touch sizing only on very narrow terminals; higher applies it to wider ones that a
36    /// mouse user would rather see packed densely. Picked by feel for that split, not measured.
37    pub const DEFAULT_BREAKPOINT_WIDTH: u16 = 64;
38
39    /// The minimum size, in cells, an interactive target should claim at this density.
40    ///
41    /// Both densities are `6` cells wide: enough for a short bracketed label like `[ OK ]` plus a
42    /// cell of breathing room, the narrowest a tappable/clickable control stays legible. Height
43    /// is where they differ. [`Density::Mouse`] is a single row, since a mouse can land on one
44    /// line precisely. [`Density::Touch`] is `3` rows so a fingertip has vertical slack to hit
45    /// (and so a bordered one-line control fits: top border, content, bottom border), at the
46    /// cost of showing fewer rows on screen. The exact `6`/`3`/`1` were picked by feel for
47    /// readable terminal controls, not measured against touch-target studies.
48    #[must_use]
49    pub const fn min_target_size(self) -> Size {
50        match self {
51            Self::Touch => Size::new(6, 3),
52            Self::Mouse => Size::new(6, 1),
53        }
54    }
55
56    /// Picks a density from a terminal (or pane) width, in cells, against
57    /// [`DEFAULT_BREAKPOINT_WIDTH`](Self::DEFAULT_BREAKPOINT_WIDTH).
58    ///
59    /// A one-line replacement for the ad hoc `if width < N { .. }` every consumer of this crate
60    /// has otherwise had to write for itself, with a different `N` each time.
61    #[must_use]
62    pub const fn for_width(width: u16) -> Self {
63        if width < Self::DEFAULT_BREAKPOINT_WIDTH {
64            Self::Touch
65        } else {
66            Self::Mouse
67        }
68    }
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74    use retroglyph_core::grid::HasSize;
75
76    #[test]
77    fn touch_rows_are_taller_than_mouse_for_fingertip_targets() {
78        let touch = Density::Touch.min_target_size();
79        let mouse = Density::Mouse.min_target_size();
80        assert!(touch.height() > mouse.height());
81    }
82
83    #[test]
84    fn mouse_still_claims_more_than_a_single_cell_wide() {
85        let size = Density::Mouse.min_target_size();
86        assert!(size.width() > 1);
87    }
88
89    #[test]
90    fn for_width_picks_touch_below_the_default_breakpoint() {
91        assert_eq!(
92            Density::for_width(Density::DEFAULT_BREAKPOINT_WIDTH - 1),
93            Density::Touch
94        );
95    }
96
97    #[test]
98    fn for_width_picks_mouse_at_or_above_the_default_breakpoint() {
99        assert_eq!(
100            Density::for_width(Density::DEFAULT_BREAKPOINT_WIDTH),
101            Density::Mouse
102        );
103        assert_eq!(
104            Density::for_width(Density::DEFAULT_BREAKPOINT_WIDTH + 1),
105            Density::Mouse
106        );
107    }
108
109    #[cfg(feature = "serde")]
110    #[test]
111    fn serializes_as_a_plain_string() {
112        let json = serde_json::to_string(&Density::Touch).expect("serialize");
113        assert_eq!(json, "\"Touch\"");
114        assert_eq!(
115            serde_json::from_str::<Density>(&json).expect("deserialize"),
116            Density::Touch
117        );
118    }
119}