cadmus_core/view/
toggle.rs

1use super::{Align, Bus, Event, Hub, Id, RenderQueue, View, ID_FEEDER};
2use crate::color::{BLACK, GRAY08, TEXT_NORMAL};
3use crate::context::Context;
4use crate::device::CURRENT_DEVICE;
5use crate::font::{font_from_style, Fonts, NORMAL_STYLE};
6use crate::framebuffer::Framebuffer;
7use crate::geom::{BorderSpec, Rectangle};
8use crate::unit::scale_by_dpi;
9use crate::view::filler::Filler;
10use crate::view::label::Label;
11
12use super::{THICKNESS_MEDIUM, THICKNESS_SMALL};
13
14/// A minimal selection box indicator that renders tightly around selected label text.
15///
16/// This is a leaf view (no children) that draws a rounded rectangle border
17/// around the actual rendered text dimensions.
18struct SelectionBox {
19    id: Id,
20    rect: Rectangle,
21    children: Vec<Box<dyn View>>,
22    target_rect: Rectangle,
23    text_width: i32,
24    visible: bool,
25}
26
27impl SelectionBox {
28    fn new(rect: Rectangle, target_rect: Rectangle, text_width: i32, visible: bool) -> Self {
29        Self {
30            id: ID_FEEDER.next(),
31            rect,
32            children: Vec::new(),
33            target_rect,
34            text_width,
35            visible,
36        }
37    }
38
39    fn set_target(&mut self, target_rect: Rectangle, text_width: i32, visible: bool) {
40        self.target_rect = target_rect;
41        self.text_width = text_width;
42        self.visible = visible;
43    }
44}
45
46impl View for SelectionBox {
47    #[cfg_attr(feature = "otel", tracing::instrument(skip(self, _hub, _bus, _rq, _context), fields(event = ?_evt), ret(level=tracing::Level::TRACE)))]
48    fn handle_event(
49        &mut self,
50        _evt: &Event,
51        _hub: &Hub,
52        _bus: &mut Bus,
53        _rq: &mut RenderQueue,
54        _context: &mut Context,
55    ) -> bool {
56        false
57    }
58
59    #[cfg_attr(feature = "otel", tracing::instrument(skip(self, fb, fonts), fields(rect = ?rect)))]
60    fn render(&self, fb: &mut dyn Framebuffer, rect: Rectangle, fonts: &mut Fonts) {
61        if !self.visible {
62            return;
63        }
64
65        let render_rect = rect.intersection(&self.target_rect);
66        if render_rect.is_none() {
67            return;
68        }
69
70        let dpi = CURRENT_DEVICE.dpi;
71        let font = font_from_style(fonts, &NORMAL_STYLE, dpi);
72
73        let padding = font.em() as i32 / 2 - scale_by_dpi(3.0, dpi) as i32;
74        let x_height = font.x_heights.0 as i32;
75        let border_box_height = 3 * x_height;
76        let border_box_width = self.text_width + padding;
77
78        let x_offset = padding;
79        let dy = (self.target_rect.height() as i32 - x_height) / 2;
80        let y_offset = dy + x_height - 2 * x_height;
81        let pt = self.target_rect.min + pt!(x_offset, y_offset);
82        let border_box_rect = rect![pt, pt + pt!(border_box_width, border_box_height)];
83
84        let border_thickness = scale_by_dpi(THICKNESS_SMALL, dpi) as u16;
85
86        fb.draw_rectangle_outline(
87            &border_box_rect,
88            &BorderSpec {
89                thickness: border_thickness,
90                color: BLACK,
91            },
92        );
93    }
94
95    fn rect(&self) -> &Rectangle {
96        &self.rect
97    }
98
99    fn rect_mut(&mut self) -> &mut Rectangle {
100        &mut self.rect
101    }
102
103    fn children(&self) -> &Vec<Box<dyn View>> {
104        &self.children
105    }
106
107    fn children_mut(&mut self) -> &mut Vec<Box<dyn View>> {
108        &mut self.children
109    }
110
111    fn id(&self) -> Id {
112        self.id
113    }
114}
115
116/// A toggle component that displays two options side-by-side, separated by a vertical line.
117///
118/// The Toggle component provides a binary choice control where one option is highlighted
119/// with a minimal border box while the other appears without highlighting. Tapping either
120/// label toggles the state and emits a configured event.
121///
122/// # Implementation Note
123///
124/// Toggle uses a child view approach for the selection box. The SelectionBox is added as
125/// the 4th child and renders on top of the labels (due to z-order). When the toggle state
126/// changes, the SelectionBox is updated to reposition around the selected label.
127///
128/// # Visual Layout
129///
130/// ```text
131/// ┌─────────────────────────┐
132/// │ ┌─────────┐ │           │
133/// │ │Option A │ │ Option B  │ ← enabled = true (A selected)
134/// │ └─────────┘ │           │
135/// └─────────────────────────┘
136///      ↑             ↑
137///   Selected      Normal
138///   (border)   (no border)
139/// ```
140///
141/// # Event Flow
142///
143/// 1. User taps on either label
144/// 2. Label emits its configured event (bubbles to parent via bus)
145/// 3. Toggle intercepts this event in its handle_event()
146/// 4. Toggle updates internal state (flips enabled)
147/// 5. Toggle updates the SelectionBox child to reposition
148/// 6. Toggle triggers a re-render
149/// 7. Toggle re-emits the event to continue bubbling up
150///
151/// # Example
152///
153/// ```
154/// use cadmus_core::view::toggle::Toggle;
155/// use cadmus_core::view::{Align, Event, ViewId, ToggleEvent};
156/// use cadmus_core::font::Fonts;
157/// use cadmus_core::rect;
158/// use std::env;
159/// use std::path::PathBuf;
160///
161/// let fonts = &mut Fonts::load_from(PathBuf::from(env::var("TEST_ROOT_DIR").unwrap())).unwrap();
162///
163/// let rect = rect![10, 100, 410, 160];
164/// let wifi_toggle = Toggle::new(
165///     rect,
166///     "On",       // First option text
167///     "Off",      // Second option text
168///     true,       // Initial state (On selected)
169///     Event::Toggle(ToggleEvent::View(ViewId::SettingsMenu)),
170///     fonts,
171///     Align::Right(10)
172/// );
173/// ```
174///
175/// # Alignment Behavior
176///
177/// The right label uses the provided alignment, while the left label remains
178/// centered to avoid crowding the separator. This keeps the toggle right-aligned
179/// with other setting values while maintaining consistent padding to the edge.
180///
181/// # Fields
182///
183/// * `id` - Unique identifier for this view
184/// * `rect` - The rectangular bounds of the toggle
185/// * `children` - Contains 4 children: [Label, Filler, Label, SelectionBox]
186/// * `enabled` - true = first option selected, false = second option selected
187/// * `event` - Event to emit and intercept when toggling
188/// * `left_label_index` - Index of left label in children vec
189/// * `right_label_index` - Index of right label in children vec
190/// * `selection_box_index` - Index of selection box in children vec
191pub struct Toggle {
192    id: Id,
193    rect: Rectangle,
194    children: Vec<Box<dyn View>>,
195    enabled: bool,
196    event: Event,
197    left_label_index: usize,
198    right_label_index: usize,
199    selection_box_index: usize,
200    left_text_width: i32,
201    right_text_width: i32,
202}
203
204impl Toggle {
205    /// Creates a new Toggle component.
206    ///
207    /// # Arguments
208    ///
209    /// * `rect` - The rectangular bounds for the toggle
210    /// * `text_enabled` - Text for the first option (shown with border when enabled=true)
211    /// * `text_disabled` - Text for the second option (shown with border when enabled=false)
212    /// * `enabled` - Initial state (true = first option selected)
213    /// * `event` - Event to emit when toggled
214    /// * `align` - Alignment to apply to the right label
215    ///
216    /// # Returns
217    ///
218    /// A new Toggle instance with two labels separated by a vertical line, right-aligned
219    pub fn new(
220        rect: Rectangle,
221        text_enabled: &str,
222        text_disabled: &str,
223        enabled: bool,
224        event: Event,
225        fonts: &mut Fonts,
226        align: Align,
227    ) -> Toggle {
228        let dpi = CURRENT_DEVICE.dpi;
229        let separator_width = scale_by_dpi(THICKNESS_MEDIUM, dpi) as i32;
230
231        let font = font_from_style(fonts, &NORMAL_STYLE, dpi);
232        let padding = font.em() as i32;
233        let left_plan = font.plan(text_enabled, None, None);
234        let right_plan = font.plan(text_disabled, None, None);
235        let left_text_width = left_plan.width;
236        let right_text_width = right_plan.width;
237        let left_width = left_text_width + padding;
238        let right_width = right_text_width + padding;
239        let total_width = left_width + separator_width + right_width;
240
241        let x_offset = rect.width() as i32 - total_width;
242
243        let mut children = Vec::new();
244
245        let left_rect = rect![
246            rect.min.x + x_offset,
247            rect.min.y,
248            rect.min.x + x_offset + left_width,
249            rect.max.y
250        ];
251        let left_label = Label::new(left_rect, text_enabled.to_string(), Align::Center)
252            .scheme(TEXT_NORMAL)
253            .event(Some(event.clone()));
254        children.push(Box::new(left_label) as Box<dyn View>);
255        let left_label_index = children.len() - 1;
256
257        let separator_height = rect.height() as i32;
258        let separator_padding = separator_height / 4;
259        let separator_rect = rect![
260            rect.min.x + x_offset + left_width,
261            rect.min.y + separator_padding,
262            rect.min.x + x_offset + left_width + separator_width,
263            rect.max.y - separator_padding
264        ];
265        let separator = Filler::new(separator_rect, GRAY08);
266        children.push(Box::new(separator) as Box<dyn View>);
267
268        let right_rect = rect![
269            rect.min.x + x_offset + left_width + separator_width,
270            rect.min.y,
271            rect.max.x,
272            rect.max.y
273        ];
274        let right_label = Label::new(right_rect, text_disabled.to_string(), align)
275            .scheme(TEXT_NORMAL)
276            .event(Some(event.clone()));
277        children.push(Box::new(right_label) as Box<dyn View>);
278        let right_label_index = children.len() - 1;
279
280        let selected_rect = if enabled { left_rect } else { right_rect };
281        let selected_text_width = if enabled {
282            left_text_width
283        } else {
284            right_text_width
285        };
286        let selection_box = SelectionBox::new(rect, selected_rect, selected_text_width, true);
287        children.push(Box::new(selection_box) as Box<dyn View>);
288        let selection_box_index = children.len() - 1;
289
290        Toggle {
291            id: ID_FEEDER.next(),
292            rect,
293            children,
294            enabled,
295            event,
296            left_label_index,
297            right_label_index,
298            selection_box_index,
299            left_text_width,
300            right_text_width,
301        }
302    }
303
304    fn request_rerender(&mut self, rq: &mut RenderQueue) {
305        rq.add(crate::view::RenderData::new(
306            self.id,
307            self.rect,
308            crate::framebuffer::UpdateMode::Gui,
309        ));
310    }
311
312    fn update_selection_box(&mut self, rq: &mut RenderQueue) {
313        let selected_label_index = if self.enabled {
314            self.left_label_index
315        } else {
316            self.right_label_index
317        };
318
319        let text_width = if self.enabled {
320            self.left_text_width
321        } else {
322            self.right_text_width
323        };
324
325        let selected_rect = *self.children[selected_label_index].rect();
326
327        if let Some(selection_box) =
328            self.children[self.selection_box_index].downcast_mut::<SelectionBox>()
329        {
330            selection_box.set_target(selected_rect, text_width, true);
331        }
332        self.request_rerender(rq);
333    }
334
335    #[cfg(test)]
336    pub fn is_enabled(&self) -> bool {
337        self.enabled
338    }
339}
340
341impl View for Toggle {
342    #[cfg_attr(feature = "otel", tracing::instrument(skip(self, _hub, bus, rq, _context), fields(event = ?evt), ret(level=tracing::Level::TRACE)))]
343    fn handle_event(
344        &mut self,
345        evt: &Event,
346        _hub: &Hub,
347        bus: &mut Bus,
348        rq: &mut RenderQueue,
349        _context: &mut Context,
350    ) -> bool {
351        if let (Event::Toggle(incoming), Event::Toggle(stored)) = (evt, &self.event) {
352            if incoming == stored {
353                self.enabled = !self.enabled;
354                self.update_selection_box(rq);
355                bus.push_back(evt.clone());
356
357                return true;
358            }
359        }
360
361        false
362    }
363
364    #[cfg_attr(feature = "otel", tracing::instrument(skip(self, _fb, _fonts), fields(rect = ?_rect)))]
365    fn render(&self, _fb: &mut dyn Framebuffer, _rect: Rectangle, _fonts: &mut Fonts) {}
366
367    fn rect(&self) -> &Rectangle {
368        &self.rect
369    }
370
371    fn rect_mut(&mut self) -> &mut Rectangle {
372        &mut self.rect
373    }
374
375    fn children(&self) -> &Vec<Box<dyn View>> {
376        &self.children
377    }
378
379    fn children_mut(&mut self) -> &mut Vec<Box<dyn View>> {
380        &mut self.children
381    }
382
383    fn id(&self) -> Id {
384        self.id
385    }
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391    use crate::context::test_helpers::create_test_context;
392    use crate::view::{ToggleEvent, ViewId};
393    use std::collections::VecDeque;
394    use std::sync::mpsc::channel;
395
396    #[test]
397    fn test_toggle_starts_in_enabled_state() {
398        let mut context = create_test_context();
399        let rect = rect![0, 0, 200, 50];
400        let toggle_event = Event::Toggle(ToggleEvent::View(ViewId::SettingsMenu));
401        let toggle = Toggle::new(
402            rect,
403            "On",
404            "Off",
405            true,
406            toggle_event,
407            &mut context.fonts,
408            Align::Center,
409        );
410        assert!(toggle.is_enabled());
411    }
412
413    #[test]
414    fn test_toggle_starts_in_disabled_state() {
415        let mut context = create_test_context();
416        let rect = rect![0, 0, 200, 50];
417        let toggle_event = Event::Toggle(ToggleEvent::View(ViewId::SettingsMenu));
418        let toggle = Toggle::new(
419            rect,
420            "On",
421            "Off",
422            false,
423            toggle_event,
424            &mut context.fonts,
425            Align::Center,
426        );
427        assert!(!toggle.is_enabled());
428    }
429
430    #[test]
431    fn test_toggle_event_intercepted_and_state_flipped() {
432        let mut context = create_test_context();
433        let rect = rect![0, 0, 200, 50];
434        let toggle_event = Event::Toggle(ToggleEvent::View(ViewId::SettingsMenu));
435        let mut toggle = Toggle::new(
436            rect,
437            "On",
438            "Off",
439            true,
440            toggle_event.clone(),
441            &mut context.fonts,
442            Align::Center,
443        );
444
445        let (hub, _receiver) = channel();
446        let mut bus = VecDeque::new();
447        let mut rq = RenderQueue::new();
448
449        let handled = toggle.handle_event(&toggle_event, &hub, &mut bus, &mut rq, &mut context);
450
451        assert!(handled);
452        assert!(!toggle.is_enabled());
453
454        assert_eq!(bus.len(), 1);
455        assert!(matches!(
456            bus.pop_front(),
457            Some(Event::Toggle(ToggleEvent::View(ViewId::SettingsMenu)))
458        ));
459
460        assert!(!rq.is_empty());
461    }
462
463    #[test]
464    fn test_labels_have_correct_events_configured() {
465        let mut context = create_test_context();
466        let rect = rect![0, 0, 200, 50];
467        let toggle_event = Event::Toggle(ToggleEvent::View(ViewId::SettingsMenu));
468        let toggle = Toggle::new(
469            rect,
470            "On",
471            "Off",
472            true,
473            toggle_event,
474            &mut context.fonts,
475            Align::Center,
476        );
477
478        let left_label = toggle.children[0].downcast_ref::<Label>().unwrap();
479        assert!(left_label.text() == "On");
480
481        let right_label = toggle.children[2].downcast_ref::<Label>().unwrap();
482        assert!(right_label.text() == "Off");
483    }
484
485    #[test]
486    fn test_labels_use_normal_scheme() {
487        let mut context = create_test_context();
488        let rect = rect![0, 0, 200, 50];
489        let toggle_event = Event::Toggle(ToggleEvent::View(ViewId::SettingsMenu));
490        let toggle = Toggle::new(
491            rect,
492            "On",
493            "Off",
494            true,
495            toggle_event,
496            &mut context.fonts,
497            Align::Center,
498        );
499
500        let left_label = toggle.children[0].downcast_ref::<Label>().unwrap();
501        assert_eq!(left_label.get_scheme(), TEXT_NORMAL);
502
503        let right_label = toggle.children[2].downcast_ref::<Label>().unwrap();
504        assert_eq!(right_label.get_scheme(), TEXT_NORMAL);
505    }
506
507    #[test]
508    fn test_filler_separator_is_present() {
509        let mut context = create_test_context();
510        let rect = rect![0, 0, 200, 50];
511        let toggle_event = Event::Toggle(ToggleEvent::View(ViewId::SettingsMenu));
512        let toggle = Toggle::new(
513            rect,
514            "On",
515            "Off",
516            true,
517            toggle_event,
518            &mut context.fonts,
519            Align::Center,
520        );
521
522        assert!(toggle.children[1].is::<Filler>());
523    }
524
525    #[test]
526    fn test_multiple_toggles_flips_state_multiple_times() {
527        let mut context = create_test_context();
528        let rect = rect![0, 0, 200, 50];
529        let toggle_event = Event::Toggle(ToggleEvent::View(ViewId::SettingsMenu));
530        let mut toggle = Toggle::new(
531            rect,
532            "On",
533            "Off",
534            true,
535            toggle_event.clone(),
536            &mut context.fonts,
537            Align::Center,
538        );
539
540        let (hub, _receiver) = channel();
541        let mut bus = VecDeque::new();
542        let mut rq = RenderQueue::new();
543
544        toggle.handle_event(&toggle_event, &hub, &mut bus, &mut rq, &mut context);
545        assert!(!toggle.is_enabled());
546
547        toggle.handle_event(&toggle_event, &hub, &mut bus, &mut rq, &mut context);
548        assert!(toggle.is_enabled());
549
550        toggle.handle_event(&toggle_event, &hub, &mut bus, &mut rq, &mut context);
551        assert!(!toggle.is_enabled());
552    }
553
554    #[test]
555    fn test_non_toggle_events_are_ignored() {
556        let mut context = create_test_context();
557        let rect = rect![0, 0, 200, 50];
558        let toggle_event = Event::Toggle(ToggleEvent::View(ViewId::SettingsMenu));
559        let mut toggle = Toggle::new(
560            rect,
561            "On",
562            "Off",
563            true,
564            toggle_event,
565            &mut context.fonts,
566            Align::Center,
567        );
568
569        let (hub, _receiver) = channel();
570        let mut bus = VecDeque::new();
571        let mut rq = RenderQueue::new();
572
573        let other_event = Event::Back;
574        let handled = toggle.handle_event(&other_event, &hub, &mut bus, &mut rq, &mut context);
575
576        assert!(!handled);
577        assert!(toggle.is_enabled());
578        assert_eq!(bus.len(), 0);
579    }
580
581    #[test]
582    fn test_event_bubbling_continues_after_toggle() {
583        let mut context = create_test_context();
584        let rect = rect![0, 0, 200, 50];
585        let toggle_event = Event::Toggle(ToggleEvent::View(ViewId::SettingsMenu));
586        let mut toggle = Toggle::new(
587            rect,
588            "On",
589            "Off",
590            true,
591            toggle_event.clone(),
592            &mut context.fonts,
593            Align::Center,
594        );
595
596        let (hub, _receiver) = channel();
597        let mut bus = VecDeque::new();
598        let mut rq = RenderQueue::new();
599
600        toggle.handle_event(&toggle_event, &hub, &mut bus, &mut rq, &mut context);
601
602        assert_eq!(bus.len(), 1);
603        let emitted_event = bus.pop_front().unwrap();
604        assert!(matches!(
605            emitted_event,
606            Event::Toggle(ToggleEvent::View(ViewId::SettingsMenu))
607        ));
608    }
609
610    #[test]
611    fn test_has_four_children() {
612        let mut context = create_test_context();
613        let rect = rect![0, 0, 200, 50];
614        let toggle_event = Event::Toggle(ToggleEvent::View(ViewId::SettingsMenu));
615        let toggle = Toggle::new(
616            rect,
617            "On",
618            "Off",
619            true,
620            toggle_event,
621            &mut context.fonts,
622            Align::Center,
623        );
624
625        assert_eq!(toggle.children.len(), 4);
626        assert!(toggle.children[0].is::<Label>());
627        assert!(toggle.children[1].is::<Filler>());
628        assert!(toggle.children[2].is::<Label>());
629        assert!(toggle.children[3].is::<SelectionBox>());
630    }
631}