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:
2026-06-04 04:23:42 +00:00
commit e65e9cc623
286 changed files with 46136 additions and 0 deletions
+14
View File
@@ -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 }
+361
View File
@@ -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");
}
}