1#![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#[allow(clippy::too_many_arguments)]
164#[composable]
165pub fn CaretActionMenu(
166 anchor: Point,
167 can_paste: bool,
168 can_undo: bool,
169 can_redo: bool,
170 on_paste: impl Fn() + 'static,
171 on_select_all: impl Fn() + 'static,
172 on_undo: impl Fn() + 'static,
173 on_redo: impl Fn() + 'static,
174) {
175 let popup_anchor = Rect {
177 x: (anchor.x - 8.0).max(0.0),
178 y: (anchor.y - MENU_HEIGHT).max(0.0),
179 width: 0.0,
180 height: 0.0,
181 };
182
183 let on_paste: Rc<dyn Fn()> = Rc::new(on_paste);
184 let on_select_all: Rc<dyn Fn()> = Rc::new(on_select_all);
185 let on_undo: Rc<dyn Fn()> = Rc::new(on_undo);
186 let on_redo: Rc<dyn Fn()> = Rc::new(on_redo);
187
188 Popup(popup_anchor, Point { x: 0.0, y: 0.0 }, move || {
189 let on_paste = Rc::clone(&on_paste);
190 let on_select_all = Rc::clone(&on_select_all);
191 let on_undo = Rc::clone(&on_undo);
192 let on_redo = Rc::clone(&on_redo);
193 Box(
194 Modifier::empty().background(MENU_BG).rounded_corners(6.0),
195 BoxSpec::default(),
196 move || {
197 let on_paste = Rc::clone(&on_paste);
198 let on_select_all = Rc::clone(&on_select_all);
199 let on_undo = Rc::clone(&on_undo);
200 let on_redo = Rc::clone(&on_redo);
201 Row(Modifier::empty(), RowSpec::default(), move || {
202 if can_paste {
203 menu_item("Paste", Rc::clone(&on_paste));
204 }
205 menu_item("Select all", Rc::clone(&on_select_all));
206 if can_undo {
207 menu_item("Undo", Rc::clone(&on_undo));
208 }
209 if can_redo {
210 menu_item("Redo", Rc::clone(&on_redo));
211 }
212 });
213 },
214 );
215 });
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221 use crate::modifier::{collect_slices_from_modifier, ModifierNodeSlices};
222 use cranpose_foundation::PointerEvent;
223 use cranpose_ui_graphics::Point;
224 use std::cell::Cell;
225
226 fn button_handler(modifier: &Modifier) -> (Rc<dyn Fn(PointerEvent)>, ModifierNodeSlices) {
231 let slices = collect_slices_from_modifier(modifier);
232 assert_eq!(
233 slices.pointer_inputs().len(),
234 1,
235 "menu button must install exactly one pointer-input gesture"
236 );
237 let handler = slices.pointer_inputs()[0].clone();
238 (handler, slices)
239 }
240
241 fn down(x: f32, y: f32) -> PointerEvent {
242 PointerEvent::new(PointerEventKind::Down, Point { x, y }, Point { x, y })
243 }
244 fn up(x: f32, y: f32) -> PointerEvent {
245 PointerEvent::new(PointerEventKind::Up, Point { x, y }, Point { x, y })
246 }
247
248 #[test]
253 fn menu_button_consumes_the_tap_and_runs_the_action() {
254 let _app_context = crate::render_state::app_context_test_scope();
255 let ran = Rc::new(Cell::new(false));
256 let action: Rc<dyn Fn()> = {
257 let ran = Rc::clone(&ran);
258 Rc::new(move || ran.set(true))
259 };
260 let modifier = menu_item_pointer_input("Copy", action);
261 let (handler, _slices) = button_handler(&modifier);
262
263 let press = down(5.0, 5.0);
264 handler(press.clone());
265 assert!(
266 press.is_consumed(),
267 "the press must be consumed so it never reaches the field and collapses the selection"
268 );
269 assert!(!ran.get(), "the action fires on release, not on press");
270
271 let release = up(6.0, 6.0);
272 handler(release.clone());
273 assert!(release.is_consumed(), "the release must be consumed too");
274 assert!(
275 ran.get(),
276 "releasing after a press on the button runs the action"
277 );
278 }
279
280 #[test]
283 fn menu_button_release_without_press_is_consumed_but_inert() {
284 let _app_context = crate::render_state::app_context_test_scope();
285 let ran = Rc::new(Cell::new(false));
286 let action: Rc<dyn Fn()> = {
287 let ran = Rc::clone(&ran);
288 Rc::new(move || ran.set(true))
289 };
290 let modifier = menu_item_pointer_input("Cut", action);
291 let (handler, _slices) = button_handler(&modifier);
292
293 let release = up(5.0, 5.0);
294 handler(release.clone());
295 assert!(
296 release.is_consumed(),
297 "a release on the menu is consumed so it never hits the field"
298 );
299 assert!(!ran.get(), "a release with no matching press must not act");
300 }
301}