1use std::cell::Cell;
4use std::fmt;
5use std::rc::Rc;
6
7use ravenrs_core::{Border, Color, CursorIcon, Effect, Insets, Key, Quad, Rect, Role};
8use ravenrs_layout::{Align, Axis, Justify, LayoutStyle};
9use ravenrs_reactive::{Binding, State};
10use ravenrs_style::{Semantic, TextStyle, Weight};
11
12use crate::context::{BuildCx, DragPhase, NodeId, Paint, Placement, QuadPaint, Semantics};
13use crate::overlay::{Entry, MenuList};
14use crate::{AnyView, ButtonSize, ButtonStyle, IconButton, IconName, MenuItem, Motion, View, util};
15
16#[derive(Clone, Copy, Debug)]
18#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
19pub struct Spinner {
20 size: f32,
21 color: Option<Color>,
22}
23
24impl Default for Spinner {
25 fn default() -> Self {
26 Self { size: 20.0, color: None }
27 }
28}
29
30#[cfg_attr(feature = "live", ravenrs_live::expose)]
31impl Spinner {
32 pub fn new() -> Self {
34 Self::default()
35 }
36
37 pub fn size(mut self, size: f32) -> Self {
39 self.size = size;
40 self
41 }
42
43 pub fn color(mut self, color: Color) -> Self {
46 self.color = Some(color);
47 self
48 }
49}
50
51pub(crate) fn spinner_paint(size: f32, color: Color) -> Paint {
54 Paint::Live(Rc::new(move |f: Rect, time, scene| {
55 let c = f.center();
56 let dot = (size * 0.18).max(2.0);
57 let r = size / 2.0 - dot / 2.0;
58 let head = (time / 0.8).fract() as f32 * 8.0;
60 for i in 0..8 {
61 let a = i as f32 / 8.0 * std::f32::consts::TAU;
62 let age = (i as f32 - head).rem_euclid(8.0);
63 let alpha = 1.0 - age / 9.0;
64 let rect = Rect::new(c.x + r * a.cos() - dot / 2.0, c.y + r * a.sin() - dot / 2.0, dot, dot);
65 scene.push_quad(Quad::new(rect, color.with_alpha(color.a * alpha)).radius(dot / 2.0));
66 }
67 }))
68}
69
70impl View for Spinner {
71 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
72 let theme = *cx.theme();
73 let size = self.size;
74 let color =
75 self.color.unwrap_or_else(|| theme.color(Semantic::Accent).lerp(theme.color(Semantic::Ink), 0.2));
76 let node = cx.container(
77 LayoutStyle { width: Some(size), height: Some(size), ..LayoutStyle::leaf() },
78 &[],
79 spinner_paint(size, color),
80 );
81 let name = cx.labels().loading.clone();
82 cx.semantics(node, Semantics::new(Role::ProgressIndicator).name(name));
83 node
84 }
85}
86
87#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
89pub struct Carousel {
90 slides: Vec<AnyView>,
91 height: f32,
92 autoplay: Option<f64>,
93}
94
95impl fmt::Debug for Carousel {
96 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97 f.debug_struct("Carousel").field("slides", &self.slides.len()).finish_non_exhaustive()
98 }
99}
100
101#[cfg_attr(feature = "live", ravenrs_live::expose)]
102impl Carousel {
103 pub fn new(slides: Vec<AnyView>) -> Self {
105 Self { slides, height: 240.0, autoplay: None }
106 }
107
108 pub fn height(mut self, height: f32) -> Self {
110 self.height = height;
111 self
112 }
113
114 pub fn autoplay(mut self, seconds: f64) -> Self {
116 self.autoplay = Some(seconds.max(0.5));
117 self
118 }
119}
120
121struct Slide<'a>(&'a dyn View, f32);
122
123impl View for Slide<'_> {
124 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
125 let m = cx.theme().motion;
126 let child = cx.child(self.0);
127 let node = cx.container(
128 LayoutStyle {
129 grow: 1.0,
130 align: Align::Stretch,
131 justify: Justify::Center,
132 ..LayoutStyle::stack(Axis::Vertical)
133 },
134 &[child],
135 Paint::None,
136 );
137 cx.motion(
138 node,
139 Motion::new()
140 .enter(Effect::moved(32.0 * self.1, 0.0).with_opacity(0.0), m.snappy)
141 .exit(Effect::hidden(), m.fast),
142 );
143 node
144 }
145}
146
147impl View for Carousel {
148 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
149 let theme = *cx.theme();
150 let n = self.slides.len().max(1);
151 let index: State<usize> = cx.use_state(|| 0);
152 let since: State<f64> = cx.use_state(|| f64::NAN);
153 let now = cx.now();
154 if let Some(every) = self.autoplay {
155 let start = *since.peek();
156 if start.is_nan() {
157 since.set_if_changed(now);
158 } else if now - start >= every {
159 index.update(|i| *i = (*i + 1) % n);
160 since.set(now);
161 }
162 cx.wake_at(if start.is_nan() { now + every } else { start + every });
163 }
164 let current = index.get() % n;
165 let shown = cx.use_state(|| current);
167 let direction = cx.use_state(|| 1.0_f32);
168 let previous = *shown.peek();
169 if previous != current {
170 let forward = (current + n - previous) % n <= n / 2;
171 direction.set_if_changed(if forward { 1.0 } else { -1.0 });
172 shown.set_if_changed(current);
173 }
174 let heading = *direction.peek();
175 let slide = match self.slides.get(current) {
176 Some(s) => cx.keyed_child(current as u64, &Slide(s.as_ref(), heading)),
177 None => cx.empty(),
178 };
179 let stage = cx.container(
180 LayoutStyle {
181 height: Some(self.height),
182 align: Align::Stretch,
183 justify: Justify::Center,
184 overflow_hidden: true,
185 align_self: Some(Align::Stretch),
186 ..LayoutStyle::stack(Axis::Vertical)
187 },
188 &[slide],
189 Paint::Quad(QuadPaint {
190 corner_radius: theme.radii.lg,
191 ..QuadPaint::new(theme.color(Semantic::Control))
192 }),
193 );
194 cx.clip(stage);
195 let labels = cx.labels();
196 let (prev, next, s1, s2) = (index.clone(), index.clone(), since.clone(), since.clone());
197 let back = cx.child(
198 &IconButton::new(IconName::ChevronLeft)
199 .label(cx.labels().carousel_previous.clone())
200 .style(ButtonStyle::Secondary)
201 .size(ButtonSize::Small)
202 .on_click(move || {
203 prev.update(|i| *i = (*i + n - 1) % n);
204 s1.set(f64::NAN);
205 }),
206 );
207 let forward = cx.child(
208 &IconButton::new(IconName::ChevronRight)
209 .label(cx.labels().carousel_next.clone())
210 .style(ButtonStyle::Secondary)
211 .size(ButtonSize::Small)
212 .on_click(move || {
213 next.update(|i| *i = (*i + 1) % n);
214 s2.set(f64::NAN);
215 }),
216 );
217 let mut dots = Vec::new();
218 for i in 0..n {
219 let on = i == current;
220 let color = if on {
221 theme.color(Semantic::Accent)
222 } else {
223 theme.color(Semantic::InkMuted).with_alpha(0.4)
224 };
225 let dot = cx.container(
226 LayoutStyle {
227 width: Some(if on { 20.0 } else { 8.0 }),
228 height: Some(8.0),
229 ..LayoutStyle::leaf()
230 },
231 &[],
232 Paint::Quad(QuadPaint { corner_radius: 4.0, ..QuadPaint::new(color) }),
233 );
234 cx.semantics(
235 dot,
236 Semantics::new(Role::Button)
237 .name(format!("{} {} {} {n}", labels.carousel_slide, i + 1, labels.grid_of))
238 .selected(on),
239 );
240 let idx = index.clone();
241 cx.on_click(dot, Rc::new(move || idx.set(i)));
242 cx.cursor(dot, CursorIcon::Pointer);
243 cx.motion(dot, Motion::new().key(i as u64).layout(theme.motion.snappy).paint(theme.motion.fast));
244 dots.push(dot);
245 }
246 let dots = cx.container(
247 LayoutStyle {
248 spacing: 6.0,
249 align: Align::Center,
250 grow: 1.0,
251 justify: Justify::Center,
252 ..LayoutStyle::stack(Axis::Horizontal)
253 },
254 &dots,
255 Paint::None,
256 );
257 let controls = cx.container(
258 LayoutStyle {
259 align: Align::Center,
260 align_self: Some(Align::Stretch),
261 ..LayoutStyle::stack(Axis::Horizontal)
262 },
263 &[back, dots, forward],
264 Paint::None,
265 );
266 cx.container(
267 LayoutStyle {
268 spacing: 12.0,
269 align: Align::Stretch,
270 align_self: Some(Align::Stretch),
271 ..LayoutStyle::stack(Axis::Vertical)
272 },
273 &[stage, controls],
274 Paint::None,
275 )
276 }
277}
278
279const SPLIT_STEP: f32 = 16.0;
281pub(crate) const SPLIT_BAR: f32 = 10.0;
283
284fn split_bar_paint(vertical_stack: bool, line: Color, grip: Color) -> Paint {
286 Paint::Custom(Rc::new(move |f: Rect, scene| {
287 let c = f.center();
288 if vertical_stack {
289 scene.push_quad(Quad::new(Rect::new(f.x(), c.y - 0.5, f.width(), 1.0), line));
290 scene.push_quad(Quad::new(Rect::new(c.x - 12.0, c.y - 2.0, 24.0, 4.0), grip).radius(2.0));
291 } else {
292 scene.push_quad(Quad::new(Rect::new(c.x - 0.5, f.y(), 1.0, f.height()), line));
293 scene.push_quad(Quad::new(Rect::new(c.x - 2.0, c.y - 12.0, 4.0, 24.0), grip).radius(2.0));
294 }
295 }))
296}
297
298fn split_arrow(key: &Key, vertical_stack: bool) -> Option<f32> {
301 match (key, vertical_stack) {
302 (Key::ArrowUp, true) | (Key::ArrowLeft | Key::ArrowDown, false) => Some(-1.0),
303 (Key::ArrowDown, true) | (Key::ArrowRight | Key::ArrowUp, false) => Some(1.0),
304 _ => None,
305 }
306}
307
308#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
324pub struct SplitHandle {
325 axis: Axis,
326 value: Binding<f32>,
327 min: f32,
328 max: f32,
329 inverted: bool,
330 label: Option<String>,
331 on_end: Option<Rc<dyn Fn()>>,
332}
333
334impl fmt::Debug for SplitHandle {
335 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
336 f.debug_struct("SplitHandle").field("axis", &self.axis).finish_non_exhaustive()
337 }
338}
339
340#[cfg_attr(feature = "live", ravenrs_live::expose)]
341impl SplitHandle {
342 pub fn new(axis: Axis, value: Binding<f32>) -> Self {
345 Self { axis, value, min: 0.0, max: f32::MAX, inverted: false, label: None, on_end: None }
346 }
347
348 pub fn range(mut self, min: f32, max: f32) -> Self {
350 self.min = min;
351 self.max = max.max(min);
352 self
353 }
354
355 pub fn inverted(mut self) -> Self {
357 self.inverted = true;
358 self
359 }
360
361 pub fn label(mut self, label: impl Into<String>) -> Self {
363 self.label = Some(label.into());
364 self
365 }
366
367 pub fn on_end(mut self, handler: impl Fn() + 'static) -> Self {
369 self.on_end = Some(Rc::new(handler));
370 self
371 }
372}
373
374impl View for SplitHandle {
375 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
376 let theme = *cx.theme();
377 let vertical = self.axis == Axis::Vertical;
378 let lit = cx.is_hovered() || cx.is_focus_visible();
379 let (line, grip) = (
380 theme.color(Semantic::Border),
381 theme.color(if lit { Semantic::InkMuted } else { Semantic::Border }),
382 );
383 let style = if vertical {
384 LayoutStyle { height: Some(SPLIT_BAR), align_self: Some(Align::Stretch), ..LayoutStyle::leaf() }
385 } else {
386 LayoutStyle { width: Some(SPLIT_BAR), align_self: Some(Align::Stretch), ..LayoutStyle::leaf() }
387 };
388 let node = cx.container(style, &[], split_bar_paint(vertical, line, grip));
389 let (min, max, sign) = (self.min, self.max, if self.inverted { -1.0 } else { 1.0 });
390 let name = self.label.clone().unwrap_or_else(|| cx.labels().split_resize.clone());
391 let step = Some(f64::from(SPLIT_STEP));
392 let current = self.value.get();
393 cx.semantics(
395 node,
396 Semantics::new(Role::Slider).name(name).numeric(
397 f64::from(current),
398 f64::from(min),
399 f64::from(max),
400 step,
401 ),
402 );
403 let start = cx.use_state(|| Rc::new(Cell::new((0.0_f32, 0.0_f32)))).get();
404 let (value, on_end) = (self.value.clone(), self.on_end.clone());
405 cx.on_drag(
406 node,
407 Rc::new(move |d| {
408 let position = if vertical { d.position.y } else { d.position.x };
409 match d.phase {
410 DragPhase::Start => start.set((position, value.get())),
411 DragPhase::Move => {
412 let (from, base) = start.get();
413 value.set((base + sign * (position - from)).clamp(min, max));
414 }
415 DragPhase::End => {
416 if let Some(on_end) = &on_end {
417 on_end();
418 }
419 }
420 }
421 }),
422 );
423 let (value, on_end) = (self.value.clone(), self.on_end.clone());
424 cx.on_key(
425 node,
426 Rc::new(move |key, modifiers| {
427 let next = match key {
428 Key::Home => min,
429 Key::End => max,
430 _ => {
431 let Some(arrow) = split_arrow(key, vertical) else { return false };
432 let far = if modifiers.shift { 4.0 } else { 1.0 };
433 (value.get() + sign * arrow * far * SPLIT_STEP).clamp(min, max)
434 }
435 };
436 value.set(next);
437 if let Some(on_end) = &on_end {
438 on_end();
439 }
440 true
441 }),
442 );
443 cx.focusable(node, 2.0);
444 cx.cursor(node, if vertical { CursorIcon::RowResize } else { CursorIcon::ColResize });
445 node
446 }
447}
448
449#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
451pub struct SplitPane {
452 content: AnyView,
453 size: Option<f32>,
454 min: f32,
455 max: f32,
456 visible: bool,
457}
458
459impl fmt::Debug for SplitPane {
460 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
461 f.debug_struct("SplitPane").field("size", &self.size).finish_non_exhaustive()
462 }
463}
464
465#[cfg_attr(feature = "live", ravenrs_live::expose)]
466impl SplitPane {
467 pub fn new(content: impl View + 'static) -> Self {
469 Self { content: Box::new(content), size: None, min: 0.0, max: f32::MAX, visible: true }
470 }
471
472 pub fn size(mut self, size: f32) -> Self {
474 self.size = Some(size);
475 self
476 }
477
478 pub fn range(mut self, min: f32, max: f32) -> Self {
481 self.min = min;
482 self.max = max.max(min);
483 self
484 }
485
486 pub fn visible(mut self, visible: bool) -> Self {
489 self.visible = visible;
490 self
491 }
492}
493
494fn split_sizes(room: f32, asked: &[Option<f32>], limits: &[(f32, f32)]) -> Vec<f32> {
497 let mut sizes: Vec<f32> =
498 asked.iter().zip(limits).map(|(size, (min, max))| size.unwrap_or(*min).clamp(*min, *max)).collect();
499 let free = room - asked.iter().zip(&sizes).filter(|(a, _)| a.is_some()).map(|(_, s)| *s).sum::<f32>();
500 let flexible = asked.iter().filter(|a| a.is_none()).count();
501 if flexible > 0 {
502 let share = (free / flexible as f32).max(0.0);
503 for ((size, ask), (min, max)) in sizes.iter_mut().zip(asked).zip(limits) {
504 if ask.is_none() {
505 *size = share.clamp(*min, *max);
506 }
507 }
508 }
509 let mut over = sizes.iter().sum::<f32>() - room;
510 for (size, (min, _)) in sizes.iter_mut().zip(limits).rev() {
511 if over <= 0.0 {
512 break;
513 }
514 let taken = (*size - min).min(over).max(0.0);
515 *size -= taken;
516 over -= taken;
517 }
518 sizes
519}
520
521#[derive(Debug)]
536#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
537pub struct SplitPanes {
538 axis: Axis,
539 panes: Vec<SplitPane>,
540 sizes: Option<Binding<Vec<Option<f32>>>>,
541 height: Option<f32>,
542 on_end: Option<SplitEnd>,
543}
544
545struct SplitEnd(Rc<dyn Fn()>);
547
548impl fmt::Debug for SplitEnd {
549 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
550 f.write_str("SplitEnd")
551 }
552}
553
554#[cfg_attr(feature = "live", ravenrs_live::expose)]
555impl SplitPanes {
556 pub fn horizontal() -> Self {
558 Self { axis: Axis::Horizontal, panes: Vec::new(), sizes: None, height: None, on_end: None }
559 }
560
561 pub fn vertical() -> Self {
563 Self { axis: Axis::Vertical, ..Self::horizontal() }
564 }
565
566 pub fn pane(mut self, pane: SplitPane) -> Self {
568 self.panes.push(pane);
569 self
570 }
571
572 pub fn sizes(mut self, sizes: Binding<Vec<Option<f32>>>) -> Self {
575 self.sizes = Some(sizes);
576 self
577 }
578
579 pub fn on_end(mut self, handler: impl Fn() + 'static) -> Self {
581 self.on_end = Some(SplitEnd(Rc::new(handler)));
582 self
583 }
584
585 pub fn height(mut self, height: f32) -> Self {
587 self.height = Some(height);
588 self
589 }
590}
591
592impl View for SplitPanes {
593 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
594 let vertical = self.axis == Axis::Vertical;
595 let own: State<Vec<Option<f32>>> = cx.use_state(Vec::new);
596 let store = self.sizes.clone().unwrap_or_else(|| own.binding());
597 let stored = store.get();
598 let asked_of = |i: usize, pane: &SplitPane| stored.get(i).copied().unwrap_or(pane.size);
599 let shown: Vec<usize> =
600 self.panes.iter().enumerate().filter(|(_, p)| p.visible).map(|(i, _)| i).collect();
601 let asked: Vec<Option<f32>> = shown.iter().map(|&i| asked_of(i, &self.panes[i])).collect();
602 let limits: Vec<(f32, f32)> = shown.iter().map(|&i| (self.panes[i].min, self.panes[i].max)).collect();
603 let room = cx.scroll_viewport().map(|v| {
605 (if vertical { v.height } else { v.width }) - shown.len().saturating_sub(1) as f32 * SPLIT_BAR
606 });
607 let sizes = room.map(|room| split_sizes(room, &asked, &limits));
608 let mut children = Vec::with_capacity(shown.len() * 2);
609 for (at, &index) in shown.iter().enumerate() {
610 if at > 0 {
611 let (a, b) = (at - 1, at);
612 let (size_a, size_b) = sizes.as_ref().map_or_else(
613 || (asked[a].unwrap_or(limits[a].0), asked[b].unwrap_or(limits[b].0)),
614 |s| (s[a], s[b]),
615 );
616 let both = size_a + size_b;
617 let min = limits[a].0.max(both - limits[b].1);
618 let max = limits[a].1.min(both - limits[b].0).max(min);
619 let write = store.clone();
620 let panes = (shown[a], shown[b]);
621 let fixed = (asked[a].is_some(), asked[b].is_some());
622 let defaults: Vec<Option<f32>> = self.panes.iter().map(|p| p.size).collect();
623 let value = Binding::new(
624 move || size_a,
625 move |next: f32| {
626 let mut all = write.get();
627 if all.len() < defaults.len() {
628 let have = all.len();
629 all.extend_from_slice(&defaults[have..]);
630 }
631 if fixed.0 || !fixed.1 {
633 all[panes.0] = Some(next);
634 }
635 if fixed.1 {
636 all[panes.1] = Some(both - next);
637 }
638 write.set(all);
639 },
640 );
641 let mut bar = SplitHandle::new(self.axis, value).range(min, max);
642 if let Some(end) = &self.on_end {
643 let end = end.0.clone();
644 bar = bar.on_end(move || end());
645 }
646 children.push(cx.keyed_child(u64::MAX - index as u64, &bar));
647 }
648 let pane = &self.panes[index];
649 let content = cx.keyed_child(index as u64, pane.content.as_ref());
650 let size = asked[at].map(|asked| sizes.as_ref().map_or(asked, |s| s[at]));
652 let node = cx.container(
653 LayoutStyle {
654 grow: if size.is_some() { 0.0 } else { 1.0 },
655 width: if vertical { None } else { size.or(Some(0.0)) },
656 height: if vertical { size.or(Some(0.0)) } else { None },
657 min_width: Some(0.0),
658 min_height: Some(0.0),
659 align: Align::Stretch,
660 overflow_hidden: true,
661 ..LayoutStyle::stack(Axis::Vertical)
662 },
663 &[content],
664 Paint::None,
665 );
666 cx.clip(node);
667 children.push(node);
668 }
669 let node = cx.container(
670 LayoutStyle {
671 height: self.height,
672 grow: if self.height.is_none() { 1.0 } else { 0.0 },
673 min_width: Some(0.0),
674 min_height: Some(0.0),
675 align: Align::Stretch,
676 align_self: Some(Align::Stretch),
677 overflow_hidden: true,
678 ..LayoutStyle::stack(self.axis)
679 },
680 &children,
681 Paint::None,
682 );
683 cx.report_frame(node);
684 node
685 }
686}
687
688pub struct Splitter<A, B> {
701 first: A,
702 second: B,
703 vertical: bool,
704 initial: f32,
705 height: f32,
706 limits: (f32, f32),
707}
708
709impl<A, B> fmt::Debug for Splitter<A, B> {
710 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
711 f.debug_struct("Splitter").field("vertical", &self.vertical).finish_non_exhaustive()
712 }
713}
714
715#[cfg_attr(feature = "live", ravenrs_live::expose)]
716impl<A: View, B: View> Splitter<A, B> {
717 pub fn horizontal(first: A, second: B) -> Self {
719 Self { first, second, vertical: false, initial: 0.5, height: 260.0, limits: (0.1, 0.9) }
720 }
721
722 pub fn vertical(first: A, second: B) -> Self {
724 Self { first, second, vertical: true, initial: 0.5, height: 260.0, limits: (0.1, 0.9) }
725 }
726
727 pub fn ratio(mut self, ratio: f32) -> Self {
729 self.initial = ratio;
730 self
731 }
732
733 pub fn limits(mut self, min: f32, max: f32) -> Self {
735 let min = min.clamp(0.0, 1.0);
736 self.limits = (min, max.clamp(min, 1.0));
737 self
738 }
739
740 pub fn height(mut self, height: f32) -> Self {
742 self.height = height;
743 self
744 }
745}
746
747impl<A: View, B: View> View for Splitter<A, B> {
748 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
749 let theme = *cx.theme();
750 let (min, max) = self.limits;
751 let initial = self.initial.clamp(min, max);
752 let ratio: State<f32> = cx.use_state(move || initial);
753 let r = ratio.get();
754 let pane = |grow: f32| LayoutStyle {
755 grow,
756 width: (!self.vertical).then_some(0.0),
757 height: self.vertical.then_some(0.0),
758 min_width: Some(0.0),
759 min_height: Some(0.0),
760 align: Align::Stretch,
761 overflow_hidden: true,
762 ..LayoutStyle::stack(Axis::Vertical)
763 };
764 let a = cx.child(&self.first);
765 let a = cx.container(pane(r), &[a], Paint::None);
766 cx.clip(a);
767 let b = cx.child(&self.second);
768 let b = cx.container(pane(1.0 - r), &[b], Paint::None);
769 cx.clip(b);
770 let vertical = self.vertical;
771 let (line, grip) = (theme.color(Semantic::Border), theme.color(Semantic::InkMuted));
772 let lit = cx.is_hover_within() || cx.is_focus_visible();
773 let handle_style = if vertical {
774 LayoutStyle { height: Some(SPLIT_BAR), align_self: Some(Align::Stretch), ..LayoutStyle::leaf() }
775 } else {
776 LayoutStyle { width: Some(SPLIT_BAR), align_self: Some(Align::Stretch), ..LayoutStyle::leaf() }
777 };
778 let handle =
779 cx.container(handle_style, &[], split_bar_paint(vertical, line, if lit { grip } else { line }));
780 let drag = ratio.clone();
781 cx.on_drag(
782 handle,
783 Rc::new(move |d| {
784 if d.phase == DragPhase::End {
785 return;
786 }
787 let (pos, start, len) = if vertical {
788 (d.position.y, d.container.y(), d.container.height())
789 } else {
790 (d.position.x, d.container.x(), d.container.width())
791 };
792 if len > SPLIT_BAR {
793 drag.set(((pos - start - SPLIT_BAR / 2.0) / (len - SPLIT_BAR)).clamp(min, max));
794 }
795 }),
796 );
797 let name = cx.labels().split_resize.clone();
798 cx.semantics(
799 handle,
800 Semantics::new(Role::Slider).name(name).numeric(
801 f64::from(r),
802 f64::from(min),
803 f64::from(max),
804 Some(0.02),
805 ),
806 );
807 let keys = ratio.clone();
808 cx.on_key(
809 handle,
810 Rc::new(move |key, modifiers| {
811 let next = match key {
812 Key::Home => min,
813 Key::End => max,
814 _ => {
815 let Some(arrow) = split_arrow(key, vertical) else { return false };
816 let far = if modifiers.shift { 0.08 } else { 0.02 };
817 (*keys.peek() + arrow * far).clamp(min, max)
818 }
819 };
820 keys.set(next);
821 true
822 }),
823 );
824 cx.focusable(handle, 2.0);
825 cx.cursor(handle, if vertical { CursorIcon::RowResize } else { CursorIcon::ColResize });
826 let node = cx.container(
827 LayoutStyle {
828 height: Some(self.height),
829 align: Align::Stretch,
830 align_self: Some(Align::Stretch),
831 ..LayoutStyle::stack(if vertical { Axis::Vertical } else { Axis::Horizontal })
832 },
833 &[a, handle, b],
834 Paint::Quad(QuadPaint {
835 corner_radius: theme.radii.lg,
836 border: Some(Border { width: 1.0, color: theme.color(Semantic::Border) }),
837 ..QuadPaint::new(theme.color(Semantic::Surface))
838 }),
839 );
840 cx.clip(node);
841 node
842 }
843}
844
845#[derive(Default)]
847#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
848pub struct Menubar {
849 menus: Vec<(String, Vec<Entry>)>,
850}
851
852impl fmt::Debug for Menubar {
853 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
854 f.debug_list().entries(self.menus.iter().map(|m| &m.0)).finish()
855 }
856}
857
858#[cfg_attr(feature = "live", ravenrs_live::expose)]
859impl Menubar {
860 pub fn new() -> Self {
862 Self::default()
863 }
864
865 pub fn menu(mut self, title: impl Into<String>) -> Self {
867 self.menus.push((title.into(), Vec::new()));
868 self
869 }
870
871 pub fn item(mut self, item: MenuItem) -> Self {
873 if let Some((_, e)) = self.menus.last_mut() {
874 e.push(Entry::Item(item));
875 }
876 self
877 }
878
879 pub fn separator(mut self) -> Self {
881 if let Some((_, e)) = self.menus.last_mut() {
882 e.push(Entry::Separator);
883 }
884 self
885 }
886}
887
888struct MenuTitle<'a> {
889 bar: &'a Menubar,
890 open: State<Option<usize>>,
891 roving: util::Roving,
892}
893
894impl View for MenuTitle<'_> {
895 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
896 let theme = *cx.theme();
897 let (i, count) = (self.roving.index, self.roving.count);
898 let is_open = self.open.get() == Some(i);
899 let hovered = cx.is_hovered();
900 let ink = theme.color(Semantic::Ink);
901 let label = util::label(cx, &self.bar.menus[i].0, TextStyle::Callout, Some(Weight::SEMIBOLD), ink);
902 let fill = if is_open {
903 theme.color(Semantic::Control)
904 } else if hovered {
905 ink.with_alpha(0.06)
906 } else {
907 Color::TRANSPARENT
908 };
909 let node = cx.container(
910 LayoutStyle {
911 height: Some(30.0),
912 padding: Insets::symmetric(0.0, 10.0),
913 align: Align::Center,
914 ..LayoutStyle::stack(Axis::Horizontal)
915 },
916 &[label],
917 Paint::Quad(QuadPaint { corner_radius: 6.0, ..QuadPaint::new(fill) }),
918 );
919 cx.semantics(node, Semantics::new(Role::MenuItem).expanded(is_open));
920 let open = self.open.clone();
921 cx.on_click(node, Rc::new(move || open.update(|o| *o = if *o == Some(i) { None } else { Some(i) })));
922 cx.cursor(node, CursorIcon::Pointer);
923 cx.focusable(node, 6.0);
924 let opener = self.open.clone();
927 let before: crate::KeyHandler = Rc::new(move |key, _| {
928 let opens = matches!(key, Key::ArrowDown | Key::Enter | Key::Space);
929 if opens {
930 opener.set(Some(i));
931 }
932 opens || *key == Key::ArrowUp
933 });
934 let follow = self.open.clone();
935 let on_move: Rc<dyn Fn(usize)> = Rc::new(move |to| {
936 if follow.get().is_some() {
937 follow.set(Some(to));
938 }
939 });
940 self.roving.wire_with(cx, node, Some(on_move), Some(before));
941 let was_open = cx.use_state(|| Rc::new(Cell::new(false))).get();
942 if is_open {
943 was_open.set(true);
944 let close: Rc<dyn Fn()> = {
945 let open = self.open.clone();
946 Rc::new(move || open.set(None))
947 };
948 let side_open = self.open.clone();
950 let on_side: Rc<dyn Fn(bool)> = Rc::new(move |forward| {
951 side_open.set(Some(if forward { (i + 1) % count } else { (i + count - 1) % count }));
952 });
953 cx.overlay(
954 node,
955 Placement::Below,
956 &MenuList { entries: &self.bar.menus[i].1, close: close.clone(), on_side: Some(on_side) },
957 Some(close),
958 );
959 } else if was_open.replace(false) && self.open.get().is_none() {
960 cx.request_focus_node(node);
962 }
963 node
964 }
965}
966
967impl View for Menubar {
968 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
969 let theme = *cx.theme();
970 let open: State<Option<usize>> = cx.use_state(|| None);
971 let (count, focus_to) = (self.menus.len(), util::roving_cell(cx, None));
972 let titles: Vec<NodeId> = (0..count)
973 .map(|index| {
974 let roving = util::Roving { index, count, focus_to: focus_to.clone() };
975 cx.keyed_child(index as u64, &MenuTitle { bar: self, open: open.clone(), roving })
976 })
977 .collect();
978 let node = cx.container(
979 LayoutStyle {
980 padding: Insets::uniform(4.0),
981 spacing: 2.0,
982 align_self: Some(Align::Fit),
983 ..LayoutStyle::stack(Axis::Horizontal)
984 },
985 &titles,
986 Paint::Quad(QuadPaint {
987 corner_radius: theme.radii.md,
988 border: Some(Border { width: 1.0, color: theme.color(Semantic::Border) }),
989 ..QuadPaint::new(theme.color(Semantic::Surface))
990 }),
991 );
992 cx.semantics(node, Semantics::new(Role::MenuBar));
993 node
994 }
995}
996
997#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
999pub struct InputOtp {
1000 value: Binding<String>,
1001 length: usize,
1002 on_complete: Option<Rc<dyn Fn(String)>>,
1003}
1004
1005impl fmt::Debug for InputOtp {
1006 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1007 f.debug_struct("InputOtp").field("length", &self.length).finish_non_exhaustive()
1008 }
1009}
1010
1011#[cfg_attr(feature = "live", ravenrs_live::expose)]
1012impl InputOtp {
1013 pub fn new(value: Binding<String>, length: usize) -> Self {
1015 Self { value, length: length.clamp(1, 12), on_complete: None }
1016 }
1017
1018 pub fn on_complete(mut self, handler: impl Fn(String) + 'static) -> Self {
1020 self.on_complete = Some(Rc::new(handler));
1021 self
1022 }
1023}
1024
1025impl View for InputOtp {
1026 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1027 let theme = *cx.theme();
1028 let focused = cx.is_focused();
1029 let text: Vec<char> = self.value.get().chars().take(self.length).collect();
1030 let mut boxes = Vec::new();
1031 let mut attrs = util::attrs(&theme, TextStyle::Title, Some(Weight::SEMIBOLD));
1032 attrs.size = 20.0;
1033 for i in 0..self.length {
1034 let ch = text.get(i).map(|c| c.to_string()).unwrap_or_default();
1035 let label = cx.text(&ch, &attrs, theme.color(Semantic::Ink), LayoutStyle::leaf());
1036 let active = focused && i == text.len().min(self.length - 1);
1037 let border = if active {
1038 Border { width: 1.5, color: theme.color(Semantic::Accent) }
1039 } else {
1040 Border { width: 1.0, color: theme.color(Semantic::Border) }
1041 };
1042 boxes.push(cx.container(
1043 LayoutStyle {
1044 width: Some(44.0),
1045 height: Some(52.0),
1046 align: Align::Center,
1047 justify: Justify::Center,
1048 ..LayoutStyle::stack(Axis::Horizontal)
1049 },
1050 &[label],
1051 Paint::Quad(QuadPaint {
1052 corner_radius: theme.radii.md,
1053 border: Some(border),
1054 ..QuadPaint::new(theme.color(Semantic::Control))
1055 }),
1056 ));
1057 }
1058 let node = cx.container(
1059 LayoutStyle {
1060 spacing: 8.0,
1061 align_self: Some(Align::Fit),
1062 ..LayoutStyle::stack(Axis::Horizontal)
1063 },
1064 &boxes,
1065 Paint::None,
1066 );
1067 for b in &boxes {
1068 cx.semantics(*b, Semantics::hidden());
1069 }
1070 let typed: String = self.value.get().chars().take(self.length).collect();
1071 let mut semantics = Semantics::new(Role::TextInput)
1072 .name(cx.labels().verification_code.clone())
1073 .value(typed)
1074 .description(format!("{} digits", self.length));
1075 semantics.fallback_name = true;
1076 cx.semantics(node, semantics);
1077 let (value, len, done) = (self.value.clone(), self.length, self.on_complete.clone());
1078 cx.on_key(
1079 node,
1080 Rc::new(move |key, _| {
1081 let mut v: String = value.get().chars().take(len).collect();
1082 match key {
1083 Key::Character(s) => {
1084 for c in s.chars().filter(char::is_ascii_digit) {
1085 if v.chars().count() < len {
1086 v.push(c);
1087 }
1088 }
1089 }
1090 Key::Backspace => {
1091 v.pop();
1092 }
1093 _ => return false,
1094 }
1095 let complete = v.chars().count() == len;
1096 value.set(v.clone());
1097 if complete && let Some(d) = &done {
1098 d(v);
1099 }
1100 true
1101 }),
1102 );
1103 cx.focusable(node, theme.radii.md);
1104 cx.cursor(node, CursorIcon::Text);
1105 node
1106 }
1107}
1108
1109#[cfg_attr(feature = "live", ravenrs_live::expose)]
1111pub mod validate {
1112 pub fn required(value: &str) -> Result<(), String> {
1114 if value.trim().is_empty() { Err("This field is required.".into()) } else { Ok(()) }
1115 }
1116
1117 pub fn email(value: &str) -> Result<(), String> {
1119 let ok = value.split_once('@').is_some_and(|(user, domain)| {
1120 !user.is_empty() && domain.contains('.') && !domain.starts_with('.') && !domain.ends_with('.')
1121 });
1122 if ok || value.is_empty() { Ok(()) } else { Err("Enter an email like name@example.com.".into()) }
1123 }
1124
1125 pub fn min_length(n: usize) -> impl Fn(&str) -> Result<(), String> {
1127 move |value| {
1128 if value.chars().count() >= n || value.is_empty() {
1129 Ok(())
1130 } else {
1131 Err(format!("Use at least {n} characters."))
1132 }
1133 }
1134 }
1135}
1136
1137pub type Rule = Rc<dyn Fn(&str) -> Result<(), String>>;
1139
1140pub struct FormField<V> {
1162 label: crate::Label,
1163 content: V,
1164 hint: Option<String>,
1165 value: Option<Binding<String>>,
1166 rules: Vec<Rule>,
1167 show_errors: bool,
1168 required: bool,
1169 told_error: Option<String>,
1170 told_note: Option<(String, crate::Tone)>,
1171 fill: Option<Semantic>,
1172 accessible_name: Option<String>,
1173 accessible_description: Option<String>,
1174}
1175
1176impl<V> fmt::Debug for FormField<V> {
1177 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1178 f.debug_struct("FormField").field("rules", &self.rules.len()).finish_non_exhaustive()
1179 }
1180}
1181
1182#[cfg_attr(feature = "live", ravenrs_live::expose)]
1183impl<V: View> FormField<V> {
1184 pub fn new(label: impl Into<String>, content: V) -> Self {
1186 Self {
1187 label: crate::Label::new(label),
1188 content,
1189 hint: None,
1190 value: None,
1191 rules: Vec::new(),
1192 show_errors: false,
1193 required: false,
1194 told_error: None,
1195 told_note: None,
1196 fill: None,
1197 accessible_name: None,
1198 accessible_description: None,
1199 }
1200 }
1201
1202 pub fn accessible_name(mut self, name: impl Into<String>) -> Self {
1205 self.accessible_name = Some(name.into());
1206 self
1207 }
1208
1209 pub fn accessible_description(mut self, text: impl Into<String>) -> Self {
1212 self.accessible_description = Some(text.into());
1213 self
1214 }
1215
1216 pub fn required(mut self) -> Self {
1218 self.label = self.label.required();
1219 self.required = true;
1220 self
1221 }
1222
1223 pub fn error_text(mut self, error: Option<String>) -> Self {
1225 self.told_error = error;
1226 self
1227 }
1228
1229 pub fn label_ink(mut self, ink: Semantic) -> Self {
1231 self.label = self.label.ink(ink);
1232 self
1233 }
1234
1235 pub fn tint(mut self, fill: Semantic) -> Self {
1237 self.fill = Some(fill);
1238 self
1239 }
1240
1241 pub fn note_text(mut self, note: Option<String>, tone: crate::Tone) -> Self {
1243 self.told_note = note.map(|note| (note, tone));
1244 self
1245 }
1246
1247 pub fn hint(mut self, hint: impl Into<String>) -> Self {
1249 self.hint = Some(hint.into());
1250 self
1251 }
1252
1253 pub fn validate(
1255 mut self,
1256 value: Binding<String>,
1257 rule: impl Fn(&str) -> Result<(), String> + 'static,
1258 ) -> Self {
1259 self.value = Some(value);
1260 self.rules.push(Rc::new(rule));
1261 self
1262 }
1263
1264 pub fn show_errors(mut self, show: bool) -> Self {
1266 self.show_errors = show;
1267 self
1268 }
1269
1270 pub fn error(&self) -> Option<String> {
1272 let v = self.value.as_ref()?.get();
1273 if self.required
1274 && let Err(e) = validate::required(&v)
1275 {
1276 return Some(e);
1277 }
1278 self.rules.iter().find_map(|r| r(&v).err())
1279 }
1280}
1281
1282impl<V: View> View for FormField<V> {
1283 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1284 let theme = *cx.theme();
1285 let label = cx.child(&self.label);
1286 let content = cx.child(&self.content);
1287 let mut children = vec![label, content];
1288 let value = self.value.as_ref().map(Binding::get).unwrap_or_default();
1289 let blank = self.required && self.value.is_some() && value.trim().is_empty();
1290 let error = self.told_error.clone().or_else(|| {
1292 (self.show_errors || !value.is_empty())
1293 .then(|| if blank { Some(cx.labels().required.clone()) } else { self.error() })
1294 .flatten()
1295 });
1296 let note = self.told_note.as_ref().map(|(note, _)| note.as_str());
1297 cx.label_control(content, self.label.text(), error.as_deref().or(note).or(self.hint.as_deref()));
1298 if self.accessible_name.is_some() || self.accessible_description.is_some() {
1299 let told = error.as_deref().or(note);
1300 let description = match (told, self.accessible_description.as_deref()) {
1301 (Some(told), Some(own)) => Some(format!("{told}. {own}")),
1302 (None, Some(own)) => Some(own.to_owned()),
1303 (_, None) => None,
1304 };
1305 cx.rename_control(content, self.accessible_name.as_deref(), description.as_deref());
1306 }
1307 if let Some(e) = error {
1308 let icon = util::icon(cx, IconName::CircleAlert, 14.0, theme.color(Semantic::Danger));
1309 let text = util::label(cx, &e, TextStyle::Callout, None, theme.color(Semantic::Danger));
1310 children.push(cx.container(
1311 LayoutStyle { spacing: 6.0, align: Align::Center, ..LayoutStyle::stack(Axis::Horizontal) },
1312 &[icon, text],
1313 Paint::None,
1314 ));
1315 } else if let Some((note, tone)) = &self.told_note {
1316 let color = tone.color(&theme);
1317 let shape = if *tone == crate::Tone::Warning { IconName::TriangleAlert } else { IconName::Info };
1318 let icon = util::icon(cx, shape, 14.0, color);
1319 let text = util::label(cx, note, TextStyle::Callout, None, theme.color(Semantic::InkMuted));
1320 children.push(cx.container(
1321 LayoutStyle { spacing: 6.0, align: Align::Center, ..LayoutStyle::stack(Axis::Horizontal) },
1322 &[icon, text],
1323 Paint::None,
1324 ));
1325 } else if let Some(h) = &self.hint {
1326 children.push(util::label(cx, h, TextStyle::Callout, None, theme.color(Semantic::InkMuted)));
1327 }
1328 let (padding, paint) = match self.fill {
1329 Some(fill) => (
1330 Insets::uniform(6.0),
1331 Paint::Quad(QuadPaint {
1332 corner_radius: theme.radii.md,
1333 ..QuadPaint::new(theme.color(fill).with_alpha(0.14))
1334 }),
1335 ),
1336 None => (Insets::ZERO, Paint::None),
1337 };
1338 cx.container(
1339 LayoutStyle {
1340 spacing: 6.0,
1341 padding,
1342 align: Align::Stretch,
1343 ..LayoutStyle::stack(Axis::Vertical)
1344 },
1345 &children,
1346 paint,
1347 )
1348 }
1349}
1350
1351#[derive(Debug)]
1368pub struct AccessibleControl<V> {
1369 content: V,
1370 name: Option<String>,
1371 description: Option<String>,
1372}
1373
1374#[cfg_attr(feature = "live", ravenrs_live::expose)]
1375impl<V: View> AccessibleControl<V> {
1376 pub fn new(content: V) -> Self {
1378 Self { content, name: None, description: None }
1379 }
1380
1381 pub fn name(mut self, name: impl Into<String>) -> Self {
1383 self.name = Some(name.into());
1384 self
1385 }
1386
1387 pub fn description(mut self, text: impl Into<String>) -> Self {
1389 self.description = Some(text.into());
1390 self
1391 }
1392}
1393
1394impl<V: View> View for AccessibleControl<V> {
1395 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1396 let node = cx.child(&self.content);
1397 cx.rename_control(node, self.name.as_deref(), self.description.as_deref());
1398 node
1399 }
1400}
1401
1402#[cfg(test)]
1403mod tests {
1404 use super::validate;
1405
1406 #[test]
1407 fn validators() {
1408 assert!(validate::required(" ").is_err());
1409 assert!(validate::email("ana@raven.rs").is_ok());
1410 assert!(validate::email("ana@raven").is_err());
1411 assert!(validate::email("").is_ok(), "emptiness is required()'s job");
1412 assert!(validate::min_length(8)("short").is_err());
1413 assert!(validate::min_length(8)("long enough").is_ok());
1414 }
1415}