1use frust_core::accesskit::{Action, Role};
51use frust_core::{
52 AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EditCommand, EventCtx, EventResult,
53 InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerPhase, SelectionToolbarActions,
54 SelectionToolbarRequest, SemanticsCtx, View, Widget, any,
55};
56use frust_theme::Theme;
57use kurbo::{Point, RoundedRect, Shape, Size};
58use peniko::{Brush, Color};
59
60use crate::ChildKey;
61use crate::authoring::{PRESSED_OPACITY, presses};
62
63const MIN_HEIGHT: f64 = 44.0;
67const PAD_X: f64 = 12.0;
72const PAD_Y: f64 = 8.0;
75const RADIUS: f64 = 8.0;
77const BORDER_WIDTH: f64 = 1.0;
80const BORDER_TOLERANCE: f64 = 0.1;
83
84const FALLBACK_FILL: Color = Color::from_rgb8(0xF0, 0xF0, 0xF0);
87const FALLBACK_BORDER: Color = Color::from_rgb8(0xD0, 0xD0, 0xD0);
89const FALLBACK_INK: Color = Color::from_rgb8(0x00, 0x00, 0x00);
93
94struct ToolbarLabels {
98 cut: &'static str,
99 copy: &'static str,
100 paste: &'static str,
101 select_all: &'static str,
102}
103
104const LABELS: ToolbarLabels = ToolbarLabels {
107 cut: "Cut",
108 copy: "Copy",
109 paste: "Paste",
110 select_all: "Select all",
111};
112
113#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
117enum ToolbarAction {
118 Cut,
119 Copy,
120 Paste,
121 SelectAll,
122}
123
124struct ToolbarChrome {
130 fill: Color,
131 border: Color,
132}
133
134impl ToolbarChrome {
135 fn resolve(theme: Option<&Theme>) -> Self {
136 match theme {
137 Some(theme) => {
138 let s = theme.scheme();
139 ToolbarChrome {
140 fill: s.surface_container,
141 border: s.outline_variant,
142 }
143 }
144 None => ToolbarChrome {
145 fill: FALLBACK_FILL,
146 border: FALLBACK_BORDER,
147 },
148 }
149 }
150}
151
152struct ToolbarChild {
157 action: ToolbarAction,
158 view: AnyView<()>,
159}
160
161fn toolbar_children(actions: SelectionToolbarActions) -> Vec<ToolbarChild> {
164 let mut children = Vec::with_capacity(4);
165 if actions.cut {
166 children.push(ToolbarChild {
167 action: ToolbarAction::Cut,
168 view: item_view(ToolbarAction::Cut, LABELS.cut),
169 });
170 }
171 if actions.copy {
172 children.push(ToolbarChild {
173 action: ToolbarAction::Copy,
174 view: item_view(ToolbarAction::Copy, LABELS.copy),
175 });
176 }
177 if actions.paste {
178 children.push(ToolbarChild {
179 action: ToolbarAction::Paste,
180 view: item_view(ToolbarAction::Paste, LABELS.paste),
181 });
182 }
183 if actions.select_all {
184 children.push(ToolbarChild {
185 action: ToolbarAction::SelectAll,
186 view: item_view(ToolbarAction::SelectAll, LABELS.select_all),
187 });
188 }
189 children
190}
191
192fn item_view(action: ToolbarAction, label: &'static str) -> AnyView<()> {
194 any(ToolbarButtonView { action, label })
195}
196
197fn label_view(label: &'static str) -> AnyView<()> {
201 any::<(), _>(crate::text::text(label))
202}
203
204struct ToolbarButtonView {
206 action: ToolbarAction,
207 label: &'static str,
208}
209
210impl View<()> for ToolbarButtonView {
211 type Element = ToolbarButtonWidget;
212
213 fn build(&self, ctx: &mut BuildCtx<'_>) -> ToolbarButtonWidget {
214 let view = label_view(self.label);
215 ToolbarButtonWidget {
216 action: self.action,
217 label: self.label,
218 text: crate::authoring::build_child(&view, ctx),
219 pressed: false,
220 captured: false,
221 }
222 }
223
224 fn rebuild(
225 &self,
226 prev: &Self,
227 element: &mut ToolbarButtonWidget,
228 ctx: &mut BuildCtx<'_>,
229 ) -> ChangeFlags {
230 element.action = self.action;
231 element.label = self.label;
232 let prev_view = label_view(prev.label);
233 let next_view = label_view(self.label);
234 crate::authoring::rebuild_child(&prev_view, &next_view, &mut element.text, ctx)
235 }
236
237 fn teardown(&self, element: &mut ToolbarButtonWidget, ctx: &mut BuildCtx<'_>) {
238 let view = label_view(self.label);
239 crate::authoring::teardown_child(&view, &mut element.text, ctx);
240 }
241}
242
243struct ToolbarButtonWidget {
247 action: ToolbarAction,
248 label: &'static str,
252 text: ChildPod,
253 pressed: bool,
255 captured: bool,
260}
261
262fn inside(pos: Point, size: Size) -> bool {
266 pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
267}
268
269fn with_alpha(color: Color, alpha: f32) -> Color {
272 let c = color.components;
273 Color::new([c[0], c[1], c[2], alpha])
274}
275
276impl Widget for ToolbarButtonWidget {
277 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
278 let inset = Size::new(PAD_X * 2.0, PAD_Y * 2.0);
279 let inner_max = Size::new(
280 (bc.max().width - inset.width).max(0.0),
281 (bc.max().height - inset.height).max(0.0),
282 );
283 let label_size = self
284 .text
285 .layout_child(ctx, &BoxConstraints::loose(inner_max));
286 let width = label_size.width + inset.width;
287 let height = (label_size.height + inset.height).max(MIN_HEIGHT);
288 self.text
289 .set_origin(Point::new(PAD_X, (height - label_size.height) / 2.0));
290 bc.constrain(Size::new(width, height))
291 }
292
293 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
294 let theme = Theme::from_paint_ctx(ctx);
295 let ink = match theme {
296 Some(theme) => theme.scheme().on_surface,
297 None => FALLBACK_INK,
298 };
299 if self.pressed {
300 scene.fill_rect(ctx.origin(), ctx.size(), with_alpha(ink, PRESSED_OPACITY));
301 }
302 self.text.paint_child(ctx, scene);
303 }
304
305 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
306 let InputEvent::Pointer(p) = event else {
307 return EventResult::Ignored;
308 };
309 match p.phase {
310 PointerPhase::Down => {
311 if !presses(p) {
312 return EventResult::Ignored;
313 }
314 self.pressed = true;
315 self.captured = true;
316 ctx.capture_pointer();
317 ctx.request_redraw();
318 EventResult::Handled
319 }
320 PointerPhase::Move => {
321 if !self.captured {
322 return EventResult::Ignored;
323 }
324 self.pressed = inside(p.position, ctx.size());
325 ctx.request_redraw();
326 EventResult::Handled
327 }
328 PointerPhase::Up => {
329 if !self.captured {
330 return EventResult::Ignored;
331 }
332 if inside(p.position, ctx.size()) {
334 match self.action {
335 ToolbarAction::Cut => ctx.dispatch_edit_command(EditCommand::Cut),
336 ToolbarAction::Copy => ctx.dispatch_edit_command(EditCommand::Copy),
337 ToolbarAction::SelectAll => {
338 ctx.dispatch_edit_command(EditCommand::SelectAll)
339 }
340 ToolbarAction::Paste => ctx.request_paste(),
344 }
345 }
346 self.pressed = false;
347 self.captured = false;
348 ctx.request_redraw();
349 EventResult::Handled
350 }
351 PointerPhase::Cancel => {
352 if !self.captured {
353 return EventResult::Ignored;
354 }
355 self.pressed = false;
356 self.captured = false;
357 ctx.request_redraw();
358 EventResult::Handled
359 }
360 }
361 }
362
363 fn semantics(&self, ctx: &mut SemanticsCtx) {
364 ctx.push_node(Role::Button, |node| {
366 node.set_label(self.label);
367 node.add_action(Action::Click);
368 });
369 }
370
371 crate::authoring::visit_children!(text);
372}
373
374pub fn selection_toolbar(request: &SelectionToolbarRequest) -> AnyView<()> {
392 any(SelectionToolbarView {
393 actions: request.actions,
394 })
395}
396
397struct SelectionToolbarView {
398 actions: SelectionToolbarActions,
399}
400
401impl View<()> for SelectionToolbarView {
402 type Element = SelectionToolbarWidget;
403
404 fn build(&self, ctx: &mut BuildCtx<'_>) -> SelectionToolbarWidget {
405 let children = toolbar_children(self.actions);
406 let mut items = Vec::with_capacity(children.len());
407 for child in &children {
408 items.push(crate::authoring::build_child(&child.view, ctx));
409 }
410 SelectionToolbarWidget { items }
411 }
412
413 fn rebuild(
414 &self,
415 prev: &Self,
416 element: &mut SelectionToolbarWidget,
417 ctx: &mut BuildCtx<'_>,
418 ) -> ChangeFlags {
419 let prev_children = toolbar_children(prev.actions);
420 let next_children = toolbar_children(self.actions);
421 crate::authoring::rebuild_children::<(), ToolbarChild>(
425 &prev_children,
426 &next_children,
427 &mut element.items,
428 ctx,
429 |c| &c.view,
430 |c| Some(ChildKey::new(c.action)),
431 )
432 }
433
434 fn teardown(&self, element: &mut SelectionToolbarWidget, ctx: &mut BuildCtx<'_>) {
435 let children = toolbar_children(self.actions);
436 for (child, pod) in children.iter().zip(element.items.iter_mut()) {
437 crate::authoring::teardown_child(&child.view, pod, ctx);
438 }
439 }
440}
441
442struct SelectionToolbarWidget {
445 items: Vec<ChildPod>,
446}
447
448impl Widget for SelectionToolbarWidget {
449 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
450 let item_bc = BoxConstraints::loose(bc.max());
454 let mut x = 0.0;
455 let mut height: f64 = 0.0;
456 for pod in &mut self.items {
457 let size = pod.layout_child(ctx, &item_bc);
458 pod.set_origin(Point::new(x, 0.0));
459 x += size.width;
460 height = height.max(size.height);
461 }
462 bc.constrain(Size::new(x, height))
463 }
464
465 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
466 let theme = Theme::from_paint_ctx(ctx);
467 let chrome = ToolbarChrome::resolve(theme);
468 let origin = ctx.origin();
469 let size = ctx.size();
470 if size.width > 0.0 && size.height > 0.0 {
471 scene.fill_rounded_rect(origin, size, RADIUS, chrome.fill);
472 let half = BORDER_WIDTH / 2.0;
476 let rr = RoundedRect::new(
477 half,
478 half,
479 size.width - half,
480 size.height - half,
481 (RADIUS - half).max(0.0),
482 );
483 let path = rr.to_path(BORDER_TOLERANCE);
484 scene.stroke_path(origin, &path, BORDER_WIDTH, &Brush::Solid(chrome.border));
485 }
486 for pod in &mut self.items {
487 pod.paint_child(ctx, scene);
488 }
489 }
490
491 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
492 crate::authoring::route_event(&mut self.items, ctx, event)
493 }
494
495 fn semantics(&self, ctx: &mut SemanticsCtx) {
496 for pod in &self.items {
497 pod.semantics_child(ctx);
498 }
499 }
500
501 crate::authoring::visit_children!(items);
502}
503
504#[cfg(test)]
505mod tests {
506 use super::*;
507 use std::cell::RefCell;
508 use std::rc::Rc;
509
510 use frust_core::{FrameTime, PointerButton, PointerEvent, RenderRoot};
511 use kurbo::Rect;
512
513 use crate::overlay::{OverlayAnchor, OverlayPlacement, OverlaySlot};
514 use crate::test_support::RecordingScene;
515
516 const WINDOW: Size = Size::new(400.0, 600.0);
517
518 fn request(actions: SelectionToolbarActions) -> SelectionToolbarRequest {
519 SelectionToolbarRequest {
520 anchor: Rect::new(150.0, 100.0, 250.0, 120.0),
521 actions,
522 present_menu: true,
525 }
526 }
527
528 fn build_widget(actions: SelectionToolbarActions) -> SelectionToolbarWidget {
531 let view = SelectionToolbarView { actions };
532 let mut next_id = 0u64;
533 View::<()>::build(&view, &mut BuildCtx::new(&mut next_id))
534 }
535
536 #[test]
537 fn only_enabled_actions_render() {
538 let w = build_widget(SelectionToolbarActions {
539 cut: false,
540 copy: true,
541 paste: true,
542 select_all: false,
543 });
544 assert_eq!(w.items.len(), 2, "only Copy and Paste are enabled");
545 }
546
547 #[test]
548 fn every_action_renders_one_item_each_in_display_order() {
549 let w = build_widget(SelectionToolbarActions {
550 cut: true,
551 copy: true,
552 paste: true,
553 select_all: true,
554 });
555 assert_eq!(w.items.len(), 4);
556 }
557
558 #[test]
559 fn no_enabled_actions_renders_nothing() {
560 let w = build_widget(SelectionToolbarActions::default());
561 assert!(w.items.is_empty());
562 }
563
564 #[test]
565 fn chrome_resolves_theme_first_with_a_fallback_when_unthemed() {
566 let unthemed = ToolbarChrome::resolve(None);
567 assert_eq!(unthemed.fill, FALLBACK_FILL);
568 assert_eq!(unthemed.border, FALLBACK_BORDER);
569
570 let theme = Theme::neutral();
571 let scheme = theme.scheme();
572 let themed = ToolbarChrome::resolve(Some(&theme));
573 assert_eq!(themed.fill, scheme.surface_container);
574 assert_eq!(themed.border, scheme.outline_variant);
575 assert_ne!(
576 themed.fill, unthemed.fill,
577 "fixture sanity: the neutral theme's role must actually differ \
578 from the unthemed fallback, or this test proves nothing"
579 );
580 }
581
582 type Log = Rc<RefCell<Vec<EditCommand>>>;
588
589 struct Host {
590 actions: SelectionToolbarActions,
591 log: Log,
592 }
593
594 struct HostWidget {
595 slot: OverlaySlot<()>,
596 view: Option<AnyView<()>>,
597 actions: SelectionToolbarActions,
598 log: Log,
599 }
600
601 impl Host {
602 fn pod(&self) -> AnyView<()> {
603 selection_toolbar(&request(self.actions))
604 }
605 }
606
607 impl View<()> for Host {
608 type Element = HostWidget;
609 fn build(&self, ctx: &mut BuildCtx<'_>) -> HostWidget {
610 let mut slot = OverlaySlot::new();
611 slot.set_placement(OverlayPlacement::default());
612 slot.set_anchor(OverlayAnchor::Rect(request(self.actions).anchor));
613 let view = self.pod();
614 slot.rebuild(None, Some(&view), ctx);
615 HostWidget {
616 slot,
617 view: Some(view),
618 actions: self.actions,
619 log: Rc::clone(&self.log),
620 }
621 }
622 fn rebuild(
623 &self,
624 _prev: &Self,
625 element: &mut HostWidget,
626 ctx: &mut BuildCtx<'_>,
627 ) -> ChangeFlags {
628 element.actions = self.actions;
629 element.log = Rc::clone(&self.log);
630 let view = self.pod();
631 let flags = element
632 .slot
633 .rebuild(element.view.as_ref(), Some(&view), ctx);
634 element.view = Some(view);
635 flags
636 }
637 }
638
639 impl Widget for HostWidget {
640 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
641 self.slot.layout(ctx);
642 bc.constrain(Size::new(300.0, 300.0))
643 }
644 fn paint(&mut self, ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {
645 let size = ctx.size();
646 self.slot.paint(ctx, size);
647 }
648 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
649 let mut pod_state = ();
650 let Some(result) = self.slot.event(ctx, event, &mut pod_state) else {
651 return EventResult::Ignored;
652 };
653 for command in ctx.take_edit_commands() {
656 self.log.borrow_mut().push(command);
657 }
658 result
659 }
660 }
661
662 fn harness(actions: SelectionToolbarActions) -> (RenderRoot<(), Host>, Log) {
671 let log: Log = Rc::new(RefCell::new(Vec::new()));
672 let mut root: RenderRoot<(), Host> = RenderRoot::new();
673 let captured = Rc::clone(&log);
674 let mut build = move |_: &mut ()| Host {
675 actions,
676 log: Rc::clone(&captured),
677 };
678 let mut state = ();
679 root.rebuild(&mut build, &mut state);
680 let mut tcx = frust_text::TextContext::new();
681 root.layout_with_text(WINDOW, &mut tcx as &mut dyn std::any::Any);
682 root.paint(&mut RecordingScene::default(), FrameTime::from_nanos(0));
683 (root, log)
684 }
685
686 fn click_point() -> Point {
691 Point::new(200.0, 100.0 + 20.0 + crate::overlay::DEFAULT_OFFSET + 6.0)
692 }
693
694 fn pointer(phase: PointerPhase, p: Point) -> InputEvent {
695 InputEvent::Pointer(PointerEvent {
696 phase,
697 position: p,
698 button: PointerButton::Primary,
699 })
700 }
701
702 #[test]
703 fn a_tap_on_copy_enqueues_edit_command_copy() {
704 let (mut root, log) = harness(SelectionToolbarActions {
705 copy: true,
706 ..Default::default()
707 });
708 let mut state = ();
709 let p = click_point();
710 root.event(&mut state, &pointer(PointerPhase::Down, p));
711 root.event(&mut state, &pointer(PointerPhase::Up, p));
712 assert_eq!(
713 log.borrow().clone(),
714 vec![EditCommand::Copy],
715 "an up-inside tap on the sole Copy item must dispatch EditCommand::Copy"
716 );
717 }
718
719 #[test]
720 fn a_tap_on_paste_sets_take_paste_request() {
721 let (mut root, _log) = harness(SelectionToolbarActions {
722 paste: true,
723 ..Default::default()
724 });
725 let mut state = ();
726 assert!(!root.take_paste_request(), "nothing asked yet");
727 let p = click_point();
728 root.event(&mut state, &pointer(PointerPhase::Down, p));
729 root.event(&mut state, &pointer(PointerPhase::Up, p));
730 assert!(
731 root.take_paste_request(),
732 "an up-inside tap on the sole Paste item must raise the paste request"
733 );
734 assert!(
735 !root.take_paste_request(),
736 "draining the request must clear it"
737 );
738 }
739
740 #[test]
741 fn a_tap_that_moves_out_before_release_fires_nothing() {
742 let (mut root, log) = harness(SelectionToolbarActions {
743 copy: true,
744 ..Default::default()
745 });
746 let mut state = ();
747 let p = click_point();
748 root.event(&mut state, &pointer(PointerPhase::Down, p));
749 root.event(&mut state, &pointer(PointerPhase::Up, Point::new(0.0, 0.0)));
751 assert!(
752 log.borrow().is_empty(),
753 "an up-outside release must not dispatch anything"
754 );
755 assert!(!root.take_paste_request());
756 }
757
758 #[test]
759 fn semantics_exposes_one_button_node_per_enabled_action_with_its_label() {
760 fn logic(_s: &mut ()) -> AnyView<()> {
761 selection_toolbar(&request(SelectionToolbarActions {
762 cut: true,
763 copy: true,
764 paste: false,
765 select_all: true,
766 }))
767 }
768 let mut root: RenderRoot<(), AnyView<()>> = RenderRoot::new();
769 let mut state = ();
770 root.rebuild(&mut logic, &mut state);
771 let mut tcx = frust_text::TextContext::new();
772 root.layout_with_text(WINDOW, &mut tcx as &mut dyn std::any::Any);
773 let update = root.semantics();
774 let buttons: Vec<_> = update
775 .nodes
776 .iter()
777 .filter(|(_, n)| n.role() == Role::Button)
778 .collect();
779 assert_eq!(buttons.len(), 3, "one node per enabled verb, not per glyph");
780 let labels: Vec<_> = buttons.iter().filter_map(|(_, n)| n.label()).collect();
781 assert_eq!(labels, vec!["Cut", "Copy", "Select all"]);
782 for (_, node) in &buttons {
783 assert!(node.supports_action(Action::Click));
784 }
785 }
786}