Skip to main content

qframe/widgets/
switch.rs

1//! Switches.
2
3use super::ToggleMessage;
4use super::press::{self, Press};
5use crate::color::Rgb;
6use crate::env::Env;
7use crate::event::Event;
8use crate::geometry::{Rect, Size};
9use crate::icons::GlyphMode;
10use crate::motion::{Easing, steps};
11use crate::style::CellStyle;
12use crate::text;
13use crate::theme::State;
14use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};
15
16/// Width of the capsule and rail switches, in cells.
17const TRACK: u16 = 5;
18
19/// Width of the knob, in cells.
20const KNOB: u16 = 2;
21
22/// How a [`Switch`] looks.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
24pub enum SwitchStyle {
25    /// A flat five-cell capsule with a two-cell knob.
26    #[default]
27    Capsule,
28    /// A knob on a thin rail, from the same family as sliders. ASCII mode draws a capsule.
29    Rail,
30    /// A capsule with the state written inside, for places where the state must be read.
31    Labeled,
32}
33
34/// An on/off switch that applies at once, with an optional label.
35///
36/// The knob steps one cell every `motion.step`, and the track and knob colours blend with each
37/// step; the knob is always the brightest part. Enter, Space or a click toggles it. The
38/// application owns the state.
39///
40/// Style keys: `switch` (`track`, `track-on`, `knob`, `knob-on`) and `switch-labeled` (`bg`,
41/// `fg`, `dot`) with states `hover`, `focus`, `checked`, `disabled`; `switch-label` (`fg`).
42/// The labeled style reads its words from `quvyta.switch.on` and `quvyta.switch.off`.
43pub struct Switch<Msg> {
44    on: bool,
45    label: Option<String>,
46    style: SwitchStyle,
47    disabled: bool,
48    on_toggle: Option<ToggleMessage<Msg>>,
49}
50
51impl<Msg> Switch<Msg> {
52    /// A capsule switch showing `on`.
53    #[must_use]
54    pub fn new(on: bool) -> Self {
55        Self { on, label: None, style: SwitchStyle::Capsule, disabled: false, on_toggle: None }
56    }
57
58    /// Text after the switch; clicking it toggles too.
59    #[must_use]
60    pub fn label(mut self, label: impl Into<String>) -> Self {
61        self.label = Some(label.into());
62        self
63    }
64
65    /// Chooses how the switch looks.
66    #[must_use]
67    pub fn style(mut self, style: SwitchStyle) -> Self {
68        self.style = style;
69        self
70    }
71
72    /// Greys the switch out; it cannot be focused or toggled.
73    #[must_use]
74    pub fn disabled(mut self, disabled: bool) -> Self {
75        self.disabled = disabled;
76        self
77    }
78
79    /// Message for the new state when the switch is toggled.
80    #[must_use]
81    pub fn on_toggle(mut self, message: impl Fn(bool) -> Msg + 'static) -> Self {
82        self.on_toggle = Some(Box::new(message));
83        self
84    }
85
86    fn active(&self) -> bool {
87        !self.disabled && self.on_toggle.is_some()
88    }
89}
90
91/// The words of the labeled style in the active language: on, then off.
92fn words(env: &Env) -> (String, String) {
93    let i18n = env.i18n();
94    (i18n.translate("quvyta.switch.on", &[]), i18n.translate("quvyta.switch.off", &[]))
95}
96
97impl<Msg> Switch<Msg> {
98    fn control_width(&self, on: &str, off: &str) -> u16 {
99        match self.style {
100            SwitchStyle::Capsule | SwitchStyle::Rail => TRACK,
101            // Caps, a space, the longer word, a space, the dot and a space.
102            SwitchStyle::Labeled => text::width(on).max(text::width(off)) + 6,
103        }
104    }
105
106    fn paint_track(&self, cx: &mut PaintCx<'_>, area: Rect, states: &[State]) {
107        let style = cx.style("switch", None, states);
108        let color = |key: &str, fallback: Rgb| style.color(key).unwrap_or(fallback);
109        let (track_off, track_on) = (color("track", cx.color("raised")), color("track-on", cx.color("active")));
110        let (knob_off, knob_on) = (color("knob", cx.color("muted")), color("knob-on", cx.color("accent")));
111        let travel = TRACK - KNOB;
112        let duration = cx.env().theme().motion().step * u32::from(travel);
113        let progress = cx.animate("knob", if self.on { 1.0 } else { 0.0 }, duration, Easing::Linear);
114        let position = steps(progress, travel);
115        // Colours follow the knob's cell, so every in-between frame has an in-between colour.
116        let t = f32::from(position) / f32::from(travel);
117        let track = track_off.mix(track_on, t);
118        let knob = knob_off.mix(knob_on, t);
119        let rail = self.style == SwitchStyle::Rail && cx.env().glyph_mode() != GlyphMode::Ascii;
120        let rail_glyph = cx.env().icons().glyph("switch-rail").into_owned();
121        let knob_glyph = cx.env().icons().glyph("switch-knob").into_owned();
122        for cell in 0..TRACK.min(area.width) {
123            let x = area.x + i32::from(cell);
124            let is_knob = (position..position + KNOB).contains(&cell);
125            match (rail, is_knob) {
126                (true, true) => {
127                    cx.text(x, area.y, &knob_glyph, CellStyle::fg(knob), 1);
128                }
129                (true, false) => {
130                    cx.text(x, area.y, &rail_glyph, CellStyle::fg(track), 1);
131                }
132                (false, is_knob) => cx.clear(Rect::new(x, area.y, 1, 1), if is_knob { knob } else { track }),
133            }
134        }
135    }
136
137    fn paint_labeled(&self, cx: &mut PaintCx<'_>, area: Rect, states: &[State], width: u16, words: (String, String)) {
138        let style = cx.style("switch-labeled", None, states);
139        let surface = style.text();
140        let bg = surface.bg.unwrap_or_else(|| cx.color("raised"));
141        let dot_color = style.color("dot").unwrap_or_else(|| cx.color("muted"));
142        let width = width.min(area.width);
143        let inner = Rect::new(area.x + 1, area.y, width.saturating_sub(2), 1);
144        cx.clear(inner, bg);
145        for (key, x) in [("cap-left", area.x), ("cap-right", area.x + i32::from(width) - 1)] {
146            let glyph = cx.env().icons().glyph(key).into_owned();
147            if glyph.trim().is_empty() {
148                cx.clear(Rect::new(x, area.y, 1, 1), bg);
149            } else {
150                cx.text(x, area.y, &glyph, CellStyle::fg(bg), 1);
151            }
152        }
153        let (on_word, off_word) = words;
154        let word_width = inner.width.saturating_sub(4);
155        let dot = cx.env().icons().glyph("dot").into_owned();
156        let text_style = CellStyle { bg: None, ..surface };
157        let (word, word_x, dot_x) =
158            if self.on { (on_word, inner.x + 1, inner.right() - 2) } else { (off_word, inner.x + 3, inner.x + 1) };
159        cx.text(word_x, area.y, &word, text_style, word_width);
160        cx.text(dot_x, area.y, &dot, CellStyle::fg(dot_color), 1);
161    }
162}
163
164impl<Msg: 'static> Widget<Msg> for Switch<Msg> {
165    fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
166        let (on, off) = words(cx.env());
167        let label = self.label.as_deref().map_or(0, |label| text::width(label).saturating_add(2));
168        Size::new(self.control_width(&on, &off).saturating_add(label), 1).min(available)
169    }
170
171    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
172        let mut states = if self.active() { cx.pressable_states() } else { Vec::new() };
173        if self.disabled {
174            states.push(State::Disabled);
175        }
176        if self.on {
177            states.push(State::Checked);
178        }
179        let words = words(cx.env());
180        let width = self.control_width(&words.0, &words.1);
181        match self.style {
182            SwitchStyle::Capsule | SwitchStyle::Rail => self.paint_track(cx, area, &states),
183            SwitchStyle::Labeled => self.paint_labeled(cx, area, &states, width, words),
184        }
185        if let Some(label) = &self.label {
186            let label_style = cx.style("switch-label", None, &states).text();
187            let budget = area.width.saturating_sub(width + 2);
188            let shown = text::truncate(label, budget).into_owned();
189            cx.text(area.x + i32::from(width) + 2, area.y, &shown, label_style, budget);
190        }
191        if self.active() {
192            cx.register_hit(area);
193        }
194    }
195
196    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
197        if !self.active() {
198            return false;
199        }
200        match press::read(cx, event) {
201            Press::Ignored => false,
202            Press::Used => true,
203            Press::Key | Press::Click(..) => {
204                if let Some(message) = &self.on_toggle {
205                    cx.emit(message(!self.on));
206                }
207                true
208            }
209        }
210    }
211
212    fn focusable(&self) -> bool {
213        self.active()
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use std::time::Duration;
220
221    use super::*;
222    use crate::runtime::{App, Command, Harness};
223    use crate::widget::View;
224
225    struct Demo {
226        on: bool,
227        style: SwitchStyle,
228    }
229
230    impl App for Demo {
231        type Msg = bool;
232        fn update(&mut self, on: bool) -> Command<bool> {
233            self.on = on;
234            Command::none()
235        }
236        fn view(&self, ui: &mut View<'_, bool>) {
237            ui.add(Switch::new(self.on).style(self.style).label("Sounds").on_toggle(|on| on)).id("switch");
238        }
239    }
240
241    #[test]
242    fn knob_steps_across_with_blended_colours_and_stays_brightest() {
243        let mut h = Harness::new(Demo { on: false, style: SwitchStyle::Capsule }, 20, 1);
244        assert_eq!(h.screen(), "       Sounds\n");
245        let (knob_off, track_off) = (h.bg(0, 0), h.bg(2, 0));
246        assert_eq!(h.bg(1, 0), knob_off, "the knob is two cells");
247        assert_ne!(knob_off, track_off);
248        h.set_reduced_motion(true);
249        h.send(true);
250        let (track_on, knob_on) = (h.bg(0, 0), h.bg(4, 0));
251        h.set_reduced_motion(false);
252        h.send(false);
253        h.send(true);
254        let step = h.env().theme().motion().step;
255        h.advance(step);
256        let middle_knob = h.bg(1, 0);
257        assert_ne!(middle_knob, knob_off);
258        assert_ne!(middle_knob, knob_on);
259        assert_eq!(h.bg(0, 0), h.bg(3, 0), "track colour on both sides of the knob");
260        h.advance(step * 3);
261        assert_eq!((h.bg(0, 0), h.bg(3, 0), h.bg(4, 0)), (track_on, knob_on, knob_on));
262        let luminance = |color: Option<Rgb>| color.map_or(0.0, Rgb::relative_luminance);
263        assert!(luminance(knob_on) > luminance(track_on), "the lit knob stays brightest");
264    }
265
266    struct Disabled;
267
268    impl App for Disabled {
269        type Msg = bool;
270        fn update(&mut self, _: bool) -> Command<bool> {
271            Command::none()
272        }
273        fn view(&self, ui: &mut View<'_, bool>) {
274            ui.add(Switch::new(false).disabled(true).on_toggle(|on| on));
275        }
276    }
277
278    #[test]
279    fn a_disabled_off_knob_stays_visible_on_its_track_in_every_theme() {
280        for theme in ["monochrome", "iris", "nordic", "amber"] {
281            let mut h = Harness::new(Disabled, 10, 1);
282            h.set_theme(theme);
283            let ratio = h.bg(0, 0).zip(h.bg(4, 0)).map_or(1.0, |(knob, track)| knob.contrast_ratio(track));
284            assert!(ratio >= 1.2, "{theme}: disabled knob reads at {ratio:.2}:1 on its track");
285        }
286    }
287
288    /// A column of switches (on, off, on, on): an off knob must never read like the
289    /// lit track beside an on knob, and hovering must visibly change both states, in every theme.
290    #[test]
291    fn off_and_on_read_apart_and_hover_lifts_both_in_every_theme() {
292        let luminance = |color: Option<Rgb>| color.map_or(0.0, Rgb::relative_luminance);
293        let contrast = |a: Option<Rgb>, b: Option<Rgb>| a.zip(b).map_or(1.0, |(a, b)| a.contrast_ratio(b));
294        for theme in ["monochrome", "iris", "nordic", "amber"] {
295            let mut off = Harness::new(Demo { on: false, style: SwitchStyle::Capsule }, 20, 1);
296            let mut on = Harness::new(Demo { on: true, style: SwitchStyle::Capsule }, 20, 1);
297            for h in [&mut off, &mut on] {
298                h.set_theme(theme);
299                h.set_reduced_motion(true);
300                h.hover(12, 0);
301                h.hover(19, 0);
302            }
303            let (off_knob, off_track, on_track, on_knob) = (off.bg(0, 0), off.bg(4, 0), on.bg(0, 0), on.bg(4, 0));
304            assert!(contrast(off_knob, on_track) >= 1.6, "{theme}: an off knob looks like an on track");
305            assert!(luminance(off_knob) < luminance(on_track), "{theme}: off is quieter than on");
306            assert!(contrast(off_knob, off_track) >= 1.3, "{theme}: the off knob shows on its track");
307            assert!(contrast(on_knob, on_track) >= 1.5, "{theme}: the on knob shows on its track");
308            for (h, name) in [(&mut off, "off"), (&mut on, "on")] {
309                let rest = (h.bg(0, 0), h.bg(4, 0));
310                h.hover(0, 0);
311                let hovered = (h.bg(0, 0), h.bg(4, 0));
312                assert!(hovered.0 != rest.0 && hovered.1 != rest.1, "{theme}: hovering an {name} switch changes it");
313                assert!(luminance(hovered.0) > luminance(rest.0), "{theme}: hover lifts an {name} switch");
314            }
315        }
316    }
317
318    #[test]
319    fn reduced_motion_jumps_and_keyboard_toggles() {
320        let mut h = Harness::new(Demo { on: false, style: SwitchStyle::Capsule }, 20, 1);
321        h.set_reduced_motion(true);
322        h.press("tab").press("enter");
323        assert!(h.app().on);
324        assert_eq!(h.bg(4, 0), h.env().theme().color("accent"));
325    }
326
327    #[test]
328    fn rail_and_labeled_styles() {
329        let mut h = Harness::new(Demo { on: true, style: SwitchStyle::Rail }, 24, 1);
330        assert_eq!(h.screen(), "━━━██  Sounds\n");
331        h.set_glyph_mode(GlyphMode::Ascii);
332        assert!(h.screen().is_ascii());
333        let mut h = Harness::new(Demo { on: true, style: SwitchStyle::Labeled }, 24, 1);
334        assert_eq!(h.screen(), "▐ ON  ● ▌  Sounds\n");
335        h.click_text("Sounds");
336        h.advance(Duration::from_millis(10));
337        assert_eq!(h.screen(), "▐ ● OFF ▌  Sounds\n");
338    }
339}