Skip to main content

sequoia_elements/
switch.rs

1use sequoia_core::{Color, Edges, Length, Point, Rect, Size};
2use sequoia_input::{CursorIcon, Event, EventPhase, Key, PointerButton, PointerEvent};
3use sequoia_layout::{LayoutCtx, LayoutStyle};
4use sequoia_reactive::Signal;
5use sequoia_style::{SwitchStyle, VisualState};
6use sequoia_view::{AnyView, EventCtx, PaintCtx, Unit, View};
7
8const TRACK_W: f32 = 40.0;
9const TRACK_H: f32 = 22.0;
10const GAP: f32 = 8.0;
11const BORDER_W: f32 = 1.0;
12
13pub struct Switch {
14    checked: Signal<bool>,
15    label: Option<String>,
16    style: Option<SwitchStyle>,
17    track_on: Option<Color>,
18    track_off: Option<Color>,
19    track_disabled: Option<Color>,
20    thumb: Option<Color>,
21    thumb_on: Option<Color>,
22    thumb_disabled: Option<Color>,
23    border: Option<Color>,
24    border_on: Option<Color>,
25    text_color: Option<Color>,
26    font_size: Option<f32>,
27    track_width: Option<f32>,
28    track_height: Option<f32>,
29    thumb_size: Option<f32>,
30    disabled: bool,
31    on_toggle: Option<Box<dyn Fn(bool)>>,
32    margin: Edges,
33}
34
35impl Switch {
36    pub fn new(checked: Signal<bool>) -> Self {
37        Self {
38            checked,
39            label: None,
40            style: None,
41            track_on: None,
42            track_off: None,
43            track_disabled: None,
44            thumb: None,
45            thumb_on: None,
46            thumb_disabled: None,
47            border: None,
48            border_on: None,
49            text_color: None,
50            font_size: None,
51            track_width: None,
52            track_height: None,
53            thumb_size: None,
54            disabled: false,
55            on_toggle: None,
56            margin: Edges::ZERO,
57        }
58    }
59
60    pub fn label(mut self, label: impl Into<String>) -> Self {
61        self.label = Some(label.into());
62        self
63    }
64
65    pub fn style(mut self, style: SwitchStyle) -> Self {
66        self.style = Some(style);
67        self
68    }
69
70    pub fn track_on(mut self, color: Color) -> Self {
71        self.track_on = Some(color);
72        self
73    }
74
75    pub fn track_off(mut self, color: Color) -> Self {
76        self.track_off = Some(color);
77        self
78    }
79
80    pub fn track_disabled(mut self, color: Color) -> Self {
81        self.track_disabled = Some(color);
82        self
83    }
84
85    pub fn thumb_color(mut self, color: Color) -> Self {
86        self.thumb = Some(color);
87        self
88    }
89
90    pub fn thumb_on_color(mut self, color: Color) -> Self {
91        self.thumb_on = Some(color);
92        self
93    }
94
95    pub fn thumb_disabled_color(mut self, color: Color) -> Self {
96        self.thumb_disabled = Some(color);
97        self
98    }
99
100    pub fn border_color(mut self, color: Color) -> Self {
101        self.border = Some(color);
102        self
103    }
104
105    pub fn border_on_color(mut self, color: Color) -> Self {
106        self.border_on = Some(color);
107        self
108    }
109
110    pub fn text_color(mut self, color: Color) -> Self {
111        self.text_color = Some(color);
112        self
113    }
114
115    pub fn font_size(mut self, size: f32) -> Self {
116        self.font_size = Some(size);
117        self
118    }
119
120    pub fn track_width(mut self, width: f32) -> Self {
121        self.track_width = Some(width.max(8.0));
122        self
123    }
124
125    pub fn track_height(mut self, height: f32) -> Self {
126        self.track_height = Some(height.max(8.0));
127        self
128    }
129
130    pub fn thumb_size(mut self, size: f32) -> Self {
131        self.thumb_size = Some(size.max(4.0));
132        self
133    }
134
135    pub fn disabled(mut self, disabled: bool) -> Self {
136        self.disabled = disabled;
137        self
138    }
139
140    pub fn on_toggle(mut self, f: impl Fn(bool) + 'static) -> Self {
141        self.on_toggle = Some(Box::new(f));
142        self
143    }
144
145    pub fn margin(mut self, margin: impl Into<Edges>) -> Self {
146        let m = margin.into();
147        self.margin = Edges::only(
148            sanitize_margin(m.left),
149            sanitize_margin(m.top),
150            sanitize_margin(m.right),
151            sanitize_margin(m.bottom),
152        );
153        self
154    }
155
156    pub fn margin_xy(self, x: f32, y: f32) -> Self {
157        self.margin(Edges::xy(x, y))
158    }
159
160    pub fn margin_x(mut self, x: f32) -> Self {
161        let x = sanitize_margin(x);
162        self.margin.left = x;
163        self.margin.right = x;
164        self
165    }
166
167    pub fn margin_y(mut self, y: f32) -> Self {
168        let y = sanitize_margin(y);
169        self.margin.top = y;
170        self.margin.bottom = y;
171        self
172    }
173
174    pub fn margin_left(mut self, v: f32) -> Self {
175        self.margin.left = sanitize_margin(v);
176        self
177    }
178
179    pub fn margin_right(mut self, v: f32) -> Self {
180        self.margin.right = sanitize_margin(v);
181        self
182    }
183
184    pub fn margin_top(mut self, v: f32) -> Self {
185        self.margin.top = sanitize_margin(v);
186        self
187    }
188
189    pub fn margin_bottom(mut self, v: f32) -> Self {
190        self.margin.bottom = sanitize_margin(v);
191        self
192    }
193
194    pub fn is_on(&self) -> bool {
195        self.checked.get_untracked()
196    }
197
198    fn resolved_track_size(&self) -> (f32, f32) {
199        let style = self.style.unwrap_or_default();
200        let w = self.track_width.or(style.width).unwrap_or(TRACK_W).max(8.0);
201        let h = self
202            .track_height
203            .or(style.height)
204            .unwrap_or(TRACK_H)
205            .max(8.0);
206        (w, h)
207    }
208
209    fn resolved_thumb_d(&self, track_h: f32) -> f32 {
210        let style = self.style.unwrap_or_default();
211        self.thumb_size
212            .or(style.thumb_size)
213            .unwrap_or((track_h - 4.0).max(4.0))
214            .clamp(4.0, track_h.max(4.0))
215    }
216
217    fn toggle(&self) {
218        if self.disabled {
219            return;
220        }
221        let next = !self.checked.get_untracked();
222        self.checked.set(next);
223        if let Some(f) = &self.on_toggle {
224            f(next);
225        }
226    }
227}
228
229fn sanitize_margin(v: f32) -> f32 {
230    if v.is_finite() { v.max(0.0) } else { 0.0 }
231}
232
233impl View for Switch {
234    fn body(&self) -> impl View + 'static {
235        Unit
236    }
237    fn is_leaf(&self) -> bool {
238        true
239    }
240    fn focusable(&self) -> bool {
241        !self.disabled
242    }
243    fn has_visual_state(&self) -> bool {
244        true
245    }
246    fn cursor_icon(&self, _state: VisualState) -> CursorIcon {
247        if self.disabled {
248            CursorIcon::Default
249        } else {
250            CursorIcon::Pointer
251        }
252    }
253    fn keyed_children(&self) -> Option<Vec<(u64, AnyView)>> {
254        let _ = self.checked.get();
255        Some(Vec::new())
256    }
257    fn layout_style(&self) -> LayoutStyle {
258        LayoutStyle {
259            width: Length::Fit,
260            height: Length::Fit,
261            margin: self.margin,
262            ..LayoutStyle::default()
263        }
264    }
265    fn intrinsic_size(&self, ctx: &mut LayoutCtx) -> Option<Size> {
266        let style = self.style.unwrap_or_default();
267        let size = self.font_size.or(style.font_size).unwrap_or(14.0);
268        let (track_w, track_h) = self.resolved_track_size();
269        let label_w = self
270            .label
271            .as_deref()
272            .map(|l| ctx.text_width(l, size))
273            .unwrap_or(0.0);
274        let gap = if self.label.is_some() { GAP } else { 0.0 };
275        let h = track_h.max(LayoutCtx::text_line_height(size));
276        Some(Size::new(track_w + gap + label_w, h))
277    }
278    fn on_event(&self, event: &Event, ctx: &mut EventCtx) {
279        if self.disabled || ctx.phase != EventPhase::Target {
280            return;
281        }
282        if let Event::Pointer(PointerEvent::Up {
283            button: PointerButton::Left,
284            ..
285        }) = event
286        {
287            if ctx.state.pressed && ctx.hover_self {
288                self.toggle();
289                ctx.handled();
290            }
291            return;
292        }
293        if let Event::Key(key) = event
294            && ctx.state.focused
295            && (key.key == Key::ENTER || key.key == Key::SPACE)
296        {
297            self.toggle();
298            ctx.handled();
299        }
300    }
301    fn paint_self(&self, ctx: &mut PaintCtx) {
302        let theme = ctx.theme();
303        let mut vs = ctx.state;
304        vs.disabled = self.disabled;
305        vs.selected = self.checked.get_untracked();
306        let style = self.style.unwrap_or_default();
307        let (track_w, track_h) = self.resolved_track_size();
308        let thumb_d = self.resolved_thumb_d(track_h);
309        let thumb_r = thumb_d * 0.5;
310        let pad = ((track_h - thumb_d) * 0.5).max(1.0);
311
312        let rect = ctx.rect;
313        let track_y = rect.top() + (rect.size.height - track_h) * 0.5;
314        let track_rect = Rect::from_xywh(rect.left(), track_y, track_w, track_h);
315        let radius = track_h * 0.5;
316
317        let track = if vs.disabled {
318            self.track_disabled
319                .or(style.track_disabled)
320                .unwrap_or(theme.disabled)
321        } else if vs.selected {
322            self.track_on
323                .or(style.track_on)
324                .unwrap_or(Color::hex(0x3A3A3A))
325        } else {
326            self.track_off.or(style.track_off).unwrap_or(theme.surface)
327        };
328        let border = if vs.selected {
329            self.border_on
330                .or(style.border_on)
331                .or(self.border)
332                .or(style.border)
333        } else {
334            self.border.or(style.border)
335        };
336        let has_border = border.is_some_and(|c| c.a > 0.0);
337        if has_border {
338            ctx.bordered_quad(track_rect, track, radius, BORDER_W, border.unwrap());
339        } else {
340            ctx.quad(track_rect, track, radius);
341        }
342
343        let thumb = if vs.disabled {
344            self.thumb_disabled
345                .or(style.thumb_disabled)
346                .or(self.thumb)
347                .or(style.thumb)
348                .unwrap_or(Color::WHITE)
349        } else if vs.selected {
350            self.thumb_on
351                .or(style.thumb_on)
352                .or(self.thumb)
353                .or(style.thumb)
354                .unwrap_or(Color::WHITE)
355        } else {
356            self.thumb.or(style.thumb).unwrap_or(Color::WHITE)
357        };
358        let cx = if vs.selected {
359            track_rect.right() - pad - thumb_r
360        } else {
361            track_rect.left() + pad + thumb_r
362        };
363        let cy = track_rect.top() + track_h * 0.5;
364        ctx.disk(Point::new(cx, cy), thumb_r, thumb);
365
366        if let Some(label) = &self.label {
367            let size = self
368                .font_size
369                .or(style.font_size)
370                .unwrap_or(theme.font_size);
371            let text = self.text_color.or(style.text).unwrap_or(theme.text);
372            let x = rect.left() + track_w + GAP;
373            let y = rect.top() + rect.size.height * 0.5 + size * 0.35;
374            ctx.draw_text(label, (x, y), size, text);
375        }
376    }
377}