Skip to main content

agg_gui/widgets/text_area/
context_menu.rs

1//! Right-click Cut/Copy/Paste/Select-All for [`TextArea`].
2//!
3//! The clipboard verbs are the SINGLE implementation shared by both the
4//! keyboard shortcuts (`KeyDown` handling in `widget_impl`) and the
5//! [`TextContextMenu`] items, so the two entry points can never drift.
6//! Presentation and event plumbing live in the shared
7//! [`crate::widgets::text_context_menu`] module. A `TextArea` is always
8//! editable, so Cut/Paste are only gated on the selection state.
9
10use super::*;
11use crate::widgets::text_context_menu::TextMenuAction;
12
13impl TextArea {
14    /// Enable or disable the default right-click Cut/Copy/Paste/Select-All
15    /// context menu. On by default.
16    pub fn with_context_menu(mut self, v: bool) -> Self {
17        self.context_menu_enabled = v;
18        self
19    }
20
21    /// Copy the selection to the clipboard (no-op with an empty selection).
22    pub(super) fn clipboard_copy(&mut self) {
23        let sel = self.selected_text();
24        if !sel.is_empty() {
25            clipboard_set(&sel);
26        }
27    }
28
29    /// Cut = copy the selection then delete it.
30    pub(super) fn clipboard_cut(&mut self) {
31        let sel = self.selected_text();
32        if !sel.is_empty() {
33            clipboard_set(&sel);
34            self.delete(0);
35        }
36    }
37
38    /// Paste the clipboard text at the caret / over the selection.
39    pub(super) fn clipboard_paste(&mut self) {
40        if let Some(t) = clipboard_get() {
41            self.insert_str(&t);
42        }
43    }
44
45    /// Select the whole buffer (Ctrl+A).
46    pub(super) fn select_all_text(&mut self) {
47        let len = self.edit.borrow().text.len();
48        self.move_cursor_to(0, false);
49        self.move_cursor_to(len, true);
50    }
51
52    /// `true` when `offset` falls inside the current (non-empty) selection.
53    fn selection_contains(&self, offset: usize) -> bool {
54        match self.selection() {
55            Some((lo, hi)) => lo != hi && offset >= lo && offset <= hi,
56            None => false,
57        }
58    }
59
60    /// Open the right-click menu at `pos` (widget-local). A right-click outside
61    /// the current selection first moves the caret there; a right-click on an
62    /// existing selection keeps it.
63    pub(super) fn open_context_menu(&mut self, pos: Point) {
64        let off = self.byte_offset_at(pos);
65        if !self.selection_contains(off) {
66            self.move_cursor_to(off, false);
67        }
68        let has_sel = self.selection().map(|(l, h)| l != h).unwrap_or(false);
69        // A TextArea has no read-only mode: it is always editable.
70        self.context_menu.open(pos, has_sel, true);
71        self.focus_time = Some(Instant::now());
72        crate::animation::request_draw();
73    }
74
75    /// Dispatch a chosen menu item through the same verbs as the shortcuts.
76    pub(super) fn apply_text_menu_action(&mut self, action: TextMenuAction) {
77        match action {
78            TextMenuAction::Cut => self.clipboard_cut(),
79            TextMenuAction::Copy => self.clipboard_copy(),
80            TextMenuAction::Paste => self.clipboard_paste(),
81            TextMenuAction::SelectAll => self.select_all_text(),
82        }
83        self.ensure_cursor_visible();
84        crate::animation::request_draw();
85    }
86
87    /// Route an event into the open menu. Returns `Some(result)` when the menu
88    /// consumed it; `None` otherwise so the caller proceeds with normal
89    /// handling.
90    pub(super) fn route_context_menu(&mut self, event: &Event) -> Option<EventResult> {
91        if !self.context_menu.is_open() {
92            return None;
93        }
94        let (result, action) = self.context_menu.handle_event(event);
95        if let Some(action) = action {
96            self.apply_text_menu_action(action);
97        }
98        if result.is_consumed() {
99            crate::animation::request_draw();
100            Some(result)
101        } else {
102            None
103        }
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use crate::event::{Event, EventResult, MouseButton};
111    use crate::widget::Widget;
112    use crate::widgets::text_context_menu::TextMenuAction;
113    use std::sync::Arc;
114
115    const FONT_BYTES: &[u8] = include_bytes!("../../../../demo/assets/CascadiaCode.ttf");
116
117    fn font() -> Arc<Font> {
118        Arc::new(Font::from_slice(FONT_BYTES).expect("font"))
119    }
120
121    fn laid_out(text: &str) -> TextArea {
122        crate::widget::set_current_viewport(Size::new(800.0, 600.0));
123        let mut ta = TextArea::new(font()).with_text(text);
124        ta.layout(Size::new(200.0, 100.0));
125        ta.on_event(&Event::FocusGained);
126        ta
127    }
128
129    fn right_click(ta: &mut TextArea, x: f64, y: f64) -> EventResult {
130        ta.on_event(&Event::MouseDown {
131            pos: Point::new(x, y),
132            button: MouseButton::Right,
133            modifiers: Modifiers::default(),
134        })
135    }
136
137    #[test]
138    fn right_click_opens_menu_through_event_routing() {
139        let mut ta = laid_out("hello world");
140        assert_eq!(right_click(&mut ta, 20.0, 50.0), EventResult::Consumed);
141        assert!(ta.has_active_modal());
142        assert!(ta.context_menu.is_open());
143    }
144
145    #[test]
146    fn context_menu_opt_out() {
147        crate::widget::set_current_viewport(Size::new(800.0, 600.0));
148        let mut ta = TextArea::new(font())
149            .with_text("hello")
150            .with_context_menu(false);
151        ta.layout(Size::new(200.0, 100.0));
152        ta.on_event(&Event::FocusGained);
153        assert_eq!(right_click(&mut ta, 20.0, 50.0), EventResult::Ignored);
154        assert!(!ta.has_active_modal());
155    }
156
157    #[test]
158    fn copy_item_copies_selection_via_clipboard() {
159        let mut ta = laid_out("hello world");
160        ta.select_all_text();
161        right_click(&mut ta, 20.0, 50.0);
162        assert!(ta.context_menu.is_open());
163        // Seed a sentinel so we prove the menu's Copy actually wrote.
164        crate::clipboard::set_text("SENTINEL");
165        // Click the Copy row for real, routed through the widget's on_event so
166        // the whole modal-capture → menu → verb path runs.
167        let center = ta
168            .context_menu
169            .action_row_center(TextMenuAction::Copy)
170            .expect("Copy row is laid out");
171        let r = ta.on_event(&Event::MouseDown {
172            pos: center,
173            button: MouseButton::Left,
174            modifiers: Modifiers::default(),
175        });
176        assert_eq!(r, EventResult::Consumed);
177        assert_eq!(crate::clipboard::get_text().as_deref(), Some("hello world"));
178        assert!(!ta.context_menu.is_open(), "activating Copy closes the menu");
179    }
180}