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}