1use 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
16const TRACK: u16 = 5;
18
19const KNOB: u16 = 2;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
24pub enum SwitchStyle {
25 #[default]
27 Capsule,
28 Rail,
30 Labeled,
32}
33
34pub 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 #[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 #[must_use]
60 pub fn label(mut self, label: impl Into<String>) -> Self {
61 self.label = Some(label.into());
62 self
63 }
64
65 #[must_use]
67 pub fn style(mut self, style: SwitchStyle) -> Self {
68 self.style = style;
69 self
70 }
71
72 #[must_use]
74 pub fn disabled(mut self, disabled: bool) -> Self {
75 self.disabled = disabled;
76 self
77 }
78
79 #[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
91fn 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 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 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 #[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}