llimphi_widget_edit_menu/
lib.rs1#![forbid(unsafe_code)]
35
36use std::sync::Arc;
37
38use llimphi_theme::Theme;
39use llimphi_ui::{Key, KeyEvent, KeyState, Modifiers, NamedKey};
40use llimphi_widget_context_menu::{step_active, ContextMenuItem, ContextMenuPalette, ContextMenuSpec};
41use llimphi_widget_text_editor::{ApplyResult, Clipboard, EditorState};
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum EditAction {
47 Undo,
48 Redo,
49 Cut,
50 Copy,
51 Paste,
52 Delete,
54 SelectAll,
55}
56
57#[derive(Debug, Clone, Copy)]
60pub struct EditFlags {
61 pub has_selection: bool,
63 pub can_undo: bool,
65 pub can_redo: bool,
67 pub can_paste: bool,
70 pub has_text: bool,
72 pub masked: bool,
75}
76
77impl Default for EditFlags {
78 fn default() -> Self {
79 Self {
80 has_selection: false,
81 can_undo: false,
82 can_redo: false,
83 can_paste: true,
84 has_text: false,
85 masked: false,
86 }
87 }
88}
89
90impl EditFlags {
91 pub fn from_editor(state: &EditorState, masked: bool) -> Self {
96 Self {
97 has_selection: state.has_selection(),
98 can_undo: state.can_undo(),
99 can_redo: state.can_redo(),
100 can_paste: true,
101 has_text: !state.is_empty(),
102 masked,
103 }
104 }
105
106 pub fn from_editor_with_paste(state: &EditorState, masked: bool, can_paste: bool) -> Self {
110 Self {
111 can_paste,
112 ..Self::from_editor(state, masked)
113 }
114 }
115}
116
117fn entries(flags: EditFlags) -> (Vec<ContextMenuItem>, Vec<EditAction>) {
126 let mut items: Vec<ContextMenuItem> = Vec::with_capacity(9);
127 let mut actions: Vec<EditAction> = Vec::with_capacity(9);
128 const FILL: EditAction = EditAction::SelectAll;
129
130 let mut push = |item: ContextMenuItem, action: EditAction| {
131 items.push(item);
132 actions.push(action);
133 };
134
135 let undo = ContextMenuItem::action("Deshacer").icon("\u{21A9}").with_shortcut("Ctrl+Z");
136 push(
137 if flags.can_undo { undo } else { undo.disabled() },
138 EditAction::Undo,
139 );
140 let redo = ContextMenuItem::action("Rehacer").icon("\u{21AA}").with_shortcut("Ctrl+Y");
141 push(
142 if flags.can_redo { redo } else { redo.disabled() },
143 EditAction::Redo,
144 );
145
146 push(ContextMenuItem::separator(), FILL);
147
148 let can_copy = flags.has_selection && !flags.masked;
149 let cut = ContextMenuItem::action("Cortar").icon("\u{2702}").with_shortcut("Ctrl+X");
150 push(if can_copy { cut } else { cut.disabled() }, EditAction::Cut);
151 let copy = ContextMenuItem::action("Copiar").icon("\u{29C9}").with_shortcut("Ctrl+C");
152 push(if can_copy { copy } else { copy.disabled() }, EditAction::Copy);
153 let paste = ContextMenuItem::action("Pegar").icon("\u{2398}").with_shortcut("Ctrl+V");
154 push(
155 if flags.can_paste { paste } else { paste.disabled() },
156 EditAction::Paste,
157 );
158 let del = ContextMenuItem::action("Eliminar")
159 .icon("\u{2717}")
160 .with_shortcut("Supr")
161 .destructive();
162 push(
163 if flags.has_selection { del } else { del.disabled() },
164 EditAction::Delete,
165 );
166
167 push(ContextMenuItem::separator(), FILL);
168
169 let sel = ContextMenuItem::action("Seleccionar todo").icon("\u{2750}").with_shortcut("Ctrl+A");
170 push(
171 if flags.has_text { sel } else { sel.disabled() },
172 EditAction::SelectAll,
173 );
174
175 (items, actions)
176}
177
178pub fn edit_menu_items(flags: EditFlags) -> Vec<ContextMenuItem> {
181 entries(flags).0
182}
183
184pub fn edit_menu_step(flags: EditFlags, active: usize, direction: i32) -> usize {
189 let items = entries(flags).0;
190 step_active(&items, active, direction)
191}
192
193pub fn edit_menu_action_at(flags: EditFlags, active: usize) -> Option<EditAction> {
197 let (items, actions) = entries(flags);
198 let item = items.get(active)?;
199 if item.separator || !item.enabled {
200 return None;
201 }
202 actions.get(active).copied()
203}
204
205pub fn edit_context_menu<Msg, F>(
209 anchor: (f32, f32),
210 viewport: (f32, f32),
211 theme: &Theme,
212 flags: EditFlags,
213 on_action: F,
214 on_dismiss: Msg,
215) -> ContextMenuSpec<Msg>
216where
217 Msg: Clone + 'static,
218 F: Fn(EditAction) -> Msg + Send + Sync + 'static,
219{
220 let (items, actions) = entries(flags);
221 let actions = Arc::new(actions);
222 let on_action = Arc::new(on_action);
223 let on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync> = Arc::new(move |i: usize| {
224 let a = actions.get(i).copied().unwrap_or(EditAction::SelectAll);
228 (on_action)(a)
229 });
230
231 ContextMenuSpec {
232 anchor,
233 viewport,
234 header: Some("Edición".to_string()),
235 items,
236 active: usize::MAX,
237 on_pick,
238 on_dismiss,
239 palette: ContextMenuPalette::from_theme(theme),
240 }
241}
242
243pub fn apply(state: &mut EditorState, action: EditAction, clipboard: &mut dyn Clipboard) -> ApplyResult {
249 match action {
250 EditAction::SelectAll => {
251 state.select_all();
252 ApplyResult::CursorMoved
253 }
254 EditAction::Undo => state.apply_key_with_clipboard(&ctrl_char("z"), clipboard),
255 EditAction::Redo => state.apply_key_with_clipboard(&ctrl_char("y"), clipboard),
256 EditAction::Cut => state.apply_key_with_clipboard(&ctrl_char("x"), clipboard),
257 EditAction::Copy => state.apply_key_with_clipboard(&ctrl_char("c"), clipboard),
258 EditAction::Paste => state.apply_key_with_clipboard(&ctrl_char("v"), clipboard),
259 EditAction::Delete => state.apply_key_with_clipboard(&named(NamedKey::Delete), clipboard),
260 }
261}
262
263fn ctrl_char(s: &str) -> KeyEvent {
264 KeyEvent {
265 key: Key::Character(s.into()),
266 state: KeyState::Pressed,
267 text: Some(s.to_string()),
268 modifiers: Modifiers {
269 ctrl: true,
270 ..Modifiers::default()
271 },
272 repeat: false,
273 }
274}
275
276fn named(k: NamedKey) -> KeyEvent {
277 KeyEvent {
278 key: Key::Named(k),
279 state: KeyState::Pressed,
280 text: None,
281 modifiers: Modifiers::default(),
282 repeat: false,
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289 use llimphi_widget_text_editor::MemClipboard;
290
291 fn lleno() -> EditorState {
292 let mut s = EditorState::new();
293 s.set_text("hola mundo");
294 s
295 }
296
297 #[test]
298 fn select_all_y_copy_llevan_todo_al_clipboard() {
299 let mut s = lleno();
300 let r = apply(&mut s, EditAction::SelectAll, &mut MemClipboard::new());
301 assert_eq!(r, ApplyResult::CursorMoved);
302 assert!(s.has_selection());
303 let mut clip = MemClipboard::new();
304 apply(&mut s, EditAction::Copy, &mut clip);
305 assert_eq!(clip.get().as_deref(), Some("hola mundo"));
306 }
307
308 #[test]
309 fn cut_borra_y_copia() {
310 let mut s = lleno();
311 s.select_all();
312 let mut clip = MemClipboard::new();
313 let r = apply(&mut s, EditAction::Cut, &mut clip);
314 assert_eq!(r, ApplyResult::Changed);
315 assert!(s.is_empty());
316 assert_eq!(clip.get().as_deref(), Some("hola mundo"));
317 }
318
319 #[test]
320 fn paste_inserta_del_clipboard() {
321 let mut s = EditorState::new();
322 let mut clip = MemClipboard::with("XYZ");
323 apply(&mut s, EditAction::Paste, &mut clip);
324 assert_eq!(s.text(), "XYZ");
325 }
326
327 #[test]
328 fn flags_sin_seleccion_deshabilitan_copiar() {
329 let s = lleno();
330 let flags = EditFlags::from_editor(&s, false);
331 assert!(!flags.has_selection);
332 let items = edit_menu_items(flags);
333 assert!(!items[3].enabled, "Cortar debería estar deshabilitado sin selección");
335 }
336
337 #[test]
338 fn step_y_action_at_saltan_separadores_y_deshabilitados() {
339 let mut s = lleno();
340 s.select_all();
341 let flags = EditFlags::from_editor(&s, false);
342 let first = edit_menu_step(flags, usize::MAX, 1);
345 assert!(edit_menu_action_at(flags, first).is_some());
346 assert_eq!(edit_menu_action_at(flags, 2), None);
348 let next = edit_menu_step(flags, first, 1);
350 assert!(edit_menu_action_at(flags, next).is_some());
351 }
352
353 #[test]
354 fn masked_deshabilita_copiar_aun_con_seleccion() {
355 let mut s = lleno();
356 s.select_all();
357 let flags = EditFlags::from_editor(&s, true);
358 let items = edit_menu_items(flags);
359 assert!(!items[4].enabled, "Copiar debería estar gris en campo enmascarado");
360 }
361}