Skip to main content

cranpose_ui/widgets/
text_selection_menu.rs

1//! Floating text-selection contextual menu (Copy / Cut / Paste / Select all).
2//!
3//! Rendered in the top-level overlay via [`Popup`] so it floats above the field
4//! (and everything else) just above the active selection. Actions are supplied
5//! by the caller (`BasicTextField` wires them to the clipboard plumbing).
6
7#![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
21/// Background of the menu bar.
22const MENU_BG: Color = Color(0.18, 0.18, 0.2, 0.96);
23/// Menu item label color.
24const MENU_FG: Color = Color(0.96, 0.96, 0.98, 1.0);
25/// Estimated menu height (a single row of labels plus padding) used to float the
26/// bar above the selection.
27const 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
35/// A single tappable menu label.
36///
37/// The button drives its own [`pointer_input`](Modifier::pointer_input) gesture
38/// rather than `clickable`. This is load-bearing: `clickable` only *consumes*
39/// the pointer on the release (and never the press), so a `Down` on the menu
40/// fell through to the text field below — which placed a caret and collapsed the
41/// selection — before the release could run the action (worse after a scroll,
42/// once the finger landed straight on the field). Consuming the whole
43/// press→release gesture here keeps the tap on the menu: the field never sees
44/// it, the selection survives, and the action runs on release.
45fn 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
55/// Builds the consuming tap gesture for a menu button: it swallows the press,
56/// any moves, and the release, and fires `action` when the finger lifts after a
57/// press that started on this button. Every event is consumed so the tap can
58/// never fall through to the text field beneath the overlay. Keyed by the button
59/// label so recomposition reuses the running gesture task.
60pub(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                    // Only a release that follows a press *on this button* runs
68                    // the action; a stray release without a press is ignored. The
69                    // Down capture keeps the whole gesture on the button, so the
70                    // field never sees it.
71                    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/// A floating Copy / Cut / Paste / Select-all menu shown just above the text
103/// selection at `anchor` (window coordinates of the selection's top-center).
104///
105/// `can_paste` hides the Paste item when the clipboard is empty. Each action
106/// runs against the focused field; the caller is expected to dismiss the menu.
107#[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    // Float the bar above the selection; keep it on-screen at the top edge.
117    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/// A floating Paste / Select all / Undo / Redo menu shown near the collapsed
156/// caret at `anchor` (window coordinates of the caret's top). Opened by tapping
157/// the cursor handle (there is no selection to Copy/Cut, so this offers the
158/// caret-relevant actions instead).
159///
160/// `can_paste` hides Paste when the clipboard is empty; `can_undo`/`can_redo`
161/// hide those items when the field's history has nothing to undo/redo. Each
162/// action runs against the focused field; the caller dismisses the menu.
163#[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    // Float the bar above the caret; keep it on-screen at the top edge.
176    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    /// Collects the button's live pointer-input handler. Returns the owning
227    /// [`ModifierNodeSlices`] too: it keeps the attached node (and its running
228    /// coroutine) alive — dropping it would cancel the gesture and swallow the
229    /// events.
230    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    /// Bug 7: a tap (press then release) on a menu button consumes BOTH the
249    /// press and the release — so the tap can never fall through to the text
250    /// field below (which would collapse the selection) — and runs the action on
251    /// release.
252    #[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    /// A stray release with no preceding press on this button is still consumed
281    /// (never reaches the field) but does not run the action.
282    #[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}