1use std::collections::BTreeSet;
4use std::rc::Rc;
5
6use ravenrs_core::{Border, Color, CursorIcon, Insets, Key, Modifiers, PathFill, Quad, Rect, Role, Scene};
7use ravenrs_layout::{Align, Axis, Justify, LayoutStyle};
8use ravenrs_reactive::Binding;
9use ravenrs_style::{Semantic, TextStyle, Weight};
10
11use crate::context::{BuildCx, NodeId, Paint, QuadPaint, Semantics};
12use crate::{ButtonSize, ButtonStyle, IconButton, IconName, Tone, View, util};
13
14#[derive(Debug)]
16#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
17pub struct ToggleButton {
18 value: Binding<bool>,
19 label: String,
20 icon: Option<IconName>,
21 look: ToggleLook,
22}
23
24#[cfg_attr(feature = "live", ravenrs_live::expose)]
25impl ToggleButton {
26 pub fn new(value: Binding<bool>, label: impl Into<String>) -> Self {
28 Self { value, label: label.into(), icon: None, look: ToggleLook::default() }
29 }
30
31 pub fn icon(mut self, icon: IconName) -> Self {
33 self.icon = Some(icon);
34 self
35 }
36
37 pub fn count(mut self, count: usize) -> Self {
39 self.look.count = Some(count);
40 self
41 }
42
43 pub fn pill(mut self) -> Self {
45 self.look.pill = true;
46 self
47 }
48}
49
50#[derive(Clone, Copy, Debug, Default)]
52struct ToggleLook {
53 count: Option<usize>,
54 pill: bool,
55}
56
57fn toggle_item(
59 cx: &mut BuildCx<'_>,
60 on: bool,
61 icon: Option<IconName>,
62 label: &str,
63 look: ToggleLook,
64 on_click: Rc<dyn Fn()>,
65) -> NodeId {
66 let theme = *cx.theme();
67 let hovered = cx.is_hovered();
68 let ink = theme.color(Semantic::Ink);
69 let color = if on || hovered { ink } else { theme.color(Semantic::InkMuted) };
70 let mut children = Vec::new();
71 if let Some(i) = icon {
72 children.push(util::icon(cx, i, 16.0, color));
73 }
74 if !label.is_empty() {
75 children.push(util::label(cx, label, TextStyle::Callout, Some(Weight::SEMIBOLD), color));
76 }
77 if let Some(count) = look.count {
78 let text = util::label(cx, &count.to_string(), TextStyle::Caption, Some(Weight::SEMIBOLD), color);
79 let chip = cx.container(
80 LayoutStyle { padding: Insets::symmetric(0.0, 6.0), ..LayoutStyle::stack(Axis::Horizontal) },
81 &[text],
82 Paint::Quad(QuadPaint { corner_radius: 999.0, ..QuadPaint::new(ink.with_alpha(0.1)) }),
83 );
84 cx.semantics(chip, Semantics::hidden());
86 children.push(chip);
87 }
88 let fill = if on {
89 theme.color(Semantic::Control)
90 } else if hovered {
91 ink.with_alpha(0.06)
92 } else {
93 Color::TRANSPARENT
94 };
95 let border = on.then(|| Border { width: 1.0, color: theme.color(Semantic::Border) });
96 let icon_only = label.is_empty() && look.count.is_none();
97 let radius = if look.pill { 17.0 } else { 8.0 };
98 let node = cx.container(
99 LayoutStyle {
100 height: Some(34.0),
101 width: icon_only.then_some(34.0),
102 padding: Insets::symmetric(0.0, if icon_only { 0.0 } else { 12.0 }),
103 spacing: 6.0,
104 align: Align::Center,
105 justify: Justify::Center,
106 ..LayoutStyle::stack(Axis::Horizontal)
107 },
108 &children,
109 Paint::Quad(QuadPaint { corner_radius: radius, border, ..QuadPaint::new(fill) }),
110 );
111 let mut semantics = Semantics::new(Role::Button).toggled(on);
112 if let Some(count) = look.count {
113 semantics = semantics.value(count.to_string());
114 }
115 if icon_only && let Some(i) = icon {
116 semantics = semantics.name(i.name().replace('-', " "));
117 semantics.fallback_name = true;
118 }
119 cx.semantics(node, semantics);
120 cx.on_click(node, on_click);
121 cx.focusable(node, radius);
122 cx.cursor(node, CursorIcon::Pointer);
123 node
124}
125
126impl View for ToggleButton {
127 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
128 let v = self.value.clone();
129 let toggle = Rc::new(move || v.update(|b| *b = !*b));
130 toggle_item(cx, self.value.get(), self.icon, &self.label, self.look, toggle)
131 }
132}
133
134#[derive(Debug)]
137#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
138pub struct ToggleGroup {
139 items: Vec<(Option<IconName>, String)>,
140 single: Option<Binding<usize>>,
141 multiple: Option<Binding<BTreeSet<usize>>>,
142}
143
144#[cfg_attr(feature = "live", ravenrs_live::expose)]
145impl ToggleGroup {
146 pub fn single(
148 selected: Binding<usize>,
149 items: impl IntoIterator<Item = (Option<IconName>, &'static str)>,
150 ) -> Self {
151 Self { items: collect(items), single: Some(selected), multiple: None }
152 }
153
154 pub fn multiple(
156 selected: Binding<BTreeSet<usize>>,
157 items: impl IntoIterator<Item = (Option<IconName>, &'static str)>,
158 ) -> Self {
159 Self { items: collect(items), single: None, multiple: Some(selected) }
160 }
161}
162
163fn collect(
164 items: impl IntoIterator<Item = (Option<IconName>, &'static str)>,
165) -> Vec<(Option<IconName>, String)> {
166 items.into_iter().map(|(i, l)| (i, l.to_owned())).collect()
167}
168
169struct GroupItem<'a> {
170 index: usize,
171 group: &'a ToggleGroup,
172 roving: util::Roving,
173}
174
175impl View for GroupItem<'_> {
176 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
177 let g = self.group;
178 let i = self.index;
179 let (icon, label) = &g.items[i];
180 let (on, action): (bool, Rc<dyn Fn()>) = match (&g.single, &g.multiple) {
181 (Some(b), _) => {
182 let b2 = b.clone();
183 (b.get() == i, Rc::new(move || b2.set(i)))
184 }
185 (_, Some(b)) => {
186 let b2 = b.clone();
187 (
188 b.get().contains(&i),
189 Rc::new(move || {
190 b2.update(|s| {
191 if !s.remove(&i) {
192 s.insert(i);
193 }
194 })
195 }),
196 )
197 }
198 _ => (false, Rc::new(|| {})),
199 };
200 let node = toggle_item(cx, on, *icon, label, ToggleLook::default(), action);
201 self.roving.wire(cx, node, None);
203 node
204 }
205}
206
207impl View for ToggleGroup {
208 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
209 let (count, focus_to) = (self.items.len(), util::roving_cell(cx, None));
210 let items: Vec<NodeId> = (0..count)
211 .map(|index| {
212 let roving = util::Roving { index, count, focus_to: focus_to.clone() };
213 cx.keyed_child(index as u64, &GroupItem { index, group: self, roving })
214 })
215 .collect();
216 let node = cx.container(
217 LayoutStyle {
218 spacing: 4.0,
219 align: Align::Center,
220 align_self: Some(Align::Fit),
221 ..LayoutStyle::stack(Axis::Horizontal)
222 },
223 &items,
224 Paint::None,
225 );
226 cx.semantics(node, Semantics::new(Role::Toolbar));
227 node
228 }
229}
230
231#[derive(Debug)]
233#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
234pub struct Pagination {
235 page: Binding<usize>,
236 pages: usize,
237 previous: String,
238 next: String,
239 page_name: String,
240 compact: bool,
241}
242
243#[cfg_attr(feature = "live", ravenrs_live::expose)]
244impl Pagination {
245 pub fn new(page: Binding<usize>, pages: usize) -> Self {
247 Self {
248 page,
249 pages: pages.max(1),
250 previous: "Previous".into(),
251 next: "Next".into(),
252 page_name: "Page".into(),
253 compact: false,
254 }
255 }
256
257 pub fn compact(mut self) -> Self {
260 self.compact = true;
261 self
262 }
263
264 pub fn labels(
267 mut self,
268 previous: impl Into<String>,
269 next: impl Into<String>,
270 page_name: impl Into<String>,
271 ) -> Self {
272 self.previous = previous.into();
273 self.next = next.into();
274 self.page_name = page_name.into();
275 self
276 }
277}
278
279const COMPACT_PAGE_WIDTH: f32 = 72.0;
281
282pub(crate) fn page_window(current: usize, pages: usize) -> Vec<Option<usize>> {
284 if pages <= 7 {
285 return (0..pages).map(Some).collect();
286 }
287 let mut out = vec![Some(0)];
288 let (lo, hi) = if current <= 3 {
289 (1, 4)
290 } else if current >= pages - 4 {
291 (pages - 5, pages - 2)
292 } else {
293 (current - 1, current + 1)
294 };
295 if lo > 1 {
296 out.push(None);
297 }
298 out.extend((lo..=hi).map(Some));
299 if hi < pages - 2 {
300 out.push(None);
301 }
302 out.push(Some(pages - 1));
303 out
304}
305
306impl View for Pagination {
307 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
308 let theme = *cx.theme();
309 let current = self.page.get().min(self.pages - 1);
310 let mut items = Vec::new();
311 let prev = self.page.clone();
312 items.push(
313 cx.child(
314 &crate::Button::new(self.previous.clone())
315 .style(ButtonStyle::Ghost)
316 .size(ButtonSize::Small)
317 .icon(IconName::ChevronLeft)
318 .disabled(current == 0)
319 .on_click(move || prev.update(|p| *p = p.saturating_sub(1))),
320 ),
321 );
322 if self.compact {
323 let color = theme.color(Semantic::Ink);
324 let text = format!("{} / {}", current + 1, self.pages);
325 let label = util::label(cx, &text, TextStyle::Callout, Some(Weight::MEDIUM), color);
326 let node = cx.container(
327 LayoutStyle {
328 width: Some(COMPACT_PAGE_WIDTH),
329 justify: Justify::Center,
330 ..LayoutStyle::stack(Axis::Horizontal)
331 },
332 &[label],
333 Paint::None,
334 );
335 cx.semantics(node, Semantics::new(Role::Label).name(format!("{} {text}", self.page_name)));
336 items.push(node);
337 }
338 let window = if self.compact { Vec::new() } else { page_window(current, self.pages) };
339 for (k, entry) in window.into_iter().enumerate() {
340 match entry {
341 Some(p) => {
342 let b = self.page.clone();
343 let style = if p == current { ButtonStyle::Secondary } else { ButtonStyle::Ghost };
344 let button = crate::Button::new((p + 1).to_string())
345 .accessible_name(format!("{} {}", self.page_name, p + 1))
346 .style(style)
347 .size(ButtonSize::Small)
348 .on_click(move || b.set(p));
349 let node = cx.keyed_child(p as u64, &button);
350 cx.update_semantics(node, |s| s.selected = Some(p == current));
351 items.push(node);
352 }
353 None => {
354 let dots = util::icon(cx, IconName::Ellipsis, 16.0, theme.color(Semantic::InkMuted));
355 items.push(cx.container(
356 LayoutStyle {
357 width: Some(28.0),
358 justify: Justify::Center,
359 ..LayoutStyle::stack(Axis::Horizontal)
360 },
361 &[dots],
362 Paint::None,
363 ));
364 let _ = k;
365 }
366 }
367 }
368 let (next, last) = (self.page.clone(), self.pages - 1);
369 items.push(
370 cx.child(
371 &crate::Button::new(self.next.clone())
372 .style(ButtonStyle::Ghost)
373 .size(ButtonSize::Small)
374 .trailing_icon(IconName::ChevronRight)
375 .disabled(current == last)
376 .on_click(move || next.update(|p| *p = (*p + 1).min(last))),
377 ),
378 );
379 let node = cx.container(
380 LayoutStyle {
381 spacing: 4.0,
382 align: Align::Center,
383 wrap: true,
384 ..LayoutStyle::stack(Axis::Horizontal)
385 },
386 &items,
387 Paint::None,
388 );
389 cx.semantics(node, Semantics::new(Role::Group).name("Pagination"));
390 node
391 }
392}
393
394#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
397pub struct ColorSwatch {
398 color: Option<Color>,
399 label: String,
400 selected: bool,
401 on_click: Option<Rc<dyn Fn()>>,
402}
403
404impl std::fmt::Debug for ColorSwatch {
405 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
406 f.debug_struct("ColorSwatch").field("label", &self.label).finish_non_exhaustive()
407 }
408}
409
410#[cfg_attr(feature = "live", ravenrs_live::expose)]
411impl ColorSwatch {
412 pub fn new(color: Color, label: impl Into<String>) -> Self {
414 Self { color: Some(color), label: label.into(), selected: false, on_click: None }
415 }
416
417 pub fn none(label: impl Into<String>) -> Self {
419 Self { color: None, label: label.into(), selected: false, on_click: None }
420 }
421
422 pub fn selected(mut self, selected: bool) -> Self {
424 self.selected = selected;
425 self
426 }
427
428 pub fn on_click(mut self, handler: impl Fn() + 'static) -> Self {
430 self.on_click = Some(Rc::new(handler));
431 self
432 }
433}
434
435impl View for ColorSwatch {
436 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
437 let theme = *cx.theme();
438 let (color, selected) = (self.color, self.selected);
439 let ring = theme.color(if selected { Semantic::Accent } else { Semantic::Border });
440 let strike = theme.color(Semantic::Danger);
441 let dot = cx.container(
442 LayoutStyle { width: Some(20.0), height: Some(20.0), ..LayoutStyle::leaf() },
443 &[],
444 Paint::Custom(Rc::new(move |f: Rect, scene| {
445 let fill = color.unwrap_or(Color::TRANSPARENT);
446 scene.push_quad(
447 Quad::new(f, fill).radius(10.0).border(if selected { 2.5 } else { 1.0 }, ring),
448 );
449 if color.is_none() {
450 let bar = Rect::new(f.x() + 3.0, f.center().y - 0.75, f.width() - 6.0, 1.5);
451 scene.push_quad(Quad::new(bar, strike));
452 }
453 })),
454 );
455 let fill =
456 if cx.is_hovered() { theme.color(Semantic::Ink).with_alpha(0.06) } else { Color::TRANSPARENT };
457 let node = cx.container(
458 LayoutStyle {
459 width: Some(32.0),
460 height: Some(32.0),
461 align: Align::Center,
462 justify: Justify::Center,
463 ..LayoutStyle::stack(Axis::Horizontal)
464 },
465 &[dot],
466 Paint::Quad(QuadPaint { corner_radius: theme.radii.full, ..QuadPaint::new(fill) }),
467 );
468 cx.semantics(node, Semantics::new(Role::Button).name(self.label.clone()).selected(selected));
469 if let Some(handler) = &self.on_click {
470 cx.on_click(node, handler.clone());
471 }
472 cx.focusable(node, theme.radii.full);
473 cx.cursor(node, CursorIcon::Pointer);
474 node
475 }
476}
477
478#[derive(Debug)]
488#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
489pub struct FavoriteToggle {
490 value: Binding<bool>,
491 label: Option<String>,
492 star: bool,
493 size: f32,
494 color: Option<Color>,
495}
496
497#[cfg_attr(feature = "live", ravenrs_live::expose)]
498impl FavoriteToggle {
499 pub fn new(value: Binding<bool>) -> Self {
501 Self { value, label: None, star: false, size: 16.0, color: None }
502 }
503
504 pub fn label(mut self, label: impl Into<String>) -> Self {
506 self.label = Some(label.into());
507 self
508 }
509
510 pub fn star(mut self) -> Self {
512 self.star = true;
513 self
514 }
515
516 pub fn size(mut self, size: f32) -> Self {
518 self.size = size;
519 self
520 }
521
522 pub fn color(mut self, color: Color) -> Self {
524 self.color = Some(color);
525 self
526 }
527}
528
529impl View for FavoriteToggle {
530 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
531 let theme = *cx.theme();
532 let on = self.value.get();
533 let hovered = cx.is_hovered();
534 let (size, star) = (self.size, self.star);
535 let mark = if on {
536 let tone = if star { Tone::Warning } else { Tone::Danger };
537 let color = self.color.unwrap_or_else(|| tone.color(&theme));
538 cx.container(
539 LayoutStyle { width: Some(size), height: Some(size), ..LayoutStyle::leaf() },
540 &[],
541 Paint::Custom(Rc::new(move |frame: Rect, scene: &mut Scene| {
542 let path = if star {
543 util::star_path(frame.center(), size * 0.5)
544 } else {
545 util::heart_path(frame)
546 };
547 scene.push_path(PathFill::solid(path, color));
548 })),
549 )
550 } else {
551 let ink = theme.color(if hovered { Semantic::Ink } else { Semantic::InkMuted });
552 let glyph = util::icon(cx, if star { IconName::Star } else { IconName::Heart }, size, ink);
553 cx.semantics(glyph, Semantics::hidden());
554 glyph
555 };
556 let side = size + 3.0 * theme.spacing.s1;
557 let fill = if hovered { theme.color(Semantic::Ink).with_alpha(0.06) } else { Color::TRANSPARENT };
558 let node = cx.container(
559 LayoutStyle {
560 width: Some(side),
561 height: Some(side),
562 align: Align::Center,
563 justify: Justify::Center,
564 ..LayoutStyle::stack(Axis::Horizontal)
565 },
566 &[mark],
567 Paint::Quad(QuadPaint { corner_radius: theme.radii.sm, ..QuadPaint::new(fill) }),
568 );
569 let name = self.label.clone().unwrap_or_else(|| cx.labels().favorite.clone());
570 cx.semantics(node, Semantics::new(Role::Button).name(name).toggled(on));
571 let value = self.value.clone();
572 cx.on_click(node, Rc::new(move || value.update(|v| *v = !*v)));
573 cx.focusable(node, theme.radii.sm);
574 cx.cursor(node, CursorIcon::Pointer);
575 node
576 }
577}
578
579#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
595pub struct SwatchGrid {
596 colors: Vec<(Color, String)>,
597 selected: Option<Color>,
598 columns: usize,
599 label: Option<String>,
600 on_pick: Option<Rc<dyn Fn(Color, Modifiers)>>,
601}
602
603impl std::fmt::Debug for SwatchGrid {
604 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
605 f.debug_struct("SwatchGrid").field("colors", &self.colors.len()).finish_non_exhaustive()
606 }
607}
608
609#[cfg_attr(feature = "live", ravenrs_live::expose)]
610impl SwatchGrid {
611 pub fn new<S: Into<String>>(colors: impl IntoIterator<Item = (Color, S)>) -> Self {
613 Self {
614 colors: colors.into_iter().map(|(color, name)| (color, name.into())).collect(),
615 selected: None,
616 columns: 8,
617 label: None,
618 on_pick: None,
619 }
620 }
621
622 pub fn selected(mut self, color: Option<Color>) -> Self {
624 self.selected = color;
625 self
626 }
627
628 pub fn columns(mut self, columns: usize) -> Self {
630 self.columns = columns.max(1);
631 self
632 }
633
634 pub fn label(mut self, label: impl Into<String>) -> Self {
636 self.label = Some(label.into());
637 self
638 }
639
640 pub fn on_pick(mut self, handler: impl Fn(Color, Modifiers) + 'static) -> Self {
642 self.on_pick = Some(Rc::new(handler));
643 self
644 }
645}
646
647fn grid_step(key: &Key, index: usize, count: usize, columns: usize) -> Option<usize> {
649 let target = match key {
650 Key::ArrowRight => index + 1,
651 Key::ArrowLeft => index.checked_sub(1)?,
652 Key::ArrowDown => index + columns,
653 Key::ArrowUp => index.checked_sub(columns)?,
654 Key::Home => 0,
655 Key::End => count.checked_sub(1)?,
656 _ => return None,
657 };
658 (target < count).then_some(target)
659}
660
661impl View for SwatchGrid {
662 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
663 let focus_to = util::roving_cell(cx, None);
664 let held = cx.modifiers();
665 let (count, columns) = (self.colors.len(), self.columns);
666 let mut rows = Vec::with_capacity(count.div_ceil(columns));
667 let mut row = Vec::with_capacity(columns);
668 for (index, (color, name)) in self.colors.iter().enumerate() {
669 let mut swatch = ColorSwatch::new(*color, name.clone()).selected(self.selected == Some(*color));
670 if let Some(on_pick) = &self.on_pick {
671 let (on_pick, held, color) = (on_pick.clone(), held.clone(), *color);
672 swatch = swatch.on_click(move || on_pick(color, held.get()));
673 }
674 let node = cx.child(&swatch);
675 if focus_to.take(index) {
676 cx.request_focus_node(node);
677 }
678 let focus_to = focus_to.clone();
679 cx.on_key(
680 node,
681 Rc::new(move |key, _| match grid_step(key, index, count, columns) {
682 Some(target) => {
683 focus_to.request(target);
684 true
685 }
686 None => matches!(key, Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown),
688 }),
689 );
690 row.push(node);
691 if row.len() == columns || index + 1 == count {
692 rows.push(cx.container(LayoutStyle::stack(Axis::Horizontal), &row, Paint::None));
693 row.clear();
694 }
695 }
696 let node = cx.container(
697 LayoutStyle { align_self: Some(Align::Fit), ..LayoutStyle::stack(Axis::Vertical) },
698 &rows,
699 Paint::None,
700 );
701 let name = self.label.clone().unwrap_or_else(|| cx.labels().swatches.clone());
702 cx.semantics(node, Semantics::new(Role::Group).name(name));
703 node
704 }
705}
706
707#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
709#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
710pub enum AnchorPoint {
711 TopLeft,
713 Top,
715 TopRight,
717 Left,
719 #[default]
721 Center,
722 Right,
724 BottomLeft,
726 Bottom,
728 BottomRight,
730}
731
732#[cfg_attr(feature = "live", ravenrs_live::expose)]
733impl AnchorPoint {
734 pub const ALL: [Self; 9] = [
736 Self::TopLeft,
737 Self::Top,
738 Self::TopRight,
739 Self::Left,
740 Self::Center,
741 Self::Right,
742 Self::BottomLeft,
743 Self::Bottom,
744 Self::BottomRight,
745 ];
746
747 pub fn fraction(self) -> (f32, f32) {
755 let index = self as usize;
756 ((index % 3) as f32 / 2.0, (index / 3) as f32 / 2.0)
757 }
758
759 pub fn from_fraction(x: f32, y: f32) -> Self {
761 let cell = |share: f32| (share.clamp(0.0, 1.0) * 2.0).round() as usize;
762 Self::ALL[cell(y) * 3 + cell(x)]
763 }
764}
765
766#[derive(Debug)]
776#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
777pub struct NinePointAnchor {
778 value: Binding<AnchorPoint>,
779 cell: f32,
780 label: Option<String>,
781}
782
783#[cfg_attr(feature = "live", ravenrs_live::expose)]
784impl NinePointAnchor {
785 pub fn new(value: Binding<AnchorPoint>) -> Self {
787 Self { value, cell: 10.0, label: None }
788 }
789
790 pub fn cell_size(mut self, size: f32) -> Self {
792 self.cell = size;
793 self
794 }
795
796 pub fn label(mut self, label: impl Into<String>) -> Self {
798 self.label = Some(label.into());
799 self
800 }
801}
802
803struct AnchorCell {
806 point: AnchorPoint,
807 on: bool,
808 size: f32,
809 name: String,
810 value: Binding<AnchorPoint>,
811}
812
813impl View for AnchorCell {
814 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
815 let theme = *cx.theme();
816 let ink = theme.color(if self.on || cx.is_hovered() { Semantic::Ink } else { Semantic::InkMuted });
817 let node = cx.container(
818 LayoutStyle { width: Some(self.size), height: Some(self.size), ..LayoutStyle::leaf() },
819 &[],
820 Paint::Quad(QuadPaint {
821 border: Some(Border { width: 1.0, color: ink }),
822 ..QuadPaint::new(if self.on { ink } else { Color::TRANSPARENT })
823 }),
824 );
825 cx.semantics(
826 node,
827 Semantics::new(Role::RadioButton).name(self.name.clone()).selected(self.on).toggled(self.on),
828 );
829 let (value, point) = (self.value.clone(), self.point);
830 cx.on_click(node, Rc::new(move || value.set(point)));
831 cx.cursor(node, CursorIcon::Pointer);
832 node
833 }
834}
835
836impl View for NinePointAnchor {
837 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
838 let theme = *cx.theme();
839 let labels = cx.labels();
840 let current = self.value.get();
841 let mut rows = Vec::with_capacity(3);
842 for row in AnchorPoint::ALL.chunks(3) {
843 let cells: Vec<NodeId> = row
844 .iter()
845 .map(|&point| {
846 cx.child(&AnchorCell {
847 point,
848 on: point == current,
849 size: self.cell,
850 name: labels.anchor_names[point as usize].clone(),
851 value: self.value.clone(),
852 })
853 })
854 .collect();
855 rows.push(cx.container(
856 LayoutStyle { spacing: 1.0, ..LayoutStyle::stack(Axis::Horizontal) },
857 &cells,
858 Paint::None,
859 ));
860 }
861 let node = cx.container(
862 LayoutStyle { spacing: 1.0, align_self: Some(Align::Fit), ..LayoutStyle::stack(Axis::Vertical) },
863 &rows,
864 Paint::None,
865 );
866 let name = self.label.clone().unwrap_or_else(|| labels.anchor_point.clone());
867 cx.semantics(
868 node,
869 Semantics::new(Role::RadioGroup).name(name).value(labels.anchor_names[current as usize].clone()),
870 );
871 let value = self.value.clone();
872 cx.on_key(
873 node,
874 Rc::new(move |key, _| {
875 if !matches!(key, Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown) {
876 return false;
877 }
878 let index = value.get() as usize;
879 if let Some(target) = grid_step(key, index, 9, 3).filter(|target| match key {
881 Key::ArrowLeft | Key::ArrowRight => target / 3 == index / 3,
882 _ => true,
883 }) {
884 value.set(AnchorPoint::ALL[target]);
885 }
886 true
887 }),
888 );
889 cx.focusable(node, theme.radii.sm);
890 node
891 }
892}
893
894#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
910pub struct ColorPair {
911 foreground: Binding<Color>,
912 background: Binding<Color>,
913 defaults: Option<(Color, Color)>,
914 on_foreground: Option<Rc<dyn Fn()>>,
915 on_background: Option<Rc<dyn Fn()>>,
916 size: f32,
917}
918
919impl std::fmt::Debug for ColorPair {
920 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
921 f.debug_struct("ColorPair").field("size", &self.size).finish_non_exhaustive()
922 }
923}
924
925#[cfg_attr(feature = "live", ravenrs_live::expose)]
926impl ColorPair {
927 pub fn new(foreground: Binding<Color>, background: Binding<Color>) -> Self {
929 Self { foreground, background, defaults: None, on_foreground: None, on_background: None, size: 24.0 }
930 }
931
932 pub fn defaults(mut self, foreground: Color, background: Color) -> Self {
934 self.defaults = Some((foreground, background));
935 self
936 }
937
938 pub fn on_foreground(mut self, handler: impl Fn() + 'static) -> Self {
940 self.on_foreground = Some(Rc::new(handler));
941 self
942 }
943
944 pub fn on_background(mut self, handler: impl Fn() + 'static) -> Self {
946 self.on_background = Some(Rc::new(handler));
947 self
948 }
949
950 pub fn size(mut self, size: f32) -> Self {
952 self.size = size;
953 self
954 }
955}
956
957struct PairSquare {
960 color: Color,
961 name: String,
962 at: (f32, f32),
963 size: f32,
964 on_click: Option<Rc<dyn Fn()>>,
965}
966
967impl View for PairSquare {
968 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
969 let theme = *cx.theme();
970 let node = cx.container(
971 LayoutStyle {
972 absolute: Some(self.at),
973 width: Some(self.size),
974 height: Some(self.size),
975 ..LayoutStyle::leaf()
976 },
977 &[],
978 Paint::Quad(QuadPaint {
979 corner_radius: theme.radii.sm,
980 border: Some(Border { width: 1.0, color: theme.color(Semantic::Border) }),
981 ..QuadPaint::new(self.color)
982 }),
983 );
984 let role = if self.on_click.is_some() { Role::Button } else { Role::Image };
985 let spoken = crate::color_picker::to_hex(self.color);
986 cx.semantics(node, Semantics::new(role).name(self.name.clone()).value(spoken));
987 if let Some(handler) = &self.on_click {
988 cx.on_click(node, handler.clone());
989 cx.focusable(node, theme.radii.sm);
990 cx.cursor(node, CursorIcon::Pointer);
991 }
992 node
993 }
994}
995
996impl View for ColorPair {
997 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
998 let theme = *cx.theme();
999 let labels = cx.labels();
1000 let (front, back) = (self.foreground.get(), self.background.get());
1001 let offset = (self.size / 2.0).round();
1002 let lower = cx.child(&PairSquare {
1004 color: back,
1005 name: labels.color_background.clone(),
1006 at: (offset, offset),
1007 size: self.size,
1008 on_click: self.on_background.clone(),
1009 });
1010 let upper = cx.child(&PairSquare {
1011 color: front,
1012 name: labels.color_foreground.clone(),
1013 at: (0.0, 0.0),
1014 size: self.size,
1015 on_click: self.on_foreground.clone(),
1016 });
1017 let side = self.size + offset;
1018 let squares = cx.container(
1019 LayoutStyle { width: Some(side), height: Some(side), ..LayoutStyle::stack(Axis::Horizontal) },
1020 &[lower, upper],
1021 Paint::None,
1022 );
1023 let (foreground, background) = (self.foreground.clone(), self.background.clone());
1024 let swap = cx.child(
1025 &IconButton::new(IconName::ArrowUpDown)
1026 .label(labels.color_swap.clone())
1027 .size(ButtonSize::Mini)
1028 .on_click(move || {
1029 let front = foreground.get();
1030 foreground.set(background.get());
1031 background.set(front);
1032 }),
1033 );
1034 let mut buttons = vec![swap];
1035 if let Some((default_front, default_back)) = self.defaults {
1036 let (foreground, background) = (self.foreground.clone(), self.background.clone());
1037 buttons.push(
1038 cx.child(
1039 &IconButton::new(IconName::RotateCcw)
1040 .label(labels.color_reset.clone())
1041 .size(ButtonSize::Mini)
1042 .on_click(move || {
1043 foreground.set(default_front);
1044 background.set(default_back);
1045 }),
1046 ),
1047 );
1048 }
1049 let buttons = cx.container(
1050 LayoutStyle { spacing: theme.spacing.s1, ..LayoutStyle::stack(Axis::Vertical) },
1051 &buttons,
1052 Paint::None,
1053 );
1054 cx.container(
1055 LayoutStyle {
1056 spacing: theme.spacing.s1,
1057 align: Align::Center,
1058 align_self: Some(Align::Fit),
1059 ..LayoutStyle::stack(Axis::Horizontal)
1060 },
1061 &[squares, buttons],
1062 Paint::None,
1063 )
1064 }
1065}
1066
1067#[derive(Clone, Debug)]
1069#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
1070pub struct Label {
1071 text: String,
1072 required: bool,
1073 hint: Option<String>,
1074 ink: Option<Semantic>,
1075}
1076
1077#[cfg_attr(feature = "live", ravenrs_live::expose)]
1078impl Label {
1079 pub fn new(text: impl Into<String>) -> Self {
1081 Self { text: text.into(), required: false, hint: None, ink: None }
1082 }
1083
1084 pub fn text(&self) -> &str {
1086 &self.text
1087 }
1088
1089 pub fn required(mut self) -> Self {
1091 self.required = true;
1092 self
1093 }
1094
1095 pub fn hint(mut self, hint: impl Into<String>) -> Self {
1097 self.hint = Some(hint.into());
1098 self
1099 }
1100
1101 pub fn ink(mut self, ink: Semantic) -> Self {
1103 self.ink = Some(ink);
1104 self
1105 }
1106}
1107
1108impl View for Label {
1109 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1110 let theme = *cx.theme();
1111 let mut row = vec![util::label(
1112 cx,
1113 &self.text,
1114 TextStyle::Callout,
1115 Some(Weight::SEMIBOLD),
1116 theme.color(self.ink.unwrap_or(Semantic::Ink)),
1117 )];
1118 if self.required {
1119 row.push(util::label(
1120 cx,
1121 "*",
1122 TextStyle::Callout,
1123 Some(Weight::SEMIBOLD),
1124 theme.color(Semantic::Danger),
1125 ));
1126 }
1127 let row = cx.container(
1128 LayoutStyle { spacing: 3.0, ..LayoutStyle::stack(Axis::Horizontal) },
1129 &row,
1130 Paint::None,
1131 );
1132 let mut children = vec![row];
1133 if let Some(h) = &self.hint {
1134 children.push(util::label(cx, h, TextStyle::Caption, None, theme.color(Semantic::InkMuted)));
1135 }
1136 cx.container(
1137 LayoutStyle { spacing: 2.0, ..LayoutStyle::stack(Axis::Vertical) },
1138 &children,
1139 Paint::None,
1140 )
1141 }
1142}
1143
1144#[derive(Debug)]
1146pub struct AspectRatio<V> {
1147 ratio: f32,
1148 content: V,
1149}
1150
1151#[cfg_attr(feature = "live", ravenrs_live::expose)]
1152impl<V: View> AspectRatio<V> {
1153 pub fn new(ratio: f32, content: V) -> Self {
1155 Self { ratio: ratio.max(0.01), content }
1156 }
1157}
1158
1159impl<V: View> View for AspectRatio<V> {
1160 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1161 let child = cx.child(&self.content);
1162 cx.container(
1163 LayoutStyle {
1164 aspect_ratio: Some(self.ratio),
1165 align_self: Some(Align::Stretch),
1166 overflow_hidden: true,
1167 grid_columns: Some(1),
1170 ..LayoutStyle::stack(Axis::Vertical)
1171 },
1172 &[child],
1173 Paint::None,
1174 )
1175 }
1176}
1177
1178#[cfg(test)]
1179mod tests {
1180 use super::page_window;
1181
1182 #[test]
1183 fn page_window_keeps_ends_and_neighbors() {
1184 let show = |c, n| {
1185 page_window(c, n)
1186 .iter()
1187 .map(|p| p.map_or("…".into(), |p: usize| (p + 1).to_string()))
1188 .collect::<Vec<String>>()
1189 .join(" ")
1190 };
1191 assert_eq!(show(0, 5), "1 2 3 4 5");
1192 assert_eq!(show(0, 20), "1 2 3 4 5 … 20");
1193 assert_eq!(show(9, 20), "1 … 9 10 11 … 20");
1194 assert_eq!(show(19, 20), "1 … 16 17 18 19 20");
1195 }
1196}