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::NewToggle(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 std::mem::discriminant(evt) == std::mem::discriminant(&self.event) {
352            self.enabled = !self.enabled;
353            self.update_selection_box(rq);
354            bus.push_back(evt.clone());
355            return true;
356        }
357
358        false
359    }
360
361    #[cfg_attr(feature = "otel", tracing::instrument(skip(self, _fb, _fonts), fields(rect = ?_rect)))]
362    fn render(&self, _fb: &mut dyn Framebuffer, _rect: Rectangle, _fonts: &mut Fonts) {}
363
364    fn rect(&self) -> &Rectangle {
365        &self.rect
366    }
367
368    fn rect_mut(&mut self) -> &mut Rectangle {
369        &mut self.rect
370    }
371
372    fn children(&self) -> &Vec<Box<dyn View>> {
373        &self.children
374    }
375
376    fn children_mut(&mut self) -> &mut Vec<Box<dyn View>> {
377        &mut self.children
378    }
379
380    fn id(&self) -> Id {
381        self.id
382    }
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use crate::context::test_helpers::create_test_context;
389    use crate::view::{ToggleEvent, ViewId};
390    use std::collections::VecDeque;
391    use std::sync::mpsc::channel;
392
393    #[test]
394    fn test_toggle_starts_in_enabled_state() {
395        let mut context = create_test_context();
396        let rect = rect![0, 0, 200, 50];
397        let toggle_event = Event::NewToggle(ToggleEvent::View(ViewId::SettingsMenu));
398        let toggle = Toggle::new(
399            rect,
400            "On",
401            "Off",
402            true,
403            toggle_event,
404            &mut context.fonts,
405            Align::Center,
406        );
407        assert!(toggle.is_enabled());
408    }
409
410    #[test]
411    fn test_toggle_starts_in_disabled_state() {
412        let mut context = create_test_context();
413        let rect = rect![0, 0, 200, 50];
414        let toggle_event = Event::NewToggle(ToggleEvent::View(ViewId::SettingsMenu));
415        let toggle = Toggle::new(
416            rect,
417            "On",
418            "Off",
419            false,
420            toggle_event,
421            &mut context.fonts,
422            Align::Center,
423        );
424        assert!(!toggle.is_enabled());
425    }
426
427    #[test]
428    fn test_toggle_event_intercepted_and_state_flipped() {
429        let mut context = create_test_context();
430        let rect = rect![0, 0, 200, 50];
431        let toggle_event = Event::NewToggle(ToggleEvent::View(ViewId::SettingsMenu));
432        let mut toggle = Toggle::new(
433            rect,
434            "On",
435            "Off",
436            true,
437            toggle_event.clone(),
438            &mut context.fonts,
439            Align::Center,
440        );
441
442        let (hub, _receiver) = channel();
443        let mut bus = VecDeque::new();
444        let mut rq = RenderQueue::new();
445
446        let handled = toggle.handle_event(&toggle_event, &hub, &mut bus, &mut rq, &mut context);
447
448        assert!(handled);
449        assert!(!toggle.is_enabled());
450
451        assert_eq!(bus.len(), 1);
452        assert!(matches!(
453            bus.pop_front(),
454            Some(Event::NewToggle(ToggleEvent::View(ViewId::SettingsMenu)))
455        ));
456
457        assert!(!rq.is_empty());
458    }
459
460    #[test]
461    fn test_labels_have_correct_events_configured() {
462        let mut context = create_test_context();
463        let rect = rect![0, 0, 200, 50];
464        let toggle_event = Event::NewToggle(ToggleEvent::View(ViewId::SettingsMenu));
465        let toggle = Toggle::new(
466            rect,
467            "On",
468            "Off",
469            true,
470            toggle_event,
471            &mut context.fonts,
472            Align::Center,
473        );
474
475        let left_label = toggle.children[0].downcast_ref::<Label>().unwrap();
476        assert!(left_label.text() == "On");
477
478        let right_label = toggle.children[2].downcast_ref::<Label>().unwrap();
479        assert!(right_label.text() == "Off");
480    }
481
482    #[test]
483    fn test_labels_use_normal_scheme() {
484        let mut context = create_test_context();
485        let rect = rect![0, 0, 200, 50];
486        let toggle_event = Event::NewToggle(ToggleEvent::View(ViewId::SettingsMenu));
487        let toggle = Toggle::new(
488            rect,
489            "On",
490            "Off",
491            true,
492            toggle_event,
493            &mut context.fonts,
494            Align::Center,
495        );
496
497        let left_label = toggle.children[0].downcast_ref::<Label>().unwrap();
498        assert_eq!(left_label.get_scheme(), TEXT_NORMAL);
499
500        let right_label = toggle.children[2].downcast_ref::<Label>().unwrap();
501        assert_eq!(right_label.get_scheme(), TEXT_NORMAL);
502    }
503
504    #[test]
505    fn test_filler_separator_is_present() {
506        let mut context = create_test_context();
507        let rect = rect![0, 0, 200, 50];
508        let toggle_event = Event::NewToggle(ToggleEvent::View(ViewId::SettingsMenu));
509        let toggle = Toggle::new(
510            rect,
511            "On",
512            "Off",
513            true,
514            toggle_event,
515            &mut context.fonts,
516            Align::Center,
517        );
518
519        assert!(toggle.children[1].is::<Filler>());
520    }
521
522    #[test]
523    fn test_multiple_toggles_flips_state_multiple_times() {
524        let mut context = create_test_context();
525        let rect = rect![0, 0, 200, 50];
526        let toggle_event = Event::NewToggle(ToggleEvent::View(ViewId::SettingsMenu));
527        let mut toggle = Toggle::new(
528            rect,
529            "On",
530            "Off",
531            true,
532            toggle_event.clone(),
533            &mut context.fonts,
534            Align::Center,
535        );
536
537        let (hub, _receiver) = channel();
538        let mut bus = VecDeque::new();
539        let mut rq = RenderQueue::new();
540
541        toggle.handle_event(&toggle_event, &hub, &mut bus, &mut rq, &mut context);
542        assert!(!toggle.is_enabled());
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
551    #[test]
552    fn test_non_toggle_events_are_ignored() {
553        let mut context = create_test_context();
554        let rect = rect![0, 0, 200, 50];
555        let toggle_event = Event::NewToggle(ToggleEvent::View(ViewId::SettingsMenu));
556        let mut toggle = Toggle::new(
557            rect,
558            "On",
559            "Off",
560            true,
561            toggle_event,
562            &mut context.fonts,
563            Align::Center,
564        );
565
566        let (hub, _receiver) = channel();
567        let mut bus = VecDeque::new();
568        let mut rq = RenderQueue::new();
569
570        let other_event = Event::Back;
571        let handled = toggle.handle_event(&other_event, &hub, &mut bus, &mut rq, &mut context);
572
573        assert!(!handled);
574        assert!(toggle.is_enabled());
575        assert_eq!(bus.len(), 0);
576    }
577
578    #[test]
579    fn test_event_bubbling_continues_after_toggle() {
580        let mut context = create_test_context();
581        let rect = rect![0, 0, 200, 50];
582        let toggle_event = Event::NewToggle(ToggleEvent::View(ViewId::SettingsMenu));
583        let mut toggle = Toggle::new(
584            rect,
585            "On",
586            "Off",
587            true,
588            toggle_event.clone(),
589            &mut context.fonts,
590            Align::Center,
591        );
592
593        let (hub, _receiver) = channel();
594        let mut bus = VecDeque::new();
595        let mut rq = RenderQueue::new();
596
597        toggle.handle_event(&toggle_event, &hub, &mut bus, &mut rq, &mut context);
598
599        assert_eq!(bus.len(), 1);
600        let emitted_event = bus.pop_front().unwrap();
601        assert!(matches!(
602            emitted_event,
603            Event::NewToggle(ToggleEvent::View(ViewId::SettingsMenu))
604        ));
605    }
606
607    #[test]
608    fn test_has_four_children() {
609        let mut context = create_test_context();
610        let rect = rect![0, 0, 200, 50];
611        let toggle_event = Event::NewToggle(ToggleEvent::View(ViewId::SettingsMenu));
612        let toggle = Toggle::new(
613            rect,
614            "On",
615            "Off",
616            true,
617            toggle_event,
618            &mut context.fonts,
619            Align::Center,
620        );
621
622        assert_eq!(toggle.children.len(), 4);
623        assert!(toggle.children[0].is::<Label>());
624        assert!(toggle.children[1].is::<Filler>());
625        assert!(toggle.children[2].is::<Label>());
626        assert!(toggle.children[3].is::<SelectionBox>());
627    }
628}