agg_gui/widgets/
text_context_menu.rs1use 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#[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#[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 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}') .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}') .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}') .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 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 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 #[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 #[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 #[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 #[test]
210 fn item_enablement_tracks_selection_and_editability() {
211 let at = Point::new(10.0, 10.0);
212
213 let mut m = TextContextMenu::new();
215 m.open(at, true, 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 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 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 #[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}