1use std::cell::Cell;
5use std::fmt;
6use std::rc::Rc;
7
8use ravenrs_core::{Border, CursorIcon, Insets, Key, Point, Role, Size};
9use ravenrs_layout::{Align, Axis, Justify, LayoutStyle};
10use ravenrs_reactive::{Binding, State};
11use ravenrs_style::{Semantic, TextStyle};
12
13use crate::chat::{PANEL_MARGIN, move_by_handle, panel_inside};
14use crate::context::{BuildCx, NodeId, Paint, Placement, QuadPaint, Semantics};
15use crate::overlay::{Entry, MenuList};
16use crate::{
17 AnyView, Button, ButtonSize, ButtonStyle, Dialog, IconButton, IconName, MenuItem, Text, TextField,
18 VStack, View, util,
19};
20
21const BUTTON_PADDING: f32 = 26.0;
23const ICON_ROOM: f32 = 22.0;
24const TOOLBAR_GAP: f32 = 4.0;
25const SEPARATOR_ROOM: f32 = 9.0;
26const MORE_ROOM: f32 = 36.0;
27
28#[derive(Clone, Debug, PartialEq, Eq, Hash)]
37#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
38pub struct Hotkey {
39 key: Key,
40 command: bool,
41 shift: bool,
42}
43
44#[cfg_attr(feature = "live", ravenrs_live::expose)]
45impl Hotkey {
46 pub fn key(key: Key) -> Self {
48 Self { key, command: false, shift: false }
49 }
50
51 pub fn command(character: &str) -> Self {
53 Self { key: Key::Character(character.to_lowercase()), command: true, shift: false }
54 }
55
56 pub(crate) fn of(key: Key, command: bool, shift: bool) -> Self {
57 Self { key, command, shift }
58 }
59
60 pub fn shift(mut self) -> Self {
62 self.shift = true;
63 self
64 }
65
66 pub fn label(&self) -> String {
68 let key = match &self.key {
69 Key::Character(c) => c.to_uppercase(),
70 Key::Delete => "Del".to_owned(),
71 Key::Enter => "Enter".to_owned(),
72 Key::Escape => "Esc".to_owned(),
73 Key::Space => "Space".to_owned(),
74 other => format!("{other:?}"),
75 };
76 if cfg!(target_os = "macos") {
77 let shift = if self.shift { "⇧" } else { "" };
78 let command = if self.command { "⌘" } else { "" };
79 format!("{shift}{command}{key}")
80 } else {
81 let shift = if self.shift { "Shift+" } else { "" };
82 let command = if self.command { "Ctrl+" } else { "" };
83 format!("{command}{shift}{key}")
84 }
85 }
86
87 pub fn register(&self, cx: &mut BuildCx<'_>, handler: Rc<dyn Fn()>) {
89 if self.shift {
90 cx.shortcut_with_shift(self.key.clone(), self.command, handler);
91 } else {
92 cx.shortcut(self.key.clone(), self.command, handler);
93 }
94 }
95}
96
97#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
99pub struct ToolbarAction {
100 label: String,
101 icon: Option<IconName>,
102 style: ButtonStyle,
103 disabled: bool,
104 icon_only: bool,
105 hotkey: Option<Hotkey>,
106 on_click: Option<Rc<dyn Fn()>>,
107 accessible_name: Option<String>,
108 accessible_description: Option<String>,
109}
110
111impl fmt::Debug for ToolbarAction {
112 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113 f.debug_struct("ToolbarAction").field("label", &self.label).finish_non_exhaustive()
114 }
115}
116
117#[cfg_attr(feature = "live", ravenrs_live::expose)]
118impl ToolbarAction {
119 pub fn new(label: impl Into<String>) -> Self {
121 Self {
122 label: label.into(),
123 icon: None,
124 style: ButtonStyle::Ghost,
125 disabled: false,
126 icon_only: false,
127 hotkey: None,
128 on_click: None,
129 accessible_name: None,
130 accessible_description: None,
131 }
132 }
133
134 pub fn accessible_name(mut self, name: impl Into<String>) -> Self {
136 self.accessible_name = Some(name.into());
137 self
138 }
139
140 pub fn accessible_description(mut self, text: impl Into<String>) -> Self {
142 self.accessible_description = Some(text.into());
143 self
144 }
145
146 fn description(&self) -> Option<String> {
148 let keys = self.hotkey.as_ref().map(Hotkey::label);
149 let parts: Vec<&str> =
150 [self.accessible_description.as_deref(), keys.as_deref()].into_iter().flatten().collect();
151 (!parts.is_empty()).then(|| parts.join(". "))
152 }
153
154 pub fn icon(mut self, icon: IconName) -> Self {
156 self.icon = Some(icon);
157 self
158 }
159
160 pub fn icon_only(mut self) -> Self {
162 self.icon_only = true;
163 self
164 }
165
166 pub fn style(mut self, style: ButtonStyle) -> Self {
168 self.style = style;
169 self
170 }
171
172 pub fn disabled(mut self, disabled: bool) -> Self {
175 self.disabled = disabled;
176 self
177 }
178
179 pub fn hotkey(mut self, hotkey: Hotkey) -> Self {
182 self.hotkey = Some(hotkey);
183 self
184 }
185
186 pub fn on_click(mut self, handler: impl Fn() + 'static) -> Self {
188 self.on_click = Some(Rc::new(handler));
189 self
190 }
191
192 fn room(&self, cx: &mut BuildCx<'_>) -> f32 {
193 if self.icon_only && self.icon.is_some() {
194 return MORE_ROOM;
195 }
196 let theme = *cx.theme();
197 let attrs = util::attrs(&theme, TextStyle::Callout, None);
198 let text = cx.measure_text(&self.label, &attrs).width;
199 text + BUTTON_PADDING + if self.icon.is_some() { ICON_ROOM } else { 0.0 }
200 }
201
202 fn view(&self) -> AnyView {
203 let handler = self.on_click.clone();
204 let click = move || {
205 if let Some(handler) = &handler {
206 handler();
207 }
208 };
209 let name = self.accessible_name.clone().unwrap_or_else(|| self.label.clone());
210 let description = self.description();
211 match (self.icon_only, self.icon) {
212 (true, Some(icon)) => {
213 let mut button = IconButton::new(icon)
214 .label(name)
215 .style(self.style)
216 .size(ButtonSize::Small)
217 .disabled(self.disabled)
218 .on_click(click);
219 if let Some(description) = description {
220 button = button.description(description);
221 }
222 Box::new(button)
223 }
224 (_, icon) => {
225 let mut button = Button::new(self.label.clone())
226 .style(self.style)
227 .size(ButtonSize::Small)
228 .disabled(self.disabled)
229 .on_click(click);
230 if self.accessible_name.is_some() {
231 button = button.accessible_name(name);
232 }
233 if let Some(description) = description {
234 button = button.accessible_description(description);
235 }
236 if let Some(icon) = icon {
237 button = button.icon(icon);
238 }
239 Box::new(button)
240 }
241 }
242 }
243
244 fn menu_item(&self) -> MenuItem {
245 let mut item = MenuItem::new(self.label.clone());
246 if let Some(icon) = self.icon {
247 item = item.icon(icon);
248 }
249 if self.style == ButtonStyle::Danger {
250 item = item.destructive();
251 }
252 if let Some(hotkey) = &self.hotkey {
253 item = item.shortcut(hotkey.label());
254 }
255 if let Some(handler) = self.on_click.clone() {
256 item = item.on_select(move || handler());
257 }
258 item
259 }
260}
261
262enum ToolbarEntry {
263 Action(ToolbarAction),
264 Separator,
265 View(AnyView, f32),
266}
267
268fn register_hotkeys(cx: &mut BuildCx<'_>, entries: &[ToolbarEntry]) {
270 for wants_shift in [true, false] {
272 for entry in entries {
273 if let ToolbarEntry::Action(ToolbarAction {
274 hotkey: Some(hotkey),
275 on_click: Some(handler),
276 disabled: false,
277 ..
278 }) = entry
279 && hotkey.shift == wants_shift
280 {
281 hotkey.register(cx, handler.clone());
282 }
283 }
284 }
285}
286
287fn entry_node(cx: &mut BuildCx<'_>, index: usize, entry: &ToolbarEntry) -> NodeId {
289 match entry {
290 ToolbarEntry::Action(action) => cx.keyed_child(index as u64, action.view().as_ref()),
291 ToolbarEntry::View(view, _) => cx.keyed_child(index as u64, view.as_ref()),
292 ToolbarEntry::Separator => {
293 let line = cx.theme().color(Semantic::Border);
294 cx.container(
295 LayoutStyle { width: Some(1.0), height: Some(18.0), ..LayoutStyle::leaf() },
296 &[],
297 Paint::Quad(QuadPaint::new(line)),
298 )
299 }
300 }
301}
302
303#[derive(Default)]
317#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
318pub struct Toolbar {
319 entries: Vec<ToolbarEntry>,
320 label: Option<String>,
321 aligned_end: bool,
322}
323
324impl fmt::Debug for Toolbar {
325 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
326 f.debug_struct("Toolbar").field("entries", &self.entries.len()).finish_non_exhaustive()
327 }
328}
329
330#[cfg_attr(feature = "live", ravenrs_live::expose)]
331impl Toolbar {
332 pub fn new() -> Self {
334 Self::default()
335 }
336
337 pub fn action(mut self, action: ToolbarAction) -> Self {
339 self.entries.push(ToolbarEntry::Action(action));
340 self
341 }
342
343 pub fn separator(mut self) -> Self {
345 self.entries.push(ToolbarEntry::Separator);
346 self
347 }
348
349 pub fn view(mut self, width: f32, view: impl View + 'static) -> Self {
352 self.entries.push(ToolbarEntry::View(Box::new(view), width));
353 self
354 }
355
356 pub fn label(mut self, label: impl Into<String>) -> Self {
358 self.label = Some(label.into());
359 self
360 }
361
362 pub(crate) fn aligned_end(mut self) -> Self {
364 self.aligned_end = true;
365 self
366 }
367}
368
369pub(crate) fn entries_that_fit(widths: &[f32], available: f32, more: f32, gap: f32) -> usize {
372 let total: f32 = widths.iter().map(|w| w + gap).sum();
373 if total - gap <= available {
374 return widths.len();
375 }
376 let mut used = 0.0;
377 for (i, w) in widths.iter().enumerate() {
378 used += w + gap;
379 if used + more > available {
380 return i;
381 }
382 }
383 widths.len()
384}
385
386impl View for Toolbar {
387 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
388 register_hotkeys(cx, &self.entries);
389 let widths: Vec<f32> = self
390 .entries
391 .iter()
392 .map(|entry| match entry {
393 ToolbarEntry::Action(action) => action.room(cx),
394 ToolbarEntry::Separator => SEPARATOR_ROOM,
395 ToolbarEntry::View(_, width) => *width,
396 })
397 .collect();
398 let fits = cx
399 .scroll_viewport()
400 .map_or(widths.len(), |v| entries_that_fit(&widths, v.width, MORE_ROOM, TOOLBAR_GAP));
401 let shown = if fits < widths.len()
403 && matches!(self.entries.get(fits.wrapping_sub(1)), Some(ToolbarEntry::Separator))
404 {
405 fits - 1
406 } else {
407 fits
408 };
409
410 let mut children = Vec::with_capacity(shown + 1);
411 for (i, entry) in self.entries[..shown].iter().enumerate() {
412 children.push(entry_node(cx, i, entry));
413 }
414 let hidden = self.entries[shown..]
415 .iter()
416 .filter_map(|entry| match entry {
417 ToolbarEntry::Action(action) if !action.disabled => Some(Entry::Item(action.menu_item())),
418 _ => None,
419 })
420 .collect::<Vec<_>>();
421 if !hidden.is_empty() {
422 children.push(cx.keyed_child(u64::MAX, &MoreMenu(hidden)));
423 }
424 let node = cx.container(
425 LayoutStyle {
426 spacing: TOOLBAR_GAP,
427 align: Align::Center,
428 align_self: Some(Align::Stretch),
429 height: Some(36.0),
430 min_width: Some(0.0),
431 overflow_hidden: true,
432 grow: if self.aligned_end { 1.0 } else { 0.0 },
433 justify: if self.aligned_end { Justify::End } else { Justify::Start },
434 ..LayoutStyle::stack(Axis::Horizontal)
435 },
436 &children,
437 Paint::None,
438 );
439 cx.scrollable_virtual(node, false);
441 let mut semantics = Semantics::new(ravenrs_core::Role::Group);
442 if let Some(label) = &self.label {
443 semantics = semantics.name(label.clone());
444 }
445 cx.semantics(node, semantics);
446 node
447 }
448}
449
450const FLOATING_OPACITY: f32 = 0.92;
452
453pub struct FloatingToolbar<C> {
474 content: C,
475 bar: FloatingBar,
476 fill: bool,
477}
478
479struct FloatingBar {
481 entries: Vec<ToolbarEntry>,
482 label: Option<String>,
483 across: Align,
484 down: Align,
485 margin: f32,
486 place: Option<Binding<Option<Point>>>,
487}
488
489impl<C> fmt::Debug for FloatingToolbar<C> {
490 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
491 f.debug_struct("FloatingToolbar").field("entries", &self.bar.entries.len()).finish_non_exhaustive()
492 }
493}
494
495#[cfg_attr(feature = "live", ravenrs_live::expose)]
496impl<C: View> FloatingToolbar<C> {
497 pub fn new(content: C) -> Self {
499 Self {
500 content,
501 bar: FloatingBar {
502 entries: Vec::new(),
503 label: None,
504 across: Align::Center,
505 down: Align::End,
506 margin: 12.0,
507 place: None,
508 },
509 fill: false,
510 }
511 }
512
513 pub fn action(mut self, action: ToolbarAction) -> Self {
515 self.bar.entries.push(ToolbarEntry::Action(action));
516 self
517 }
518
519 pub fn separator(mut self) -> Self {
521 self.bar.entries.push(ToolbarEntry::Separator);
522 self
523 }
524
525 pub fn view(mut self, view: impl View + 'static) -> Self {
527 self.bar.entries.push(ToolbarEntry::View(Box::new(view), 0.0));
528 self
529 }
530
531 pub fn label(mut self, label: impl Into<String>) -> Self {
533 self.bar.label = Some(label.into());
534 self
535 }
536
537 pub fn place(mut self, across: Align, down: Align) -> Self {
540 self.bar.across = across;
541 self.bar.down = down;
542 self
543 }
544
545 pub fn margin(mut self, margin: f32) -> Self {
547 self.bar.margin = margin;
548 self
549 }
550
551 pub fn movable(mut self, place: Binding<Option<Point>>) -> Self {
555 self.bar.place = Some(place);
556 self
557 }
558
559 pub fn fill(mut self) -> Self {
561 self.fill = true;
562 self
563 }
564}
565
566struct BarHandle<'a> {
568 at: Point,
569 largest: Point,
570 place: &'a Binding<Option<Point>>,
571 dragging: State<Option<Point>>,
572 name: String,
573}
574
575impl View for BarHandle<'_> {
576 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
577 let theme = *cx.theme();
578 let ink = theme.color(if cx.is_hovered() { Semantic::Ink } else { Semantic::InkMuted });
579 let icon = util::icon(cx, IconName::EllipsisVertical, 14.0, ink);
580 let node = cx.container(
581 LayoutStyle {
582 width: Some(16.0),
583 height: Some(ButtonSize::Small.height()),
584 align: Align::Center,
585 justify: Justify::Center,
586 ..LayoutStyle::stack(Axis::Horizontal)
587 },
588 &[icon],
589 Paint::None,
590 );
591 move_by_handle(cx, node, self.at, self.largest, self.place, &self.dragging);
592 let home = self.place.clone();
593 cx.on_double_click(node, Rc::new(move || home.set(None)));
594 cx.semantics(node, Semantics::new(Role::Button).name(self.name.clone()));
595 cx.focusable(node, theme.radii.sm);
596 cx.cursor(node, CursorIcon::Pointer);
597 node
598 }
599}
600
601fn along(side: Align, room: f32, size: f32, margin: f32) -> f32 {
603 let at = match side {
604 Align::Center => (room - size) / 2.0,
605 Align::End => room - size - margin,
606 _ => margin,
607 };
608 at.max(PANEL_MARGIN)
609}
610
611struct BarSurface<'a> {
613 bar: &'a FloatingBar,
614 stage: Size,
615}
616
617impl View for BarSurface<'_> {
618 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
619 let theme = *cx.theme();
620 let bar = self.bar;
621 let size = cx.scroll_viewport().unwrap_or(Size::ZERO);
623 let largest = Point::new(
624 (self.stage.width - size.width - PANEL_MARGIN).max(PANEL_MARGIN),
625 (self.stage.height - size.height - PANEL_MARGIN).max(PANEL_MARGIN),
626 );
627 let home = Point::new(
628 along(bar.across, self.stage.width, size.width, bar.margin),
629 along(bar.down, self.stage.height, size.height, bar.margin),
630 );
631 let dragging: State<Option<Point>> = cx.use_state(|| None);
632 let at = dragging
633 .get()
634 .or_else(|| bar.place.as_ref().and_then(Binding::get))
635 .map_or(home, |wanted| panel_inside(wanted, largest));
636
637 let mut children = Vec::with_capacity(bar.entries.len() + 1);
638 if let Some(place) = &bar.place {
639 let name = match &bar.label {
640 Some(label) => format!("{}: {label}", cx.labels().panel_move),
641 None => cx.labels().panel_move.clone(),
642 };
643 let handle = BarHandle { at, largest, place, dragging, name };
644 children.push(cx.keyed_child(u64::MAX, &handle));
645 }
646 for (i, entry) in bar.entries.iter().enumerate() {
647 children.push(entry_node(cx, i, entry));
648 }
649 let fill = theme.color(Semantic::SurfaceElevated);
650 let node = cx.container(
651 LayoutStyle {
652 absolute: Some((at.x, at.y)),
653 padding: Insets::uniform(theme.spacing.s1),
654 spacing: TOOLBAR_GAP,
655 align: Align::Center,
656 ..LayoutStyle::stack(Axis::Horizontal)
657 },
658 &children,
659 Paint::Quad(QuadPaint {
660 corner_radius: theme.radii.lg,
661 border: Some(Border { width: 1.0, color: theme.color(Semantic::Border) }),
662 shadow: Some(theme.elevation.raised),
663 ..QuadPaint::new(fill.with_alpha(fill.a * FLOATING_OPACITY))
664 }),
665 );
666 cx.report_frame(node);
667 cx.cursor(node, CursorIcon::Default);
669 let mut semantics = Semantics::new(Role::Toolbar);
670 if let Some(label) = &bar.label {
671 semantics = semantics.name(label.clone());
672 }
673 cx.semantics(node, semantics);
674 node
675 }
676}
677
678impl<C: View> View for FloatingToolbar<C> {
679 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
680 register_hotkeys(cx, &self.bar.entries);
681 let content = cx.child(&self.content);
682 let stage = cx.scroll_viewport().unwrap_or(Size::ZERO);
683 let surface = cx.child(&BarSurface { bar: &self.bar, stage });
684 let root = cx.container(
685 LayoutStyle {
686 align: Align::Stretch,
687 align_self: Some(Align::Stretch),
688 grow: if self.fill { 1.0 } else { 0.0 },
689 min_height: self.fill.then_some(0.0),
690 min_width: self.fill.then_some(0.0),
691 ..LayoutStyle::stack(Axis::Vertical)
692 },
693 &[content, surface],
694 Paint::None,
695 );
696 cx.report_frame(root);
697 root
698 }
699}
700
701struct MoreMenu(Vec<Entry>);
702
703impl View for MoreMenu {
704 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
705 menu_trigger(
706 cx,
707 &self.0,
708 IconName::Ellipsis,
709 ButtonStyle::Ghost,
710 ButtonSize::Small,
711 cx.labels().more_commands.clone(),
712 )
713 }
714}
715
716pub(crate) fn menu_trigger(
718 cx: &mut BuildCx<'_>,
719 entries: &[Entry],
720 icon: IconName,
721 style: ButtonStyle,
722 size: ButtonSize,
723 name: String,
724) -> NodeId {
725 let open: State<bool> = cx.use_state(|| false);
726 let toggle = open.clone();
727 let anchor = cx.child(
728 &IconButton::new(icon)
729 .label(name)
730 .style(style)
731 .size(size)
732 .on_click(move || toggle.update(|o| *o = !*o)),
733 );
734 let is_open = open.get();
735 cx.update_semantics(anchor, |s| s.expanded = Some(is_open));
736 let was_open = cx.use_state(|| Rc::new(Cell::new(false))).get();
737 if is_open {
738 was_open.set(true);
739 let close: Rc<dyn Fn()> = Rc::new(move || open.set(false));
740 cx.overlay(
741 anchor,
742 Placement::Below,
743 &MenuList { entries, close: close.clone(), on_side: None },
744 Some(close),
745 );
746 } else if was_open.replace(false) {
747 cx.request_focus_node(anchor);
748 }
749 anchor
750}
751
752#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
765pub struct SplitButton {
766 label: String,
767 icon: Option<IconName>,
768 style: ButtonStyle,
769 size: ButtonSize,
770 disabled: bool,
771 on_click: Option<Rc<dyn Fn()>>,
772 entries: Vec<Entry>,
773}
774
775impl fmt::Debug for SplitButton {
776 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
777 f.debug_struct("SplitButton").field("label", &self.label).finish_non_exhaustive()
778 }
779}
780
781#[cfg_attr(feature = "live", ravenrs_live::expose)]
782impl SplitButton {
783 pub fn new(label: impl Into<String>) -> Self {
785 Self {
786 label: label.into(),
787 icon: None,
788 style: ButtonStyle::Primary,
789 size: ButtonSize::Medium,
790 disabled: false,
791 on_click: None,
792 entries: Vec::new(),
793 }
794 }
795
796 pub fn icon(mut self, icon: IconName) -> Self {
798 self.icon = Some(icon);
799 self
800 }
801
802 pub fn style(mut self, style: ButtonStyle) -> Self {
804 self.style = style;
805 self
806 }
807
808 pub fn size(mut self, size: ButtonSize) -> Self {
810 self.size = size;
811 self
812 }
813
814 pub fn disabled(mut self, disabled: bool) -> Self {
816 self.disabled = disabled;
817 self
818 }
819
820 pub fn on_click(mut self, handler: impl Fn() + 'static) -> Self {
822 self.on_click = Some(Rc::new(handler));
823 self
824 }
825
826 pub fn item(mut self, item: MenuItem) -> Self {
828 self.entries.push(Entry::Item(item));
829 self
830 }
831}
832
833impl View for SplitButton {
834 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
835 let handler = self.on_click.clone();
836 let mut main = Button::new(self.label.clone())
837 .style(self.style)
838 .size(self.size)
839 .disabled(self.disabled)
840 .on_click(move || {
841 if let Some(handler) = &handler {
842 handler();
843 }
844 });
845 if let Some(icon) = self.icon {
846 main = main.icon(icon);
847 }
848 let main = cx.child(&main);
849 let name = format!("{}: {}", self.label, cx.labels().more_commands);
850 let trigger = if self.disabled {
851 cx.child(
852 &IconButton::new(IconName::ChevronDown)
853 .label(name)
854 .style(self.style)
855 .size(self.size)
856 .disabled(true),
857 )
858 } else {
859 menu_trigger(cx, &self.entries, IconName::ChevronDown, self.style, self.size, name)
860 };
861 cx.container(
862 LayoutStyle {
863 spacing: 1.0,
864 align: Align::Center,
865 align_self: Some(Align::Fit),
866 ..LayoutStyle::stack(Axis::Horizontal)
867 },
868 &[main, trigger],
869 Paint::None,
870 )
871 }
872}
873
874#[derive(Clone)]
878#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
879pub struct ConfirmDialog(State<Option<Rc<Question>>>);
880
881struct Question {
882 title: String,
883 message: String,
884 action: String,
885 word: Option<String>,
887 typed: State<String>,
888 run: Rc<dyn Fn()>,
889}
890
891impl fmt::Debug for ConfirmDialog {
892 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
893 f.debug_struct("ConfirmDialog").field("asking", &self.0.peek().is_some()).finish()
894 }
895}
896
897impl Default for ConfirmDialog {
898 fn default() -> Self {
899 Self::new()
900 }
901}
902
903#[cfg_attr(feature = "live", ravenrs_live::expose)]
904impl ConfirmDialog {
905 pub fn new() -> Self {
907 Self(State::new(None))
908 }
909
910 pub fn ask(
912 &self,
913 title: impl Into<String>,
914 message: impl Into<String>,
915 action: impl Into<String>,
916 run: impl Fn() + 'static,
917 ) {
918 self.put(title.into(), message.into(), action.into(), None, Rc::new(run));
919 }
920
921 pub fn ask_typing(
925 &self,
926 title: impl Into<String>,
927 message: impl Into<String>,
928 action: impl Into<String>,
929 word: impl Into<String>,
930 run: impl Fn() + 'static,
931 ) {
932 self.put(title.into(), message.into(), action.into(), Some(word.into()), Rc::new(run));
933 }
934
935 fn put(&self, title: String, message: String, action: String, word: Option<String>, run: Rc<dyn Fn()>) {
936 let typed = State::new(String::new());
937 self.0.set(Some(Rc::new(Question { title, message, action, word, typed, run })));
938 }
939}
940
941impl View for ConfirmDialog {
942 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
943 let Some(question) = self.0.get() else { return cx.empty() };
944 let (read, write) = (self.0.clone(), self.0.clone());
945 let open = Binding::new(
946 move || read.get().is_some(),
947 move |open: bool| {
948 if !open {
949 write.set(None);
950 }
951 },
952 );
953 let (cancel, done, run) = (self.0.clone(), self.0.clone(), question.run.clone());
954 let labels = cx.labels();
955 let typed_right = question.word.as_ref().is_none_or(|word| question.typed.get() == *word);
956 let mut dialog = Dialog::new(open, question.title.clone()).description(question.message.clone());
957 if let Some(word) = &question.word {
958 let (answered, run, word_typed) = (self.0.clone(), question.run.clone(), word.clone());
959 let field = TextField::new(question.typed.binding())
960 .placeholder(word.clone())
961 .autofocus()
962 .on_submit(move |text| {
963 if text == word_typed {
964 answered.set(None);
965 run();
966 }
967 });
968 dialog = dialog.content(
969 VStack::new((
970 Text::new(format!("{} {word}", labels.confirm_by_typing)).style(TextStyle::Callout),
971 field,
972 ))
973 .spacing(8.0)
974 .align(Align::Stretch),
975 );
976 }
977 cx.child(
978 &dialog
979 .action(
980 Button::new(labels.cancel.clone())
981 .style(ButtonStyle::Secondary)
982 .on_click(move || cancel.set(None)),
983 )
984 .action(
985 Button::new(question.action.clone())
986 .style(ButtonStyle::Danger)
987 .disabled(!typed_right)
988 .on_click(move || {
989 done.set(None);
990 run();
991 }),
992 ),
993 )
994 }
995}
996
997#[cfg(test)]
998mod tests {
999 use super::entries_that_fit;
1000
1001 #[test]
1002 fn everything_fits_or_the_menu_takes_room() {
1003 let w = [50.0, 50.0, 50.0];
1004 assert_eq!(entries_that_fit(&w, 160.0, 30.0, 0.0), 3, "no menu is needed");
1005 assert_eq!(entries_that_fit(&w, 150.0, 30.0, 0.0), 3, "exactly enough");
1006 assert_eq!(entries_that_fit(&w, 149.0, 30.0, 0.0), 2, "the menu pushes one out");
1007 assert_eq!(entries_that_fit(&w, 100.0, 30.0, 0.0), 1);
1008 assert_eq!(entries_that_fit(&w, 20.0, 30.0, 0.0), 0);
1009 assert_eq!(entries_that_fit(&[], 0.0, 30.0, 0.0), 0);
1010 assert_eq!(entries_that_fit(&w, 158.0, 30.0, 4.0), 3, "gaps only between entries");
1011 }
1012}