Skip to main content

llimphi_widget_edit_menu/
lib.rs

1//! `llimphi-widget-edit-menu` — el menú de edición estándar para
2//! cualquier campo de texto Llimphi.
3//!
4//! Tanto el input single-line ([`llimphi_widget_text_input`]) como el
5//! editor IDE enriquecido ([`llimphi_widget_text_editor`]) se apoyan en
6//! el mismo [`EditorState`]. Este widget arma, a partir de ese estado,
7//! el menú contextual canónico:
8//!
9//! ```text
10//!   ┃ EDICIÓN
11//!   ┃ Deshacer            Ctrl+Z
12//!   ┃ Rehacer             Ctrl+Y
13//!   ┃ ─────────────────────
14//!   ┃ Cortar              Ctrl+X
15//!   ┃ Copiar              Ctrl+C
16//!   ┃ Pegar               Ctrl+V
17//!   ┃ Eliminar            Supr
18//!   ┃ ─────────────────────
19//!   ┃ Seleccionar todo    Ctrl+A
20//! ```
21//!
22//! Cada ítem se habilita o no según el estado real (sin selección →
23//! Cortar/Copiar/Eliminar grises; sin historial → Deshacer gris; etc).
24//!
25//! Uso típico, en tres pasos por app:
26//! 1. El campo emite la posición del click derecho — `View::on_right_click_at`
27//!    → `Msg::AbrirMenuEdicion(x, y)`. El `update` guarda el ancla.
28//! 2. `App::view_overlay` devuelve
29//!    `Some(context_menu_view(edit_menu::edit_context_menu(...)))` cuando el
30//!    ancla está presente.
31//! 3. El pick produce `Msg::Edicion(EditAction)`; el `update` llama a
32//!    [`apply`] con el `EditorState` del campo focuseado y el clipboard.
33
34#![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/// Una acción de edición del menú estándar. Es `Copy` para que el
44/// `on_pick` la capture sin clonar y la app la rebote en un `Msg`.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum EditAction {
47    Undo,
48    Redo,
49    Cut,
50    Copy,
51    Paste,
52    /// Borra la selección (Supr/Delete sin mover el resto).
53    Delete,
54    SelectAll,
55}
56
57/// Banderas que deciden qué ítems van habilitados. Derivalas del estado
58/// del campo focuseado con [`EditFlags::from_editor`].
59#[derive(Debug, Clone, Copy)]
60pub struct EditFlags {
61    /// Hay selección no-vacía → Cortar/Copiar/Eliminar habilitados.
62    pub has_selection: bool,
63    /// Hay algo que deshacer.
64    pub can_undo: bool,
65    /// Hay algo que rehacer.
66    pub can_redo: bool,
67    /// El clipboard tiene contenido pegable → Pegar habilitado. Si no se
68    /// puede saber barato, pasá `true` (Pegar no-opea si está vacío).
69    pub can_paste: bool,
70    /// El buffer no está vacío → Seleccionar todo habilitado.
71    pub has_text: bool,
72    /// Campo enmascarado (password): Cortar/Copiar se deshabilitan para
73    /// no filtrar el secreto al clipboard.
74    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    /// Deriva las banderas del estado del editor. `can_paste` se deja en
92    /// `true` (consultar el clipboard real requiere `&mut`; pegar vacío
93    /// es no-op). `masked` lo decide el caller (el input lo sabe vía
94    /// `TextInputState::is_masked`).
95    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    /// Igual que [`Self::from_editor`] pero fijando `can_paste`
107    /// explícitamente (cuando el caller ya sabe si el clipboard tiene
108    /// algo, p.ej. consultándolo una vez por frame).
109    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
117/// Los ítems del menú + la acción de cada uno alineadas por índice. Las
118/// filas separador llevan una acción de relleno (`SelectAll`) que **nunca
119/// se dispara**: el `context-menu` no engancha `on_click` en separadores
120/// ni en ítems deshabilitados, así que `on_pick(i)` sólo recibe índices
121/// de ítems-acción habilitados. Mantener un `EditAction` por fila (en vez
122/// de `Option`) permite que el closure de `on_pick` capture sólo `Arc`s
123/// y no un `Msg` crudo — clave para satisfacer `Send + Sync` sin exigirle
124/// esos bounds al `Msg` de la app.
125fn 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
178/// Sólo los ítems (para componer un menú custom que incluya el bloque de
179/// edición seguido de acciones propias de la app).
180pub fn edit_menu_items(flags: EditFlags) -> Vec<ContextMenuItem> {
181    entries(flags).0
182}
183
184/// Mueve el resaltado de teclado por las filas del menú de edición, saltando
185/// separadores y filas deshabilitadas. `active == usize::MAX` significa "ninguna
186/// fila"; `direction` +1 baja, −1 sube. Pensado para enganchar flechas
187/// arriba/abajo sobre el menú de edición abierto (paralelo a [`step_active`]).
188pub 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
193/// La [`EditAction`] de la fila `active`, o `None` si esa fila es un separador,
194/// está deshabilitada o fuera de rango. Pensado para resolver la tecla Enter
195/// sobre la fila resaltada por [`edit_menu_step`].
196pub 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
205/// Arma el [`ContextMenuSpec`] del menú de edición listo para
206/// `context_menu_view`. `on_action` rebota cada pick en un `Msg` de la
207/// app; `on_dismiss` cierra al click-fuera o Esc.
208pub 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        // `i` siempre cae en un ítem-acción habilitado (los separadores y
225        // deshabilitados no enganchan click). El `SelectAll` de relleno de
226        // los separadores nunca se alcanza.
227        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
243/// Aplica una [`EditAction`] al `EditorState`. Reutiliza
244/// `apply_key_with_clipboard` (sintetizando la tecla equivalente) para
245/// heredar exactamente el mismo comportamiento — incluido el bookkeeping
246/// de parseo incremental — que el atajo de teclado. Devuelve el
247/// [`ApplyResult`] para que el caller decida si persistir el cambio.
248pub 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        // "Cortar" es el primer ítem tras el separador (índice 3).
334        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        // Desde "ninguna fila", bajar cae en la primera seleccionable (Deshacer
343        // está gris sin historial; Cortar=3 es el primer habilitado real).
344        let first = edit_menu_step(flags, usize::MAX, 1);
345        assert!(edit_menu_action_at(flags, first).is_some());
346        // El separador (índice 2) nunca da acción.
347        assert_eq!(edit_menu_action_at(flags, 2), None);
348        // Avanzar y retroceder vuelve a una fila con acción válida.
349        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}