1use std::rc::Rc;
4
5use ravenrs_core::{
6 Border, Color, CursorIcon, Effect, Insets, Key, LinearGradient, Point, Quad, Rect, Role, Scene, Shadow,
7};
8use ravenrs_layout::{Align, Axis, Justify, LayoutStyle};
9use ravenrs_reactive::Binding;
10use ravenrs_style::{Semantic, TextStyle, Theme, Weight};
11
12use crate::context::{BuildCx, DragPhase, NodeId, Paint, QuadPaint, Semantics, WidgetId};
13use crate::{IconName, Motion, View, motion, util};
14
15fn row(cx: &mut BuildCx<'_>, children: &[NodeId]) -> NodeId {
16 let style = LayoutStyle {
17 spacing: 10.0,
18 align: Align::Center,
19 align_self: Some(Align::Fit),
20 ..LayoutStyle::stack(Axis::Horizontal)
21 };
22 cx.container(style, children, Paint::None)
23}
24
25fn make_interactive(cx: &mut BuildCx<'_>, node: NodeId, radius: f32, on_click: Rc<dyn Fn()>) {
26 cx.on_click(node, on_click);
27 cx.focusable(node, radius);
28 cx.cursor(node, CursorIcon::Pointer);
29}
30
31struct ChoiceOption {
34 roving: util::Roving,
35 selected: Binding<usize>,
36}
37
38#[cfg_attr(feature = "live", ravenrs_live::expose)]
39impl ChoiceOption {
40 fn index(&self) -> usize {
41 self.roving.index
42 }
43
44 fn wire(&self, cx: &mut BuildCx<'_>, node: NodeId, radius: f32) {
45 let (selected, index) = (self.selected.clone(), self.index());
46 make_interactive(cx, node, radius, Rc::new(move || selected.set(index)));
47 let selected = self.selected.clone();
48 self.roving.wire(cx, node, Some(Rc::new(move |i| selected.set(i))));
49 }
50}
51
52#[derive(Debug)]
54#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
55pub struct Toggle {
56 value: Binding<bool>,
57 label: Option<String>,
58 small: bool,
59 state: util::EditorState,
60}
61
62#[cfg_attr(feature = "live", ravenrs_live::expose)]
63impl Toggle {
64 pub fn new(value: Binding<bool>) -> Self {
66 Self { value, label: None, small: false, state: util::EditorState::default() }
67 }
68
69 pub fn small(mut self) -> Self {
71 self.small = true;
72 self
73 }
74
75 pub fn label(mut self, label: impl Into<String>) -> Self {
77 self.label = Some(label.into());
78 self
79 }
80
81 pub fn disabled(mut self, disabled: bool) -> Self {
83 self.state.disabled = disabled;
84 self
85 }
86
87 pub fn read_only(mut self, read_only: bool) -> Self {
89 self.state.read_only = read_only;
90 self
91 }
92}
93
94impl View for Toggle {
95 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
96 let theme = *cx.theme();
97 let on = self.value.get();
98 let state = self.state;
99 let hovered = cx.is_hovered() && state.accepts_input();
100 let track = if on {
101 theme.color(if hovered { Semantic::AccentHover } else { Semantic::Accent })
102 } else {
103 theme.color(Semantic::Control).lerp(theme.color(Semantic::Ink), if hovered { 0.08 } else { 0.0 })
104 };
105 let track = state.fill(track);
106 let border = (!on).then(|| Border { width: 1.0, color: state.ink(theme.color(Semantic::Border)) });
107 let (track_width, track_height, knob, inset) =
108 if self.small { (32.0, 18.0, 14.0, 2.0) } else { (40.0, 24.0, 18.0, 3.0) };
109 let thumb = cx.container(
112 LayoutStyle {
113 width: Some(if cx.is_pressed() && state.accepts_input() { knob + 4.0 } else { knob }),
114 height: Some(knob),
115 ..LayoutStyle::leaf()
116 },
117 &[],
118 Paint::Quad(QuadPaint {
119 corner_radius: knob / 2.0,
120 shadow: Some(Shadow {
121 offset: Point::new(0.0, 1.0),
122 blur: 3.0,
123 color: Color::rgba(0.0, 0.0, 0.0, 0.3),
124 }),
125 ..QuadPaint::new(state.ink(Color::WHITE))
126 }),
127 );
128 let switch = cx.container(
129 LayoutStyle {
130 width: Some(track_width),
131 height: Some(track_height),
132 padding: Insets::uniform(inset),
133 justify: if on { Justify::End } else { Justify::Start },
134 ..LayoutStyle::stack(Axis::Horizontal)
135 },
136 &[thumb],
137 Paint::Quad(QuadPaint { corner_radius: track_height / 2.0, border, ..QuadPaint::new(track) }),
138 );
139 cx.motion(thumb, Motion::new().layout(theme.motion.snappy));
140 motion::control(cx, switch);
141 let mut children = vec![switch];
142 if let Some(label) = &self.label {
143 children.push(util::label(
144 cx,
145 label,
146 TextStyle::Body,
147 None,
148 state.ink(theme.color(Semantic::Ink)),
149 ));
150 }
151 let node = row(cx, &children);
152 cx.semantics(node, state.semantics(Semantics::new(Role::Switch).toggled(on)));
153 if state.accepts_input() {
154 let value = self.value.clone();
155 make_interactive(cx, node, 8.0, Rc::new(move || value.update(|v| *v = !*v)));
156 } else if !state.disabled {
157 cx.focusable(node, 8.0);
158 }
159 node
160 }
161}
162
163#[derive(Debug)]
165#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
166pub struct Checkbox {
167 value: Binding<bool>,
168 label: String,
169 leading: Option<f32>,
170 state: util::EditorState,
171}
172
173#[cfg_attr(feature = "live", ravenrs_live::expose)]
174impl Checkbox {
175 pub fn new(label: impl Into<String>, value: Binding<bool>) -> Self {
177 Self { value, label: label.into(), leading: None, state: util::EditorState::default() }
178 }
179
180 pub fn leading(mut self, width: f32) -> Self {
183 self.leading = Some(width);
184 self
185 }
186
187 pub fn disabled(mut self, disabled: bool) -> Self {
189 self.state.disabled = disabled;
190 self
191 }
192
193 pub fn read_only(mut self, read_only: bool) -> Self {
195 self.state.read_only = read_only;
196 self
197 }
198}
199
200pub(crate) fn check_box(cx: &mut BuildCx<'_>, theme: &Theme, checked: bool, hovered: bool) -> NodeId {
201 let mark = if checked {
202 vec![util::icon(cx, IconName::Check, 14.0, theme.color(Semantic::OnAccent))]
203 } else {
204 vec![]
205 };
206 check_box_around(cx, theme, checked, hovered, util::EditorState::default(), &mark)
207}
208
209pub(crate) fn check_box_around(
210 cx: &mut BuildCx<'_>,
211 theme: &Theme,
212 checked: bool,
213 hovered: bool,
214 state: util::EditorState,
215 mark: &[NodeId],
216) -> NodeId {
217 let paint = if checked {
218 QuadPaint { corner_radius: 5.0, ..QuadPaint::new(state.fill(theme.color(Semantic::Accent))) }
219 } else {
220 let edge = if hovered { theme.color(Semantic::Ink) } else { theme.color(Semantic::InkMuted) };
221 QuadPaint {
222 corner_radius: 5.0,
223 border: Some(Border { width: 1.5, color: state.ink(edge.with_alpha(0.7)) }),
224 ..QuadPaint::new(Color::TRANSPARENT)
225 }
226 };
227 let style = LayoutStyle {
228 width: Some(18.0),
229 height: Some(18.0),
230 align: Align::Center,
231 justify: Justify::Center,
232 ..LayoutStyle::stack(Axis::Horizontal)
233 };
234 cx.container(style, mark, Paint::Quad(paint))
235}
236
237fn pop_mark(cx: &mut BuildCx<'_>, mark: NodeId) {
239 let m = cx.theme().motion;
240 cx.motion(
241 mark,
242 Motion::new()
243 .enter(Effect::hidden().with_scale(0.5), m.snappy)
244 .exit(Effect::hidden().with_scale(0.5), m.fast),
245 );
246}
247
248impl View for Checkbox {
249 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
250 let theme = *cx.theme();
251 let checked = self.value.get();
252 let state = self.state;
253 let mark = checked.then(|| util::icon(cx, IconName::Check, 14.0, theme.color(Semantic::OnAccent)));
254 let hovered = cx.is_hovered() && state.accepts_input();
255 let mut b = check_box_around(cx, &theme, checked, hovered, state, mark.as_slice());
256 motion::control(cx, b);
257 if let Some(mark) = mark {
258 pop_mark(cx, mark);
259 }
260 if let Some(width) = self.leading {
261 b = cx.container(
262 LayoutStyle { width: Some(width), ..LayoutStyle::stack(Axis::Horizontal) },
263 &[b],
264 Paint::None,
265 );
266 }
267 let label =
268 util::label(cx, &self.label, TextStyle::Body, None, state.ink(theme.color(Semantic::Ink)));
269 let node = row(cx, &[b, label]);
270 cx.semantics(node, state.semantics(Semantics::new(Role::CheckBox).toggled(checked)));
271 if state.accepts_input() {
272 let value = self.value.clone();
273 make_interactive(cx, node, 6.0, Rc::new(move || value.update(|v| *v = !*v)));
274 } else if !state.disabled {
275 cx.focusable(node, 6.0);
276 }
277 node
278 }
279}
280
281#[derive(Debug)]
284#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
285pub struct RadioGroup {
286 selected: Binding<usize>,
287 options: Vec<String>,
288 label: Option<String>,
289}
290
291#[cfg_attr(feature = "live", ravenrs_live::expose)]
292impl RadioGroup {
293 pub fn new(selected: Binding<usize>, options: impl IntoIterator<Item = impl Into<String>>) -> Self {
295 Self { selected, options: options.into_iter().map(Into::into).collect(), label: None }
296 }
297
298 pub fn label(mut self, label: impl Into<String>) -> Self {
300 self.label = Some(label.into());
301 self
302 }
303}
304
305struct RadioOption {
306 choice: ChoiceOption,
307 label: String,
308}
309
310impl View for RadioOption {
311 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
312 let theme = *cx.theme();
313 let on = self.choice.selected.get() == self.choice.index();
314 let hovered = cx.is_hovered();
315 let dot = if on {
316 vec![cx.container(
317 LayoutStyle { width: Some(8.0), height: Some(8.0), ..LayoutStyle::leaf() },
318 &[],
319 Paint::Quad(QuadPaint {
320 corner_radius: 4.0,
321 ..QuadPaint::new(theme.color(Semantic::OnAccent))
322 }),
323 )]
324 } else {
325 vec![]
326 };
327 let edge = if hovered { theme.color(Semantic::Ink) } else { theme.color(Semantic::InkMuted) };
328 let paint = if on {
329 QuadPaint { corner_radius: 9.0, ..QuadPaint::new(theme.color(Semantic::Accent)) }
330 } else {
331 QuadPaint {
332 corner_radius: 9.0,
333 border: Some(Border { width: 1.5, color: edge.with_alpha(0.7) }),
334 ..QuadPaint::new(Color::TRANSPARENT)
335 }
336 };
337 let circle = cx.container(
338 LayoutStyle {
339 width: Some(18.0),
340 height: Some(18.0),
341 align: Align::Center,
342 justify: Justify::Center,
343 ..LayoutStyle::stack(Axis::Horizontal)
344 },
345 &dot,
346 Paint::Quad(paint),
347 );
348 motion::control(cx, circle);
349 if let Some(&dot) = dot.first() {
350 pop_mark(cx, dot);
351 }
352 let label = util::label(cx, &self.label, TextStyle::Body, None, theme.color(Semantic::Ink));
353 let node = row(cx, &[circle, label]);
354 cx.semantics(node, Semantics::new(Role::RadioButton).selected(on).toggled(on));
355 self.choice.wire(cx, node, 6.0);
356 node
357 }
358}
359
360impl View for RadioGroup {
361 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
362 let focus_to = util::roving_cell(cx, None);
363 let count = self.options.len();
364 let options: Vec<NodeId> = self
365 .options
366 .iter()
367 .enumerate()
368 .map(|(index, label)| {
369 let roving = util::Roving { index, count, focus_to: focus_to.clone() };
370 let choice = ChoiceOption { roving, selected: self.selected.clone() };
371 cx.child(&RadioOption { choice, label: label.clone() })
372 })
373 .collect();
374 let node = cx.container(
375 LayoutStyle { spacing: 10.0, ..LayoutStyle::stack(Axis::Vertical) },
376 &options,
377 Paint::None,
378 );
379 cx.semantics(node, group_semantics(self.label.as_deref()));
380 node
381 }
382}
383
384fn group_semantics(label: Option<&str>) -> Semantics {
385 let semantics = Semantics::new(Role::RadioGroup);
386 match label {
387 Some(l) => semantics.name(l),
388 None => semantics,
389 }
390}
391
392#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
404pub struct Slider {
405 value: Binding<f32>,
406 min: f32,
407 max: f32,
408 step: f32,
409 width: Option<f32>,
410 label: Option<String>,
411 marks: Vec<f32>,
412 track_colors: Vec<Color>,
413 neutral: Option<f32>,
414 show_value: bool,
415 format: Option<Rc<dyn Fn(f32) -> String>>,
416}
417
418impl std::fmt::Debug for Slider {
419 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
420 f.debug_struct("Slider").field("min", &self.min).field("max", &self.max).finish_non_exhaustive()
421 }
422}
423
424#[cfg_attr(feature = "live", ravenrs_live::expose)]
425impl Slider {
426 pub fn new(value: Binding<f32>) -> Self {
428 Self {
429 value,
430 min: 0.0,
431 max: 1.0,
432 step: 0.01,
433 width: None,
434 label: None,
435 marks: Vec::new(),
436 track_colors: Vec::new(),
437 neutral: None,
438 show_value: false,
439 format: None,
440 }
441 }
442
443 pub fn range(mut self, min: f32, max: f32) -> Self {
445 self.min = min;
446 self.max = max.max(min + f32::EPSILON);
447 self.step = (self.max - self.min) / 100.0;
448 self
449 }
450
451 pub fn step(mut self, step: f32) -> Self {
453 self.step = step.max(f32::EPSILON);
454 self
455 }
456
457 pub fn width(mut self, width: f32) -> Self {
459 self.width = Some(width);
460 self
461 }
462
463 pub fn label(mut self, label: impl Into<String>) -> Self {
465 self.label = Some(label.into());
466 self
467 }
468
469 pub fn marks(mut self, marks: impl IntoIterator<Item = f32>) -> Self {
471 self.marks = marks.into_iter().collect();
472 self
473 }
474
475 pub fn track_colors(mut self, colors: impl IntoIterator<Item = Color>) -> Self {
479 self.track_colors = colors.into_iter().collect();
480 self
481 }
482
483 pub fn neutral(mut self, neutral: f32) -> Self {
486 self.neutral = Some(neutral);
487 self
488 }
489
490 pub fn show_value(mut self) -> Self {
492 self.show_value = true;
493 self
494 }
495
496 pub fn value_format(mut self, format: impl Fn(f32) -> String + 'static) -> Self {
499 self.show_value = true;
500 self.format = Some(Rc::new(format));
501 self
502 }
503
504 fn value_text(&self, value: f32) -> String {
505 match &self.format {
506 Some(format) => format(value),
507 None => {
508 let decimals = step_decimals(self.step);
509 format!("{value:.decimals$}")
510 }
511 }
512 }
513}
514
515pub(crate) fn step_decimals(step: f32) -> usize {
517 (-step.log10() - 1e-3).ceil().clamp(0.0, 4.0) as usize
519}
520
521fn paint_color_track(scene: &mut Scene, track: Rect, colors: &[Color]) {
524 let (Some(first), Some(last)) = (colors.first(), colors.last()) else { return };
525 let radius = track.height() / 2.0;
526 let cap = track.height();
527 scene.push_quad(Quad::new(Rect::new(track.x(), track.y(), cap, cap), *first).radius(radius));
528 scene.push_quad(Quad::new(Rect::new(track.max_x() - cap, track.y(), cap, cap), *last).radius(radius));
529 let inner = (track.width() - cap).max(0.0);
530 let bands = colors.len().saturating_sub(1);
531 if bands == 0 {
532 scene.push_quad(Quad::new(Rect::new(track.x() + radius, track.y(), inner, cap), *first));
533 return;
534 }
535 let band = inner / bands as f32;
536 for (i, pair) in colors.windows(2).enumerate() {
537 let overlap = if i + 1 < bands { 0.5 } else { 0.0 };
539 let rect = Rect::new(track.x() + radius + band * i as f32, track.y(), band + overlap, track.height());
540 let gradient = LinearGradient {
541 start: Point::new(0.0, 0.5),
542 end: Point::new(1.0, 0.5),
543 from: pair[0],
544 to: pair[1],
545 };
546 scene.push_quad(Quad::new(rect, gradient));
547 }
548}
549
550struct SliderTrack<'a> {
553 slider: &'a Slider,
554 in_row: bool,
556}
557
558impl View for SliderTrack<'_> {
559 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
560 let slider = self.slider;
561 let theme = *cx.theme();
562 let (min, max, step) = (slider.min, slider.max, slider.step);
563 let value = slider.value.get().clamp(min, max);
564 let t = (value - min) / (max - min);
565 let rest = slider.neutral.map_or(0.0, |n| ((n - min) / (max - min)).clamp(0.0, 1.0));
566 let (track, fill) = (theme.color(Semantic::Control), theme.color(Semantic::Accent));
567 let ring = theme.color(Semantic::Accent).with_alpha(if cx.is_pressed() || cx.is_hovered() {
568 0.25
569 } else {
570 0.0
571 });
572 let style = LayoutStyle {
573 width: if self.in_row { None } else { slider.width },
574 height: Some(24.0),
575 min_width: Some(120.0),
576 grow: if self.in_row { 1.0 } else { 0.0 },
577 align_self: Some(if slider.width.is_some() && !self.in_row {
578 Align::Start
579 } else {
580 Align::Stretch
581 }),
582 ..LayoutStyle::leaf()
583 };
584 let notch = theme.color(Semantic::InkMuted);
585 let marks: Vec<f32> =
586 slider.marks.iter().map(|m| ((m - min) / (max - min)).clamp(0.0, 1.0)).collect();
587 let colors = slider.track_colors.clone();
588 let node = cx.container(
589 style,
590 &[],
591 Paint::Custom(Rc::new(move |f: Rect, scene| {
592 let usable = f.width() - 18.0;
593 let cy = f.center().y;
594 let bar = Rect::new(f.x() + 9.0, cy - 2.0, usable, 4.0);
595 if colors.is_empty() {
596 scene.push_quad(Quad::new(bar, track).radius(2.0));
597 let (from, to) = (rest.min(t), rest.max(t));
598 scene.push_quad(
599 Quad::new(
600 Rect::new(bar.x() + usable * from, bar.y(), usable * (to - from), 4.0),
601 fill,
602 )
603 .radius(2.0),
604 );
605 } else {
606 paint_color_track(scene, bar, &colors);
607 }
608 for mark in &marks {
609 let x = f.x() + 9.0 + usable * mark;
610 scene.push_quad(Quad::new(Rect::new(x - 1.0, cy - 5.0, 2.0, 10.0), notch).radius(1.0));
611 }
612 let cx_ = f.x() + 9.0 + usable * t;
613 if ring.a > 0.0 {
614 scene.push_quad(
615 Quad::new(Rect::new(cx_ - 14.0, cy - 14.0, 28.0, 28.0), ring).radius(14.0),
616 );
617 }
618 scene.push_quad(
619 Quad::new(Rect::new(cx_ - 9.0, cy - 9.0, 18.0, 18.0), Color::WHITE).radius(9.0).shadow(
620 Shadow {
621 offset: Point::new(0.0, 1.0),
622 blur: 4.0,
623 color: Color::rgba(0.0, 0.0, 0.0, 0.35),
624 },
625 ),
626 );
627 })),
628 );
629 let mut semantics =
630 Semantics::new(Role::Slider).numeric(value as f64, min as f64, max as f64, Some(step as f64));
631 if let Some(label) = &slider.label {
632 semantics = semantics.name(label.clone());
633 }
634 if slider.show_value {
635 semantics = semantics.value(slider.value_text(value));
636 }
637 cx.semantics(node, semantics);
638 let snap = move |v: f32| ((v - min) / step).round() * step + min;
639 let drag_value = slider.value.clone();
640 cx.on_drag(
641 node,
642 Rc::new(move |d| {
643 if d.phase == DragPhase::End {
644 return;
645 }
646 let usable = (d.frame.width() - 18.0).max(1.0);
647 let frac = ((d.position.x - d.frame.x() - 9.0) / usable).clamp(0.0, 1.0);
648 drag_value.set(snap(min + frac * (max - min)).clamp(min, max));
649 }),
650 );
651 let neutral = slider.neutral.map(|n| n.clamp(min, max));
652 if let Some(neutral) = neutral {
653 let reset = slider.value.clone();
654 cx.on_double_click(node, Rc::new(move || reset.set(neutral)));
655 }
656 let key_value = slider.value.clone();
657 cx.on_key(
658 node,
659 Rc::new(move |key, _| {
660 if let (Key::Backspace | Key::Delete, Some(neutral)) = (key, neutral) {
661 key_value.set(neutral);
662 return true;
663 }
664 let delta = match key {
665 Key::ArrowLeft | Key::ArrowDown => -step,
666 Key::ArrowRight | Key::ArrowUp => step,
667 Key::Home => -(max - min),
668 Key::End => max - min,
669 _ => return false,
670 };
671 key_value.update(|v| *v = snap(*v + delta).clamp(min, max));
672 true
673 }),
674 );
675 cx.focusable(node, 12.0);
676 cx.cursor(node, CursorIcon::Pointer);
677 node
678 }
679}
680
681impl View for Slider {
682 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
683 if !self.show_value {
684 return SliderTrack { slider: self, in_row: false }.build(cx);
685 }
686 let theme = *cx.theme();
687 let track = cx.child(&SliderTrack { slider: self, in_row: true });
688 let value = self.value.get().clamp(self.min, self.max);
689 let text = util::label(
690 cx,
691 &self.value_text(value),
692 TextStyle::Callout,
693 None,
694 theme.color(Semantic::InkMuted),
695 );
696 cx.semantics(text, Semantics::hidden());
698 let attrs = util::attrs(&theme, TextStyle::Callout, None);
700 let widest = cx
701 .measure_text(&self.value_text(self.min), &attrs)
702 .width
703 .max(cx.measure_text(&self.value_text(self.max), &attrs).width);
704 let number = cx.container(
705 LayoutStyle {
706 min_width: Some(widest.ceil()),
707 justify: Justify::End,
708 ..LayoutStyle::stack(Axis::Horizontal)
709 },
710 &[text],
711 Paint::None,
712 );
713 cx.container(
714 LayoutStyle {
715 width: self.width,
716 spacing: theme.spacing.s2,
717 align: Align::Center,
718 align_self: Some(if self.width.is_some() { Align::Start } else { Align::Stretch }),
719 ..LayoutStyle::stack(Axis::Horizontal)
720 },
721 &[track, number],
722 Paint::None,
723 )
724 }
725}
726
727#[derive(Debug)]
730#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
731pub struct Stepper {
732 value: Binding<i32>,
733 min: i32,
734 max: i32,
735 label: Option<String>,
736 state: util::EditorState,
737}
738
739#[cfg_attr(feature = "live", ravenrs_live::expose)]
740impl Stepper {
741 pub fn new(value: Binding<i32>) -> Self {
743 Self { value, min: i32::MIN, max: i32::MAX, label: None, state: util::EditorState::default() }
744 }
745
746 pub fn range(mut self, min: i32, max: i32) -> Self {
748 self.min = min;
749 self.max = max.max(min);
750 self
751 }
752
753 pub fn label(mut self, label: impl Into<String>) -> Self {
755 self.label = Some(label.into());
756 self
757 }
758
759 pub fn disabled(mut self, disabled: bool) -> Self {
761 self.state.disabled = disabled;
762 self
763 }
764
765 pub fn read_only(mut self, read_only: bool) -> Self {
767 self.state.read_only = read_only;
768 self
769 }
770}
771
772impl View for Stepper {
773 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
774 let theme = *cx.theme();
775 let v = self.value.get();
776 let (min, max, state) = (self.min, self.max, self.state);
777 let dec = self.value.clone();
778 let inc = self.value.clone();
779 let minus = cx.child(
780 &crate::IconButton::new(IconName::Minus)
781 .label(cx.labels().decrease.clone())
782 .size(crate::ButtonSize::Small)
783 .disabled(!state.accepts_input())
784 .on_click(move || dec.update(|v| *v = (*v - 1).max(min))),
785 );
786 let mut attrs = util::attrs(&theme, TextStyle::Body, Some(Weight::SEMIBOLD));
787 attrs.size = 15.0;
788 let text =
789 cx.text(&v.to_string(), &attrs, state.ink(theme.color(Semantic::Ink)), LayoutStyle::leaf());
790 let value = cx.container(
791 LayoutStyle {
792 min_width: Some(36.0),
793 justify: Justify::Center,
794 ..LayoutStyle::stack(Axis::Horizontal)
795 },
796 &[text],
797 Paint::None,
798 );
799 cx.semantics(value, Semantics::hidden());
801 let plus = cx.child(
802 &crate::IconButton::new(IconName::Plus)
803 .label(cx.labels().increase.clone())
804 .size(crate::ButtonSize::Small)
805 .disabled(!state.accepts_input())
806 .on_click(move || inc.update(|v| *v = (*v + 1).min(max))),
807 );
808 let style = LayoutStyle {
809 padding: Insets::uniform(3.0),
810 spacing: 2.0,
811 align: Align::Center,
812 align_self: Some(Align::Fit),
813 ..LayoutStyle::stack(Axis::Horizontal)
814 };
815 let paint = QuadPaint {
816 corner_radius: theme.radii.md,
817 border: Some(Border { width: 1.0, color: state.ink(theme.color(Semantic::Border)) }),
818 ..QuadPaint::new(state.fill(theme.color(Semantic::Control)))
819 };
820 let node = cx.container(style, &[minus, value, plus], Paint::Quad(paint));
821 let mut semantics =
822 Semantics::new(Role::SpinButton).numeric(v as f64, min as f64, max as f64, Some(1.0));
823 if let Some(label) = &self.label {
824 semantics = semantics.name(label.clone());
825 }
826 cx.semantics(node, state.semantics(semantics));
827 if state.disabled {
828 return node;
829 }
830 let value = self.value.clone();
831 cx.on_key(
832 node,
833 Rc::new(move |key, _| {
834 if !state.accepts_input() {
835 return false;
836 }
837 let next = |v: i32| match key {
838 Key::ArrowUp | Key::ArrowRight => Some(v.saturating_add(1)),
839 Key::ArrowDown | Key::ArrowLeft => Some(v.saturating_sub(1)),
840 Key::Home => Some(min),
841 Key::End => Some(max),
842 _ => None,
843 };
844 let Some(v) = next(value.get()) else { return false };
845 value.set(v.clamp(min, max));
846 true
847 }),
848 );
849 cx.focusable(node, theme.radii.md);
850 node
851 }
852}
853
854#[derive(Debug)]
857#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
858pub struct SegmentedControl {
859 selected: Binding<usize>,
860 options: Vec<(Option<IconName>, String)>,
861 label: Option<String>,
862}
863
864#[cfg_attr(feature = "live", ravenrs_live::expose)]
865impl SegmentedControl {
866 pub fn new(selected: Binding<usize>, options: impl IntoIterator<Item = impl Into<String>>) -> Self {
868 Self { selected, options: options.into_iter().map(|o| (None, o.into())).collect(), label: None }
869 }
870
871 pub fn with_icons(
873 selected: Binding<usize>,
874 options: impl IntoIterator<Item = (IconName, impl Into<String>)>,
875 ) -> Self {
876 Self {
877 selected,
878 options: options.into_iter().map(|(i, l)| (Some(i), l.into())).collect(),
879 label: None,
880 }
881 }
882
883 pub fn label(mut self, label: impl Into<String>) -> Self {
885 self.label = Some(label.into());
886 self
887 }
888}
889
890struct Segment {
891 scope: WidgetId,
892 choice: ChoiceOption,
893 icon: Option<IconName>,
894 label: String,
895}
896
897impl View for Segment {
898 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
899 let theme = *cx.theme();
900 let on = self.choice.selected.get() == self.choice.index();
901 let hovered = cx.is_hovered();
902 let color = if on || hovered { theme.color(Semantic::Ink) } else { theme.color(Semantic::InkMuted) };
903 let mut children = Vec::new();
904 if let Some(icon) = self.icon {
905 children.push(util::icon(cx, icon, 14.0, color));
906 }
907 let label = util::label(cx, &self.label, TextStyle::Callout, Some(Weight::SEMIBOLD), color);
908 motion::control(cx, label);
909 children.push(label);
910 let style = LayoutStyle {
911 padding: Insets::symmetric(0.0, 14.0),
912 height: Some(30.0),
913 spacing: 6.0,
914 align: Align::Center,
915 justify: Justify::Center,
916 ..LayoutStyle::stack(Axis::Horizontal)
917 };
918 let paint = if on {
919 Paint::Quad(QuadPaint {
920 corner_radius: 7.0,
921 border: Some(Border { width: 1.0, color: theme.color(Semantic::Border) }),
922 shadow: Some(Shadow {
923 offset: Point::new(0.0, 1.0),
924 blur: 4.0,
925 color: Color::rgba(0.0, 0.0, 0.0, 0.25),
926 }),
927 ..QuadPaint::new(theme.color(Semantic::SurfaceElevated))
928 })
929 } else {
930 Paint::None
931 };
932 let node = cx.container(style, &children, paint);
933 if on {
934 cx.motion(
936 node,
937 Motion::new().shared_layout(self.scope, 0, theme.motion.snappy).shape_only().underlay(),
938 );
939 }
940 cx.semantics(node, Semantics::new(Role::RadioButton).selected(on).toggled(on));
941 self.choice.wire(cx, node, 7.0);
942 node
943 }
944}
945
946impl View for SegmentedControl {
947 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
948 let theme = *cx.theme();
949 let focus_to = util::roving_cell(cx, None);
950 let count = self.options.len();
951 let scope = cx.widget_id();
952 let segments: Vec<NodeId> = self
953 .options
954 .iter()
955 .enumerate()
956 .map(|(index, (icon, label))| {
957 let roving = util::Roving { index, count, focus_to: focus_to.clone() };
958 let choice = ChoiceOption { roving, selected: self.selected.clone() };
959 cx.child(&Segment { scope, choice, icon: *icon, label: label.clone() })
960 })
961 .collect();
962 let style = LayoutStyle {
963 padding: Insets::uniform(3.0),
964 spacing: 2.0,
965 align_self: Some(Align::Fit),
966 ..LayoutStyle::stack(Axis::Horizontal)
967 };
968 let paint =
969 QuadPaint { corner_radius: theme.radii.md, ..QuadPaint::new(theme.color(Semantic::Control)) };
970 let node = cx.container(style, &segments, Paint::Quad(paint));
971 cx.semantics(node, group_semantics(self.label.as_deref()));
972 node
973 }
974}
975
976#[derive(Clone)]
979#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
980pub struct ThemeSwitcher {
981 themes: [Theme; 3],
982 on_change: Option<Rc<dyn Fn(Theme)>>,
983}
984
985impl std::fmt::Debug for ThemeSwitcher {
986 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
987 f.debug_struct("ThemeSwitcher")
988 .field("themes", &self.themes.map(|theme| theme.name))
989 .field("on_change", &self.on_change.is_some())
990 .finish()
991 }
992}
993
994impl Default for ThemeSwitcher {
995 fn default() -> Self {
996 Self::new()
997 }
998}
999
1000#[cfg_attr(feature = "live", ravenrs_live::expose)]
1001impl ThemeSwitcher {
1002 pub fn new() -> Self {
1004 Self { themes: [Theme::raven_dark(), Theme::raven_light(), Theme::raven_glass()], on_change: None }
1005 }
1006
1007 pub fn themes(mut self, dark: Theme, light: Theme, glass: Theme) -> Self {
1010 self.themes = [dark, light, glass];
1011 self
1012 }
1013
1014 pub fn on_change(mut self, action: impl Fn(Theme) + 'static) -> Self {
1017 self.on_change = Some(Rc::new(action));
1018 self
1019 }
1020}
1021
1022impl View for ThemeSwitcher {
1023 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1024 let themes = self.themes;
1025 let on_change = self.on_change.clone();
1026 let labels = cx.labels();
1027 let state = cx.theme_state();
1028 let read = state.clone();
1029 let binding = Binding::new(
1030 move || read.with(|t| themes.iter().position(|x| x.name == t.name).unwrap_or(0)),
1031 move |i: usize| {
1032 let theme = themes[i.min(themes.len() - 1)];
1033 state.set(theme);
1034 if let Some(action) = &on_change {
1035 action(theme);
1036 }
1037 },
1038 );
1039 cx.child(
1040 &SegmentedControl::with_icons(
1041 binding,
1042 [
1043 (IconName::Moon, labels.theme_dark.as_str()),
1044 (IconName::Sun, labels.theme_light.as_str()),
1045 (IconName::Layers, labels.theme_glass.as_str()),
1046 ],
1047 )
1048 .label(labels.theme.clone()),
1049 )
1050 }
1051}