Skip to main content

agg_gui/widgets/
text_context_menu.rs

1//! Shared right-click Cut / Copy / Paste / Select-All context menu for the
2//! text-editing widgets ([`TextField`](crate::widgets::TextField),
3//! [`TextArea`](crate::widgets::TextArea) and
4//! [`RichTextEdit`](crate::widgets::RichTextEdit)).
5//!
6//! Each widget embeds a [`TextContextMenu`], opens it on right-click with its
7//! current selection / read-only state, routes events into it while it is open,
8//! and dispatches the chosen [`TextMenuAction`] back through its OWN existing
9//! keyboard-shortcut handlers. No clipboard logic lives here — this module only
10//! owns the menu presentation and event plumbing, reusing the same
11//! [`PopupMenu`] machinery as the menu bar and the popups demo so the styling,
12//! nesting, hover, Escape handling and viewport clamping stay identical.
13
14use std::sync::Arc;
15
16use crate::draw_ctx::DrawCtx;
17use crate::event::{Event, EventResult, Modifiers, MouseButton};
18use crate::geometry::Point;
19use crate::text::Font;
20use crate::widget::current_viewport;
21use crate::widgets::menu::{MenuEntry, MenuItem, MenuResponse, PopupMenu};
22
23/// The four standard editing commands the text context menu offers. The caller
24/// maps each back onto its own copy/cut/paste/select-all handler so there is a
25/// single implementation shared with the keyboard shortcuts.
26#[derive(Clone, Copy, PartialEq, Eq, Debug)]
27pub enum TextMenuAction {
28    Cut,
29    Copy,
30    Paste,
31    SelectAll,
32}
33
34impl TextMenuAction {
35    fn id(self) -> &'static str {
36        match self {
37            TextMenuAction::Cut => "cut",
38            TextMenuAction::Copy => "copy",
39            TextMenuAction::Paste => "paste",
40            TextMenuAction::SelectAll => "select-all",
41        }
42    }
43
44    fn from_id(id: &str) -> Option<Self> {
45        match id {
46            "cut" => Some(Self::Cut),
47            "copy" => Some(Self::Copy),
48            "paste" => Some(Self::Paste),
49            "select-all" => Some(Self::SelectAll),
50            _ => None,
51        }
52    }
53}
54
55/// Embeddable controller wrapping an optional open [`PopupMenu`]. `Default`
56/// yields a closed menu, so a widget can hold one with `..Default::default()`.
57#[derive(Clone, Default)]
58pub struct TextContextMenu {
59    menu: Option<PopupMenu>,
60}
61
62impl TextContextMenu {
63    pub fn new() -> Self {
64        Self { menu: None }
65    }
66
67    pub fn is_open(&self) -> bool {
68        self.menu.as_ref().map(PopupMenu::is_open).unwrap_or(false)
69    }
70
71    pub fn close(&mut self) {
72        self.menu = None;
73    }
74
75    /// Build the items honouring enablement, then open at `pos` (the right-click
76    /// position, in the widget's local coordinates — the same space its
77    /// `paint_global_overlay` paints in).
78    ///
79    /// * `has_selection` — Cut and Copy are enabled only with a selection.
80    /// * `editable` — Cut and Paste are enabled only when the widget accepts
81    ///   input (not read-only / disabled). Copy stays available read-only, and
82    ///   Select All is always enabled.
83    pub fn open(&mut self, pos: Point, has_selection: bool, editable: bool) {
84        let mut cut = MenuItem::action("Cut", TextMenuAction::Cut.id())
85            .icon('\u{F0C4}') // FA scissors
86            .shortcut("Ctrl+X");
87        if !(has_selection && editable) {
88            cut = cut.disabled();
89        }
90        let mut copy = MenuItem::action("Copy", TextMenuAction::Copy.id())
91            .icon('\u{F0C5}') // FA copy
92            .shortcut("Ctrl+C");
93        if !has_selection {
94            copy = copy.disabled();
95        }
96        let mut paste = MenuItem::action("Paste", TextMenuAction::Paste.id())
97            .icon('\u{F0EA}') // FA paste
98            .shortcut("Ctrl+V");
99        if !editable {
100            paste = paste.disabled();
101        }
102        let select_all =
103            MenuItem::action("Select All", TextMenuAction::SelectAll.id()).shortcut("Ctrl+A");
104
105        let items: Vec<MenuEntry> = vec![
106            cut.into(),
107            copy.into(),
108            paste.into(),
109            MenuEntry::Separator,
110            select_all.into(),
111        ];
112        let mut menu = PopupMenu::new(items);
113        menu.open_at(pos);
114        self.menu = Some(menu);
115    }
116
117    /// Route an event into the open menu. Returns the menu's [`EventResult`]
118    /// (consume it if `is_consumed()`) and any chosen action for the caller to
119    /// dispatch. Choosing an item or dismissing the menu closes it here.
120    pub fn handle_event(&mut self, event: &Event) -> (EventResult, Option<TextMenuAction>) {
121        let Some(menu) = self.menu.as_mut() else {
122            return (EventResult::Ignored, None);
123        };
124        let (result, response) = menu.handle_event(event, current_viewport());
125        let action = match response {
126            MenuResponse::Action(id) => {
127                self.menu = None;
128                TextMenuAction::from_id(&id)
129            }
130            MenuResponse::Closed => {
131                self.menu = None;
132                None
133            }
134            MenuResponse::None => None,
135        };
136        (result, action)
137    }
138
139    /// Convenience wrapper for a synthetic left click at `pos` — used by tests
140    /// to activate a menu item through the real menu event path.
141    pub fn click_at(&mut self, pos: Point) -> Option<TextMenuAction> {
142        let event = Event::MouseDown {
143            pos,
144            button: MouseButton::Left,
145            modifiers: Modifiers::default(),
146        };
147        self.handle_event(&event).1
148    }
149
150    pub fn paint(&mut self, ctx: &mut dyn DrawCtx, font: Arc<Font>, font_size: f64) {
151        if let Some(menu) = self.menu.as_mut() {
152            menu.paint(ctx, font, font_size, current_viewport());
153        }
154    }
155
156    /// Test-only: report whether the given action's item is enabled in the
157    /// currently-open menu (`None` when closed or the item is absent).
158    #[cfg(test)]
159    pub(crate) fn action_enabled(&self, action: TextMenuAction) -> Option<bool> {
160        let menu = self.menu.as_ref()?;
161        for entry in &menu.items {
162            if let MenuEntry::Item(item) = entry {
163                if item.action.as_deref() == Some(action.id()) {
164                    return Some(item.enabled);
165                }
166            }
167        }
168        None
169    }
170
171    /// Test-only: the center point of an action's laid-out row, so a test can
172    /// dispatch a real click there (through the widget or the menu directly).
173    #[cfg(test)]
174    pub(crate) fn action_row_center(&self, action: TextMenuAction) -> Option<Point> {
175        let menu = self.menu.as_ref()?;
176        let layouts = menu.state.layouts(&menu.items, current_viewport());
177        let panel = layouts.first()?;
178        for row in &panel.rows {
179            let Some(idx) = row.item_index else { continue };
180            if let Some(MenuEntry::Item(item)) = menu.items.get(idx) {
181                if item.action.as_deref() == Some(action.id()) {
182                    return Some(Point::new(
183                        row.rect.x + row.rect.width * 0.5,
184                        row.rect.y + row.rect.height * 0.5,
185                    ));
186                }
187            }
188        }
189        None
190    }
191
192    /// Test-only: activate an action by a real click on its laid-out row,
193    /// exercising the menu's hit-testing / activation path end to end.
194    #[cfg(test)]
195    pub(crate) fn click_action(&mut self, action: TextMenuAction) -> Option<TextMenuAction> {
196        let center = self.action_row_center(action)?;
197        self.click_at(center)
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use crate::geometry::Point;
205
206    /// Item enablement must reflect the selection and read-only state exactly:
207    /// Cut/Copy need a selection, Cut/Paste need an editable target, Select All
208    /// is always available.
209    #[test]
210    fn item_enablement_tracks_selection_and_editability() {
211        let at = Point::new(10.0, 10.0);
212
213        // Editable, with a selection: everything enabled.
214        let mut m = TextContextMenu::new();
215        m.open(at, /*has_selection=*/ true, /*editable=*/ true);
216        assert_eq!(m.action_enabled(TextMenuAction::Cut), Some(true));
217        assert_eq!(m.action_enabled(TextMenuAction::Copy), Some(true));
218        assert_eq!(m.action_enabled(TextMenuAction::Paste), Some(true));
219        assert_eq!(m.action_enabled(TextMenuAction::SelectAll), Some(true));
220
221        // Editable, no selection: Cut/Copy disabled, Paste/Select All enabled.
222        let mut m = TextContextMenu::new();
223        m.open(at, false, true);
224        assert_eq!(m.action_enabled(TextMenuAction::Cut), Some(false));
225        assert_eq!(m.action_enabled(TextMenuAction::Copy), Some(false));
226        assert_eq!(m.action_enabled(TextMenuAction::Paste), Some(true));
227        assert_eq!(m.action_enabled(TextMenuAction::SelectAll), Some(true));
228
229        // Read-only, with a selection: Copy stays, Cut/Paste disabled.
230        let mut m = TextContextMenu::new();
231        m.open(at, true, false);
232        assert_eq!(m.action_enabled(TextMenuAction::Cut), Some(false));
233        assert_eq!(m.action_enabled(TextMenuAction::Copy), Some(true));
234        assert_eq!(m.action_enabled(TextMenuAction::Paste), Some(false));
235        assert_eq!(m.action_enabled(TextMenuAction::SelectAll), Some(true));
236    }
237
238    /// A real click on the Copy row returns the Copy action through the menu's
239    /// own hit-testing, and closes the menu.
240    #[test]
241    fn clicking_copy_row_activates_copy_and_closes() {
242        crate::widget::set_current_viewport(crate::geometry::Size::new(800.0, 600.0));
243        let mut m = TextContextMenu::new();
244        m.open(Point::new(10.0, 10.0), true, true);
245        assert!(m.is_open());
246        assert_eq!(
247            m.click_action(TextMenuAction::Copy),
248            Some(TextMenuAction::Copy)
249        );
250        assert!(!m.is_open(), "activating an item closes the menu");
251    }
252}