cranpose_ui/widgets/
text_selection_menu.rs1#![allow(non_snake_case)]
8
9use std::rc::Rc;
10
11use crate::composable;
12use crate::modifier::{Color, Modifier};
13use crate::text::TextStyle;
14use crate::widgets::box_widget::{Box, BoxSpec};
15use crate::widgets::popup::Popup;
16use crate::widgets::{Row, RowSpec, Text};
17use crate::PointerInputScope;
18use cranpose_foundation::PointerEventKind;
19use cranpose_ui_graphics::{Point, Rect};
20
21const MENU_BG: Color = Color(0.18, 0.18, 0.2, 0.96);
23const MENU_FG: Color = Color(0.96, 0.96, 0.98, 1.0);
25const MENU_HEIGHT: f32 = 40.0;
28
29fn menu_text_style() -> TextStyle {
30 let mut style = TextStyle::default();
31 style.span_style.color = Some(MENU_FG);
32 style
33}
34
35fn menu_item(label: &str, action: Rc<dyn Fn()>) {
46 Text(
47 label.to_string(),
48 Modifier::empty()
49 .padding(10.0)
50 .then(menu_item_pointer_input(label, action)),
51 menu_text_style(),
52 );
53}
54
55pub(crate) fn menu_item_pointer_input(label: &str, action: Rc<dyn Fn()>) -> Modifier {
61 let key = label.to_string();
62 Modifier::empty().pointer_input(key, move |scope: PointerInputScope| {
63 let action = Rc::clone(&action);
64 async move {
65 scope
66 .await_pointer_event_scope(|await_scope| async move {
67 let mut pressed = false;
72 loop {
73 let event = await_scope.await_pointer_event().await;
74 match event.kind {
75 PointerEventKind::Down => {
76 pressed = true;
77 event.consume();
78 }
79 PointerEventKind::Move => {
80 event.consume();
81 }
82 PointerEventKind::Up => {
83 if pressed {
84 action();
85 }
86 pressed = false;
87 event.consume();
88 }
89 PointerEventKind::Cancel => {
90 pressed = false;
91 event.consume();
92 }
93 _ => {}
94 }
95 }
96 })
97 .await;
98 }
99 })
100}
101
102#[composable]
108pub fn TextSelectionMenu(
109 anchor: Point,
110 can_paste: bool,
111 on_copy: impl Fn() + 'static,
112 on_cut: impl Fn() + 'static,
113 on_paste: impl Fn() + 'static,
114 on_select_all: impl Fn() + 'static,
115) {
116 let popup_anchor = Rect {
118 x: (anchor.x - 8.0).max(0.0),
119 y: (anchor.y - MENU_HEIGHT).max(0.0),
120 width: 0.0,
121 height: 0.0,
122 };
123
124 let on_copy: Rc<dyn Fn()> = Rc::new(on_copy);
125 let on_cut: Rc<dyn Fn()> = Rc::new(on_cut);
126 let on_paste: Rc<dyn Fn()> = Rc::new(on_paste);
127 let on_select_all: Rc<dyn Fn()> = Rc::new(on_select_all);
128
129 Popup(popup_anchor, Point { x: 0.0, y: 0.0 }, move || {
130 let on_copy = Rc::clone(&on_copy);
131 let on_cut = Rc::clone(&on_cut);
132 let on_paste = Rc::clone(&on_paste);
133 let on_select_all = Rc::clone(&on_select_all);
134 Box(
135 Modifier::empty().background(MENU_BG).rounded_corners(6.0),
136 BoxSpec::default(),
137 move || {
138 let on_copy = Rc::clone(&on_copy);
139 let on_cut = Rc::clone(&on_cut);
140 let on_paste = Rc::clone(&on_paste);
141 let on_select_all = Rc::clone(&on_select_all);
142 Row(Modifier::empty(), RowSpec::default(), move || {
143 menu_item("Copy", Rc::clone(&on_copy));
144 menu_item("Cut", Rc::clone(&on_cut));
145 if can_paste {
146 menu_item("Paste", Rc::clone(&on_paste));
147 }
148 menu_item("Select all", Rc::clone(&on_select_all));
149 });
150 },
151 );
152 });
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158 use crate::modifier::{collect_slices_from_modifier, ModifierNodeSlices};
159 use cranpose_foundation::PointerEvent;
160 use cranpose_ui_graphics::Point;
161 use std::cell::Cell;
162
163 fn button_handler(modifier: &Modifier) -> (Rc<dyn Fn(PointerEvent)>, ModifierNodeSlices) {
168 let slices = collect_slices_from_modifier(modifier);
169 assert_eq!(
170 slices.pointer_inputs().len(),
171 1,
172 "menu button must install exactly one pointer-input gesture"
173 );
174 let handler = slices.pointer_inputs()[0].clone();
175 (handler, slices)
176 }
177
178 fn down(x: f32, y: f32) -> PointerEvent {
179 PointerEvent::new(PointerEventKind::Down, Point { x, y }, Point { x, y })
180 }
181 fn up(x: f32, y: f32) -> PointerEvent {
182 PointerEvent::new(PointerEventKind::Up, Point { x, y }, Point { x, y })
183 }
184
185 #[test]
190 fn menu_button_consumes_the_tap_and_runs_the_action() {
191 let _app_context = crate::render_state::app_context_test_scope();
192 let ran = Rc::new(Cell::new(false));
193 let action: Rc<dyn Fn()> = {
194 let ran = Rc::clone(&ran);
195 Rc::new(move || ran.set(true))
196 };
197 let modifier = menu_item_pointer_input("Copy", action);
198 let (handler, _slices) = button_handler(&modifier);
199
200 let press = down(5.0, 5.0);
201 handler(press.clone());
202 assert!(
203 press.is_consumed(),
204 "the press must be consumed so it never reaches the field and collapses the selection"
205 );
206 assert!(!ran.get(), "the action fires on release, not on press");
207
208 let release = up(6.0, 6.0);
209 handler(release.clone());
210 assert!(release.is_consumed(), "the release must be consumed too");
211 assert!(
212 ran.get(),
213 "releasing after a press on the button runs the action"
214 );
215 }
216
217 #[test]
220 fn menu_button_release_without_press_is_consumed_but_inert() {
221 let _app_context = crate::render_state::app_context_test_scope();
222 let ran = Rc::new(Cell::new(false));
223 let action: Rc<dyn Fn()> = {
224 let ran = Rc::clone(&ran);
225 Rc::new(move || ran.set(true))
226 };
227 let modifier = menu_item_pointer_input("Cut", action);
228 let (handler, _slices) = button_handler(&modifier);
229
230 let release = up(5.0, 5.0);
231 handler(release.clone());
232 assert!(
233 release.is_consumed(),
234 "a release on the menu is consumed so it never hits the field"
235 );
236 assert!(!ran.get(), "a release with no matching press must not act");
237 }
238}