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}