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#[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    /// Collects the button's live pointer-input handler. Returns the owning
164    /// [`ModifierNodeSlices`] too: it keeps the attached node (and its running
165    /// coroutine) alive — dropping it would cancel the gesture and swallow the
166    /// events.
167    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    /// Bug 7: a tap (press then release) on a menu button consumes BOTH the
186    /// press and the release — so the tap can never fall through to the text
187    /// field below (which would collapse the selection) — and runs the action on
188    /// release.
189    #[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    /// A stray release with no preceding press on this button is still consumed
218    /// (never reaches the field) but does not run the action.
219    #[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}