feat: llimphi standalone — framework UI soberano extraído del monorepo
Motor gráfico Llimphi como workspace independiente: bucle Elm (input→update→view→layout→raster→present) sobre wgpu+vello+taffy+parley. Núcleo (hal/raster/layout/text/ui/theme/surface/motion/icons) + ~40 widgets + módulos, sin dependencias al resto del monorepo. cargo check --workspace pasa (64 crates). Puerta de entrada: cargo run -p llimphi-ui --example counter. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "llimphi-widget-edit-menu"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
description = "llimphi-widget-edit-menu — el menú de edición estándar (Deshacer/Rehacer/Cortar/Copiar/Pegar/Eliminar/Seleccionar todo) para cualquier campo que use EditorState (input single-line e IDE enriquecido). Arma el ContextMenuSpec desde flags derivados del estado y aplica las acciones reutilizando apply_key_with_clipboard."
|
||||
|
||||
[dependencies]
|
||||
llimphi-ui = { workspace = true }
|
||||
llimphi-theme = { workspace = true }
|
||||
llimphi-widget-context-menu = { workspace = true }
|
||||
llimphi-widget-text-editor = { workspace = true }
|
||||
@@ -0,0 +1,361 @@
|
||||
//! `llimphi-widget-edit-menu` — el menú de edición estándar para
|
||||
//! cualquier campo de texto Llimphi.
|
||||
//!
|
||||
//! Tanto el input single-line ([`llimphi_widget_text_input`]) como el
|
||||
//! editor IDE enriquecido ([`llimphi_widget_text_editor`]) se apoyan en
|
||||
//! el mismo [`EditorState`]. Este widget arma, a partir de ese estado,
|
||||
//! el menú contextual canónico:
|
||||
//!
|
||||
//! ```text
|
||||
//! ┃ EDICIÓN
|
||||
//! ┃ Deshacer Ctrl+Z
|
||||
//! ┃ Rehacer Ctrl+Y
|
||||
//! ┃ ─────────────────────
|
||||
//! ┃ Cortar Ctrl+X
|
||||
//! ┃ Copiar Ctrl+C
|
||||
//! ┃ Pegar Ctrl+V
|
||||
//! ┃ Eliminar Supr
|
||||
//! ┃ ─────────────────────
|
||||
//! ┃ Seleccionar todo Ctrl+A
|
||||
//! ```
|
||||
//!
|
||||
//! Cada ítem se habilita o no según el estado real (sin selección →
|
||||
//! Cortar/Copiar/Eliminar grises; sin historial → Deshacer gris; etc).
|
||||
//!
|
||||
//! Uso típico, en tres pasos por app:
|
||||
//! 1. El campo emite la posición del click derecho — `View::on_right_click_at`
|
||||
//! → `Msg::AbrirMenuEdicion(x, y)`. El `update` guarda el ancla.
|
||||
//! 2. `App::view_overlay` devuelve
|
||||
//! `Some(context_menu_view(edit_menu::edit_context_menu(...)))` cuando el
|
||||
//! ancla está presente.
|
||||
//! 3. El pick produce `Msg::Edicion(EditAction)`; el `update` llama a
|
||||
//! [`apply`] con el `EditorState` del campo focuseado y el clipboard.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use llimphi_theme::Theme;
|
||||
use llimphi_ui::{Key, KeyEvent, KeyState, Modifiers, NamedKey};
|
||||
use llimphi_widget_context_menu::{step_active, ContextMenuItem, ContextMenuPalette, ContextMenuSpec};
|
||||
use llimphi_widget_text_editor::{ApplyResult, Clipboard, EditorState};
|
||||
|
||||
/// Una acción de edición del menú estándar. Es `Copy` para que el
|
||||
/// `on_pick` la capture sin clonar y la app la rebote en un `Msg`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EditAction {
|
||||
Undo,
|
||||
Redo,
|
||||
Cut,
|
||||
Copy,
|
||||
Paste,
|
||||
/// Borra la selección (Supr/Delete sin mover el resto).
|
||||
Delete,
|
||||
SelectAll,
|
||||
}
|
||||
|
||||
/// Banderas que deciden qué ítems van habilitados. Derivalas del estado
|
||||
/// del campo focuseado con [`EditFlags::from_editor`].
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct EditFlags {
|
||||
/// Hay selección no-vacía → Cortar/Copiar/Eliminar habilitados.
|
||||
pub has_selection: bool,
|
||||
/// Hay algo que deshacer.
|
||||
pub can_undo: bool,
|
||||
/// Hay algo que rehacer.
|
||||
pub can_redo: bool,
|
||||
/// El clipboard tiene contenido pegable → Pegar habilitado. Si no se
|
||||
/// puede saber barato, pasá `true` (Pegar no-opea si está vacío).
|
||||
pub can_paste: bool,
|
||||
/// El buffer no está vacío → Seleccionar todo habilitado.
|
||||
pub has_text: bool,
|
||||
/// Campo enmascarado (password): Cortar/Copiar se deshabilitan para
|
||||
/// no filtrar el secreto al clipboard.
|
||||
pub masked: bool,
|
||||
}
|
||||
|
||||
impl Default for EditFlags {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
has_selection: false,
|
||||
can_undo: false,
|
||||
can_redo: false,
|
||||
can_paste: true,
|
||||
has_text: false,
|
||||
masked: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EditFlags {
|
||||
/// Deriva las banderas del estado del editor. `can_paste` se deja en
|
||||
/// `true` (consultar el clipboard real requiere `&mut`; pegar vacío
|
||||
/// es no-op). `masked` lo decide el caller (el input lo sabe vía
|
||||
/// `TextInputState::is_masked`).
|
||||
pub fn from_editor(state: &EditorState, masked: bool) -> Self {
|
||||
Self {
|
||||
has_selection: state.has_selection(),
|
||||
can_undo: state.can_undo(),
|
||||
can_redo: state.can_redo(),
|
||||
can_paste: true,
|
||||
has_text: !state.is_empty(),
|
||||
masked,
|
||||
}
|
||||
}
|
||||
|
||||
/// Igual que [`Self::from_editor`] pero fijando `can_paste`
|
||||
/// explícitamente (cuando el caller ya sabe si el clipboard tiene
|
||||
/// algo, p.ej. consultándolo una vez por frame).
|
||||
pub fn from_editor_with_paste(state: &EditorState, masked: bool, can_paste: bool) -> Self {
|
||||
Self {
|
||||
can_paste,
|
||||
..Self::from_editor(state, masked)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Los ítems del menú + la acción de cada uno alineadas por índice. Las
|
||||
/// filas separador llevan una acción de relleno (`SelectAll`) que **nunca
|
||||
/// se dispara**: el `context-menu` no engancha `on_click` en separadores
|
||||
/// ni en ítems deshabilitados, así que `on_pick(i)` sólo recibe índices
|
||||
/// de ítems-acción habilitados. Mantener un `EditAction` por fila (en vez
|
||||
/// de `Option`) permite que el closure de `on_pick` capture sólo `Arc`s
|
||||
/// y no un `Msg` crudo — clave para satisfacer `Send + Sync` sin exigirle
|
||||
/// esos bounds al `Msg` de la app.
|
||||
fn entries(flags: EditFlags) -> (Vec<ContextMenuItem>, Vec<EditAction>) {
|
||||
let mut items: Vec<ContextMenuItem> = Vec::with_capacity(9);
|
||||
let mut actions: Vec<EditAction> = Vec::with_capacity(9);
|
||||
const FILL: EditAction = EditAction::SelectAll;
|
||||
|
||||
let mut push = |item: ContextMenuItem, action: EditAction| {
|
||||
items.push(item);
|
||||
actions.push(action);
|
||||
};
|
||||
|
||||
let undo = ContextMenuItem::action("Deshacer").icon("\u{21A9}").with_shortcut("Ctrl+Z");
|
||||
push(
|
||||
if flags.can_undo { undo } else { undo.disabled() },
|
||||
EditAction::Undo,
|
||||
);
|
||||
let redo = ContextMenuItem::action("Rehacer").icon("\u{21AA}").with_shortcut("Ctrl+Y");
|
||||
push(
|
||||
if flags.can_redo { redo } else { redo.disabled() },
|
||||
EditAction::Redo,
|
||||
);
|
||||
|
||||
push(ContextMenuItem::separator(), FILL);
|
||||
|
||||
let can_copy = flags.has_selection && !flags.masked;
|
||||
let cut = ContextMenuItem::action("Cortar").icon("\u{2702}").with_shortcut("Ctrl+X");
|
||||
push(if can_copy { cut } else { cut.disabled() }, EditAction::Cut);
|
||||
let copy = ContextMenuItem::action("Copiar").icon("\u{29C9}").with_shortcut("Ctrl+C");
|
||||
push(if can_copy { copy } else { copy.disabled() }, EditAction::Copy);
|
||||
let paste = ContextMenuItem::action("Pegar").icon("\u{2398}").with_shortcut("Ctrl+V");
|
||||
push(
|
||||
if flags.can_paste { paste } else { paste.disabled() },
|
||||
EditAction::Paste,
|
||||
);
|
||||
let del = ContextMenuItem::action("Eliminar")
|
||||
.icon("\u{2717}")
|
||||
.with_shortcut("Supr")
|
||||
.destructive();
|
||||
push(
|
||||
if flags.has_selection { del } else { del.disabled() },
|
||||
EditAction::Delete,
|
||||
);
|
||||
|
||||
push(ContextMenuItem::separator(), FILL);
|
||||
|
||||
let sel = ContextMenuItem::action("Seleccionar todo").icon("\u{2750}").with_shortcut("Ctrl+A");
|
||||
push(
|
||||
if flags.has_text { sel } else { sel.disabled() },
|
||||
EditAction::SelectAll,
|
||||
);
|
||||
|
||||
(items, actions)
|
||||
}
|
||||
|
||||
/// Sólo los ítems (para componer un menú custom que incluya el bloque de
|
||||
/// edición seguido de acciones propias de la app).
|
||||
pub fn edit_menu_items(flags: EditFlags) -> Vec<ContextMenuItem> {
|
||||
entries(flags).0
|
||||
}
|
||||
|
||||
/// Mueve el resaltado de teclado por las filas del menú de edición, saltando
|
||||
/// separadores y filas deshabilitadas. `active == usize::MAX` significa "ninguna
|
||||
/// fila"; `direction` +1 baja, −1 sube. Pensado para enganchar flechas
|
||||
/// arriba/abajo sobre el menú de edición abierto (paralelo a [`step_active`]).
|
||||
pub fn edit_menu_step(flags: EditFlags, active: usize, direction: i32) -> usize {
|
||||
let items = entries(flags).0;
|
||||
step_active(&items, active, direction)
|
||||
}
|
||||
|
||||
/// La [`EditAction`] de la fila `active`, o `None` si esa fila es un separador,
|
||||
/// está deshabilitada o fuera de rango. Pensado para resolver la tecla Enter
|
||||
/// sobre la fila resaltada por [`edit_menu_step`].
|
||||
pub fn edit_menu_action_at(flags: EditFlags, active: usize) -> Option<EditAction> {
|
||||
let (items, actions) = entries(flags);
|
||||
let item = items.get(active)?;
|
||||
if item.separator || !item.enabled {
|
||||
return None;
|
||||
}
|
||||
actions.get(active).copied()
|
||||
}
|
||||
|
||||
/// Arma el [`ContextMenuSpec`] del menú de edición listo para
|
||||
/// `context_menu_view`. `on_action` rebota cada pick en un `Msg` de la
|
||||
/// app; `on_dismiss` cierra al click-fuera o Esc.
|
||||
pub fn edit_context_menu<Msg, F>(
|
||||
anchor: (f32, f32),
|
||||
viewport: (f32, f32),
|
||||
theme: &Theme,
|
||||
flags: EditFlags,
|
||||
on_action: F,
|
||||
on_dismiss: Msg,
|
||||
) -> ContextMenuSpec<Msg>
|
||||
where
|
||||
Msg: Clone + 'static,
|
||||
F: Fn(EditAction) -> Msg + Send + Sync + 'static,
|
||||
{
|
||||
let (items, actions) = entries(flags);
|
||||
let actions = Arc::new(actions);
|
||||
let on_action = Arc::new(on_action);
|
||||
let on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync> = Arc::new(move |i: usize| {
|
||||
// `i` siempre cae en un ítem-acción habilitado (los separadores y
|
||||
// deshabilitados no enganchan click). El `SelectAll` de relleno de
|
||||
// los separadores nunca se alcanza.
|
||||
let a = actions.get(i).copied().unwrap_or(EditAction::SelectAll);
|
||||
(on_action)(a)
|
||||
});
|
||||
|
||||
ContextMenuSpec {
|
||||
anchor,
|
||||
viewport,
|
||||
header: Some("Edición".to_string()),
|
||||
items,
|
||||
active: usize::MAX,
|
||||
on_pick,
|
||||
on_dismiss,
|
||||
palette: ContextMenuPalette::from_theme(theme),
|
||||
}
|
||||
}
|
||||
|
||||
/// Aplica una [`EditAction`] al `EditorState`. Reutiliza
|
||||
/// `apply_key_with_clipboard` (sintetizando la tecla equivalente) para
|
||||
/// heredar exactamente el mismo comportamiento — incluido el bookkeeping
|
||||
/// de parseo incremental — que el atajo de teclado. Devuelve el
|
||||
/// [`ApplyResult`] para que el caller decida si persistir el cambio.
|
||||
pub fn apply(state: &mut EditorState, action: EditAction, clipboard: &mut dyn Clipboard) -> ApplyResult {
|
||||
match action {
|
||||
EditAction::SelectAll => {
|
||||
state.select_all();
|
||||
ApplyResult::CursorMoved
|
||||
}
|
||||
EditAction::Undo => state.apply_key_with_clipboard(&ctrl_char("z"), clipboard),
|
||||
EditAction::Redo => state.apply_key_with_clipboard(&ctrl_char("y"), clipboard),
|
||||
EditAction::Cut => state.apply_key_with_clipboard(&ctrl_char("x"), clipboard),
|
||||
EditAction::Copy => state.apply_key_with_clipboard(&ctrl_char("c"), clipboard),
|
||||
EditAction::Paste => state.apply_key_with_clipboard(&ctrl_char("v"), clipboard),
|
||||
EditAction::Delete => state.apply_key_with_clipboard(&named(NamedKey::Delete), clipboard),
|
||||
}
|
||||
}
|
||||
|
||||
fn ctrl_char(s: &str) -> KeyEvent {
|
||||
KeyEvent {
|
||||
key: Key::Character(s.into()),
|
||||
state: KeyState::Pressed,
|
||||
text: Some(s.to_string()),
|
||||
modifiers: Modifiers {
|
||||
ctrl: true,
|
||||
..Modifiers::default()
|
||||
},
|
||||
repeat: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn named(k: NamedKey) -> KeyEvent {
|
||||
KeyEvent {
|
||||
key: Key::Named(k),
|
||||
state: KeyState::Pressed,
|
||||
text: None,
|
||||
modifiers: Modifiers::default(),
|
||||
repeat: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use llimphi_widget_text_editor::MemClipboard;
|
||||
|
||||
fn lleno() -> EditorState {
|
||||
let mut s = EditorState::new();
|
||||
s.set_text("hola mundo");
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_all_y_copy_llevan_todo_al_clipboard() {
|
||||
let mut s = lleno();
|
||||
let r = apply(&mut s, EditAction::SelectAll, &mut MemClipboard::new());
|
||||
assert_eq!(r, ApplyResult::CursorMoved);
|
||||
assert!(s.has_selection());
|
||||
let mut clip = MemClipboard::new();
|
||||
apply(&mut s, EditAction::Copy, &mut clip);
|
||||
assert_eq!(clip.get().as_deref(), Some("hola mundo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cut_borra_y_copia() {
|
||||
let mut s = lleno();
|
||||
s.select_all();
|
||||
let mut clip = MemClipboard::new();
|
||||
let r = apply(&mut s, EditAction::Cut, &mut clip);
|
||||
assert_eq!(r, ApplyResult::Changed);
|
||||
assert!(s.is_empty());
|
||||
assert_eq!(clip.get().as_deref(), Some("hola mundo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_inserta_del_clipboard() {
|
||||
let mut s = EditorState::new();
|
||||
let mut clip = MemClipboard::with("XYZ");
|
||||
apply(&mut s, EditAction::Paste, &mut clip);
|
||||
assert_eq!(s.text(), "XYZ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flags_sin_seleccion_deshabilitan_copiar() {
|
||||
let s = lleno();
|
||||
let flags = EditFlags::from_editor(&s, false);
|
||||
assert!(!flags.has_selection);
|
||||
let items = edit_menu_items(flags);
|
||||
// "Cortar" es el primer ítem tras el separador (índice 3).
|
||||
assert!(!items[3].enabled, "Cortar debería estar deshabilitado sin selección");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn step_y_action_at_saltan_separadores_y_deshabilitados() {
|
||||
let mut s = lleno();
|
||||
s.select_all();
|
||||
let flags = EditFlags::from_editor(&s, false);
|
||||
// Desde "ninguna fila", bajar cae en la primera seleccionable (Deshacer
|
||||
// está gris sin historial; Cortar=3 es el primer habilitado real).
|
||||
let first = edit_menu_step(flags, usize::MAX, 1);
|
||||
assert!(edit_menu_action_at(flags, first).is_some());
|
||||
// El separador (índice 2) nunca da acción.
|
||||
assert_eq!(edit_menu_action_at(flags, 2), None);
|
||||
// Avanzar y retroceder vuelve a una fila con acción válida.
|
||||
let next = edit_menu_step(flags, first, 1);
|
||||
assert!(edit_menu_action_at(flags, next).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masked_deshabilita_copiar_aun_con_seleccion() {
|
||||
let mut s = lleno();
|
||||
s.select_all();
|
||||
let flags = EditFlags::from_editor(&s, true);
|
||||
let items = edit_menu_items(flags);
|
||||
assert!(!items[4].enabled, "Copiar debería estar gris en campo enmascarado");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user