//! `llimphi-module-allichay` — el renderizador único de la configuración //! declarativa. //! //! Toma un [`allichay::Schema`] y lo pinta con el **sidebar unificado** //! (`llimphi-widget-rag-sidebar`): un diente por sección top-level y, en el panel //! del diente activo, controles escalares para los campos (toggle, slider, //! dropdown, color, texto). El rail, el panel, el header uniforme (título de la //! sección), el buscador y el control de disposición los pinta UN solo widget — //! allichay no re-deriva chrome. Los cambios salen como un [`AllichayMsg`] que el //! host mapea a su propio `Msg`. //! //! El cuerpo schema-driven ([`section_view`]/[`schema_panel`]) queda expuesto //! aparte para hosts que ya tienen su propio rail (p. ej. `wawa-panel`): esos //! reutilizan el renderizado de campos sin el sidebar interno. //! //! Es un módulo al estilo de `command-palette`: aporta `state + Msg + apply_key //! + view`. El host: //! //! 1. guarda un [`AllichayState`] en su modelo; //! 2. llama [`allichay_view`] en su `view`, mapeando [`AllichayMsg`] → su `Msg`; //! 3. enruta las teclas a [`AllichayState::apply_key`] cuando hay un campo de //! texto focado; //! 4. ante un [`AllichayMsg::Change`], aplica el `(FieldPath, FieldValue)` a su //! config (vía [`allichay::Configurable::apply`]) y la persiste con su propio //! `save()` — allichay no toca el disco. //! //! Subsecciones: en v1 se pintan como grupos con encabezado dentro del panel de //! la sección activa (un solo nivel de dientes). El panel central combina las //! apps en un esquema donde cada app es una sección-diente y sus secciones //! reales bajan a subsecciones — así un mismo rail navega "apps → secciones". #![forbid(unsafe_code)] use allichay::{Control, Field, FieldPath, FieldValue, Schema}; use llimphi_compositor::llimphi_layout::taffy::{ prelude::{length, percent, Dimension, FlexDirection, Size, Style}, AlignItems, FlexWrap, JustifyContent, Rect, }; use llimphi_compositor::peniko::kurbo::Affine; use llimphi_compositor::peniko::Color; use llimphi_compositor::llimphi_text::Alignment; use llimphi_compositor::{DragPhase, Key, KeyEvent, KeyState, NamedKey, View}; use llimphi_theme::Theme; use std::sync::Arc; use llimphi_widget_color_picker::{ color_popup_view, parse_hex, ColorPopupPalette, ColorPopupSpec, ConeOrbit, HexField, DEFAULT_SWATCHES, }; /// Re-exportado para que los hosts siembren el buffer del campo hex sin duplicar /// el formato `#RRGGBB` (lo usan al manejar [`AllichayMsg::FocusHex`]). pub use llimphi_widget_color_picker::rgba_to_hex as color_hex; use llimphi_widget_modal::{modal_view, ModalButton, ModalPalette, ModalSpec}; use llimphi_widget_rag_sidebar::{ rag_sidebar_view, RagSidebarPalette, RagSidebarView, RagSide, RagTooth, }; /// Re-exportados para que el host enrute [`AllichayMsg::Sidebar`] y guarde el /// estado del sidebar unificado sin depender del crate del widget a mano. pub use llimphi_widget_rag_sidebar::{RagSidebarMsg, RagSidebarState}; use llimphi_widget_scroll::{clamp_offset, scroll_y, ScrollPalette}; use llimphi_widget_select::{ select_menu_view, select_trigger_view, SelectItem, SelectMenuSpec, SelectPalette, SelectPhase, }; use llimphi_widget_slider::{slider_view, SliderPalette}; use llimphi_widget_switch::{switch_view, SwitchPalette}; use llimphi_widget_table::{list_view, table_view, table_height, TablePalette}; use llimphi_widget_text_input::{ float_target, text_input_field_view, text_input_view_full, MemClipboard, TextInputEvent, TextInputPalette, TextInputState, }; use llimphi_widget_edit_menu::{self as editmenu, EditFlags}; pub use llimphi_widget_edit_menu::EditAction; use llimphi_widget_context_menu::context_menu_view; use std::collections::BTreeMap; /// Ancho del rail de dientes (px). El diente es la pestañita que sobresale: un /// icono, no un rótulo (el nombre lo lleva el encabezado del panel desplegado). const RAIL_W: f32 = 52.0; // ===================================================================== // Mensajes del módulo // ===================================================================== /// Lo que emite el renderizador. El host lo envuelve en su propio `Msg` /// (típicamente `Msg::Allichay(AllichayMsg)`) y lo resuelve en su `update`. // Sin `PartialEq`: `Input` lleva un `TextInputEvent` (con `KeyEvent`, que no es // comparable). No se compara `AllichayMsg` en ningún lado; los `Msg` de los hosts // solo derivan `Clone`. #[derive(Debug, Clone)] pub enum AllichayMsg { /// Se clickeó el diente de la sección `índice`. SelectSection(usize), /// Mensaje del **sidebar unificado** (`rag-sidebar`): ejes de disposición /// (flota/fijo/autoesconde/un-dos-pasos), buscador y control. El host lo /// enruta a [`AllichayState::apply_sidebar`]. El `Activate` del rail NO llega /// aquí: la vista lo intercepta y lo convierte en [`AllichayMsg::SelectSection`] /// (selector fijo — el panel de la sección activa nunca colapsa). Sidebar(RagSidebarMsg), /// Se enfocó un campo de texto (para empezar a teclear sobre él). Focus(FieldPath), /// Se enfocó la celda `(row, col)` de un agregado (lista/tabla) en `path`. /// El host siembra el buffer con [`AllichayState::focus_cell`] pasándole el /// [`FieldValue`] actual del campo. FocusCell(FieldPath, usize, usize), /// Se enfocó el campo **hex** de un [`Control::ColorPicker`] en `path`. El /// host siembra el buffer con [`AllichayState::focus_hex`]. FocusHex(FieldPath), /// Un campo cambió de valor. El host lo aplica a su config y persiste. Change(FieldPath, FieldValue), /// El panel se desplazó: nuevo offset absoluto (ya clampeado). ScrollTo(f32), /// Un **evento de mouse** del campo `path` (posicionar caret / doble-click = /// palabra / triple = todo / arrastre = selección), emitido por el /// `text_input_view_full`. El host, ante un `Press` sobre un campo **no focado**, /// lo enfoca+siembra primero (así el PRIMER click llega al estado y el /// doble-click cuenta desde ahí), y luego aplica con [`AllichayState::apply_input`] /// (que usa el buffer focado). El `path` es para ese enfoque-al-Press. Input(FieldPath, TextInputEvent), /// Se eligió una acción del **menú de edición** (click derecho → cortar/ /// copiar/pegar/…). El host la aplica con [`AllichayState::apply_edit_action`] /// y persiste el cambio si lo hubo. EditAction(EditAction), /// Se cerró el menú de edición (click-fuera o Esc). El host llama a /// [`AllichayState::close_edit_menu`]. CloseEditMenu, /// Se clickeó el disparador de un **select** (`Control::Dropdown`): abre su /// menú anclado en `(x, y)` (esquina inferior-izquierda del disparador, coords /// absolutas) con ancho `w` (= el del disparador), o lo cierra si ya estaba /// abierto. El host llama a [`AllichayState::toggle_dropdown`]. ToggleDropdown(FieldPath, f32, f32, f32), /// Se clickeó el disparador (swatch) de un **color-picker** (`Control::ColorPicker`): /// abre su POPUP anclado en `(x, y)` (esquina inferior-izquierda del swatch) con /// ancho `w`, o lo cierra si ya estaba abierto. Así el picker NO ocupa la ventana /// inline. El host llama a [`AllichayState::toggle_colorpicker`]. ToggleColorPicker(FieldPath, f32, f32, f32), /// Arrastre sobre el cono HSV 3D del popup de color: `(d_yaw, d_pitch)` en /// radianes. El host llama a [`AllichayState::orbit_cone`]. ColorOrbit(f32, f32), } // ===================================================================== // Estado del módulo // ===================================================================== /// El estado que el host guarda en su modelo: qué diente está activo y los /// buffers de edición de los campos de texto (con su foco). #[derive(Debug, Clone, Default)] pub struct AllichayState { selected: usize, /// Buffers de los campos de texto, por clave de foco. Para un campo escalar /// la clave es su `FieldPath` serializado; para una celda de agregado es una /// clave compuesta (ver [`cell_key`]). Sólo existe entrada para lo que se /// está editando. inputs: BTreeMap, /// La clave del campo/celda de texto focado, si hay uno. focused: Option, /// Contexto de edición cuando lo focado es una celda de agregado: el campo, /// su valor base (la lista/tabla completa, fija durante la edición) y la /// coordenada. `None` cuando se edita un campo de texto escalar. edit_cell: Option, /// `true` cuando lo focado es el campo **hex** de un `ColorPicker` (la clave /// focada es el `FieldPath` del campo Color). Mutuamente excluyente con /// `edit_cell` y con la edición de texto escalar. edit_hex: bool, /// Desplazamiento vertical (px) del panel activo, si su contenido excede el /// viewport. Se reinicia al cambiar de diente. scroll: f32, /// Clipboard en memoria para copiar/cortar/pegar dentro del campo focado. clipboard: MemClipboard, /// Ancla (coords de ventana) del **menú de edición** abierto con click /// derecho sobre un campo, o `None` si está cerrado. El host lo monta en su /// `view_overlay` con [`edit_menu_overlay`]. `None` por [`Default`]. edit_menu: Option<(f32, f32)>, /// El **select desplegado** (`Control::Dropdown` abierto): su path + el /// **ancla** `(x, y)` (esquina inferior-izquierda del disparador, coords /// absolutas) + el **ancho** del disparador, para pintar el menú en /// `view_overlay` con el mismo ancho y saliendo de abajo. `None` = todos /// cerrados. Sólo uno a la vez; al elegir/cambiar se cierra. open_dropdown: Option<(FieldPath, f32, f32, f32)>, /// El **color-picker desplegado** (`Control::ColorPicker` abierto): su path + /// ancla `(x, y)` + ancho del swatch, para pintar el POPUP en `view_overlay` /// con [`colorpicker_overlay`]. `None` = cerrado. Sólo uno a la vez. open_colorpicker: Option<(FieldPath, f32, f32, f32)>, /// Órbita del cono HSV 3D del popup de color (la mantiene el estado para que /// el arrastre persista entre re-renders). cone_orbit: ConeOrbit, /// Estado del **sidebar unificado** (widget `rag-sidebar`): los cuatro ejes /// de disposición, el buscador y el diente abierto. En allichay es un /// **selector fijo**: el diente abierto sigue a [`Self::selected`] y nunca /// colapsa (lo re-fija [`Self::select`]/[`Self::apply_sidebar`] y la vista). /// Los hosts con su propio rail (wawa-panel) no tocan este campo: su /// [`RagSidebarState::search`] queda vacío, así que el cuerpo compartido /// (`schema_panel`) no filtra nada. pub sidebar: RagSidebarState, } /// Lo que el estado guarda mientras se teclea una celda de lista/tabla: con esto /// reconstruye el [`FieldValue`] entero y nuevo en cada tecla (protocolo "valor /// entero"), sin que el host tenga que conocer las coordenadas. #[derive(Debug, Clone)] struct EditCell { path: FieldPath, base: FieldValue, row: usize, col: usize, } /// La clave de buffer de una celda de agregado. Usa separadores que no aparecen /// en un `FieldPath` (sus segmentos van con `.`). fn cell_key(path: &FieldPath, row: usize, col: usize) -> String { format!("{path}\u{1}{row}\u{1}{col}") } impl AllichayState { /// Estado inicial: primera sección, sin foco. pub fn new() -> Self { Self::default() } /// Índice de la sección activa. pub fn selected(&self) -> usize { self.selected } /// Selecciona la sección `i`. Limpia el foco de texto, el scroll y el filtro /// del buscador (cambiar de diente arranca arriba, sin edición en curso), y /// re-fija el diente abierto del sidebar unificado en la sección elegida (el /// panel de la sección activa nunca colapsa). pub fn select(&mut self, i: usize) { self.selected = i; self.scroll = 0.0; self.sidebar.open = Some(i as u64); self.sidebar.selected = Some(i as u64); self.sidebar.search.clear(); self.blur(); } /// Enruta un [`RagSidebarMsg`] del sidebar unificado (ejes de disposición, /// buscador, control) al estado. El `Activate` del rail NO llega aquí: la /// vista lo intercepta como [`AllichayMsg::SelectSection`]. Tras reducir los /// ejes/buscador, re-fija el diente abierto en la sección seleccionada para /// que el panel **nunca colapse** (selector fijo). pub fn apply_sidebar(&mut self, msg: RagSidebarMsg) { self.sidebar.update(msg); self.sidebar.open = Some(self.selected as u64); self.sidebar.selected = Some(self.selected as u64); } /// Offset de scroll actual del panel (px). pub fn scroll(&self) -> f32 { self.scroll } /// Fija el offset de scroll (el valor ya viene clampeado por el renderer). pub fn set_scroll(&mut self, offset: f32) { self.scroll = offset; } /// Enfoca un campo de texto, sembrando su buffer con el valor actual `seed`. /// /// NO recibe si el campo es secreto, y es deliberado: el enmascarado lo /// garantiza el RENDER, que es el único que ve el [`Control`] del campo. Si /// dependiera de que cada host se acuerde de pedirlo acá, alcanzaría con /// que un panel nuevo lo olvidara para dejar una contraseña a la vista. pub fn focus(&mut self, path: &FieldPath, seed: &str) { let key = path.to_string(); let mut st = TextInputState::new(); st.set_text(seed); self.inputs.insert(key.clone(), st); self.focused = Some(key); self.edit_cell = None; self.edit_hex = false; } /// Enfoca el campo hex de un `ColorPicker`. `seed` es el hex actual /// (`#RRGGBB`). La clave focada es el `FieldPath` del campo Color. pub fn focus_hex(&mut self, path: &FieldPath, seed: &str) { let key = path.to_string(); let mut st = TextInputState::new(); st.set_text(seed); self.inputs.insert(key.clone(), st); self.focused = Some(key); self.edit_cell = None; self.edit_hex = true; } /// Enfoca la celda `(row, col)` de un agregado. El host le pasa el /// [`FieldValue`] actual del campo (`value`); el estado lee de ahí el texto /// inicial de la celda y guarda el valor como base para reconstruir el /// cambio entero en cada tecla. Si la coordenada no corresponde a una celda, /// no enfoca nada. pub fn focus_cell(&mut self, path: &FieldPath, value: FieldValue, row: usize, col: usize) { let Some(seed) = value.cell(row, col).map(str::to_string) else { return; }; let key = cell_key(path, row, col); let mut st = TextInputState::new(); st.set_text(&seed); self.inputs.insert(key.clone(), st); self.focused = Some(key); self.edit_cell = Some(EditCell { path: path.clone(), base: value, row, col, }); self.edit_hex = false; } /// Quita el foco de texto (sin descartar nada — el valor ya viajó por /// `Change` en cada tecla). pub fn blur(&mut self) { self.focused = None; self.edit_cell = None; self.edit_hex = false; self.inputs.clear(); } /// `true` si hay un campo de texto en edición (para que el host enrute las /// teclas a [`AllichayState::apply_key`] sólo cuando hace falta). pub fn is_editing(&self) -> bool { self.focused.is_some() } /// `true` si `path` es el campo de texto focado. pub fn is_focused(&self, path: &FieldPath) -> bool { self.focused.as_deref() == Some(path.to_string().as_str()) } /// `true` si la celda `(row, col)` de `path` es la focada. pub fn is_focused_cell(&self, path: &FieldPath, row: usize, col: usize) -> bool { self.focused.as_deref() == Some(cell_key(path, row, col).as_str()) } /// `true` si el campo hex de `path` (un `ColorPicker`) está en edición. pub fn is_editing_hex(&self, path: &FieldPath) -> bool { self.edit_hex && self.focused.as_deref() == Some(path.to_string().as_str()) } /// Enruta una tecla al campo/celda de texto focado. Devuelve el cambio /// resultante (`FieldPath`, [`FieldValue`]) si el contenido cambió, para que /// el host lo aplique y persista. Para un campo escalar es un /// [`FieldValue::Text`]; para una celda de agregado es el agregado entero /// con esa celda reemplazada. `None` si no hay foco o la tecla no editó. pub fn apply_key(&mut self, event: &KeyEvent) -> Option<(FieldPath, FieldValue)> { // Buscador del sidebar unificado con foco: las teclas editan el filtro // (no los campos). El módulo no recibe teclado propio, así que el host // rutea CADA tecla aquí mientras el panel está abierto; este atajo la // resuelve sin que el host tenga que conocer el buscador. No cambia // ningún campo → devuelve `None`. if self.sidebar.search_focused { self.apply_search_key(event); return None; } let key = self.focused.clone()?; let st = self.inputs.get_mut(&key)?; if !st.apply_key_with_clipboard(event, &mut self.clipboard) { return None; } let text = st.text(); self.change_from_focused(&key, text) } /// Aplica una tecla al **buscador** del sidebar unificado (cuando tiene el /// foco): Esc/Enter lo sueltan, Backspace borra, el resto inserta el texto /// tipeado. Espeja el ruteo canónico de agora/nakui. fn apply_search_key(&mut self, event: &KeyEvent) { if event.state != KeyState::Pressed { return; } match &event.key { Key::Named(NamedKey::Escape) | Key::Named(NamedKey::Enter) => { self.sidebar.search_focused = false; } Key::Named(NamedKey::Backspace) => { self.sidebar.search.pop(); } _ => { if let Some(t) = &event.text { if !t.is_empty() && !t.chars().any(char::is_control) { self.sidebar.search.push_str(t); } } } } } /// Arma el `(FieldPath, FieldValue)` del campo focado a partir de su texto /// actual — según sea celda de agregado, hex, o escalar. Compartido por /// [`Self::apply_key`], [`Self::apply_input`] y [`Self::apply_edit_action`] /// (un solo lugar decide la forma del cambio). El hex sólo emite si parsea. fn change_from_focused(&self, key: &str, text: String) -> Option<(FieldPath, FieldValue)> { if let Some(c) = &self.edit_cell { return Some((c.path.clone(), c.base.clone().with_cell(c.row, c.col, &text))); } if self.edit_hex { // El buffer es un hex; sólo emitimos cuando parsea a un color válido // (mientras se escribe a medias no se aplica, pero el buffer se // conserva). El alfa actual no está aquí → 255; el slider A lo ajusta. let path = FieldPath::from(key); return parse_hex(&text, 255).map(|rgba| (path, FieldValue::Color(rgba))); } Some((FieldPath::from(key), FieldValue::Text(text))) } /// Aplica un **evento de mouse** ([`AllichayMsg::Input`]) al campo focado: /// posiciona el caret / selecciona palabra (doble-click) / todo (triple) / /// arrastra la selección. Devuelve `Some((path, value))` sólo si el texto /// cambió (raro para gestos de mouse; el pegado va por `apply_key`); el resto /// muta la selección/caret y el host re-pinta igual. Requiere el campo focado /// (su buffer existe): el `text_input_view_full` sólo emite eventos cuando lo /// está. pub fn apply_input(&mut self, ev: TextInputEvent) -> Option<(FieldPath, FieldValue)> { // Click derecho: abre el menú de edición en `(x, y)` en vez de tocar el // texto. Requiere foco (la vista full sólo emite eventos focada; el host // enfoca al Press/Context de un campo no focado antes de llamar aquí). if let TextInputEvent::Context { x, y } = ev { if self.focused.is_some() { self.edit_menu = Some((x, y)); } return None; } // Usa el buffer FOCADO (`self.focused`), no un path: así vale igual para un // campo escalar, el hex, o una **celda** (cuya clave es `cell_key`). Espeja // a `apply_key`. let key = self.focused.clone()?; let st = self.inputs.get_mut(&key)?; let antes = st.text(); if !st.handle(ev, &mut self.clipboard) { return None; } let text = st.text(); // Los gestos de MOUSE (Press/Drag) mueven caret/selección pero NO cambian el // texto — `handle` devuelve `true` igual (hay que repintar el caret). Si // emitiéramos un `Change` con el mismo texto en cada Press, el host haría // route_change→persistir→re-sembrar el campo ENTRE clicks, reseteando el // contador de multi-click (era el bug del "doble-click a los 16 clicks"). if text == antes { return None; } self.change_from_focused(&key, text) } // ================================================================= // Menú de edición (click derecho sobre un campo) // ================================================================= /// Ancla (coords de ventana) del menú de edición abierto, o `None`. El host /// lo consulta en su `view_overlay` para montar [`edit_menu_overlay`]. pub fn edit_menu_anchor(&self) -> Option<(f32, f32)> { self.edit_menu } /// Cierra el menú de edición (click-fuera, Esc, o tras elegir una acción). pub fn close_edit_menu(&mut self) { self.edit_menu = None; } /// Abre/cierra el select (`Control::Dropdown`) en `path`, anclando su menú en /// `(x, y)` (inferior-izquierda del disparador) con ancho `w`. Uno a la vez. pub fn toggle_dropdown(&mut self, path: &FieldPath, x: f32, y: f32, w: f32) { if self.open_dropdown.as_ref().map(|(p, _, _, _)| p) == Some(path) { self.open_dropdown = None; } else { self.open_dropdown = Some((path.clone(), x, y, w)); } } /// `true` si el select en `path` está desplegado. pub fn is_dropdown_open(&self, path: &FieldPath) -> bool { self.open_dropdown.as_ref().map(|(p, _, _, _)| p) == Some(path) } /// El path + ancla `(x, y)` + ancho del select abierto, para su menú overlay. pub fn open_dropdown_anchor(&self) -> Option<(&FieldPath, f32, f32, f32)> { self.open_dropdown.as_ref().map(|(p, x, y, w)| (p, *x, *y, *w)) } /// Cierra cualquier select abierto (tras elegir una opción / click-fuera). pub fn close_dropdown(&mut self) { self.open_dropdown = None; } /// Abre/cierra el POPUP de color (`Control::ColorPicker`) en `path`, anclado en /// `(x, y)` (inferior-izquierda del swatch) con ancho `w`. Uno a la vez; abrir /// uno cierra cualquier select. Al abrir, siembra el color actual en recientes. pub fn toggle_colorpicker(&mut self, path: &FieldPath, x: f32, y: f32, w: f32) { if self.open_colorpicker.as_ref().map(|(p, _, _, _)| p) == Some(path) { self.open_colorpicker = None; } else { self.open_dropdown = None; self.open_colorpicker = Some((path.clone(), x, y, w)); } } /// `true` si el color-picker en `path` está desplegado. pub fn is_colorpicker_open(&self, path: &FieldPath) -> bool { self.open_colorpicker.as_ref().map(|(p, _, _, _)| p) == Some(path) } /// El path + ancla + ancho del color-picker abierto, para su popup overlay. pub fn open_colorpicker_anchor(&self) -> Option<(&FieldPath, f32, f32, f32)> { self.open_colorpicker.as_ref().map(|(p, x, y, w)| (p, *x, *y, *w)) } /// La órbita actual del cono HSV 3D del popup. pub fn cone_orbit(&self) -> ConeOrbit { self.cone_orbit } /// Aplica un arrastre `(d_yaw, d_pitch)` (rad) al cono HSV del popup. pub fn orbit_cone(&mut self, d_yaw: f32, d_pitch: f32) { self.cone_orbit.orbit(d_yaw, d_pitch); } /// Banderas de habilitación de los ítems del menú, derivadas del campo /// focado (sin selección → Cortar/Copiar grises; masked → sin Copiar; etc). /// [`EditFlags::default`] si no hay foco. pub fn edit_flags(&self) -> EditFlags { match self.focused.as_ref().and_then(|k| self.inputs.get(k)) { Some(st) => EditFlags::from_editor(st.editor(), st.is_masked()), None => EditFlags::default(), } } /// Aplica una [`EditAction`] del menú al campo focado (reusa el mismo /// `apply` del atajo de teclado, vía [`llimphi_widget_edit_menu::apply`]), /// cierra el menú y devuelve el cambio si el texto cambió (para persistir). /// Espeja [`Self::apply_key`]. pub fn apply_edit_action(&mut self, action: EditAction) -> Option<(FieldPath, FieldValue)> { self.edit_menu = None; let key = self.focused.clone()?; let st = self.inputs.get_mut(&key)?; let antes = st.text(); editmenu::apply(st.editor_mut(), action, &mut self.clipboard); let text = st.text(); if text == antes { return None; } self.change_from_focused(&key, text) } /// Acceso interno al buffer de un campo escalar focado para pintarlo. fn input(&self, path: &FieldPath) -> Option<&TextInputState> { self.inputs.get(&path.to_string()) } /// Acceso interno al buffer de una celda focada para pintarla. fn input_cell(&self, path: &FieldPath, row: usize, col: usize) -> Option<&TextInputState> { self.inputs.get(&cell_key(path, row, col)) } /// La celda focada `(row, col)` si pertenece a `path`; `None` si el foco está /// en otro campo o no hay foco de celda. Para pasarle al widget de tabla cuál /// celda de ESTE agregado está en edición. fn focused_cell_of(&self, path: &FieldPath) -> Option<(usize, usize)> { self.edit_cell .as_ref() .filter(|c| &c.path == path) .map(|c| (c.row, c.col)) } } // ===================================================================== // Vista // ===================================================================== /// Pinta el esquema completo con el **sidebar unificado** (`rag-sidebar`): un /// diente por sección del schema y, en el panel del diente activo, el cuerpo /// schema-driven de la sección seleccionada. El rail, el panel, el **header /// uniforme** (título de la sección), el **buscador** y el control de disposición /// los pinta UN solo widget — allichay no re-deriva ninguno. /// /// Es un **selector fijo**: clickear un diente selecciona su sección (nunca /// colapsa el panel). `on_msg` mapea los [`AllichayMsg`] del módulo al `Msg` del /// host; el host resuelve `SelectSection` con [`AllichayState::select`] y el /// resto de ejes/buscador con [`AllichayState::apply_sidebar`]. pub fn allichay_view( schema: &Schema, state: &AllichayState, theme: &Theme, on_msg: F, ) -> View where Msg: Clone + Send + Sync + 'static, F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static, { // Sin secciones: sólo el cuerpo vacío (no hay dientes que pintar). if schema.sections.is_empty() { return build_panel(schema, state, theme, on_msg); } let sel = state.selected.min(schema.sections.len() - 1); // Un diente por sección; el icono sale del glifo declarado en `Section.icon`. let teeth: Vec> = schema .sections .iter() .enumerate() .map(|(i, s)| { let glyph = s.icon.clone(); RagTooth::new( i as u64, s.title.clone(), Arc::new(move |size, color| tooth_icon(Some(glyph.clone()), size, color)), ) }) .collect(); // Cuerpo = los campos de la sección seleccionada (filtrados por el buscador). // El TÍTULO de la sección lo pone el cabezal uniforme del widget — el cuerpo // NO repite la franja-header (por eso ya no hay `section_head`). let body = build_panel(schema, state, theme, on_msg.clone()); // Selector FIJO: el diente abierto es SIEMPRE la sección seleccionada (nunca // colapsa). El resto de ejes (buscador, control, ancho, flota/fijo) salen del // estado real del sidebar. let mut sb = state.sidebar.clone(); sb.open = Some(sel as u64); sb.selected = Some(sel as u64); // ¿El buscador tiene coincidencias en la sección activa? (tiñe el control). let q = state.sidebar.search.trim().to_lowercase(); let sec = &schema.sections[sel]; let hits = !q.is_empty() && sec .fields .iter() .chain(sec.subsections.iter().flat_map(|s| s.fields.iter())) .any(|f| field_matches(f, &q)); let spec = RagSidebarView { teeth, body, accessory: None, search_hits: hits, t: 0.0, rail_w: RAIL_W, // Click en un diente = seleccionar la sección (nunca colapsa); el resto de // mensajes (ejes de disposición, buscador, control) van al estado. map: Arc::new(move |rm| match rm { RagSidebarMsg::Activate(id) => on_msg(AllichayMsg::SelectSection(id as usize)), other => on_msg(AllichayMsg::Sidebar(other)), }), on_drop: None, on_reorder: None, grow: None, }; rag_sidebar_view(&sb, spec, RagSide::Left, &RagSidebarPalette::from_theme(theme)) } /// Monta el **menú de edición** (cortar/copiar/pegar/deshacer/…) si está abierto /// (click derecho sobre un campo → [`AllichayState::edit_menu_anchor`]). El host /// lo devuelve **apilado** desde su `view_overlay`, encima del formulario. Los /// ítems se habilitan según el estado real del campo focado (sin selección → /// Cortar/Copiar grises; masked → sin Copiar). `viewport` = `(ancho, alto)` de /// la ventana para clampear el menú al borde. `on_msg` mapea /// [`AllichayMsg::EditAction`]/[`AllichayMsg::CloseEditMenu`] al `Msg` del host, /// que resuelve con [`AllichayState::apply_edit_action`]/[`AllichayState::close_edit_menu`]. /// Devuelve `None` cuando el menú está cerrado (el host apila lo que ya tenía). pub fn edit_menu_overlay( state: &AllichayState, theme: &Theme, viewport: (f32, f32), on_msg: F, ) -> Option> where Msg: Clone + 'static, F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static, { let anchor = state.edit_menu_anchor()?; let flags = state.edit_flags(); let act = on_msg.clone(); let spec = editmenu::edit_context_menu( anchor, viewport, theme, flags, move |a| act(AllichayMsg::EditAction(a)), on_msg(AllichayMsg::CloseEditMenu), ); Some(context_menu_view(spec)) } /// Pinta la configuración como un **modal centrado** (scrim + card), para /// **embeber** el panel dentro de una app con ventana propia (media, nada…): la /// app guarda un [`AllichayState`], abre el overlay con una tecla y lo pinta en /// su `view_overlay`, enrutando los [`AllichayMsg`] como siempre. Reusa /// `llimphi-widget-modal` (scrim + card + título + botón Cerrar) con /// [`allichay_view`] como cuerpo. `on_dismiss` se emite al clickear el scrim o /// el botón Cerrar (la app maneja Esc en su `on_key`). #[allow(clippy::too_many_arguments)] pub fn settings_overlay( title: impl Into, close_label: impl Into, schema: &Schema, state: &AllichayState, theme: &Theme, viewport: (f32, f32), on_msg: F, on_dismiss: Msg, ) -> View where Msg: Clone + Send + Sync + 'static, F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static, { let body = allichay_view(schema, state, theme, on_msg.clone()); let modal = modal_view(ModalSpec { title: title.into(), body, buttons: vec![ModalButton::cancel(close_label, on_dismiss.clone())], size: (640.0, 460.0), viewport, on_dismiss, palette: ModalPalette::from_theme(theme), body_scroll: None, }); // Menú del select abierto (si hay), ENCIMA del modal. match dropdown_overlay(schema, state, theme, viewport, on_msg) { Some(menu) => View::new(Style { size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, ..Default::default() }) .children(vec![modal, menu]), None => modal, } } /// Dibuja el icono de un diente. El glifo declarado en `Section.icon` se /// resuelve a un **ícono vectorial** de `llimphi-icons` (determinista en toda /// máquina); si el glifo no está en el catálogo, cae a texto. El color lo /// resuelve el widget según el estado activo/inactivo. fn tooth_icon(glyph: Option, size: f32, color: Color) -> View { let g = glyph.unwrap_or_else(|| "•".to_string()); View::new(Style { size: Size { width: length(size), height: length(size), }, align_items: Some(AlignItems::CENTER), justify_content: Some(JustifyContent::CENTER), ..Default::default() }) .children(vec![llimphi_icons::glyph_or_text_view(&g, size * 0.9, color, 1.8)]) } fn build_panel(schema: &Schema, state: &AllichayState, theme: &Theme, on_msg: F) -> View where Msg: Clone + Send + Sync + 'static, F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static, { if schema.sections.is_empty() { return View::new(panel_style()).text_aligned( "Sin configuración".to_string(), 13.0, theme.fg_muted, Alignment::Start, ); } let sel = state.selected.min(schema.sections.len() - 1); let section = &schema.sections[sel]; // La ruta base = id de la sección; los campos cuelgan de ahí. let base = FieldPath::empty().push(section.id.clone()); section_view(section, &base, state, theme, on_msg) } /// Pinta el panel de **una** sección: su encabezado, sus campos y sus /// subsecciones (un nivel), construyendo cada `FieldPath` a partir de `base` /// (la ruta que ya incluye el id de la sección — p. ej. `["mirada::teselado"]` /// cuando el host compone varias apps en un solo rail). /// /// Lo expone aparte de [`allichay_view`] para que un host con su propio rail /// (el panel de control central, que mezcla dientes de varias apps) reutilice /// el renderizado de campos sin el rail interno del módulo. pub fn section_view( section: &allichay::Section, base: &FieldPath, state: &AllichayState, theme: &Theme, on_msg: F, ) -> View where Msg: Clone + Send + Sync + 'static, F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static, { View::new(panel_style()).children(section_items(section, base, state, theme, on_msg)) } /// Los views de una sección (campos + subsecciones), sin el contenedor — para /// apilar varias secciones en un mismo panel ([`schema_panel`]). /// /// **Sin franja-header propia:** el título de la sección lo pone el cabezal /// uniforme del widget `rag-sidebar` (o el canvas del host); aquí sólo se foldea /// la descripción (`help`) como primera línea tenue del cuerpo. /// /// **Buscador real:** cuando [`RagSidebarState::search`] (en `state.sidebar`) /// tiene texto, se filtran los campos por su `label` (substring /// case-insensitive); una subsección sin campos coincidentes se omite entera. /// Los hosts con rail propio (wawa-panel) dejan ese buscador vacío → no filtra. fn section_items( section: &allichay::Section, base: &FieldPath, state: &AllichayState, theme: &Theme, on_msg: F, ) -> Vec> where Msg: Clone + Send + Sync + 'static, F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static, { let q = state.sidebar.search.trim().to_lowercase(); let mut children: Vec> = Vec::new(); // Descripción de la sección foldeada como primera línea tenue (sólo en // reposo; durante una búsqueda se omite para no separar los resultados). if q.is_empty() && !section.help.is_empty() { children.push(help_view(§ion.help, theme)); } for field in §ion.fields { if !field_matches(field, &q) { continue; } let path = base.clone().push(field.id.clone()); children.push(field_row(field, path, state, theme, on_msg.clone())); } for sub in §ion.subsections { let matching: Vec<&Field> = sub.fields.iter().filter(|f| field_matches(f, &q)).collect(); if matching.is_empty() { continue; } children.push(subsection_head(&sub.title, theme)); let sub_base = base.clone().push(sub.id.clone()); for field in matching { let path = sub_base.clone().push(field.id.clone()); children.push(field_row(field, path, state, theme, on_msg.clone())); } } children } /// `true` si el campo coincide con el filtro del buscador: query vacío = todos; /// si no, su `label` (case-insensitive) contiene la query. `query` ya viene en /// minúsculas y sin espacios de borde. fn field_matches(field: &Field, query: &str) -> bool { query.is_empty() || field.label.to_lowercase().contains(query) } /// Pinta el panel de un diente: **todas** las secciones de `schema` apiladas /// (cada una con su encabezado de grupo), con scroll vertical si el contenido /// excede `viewport_h`. Es el panel de un diente del panel de control — el id /// de cada sección es el prefijo de ruteo (`"app::seccion"`), así que cada /// campo emite su `FieldPath` completo. /// /// `state.scroll()` lleva el offset; el renderer emite [`AllichayMsg::ScrollTo`] /// con el offset ya clampeado para que el host sólo lo guarde. pub fn schema_panel( schema: &Schema, state: &AllichayState, theme: &Theme, viewport_h: f32, on_msg: F, ) -> View where Msg: Clone + Send + Sync + 'static, F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static, { let mut items: Vec> = Vec::new(); for section in &schema.sections { let base = FieldPath::empty().push(section.id.clone()); items.extend(section_items(section, &base, state, theme, on_msg.clone())); } let content = View::new(Style { flex_direction: FlexDirection::Column, size: Size { width: percent(1.0_f32), height: Dimension::auto(), }, gap: Size { width: length(0.0_f32), height: length(10.0_f32), }, ..Default::default() }) .children(items); let content_len = estimate_height(schema); let offset = state.scroll().min(content_len); let on_scroll = on_msg; let scroller = scroll_y( offset, content_len, viewport_h, content, move |delta| { on_scroll(AllichayMsg::ScrollTo(clamp_offset( offset + delta, content_len, viewport_h, ))) }, &ScrollPalette::from_theme(theme), ); View::new(panel_style()).children(vec![scroller]) } /// Como [`schema_panel`] pero con una **vista al pie** (`footer`, de alto `footer_h`) /// DEBAJO de los campos, todo en UN mismo scroll. Para poner un gráfico/editor /// interactivo bajo un formulario chico y que scrolleen juntos (p. ej. el editor de /// recorrido Prezi bajo su formulario). pub fn schema_panel_with_footer( schema: &Schema, footer: View, footer_h: f32, state: &AllichayState, theme: &Theme, viewport_h: f32, on_msg: F, ) -> View where Msg: Clone + Send + Sync + 'static, F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static, { let mut items: Vec> = Vec::new(); for section in &schema.sections { let base = FieldPath::empty().push(section.id.clone()); items.extend(section_items(section, &base, state, theme, on_msg.clone())); } // El pie va en un contenedor de alto fijo para que el scroll lo cuente entero. items.push( View::new(Style { size: Size { width: percent(1.0_f32), height: length(footer_h) }, flex_shrink: 0.0, ..Default::default() }) .children(vec![footer]), ); let content = View::new(Style { flex_direction: FlexDirection::Column, size: Size { width: percent(1.0_f32), height: Dimension::auto() }, gap: Size { width: length(0.0_f32), height: length(10.0_f32) }, ..Default::default() }) .children(items); let content_len = estimate_height(schema) + footer_h + 10.0; let offset = state.scroll().min(content_len); let on_scroll = on_msg; let scroller = scroll_y( offset, content_len, viewport_h, content, move |delta| { on_scroll(AllichayMsg::ScrollTo(clamp_offset(offset + delta, content_len, viewport_h))) }, &ScrollPalette::from_theme(theme), ); View::new(panel_style()).children(vec![scroller]) } /// Un **título editable** ligado a `path` (sin label ni chrome de campo), para /// usar como encabezado de un canvas — p. ej. el nombre de una instancia de /// perfil, arriba de sus tabs. Reusa el MISMO mecanismo de foco/edición que los /// campos (`AllichayState` + `text_input_view_full`): el host lo cablea con los /// mismos `AllichayMsg` (Focus/Input/Change) que ya maneja para el resto. `valor` /// es el texto actual (se muestra cuando el título no está focado). pub fn titulo_editable_view( path: FieldPath, valor: &str, state: &AllichayState, theme: &Theme, on_msg: F, ) -> View where Msg: Clone + Send + Sync + 'static, F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static, { // Input con **label flotante estilo Flutter** («Nombre» arriba del campo, que // sube al enfocar o cuando hay texto). Reusa el foco/edición de los campos. let palette = TextInputPalette::from_theme(theme); let focused = state.is_focused(&path); let mut tmp = TextInputState::new(); let st: &TextInputState = match (focused, state.input(&path)) { (true, Some(real)) => real, _ => { tmp.set_text(valor); &tmp } }; let float_t = float_target(focused, st); let ip = path.clone(); text_input_field_view(st, "Nombre", focused, float_t, &palette, move |ev| { on_msg(AllichayMsg::Input(ip.clone(), ev)) }) } /// Estimación (generosa) del alto del contenido de un schema, para el scroll. fn estimate_height(schema: &Schema) -> f32 { let mut h = 0.0_f32; for section in &schema.sections { h += 44.0; // encabezado de sección h += ayuda_h(§ion.help); for f in §ion.fields { h += field_height(f) + 13.0 + ayuda_h(&f.help); // + label/gap/separación } for sub in §ion.subsections { h += 26.0; // encabezado de subsección for f in &sub.fields { h += field_height(f) + 13.0 + ayuda_h(&f.help); } } } h + 28.0 // padding del panel } /// El alto que se lleva una línea de `help` — el mismo que fija [`help_view`]. /// /// **No contarla partía el scroll de una forma que no se veía**: `scroll_y` /// sólo pinta la barra `if max_offset > 0`, así que un panel subestimado se /// queda a la vez SIN barra y con el final inalcanzable — los dos síntomas de /// «el formulario sale cortado» salen de este único número faltante. Una /// sección con ayuda en cada campo perdía ~14 px por campo (126 en una de ocho), /// suficiente para que el panel se creyera más corto que su viewport. /// /// Se cuenta la ayuda de la sección siempre, aunque `section_items` la oculte /// cuando hay filtro: el estimate no conoce el filtro, y **sobrar** alto sólo /// deja un poco de scroll de más, mientras que faltar recorta contenido. fn ayuda_h(help: &str) -> f32 { if help.is_empty() { 0.0 } else { ALTO_AYUDA } } /// Alto fijo de una línea de ayuda. Vive acá para que [`help_view`] y /// [`estimate_height`] no puedan discrepar en silencio. const ALTO_AYUDA: f32 = 14.0; /// Alto del control de un campo (px). Para los agregados (lista/tabla) depende /// del número de filas, por eso toma el [`Field`] entero y no sólo su control. fn field_height(field: &Field) -> f32 { match &field.control { Control::Toggle => 22.0, Control::Slider { .. } => 28.0, // Select: sólo el disparador (1 fila). El menú se pinta como overlay // (`dropdown_overlay`), fuera del flujo — no ocupa alto del panel. Control::Dropdown { .. } => 34.0, // Radio: siempre lista vertical, una fila por opción. Control::Radio { options } => options.len().max(1) as f32 * 30.0, Control::TextInput | Control::Password => 34.0, // Color: sólo el swatch disparador (1 fila). El picker se pinta como POPUP // (`colorpicker_overlay`), fuera del flujo — no ocupa alto del panel. Control::ColorPicker => 28.0, // Chips + botones-ícono en una sola línea. Control::InlineActions { .. } => 28.0, // Chips de color: ~6 por línea a 26 px de paso. Control::Swatches { colors } => (colors.len().max(1).div_ceil(6)) as f32 * 26.0 + 2.0, // Multi-select: los chips (elegidos) + el «+» se envuelven; estimamos // ~3 por línea a 30px. Como mínimo una línea (sólo el disparador). Control::MultiSelect { .. } => { let n = field.value.as_list().map(|s| s.len()).unwrap_or(0) + 1; (n.div_ceil(3)).max(1) as f32 * 30.0 + 2.0 } Control::List { .. } => table_height(field.value.row_count(), false), Control::Table { .. } => table_height(field.value.row_count(), true), Control::Display => 8.0, // fila compacta sin label arriba Control::Button => 34.0, // fila compacta: el botón ES la fila } } fn panel_style() -> Style { Style { flex_direction: FlexDirection::Column, size: Size { width: Dimension::auto(), height: percent(1.0_f32), }, flex_grow: 1.0, padding: Rect { left: length(20.0_f32), right: length(20.0_f32), top: length(14.0_f32), bottom: length(14.0_f32), }, gap: Size { width: length(0.0_f32), height: length(10.0_f32), }, ..Default::default() } } // ===================================================================== // Encabezados y campos // ===================================================================== fn subsection_head(title: &str, theme: &Theme) -> View { View::new(Style { size: Size { width: percent(1.0_f32), height: length(20.0_f32), }, margin: Rect { left: length(0.0_f32), right: length(0.0_f32), top: length(6.0_f32), bottom: length(0.0_f32), }, ..Default::default() }) .text_aligned(title.to_string(), 12.5, theme.accent, Alignment::Start) } fn label_view(label: &str, theme: &Theme) -> View { View::new(Style { size: Size { width: percent(1.0_f32), height: length(16.0_f32), }, ..Default::default() }) .text_aligned(label.to_string(), 11.5, theme.fg_muted, Alignment::Start) } fn help_view(help: &str, theme: &Theme) -> View { View::new(Style { size: Size { width: percent(1.0_f32), height: length(ALTO_AYUDA), }, ..Default::default() }) .text_aligned(help.to_string(), 10.0, theme.fg_placeholder, Alignment::Start) } fn field_col_style() -> Style { Style { flex_direction: FlexDirection::Column, size: Size { width: percent(1.0_f32), height: Dimension::auto(), }, gap: Size { width: length(0.0_f32), height: length(3.0_f32), }, ..Default::default() } } /// Una fila de campo: rótulo arriba, control abajo, ayuda opcional. fn field_row( field: &Field, path: FieldPath, state: &AllichayState, theme: &Theme, on_msg: F, ) -> View where Msg: Clone + Send + Sync + 'static, F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static, { let control = match &field.control { Control::Toggle => toggle_control(field, path, theme, on_msg), Control::Slider { min, max, step } => { slider_control(field, path, *min, *max, *step, theme, on_msg) } Control::Dropdown { options } => dropdown_control(field, path, options, state, theme, on_msg), Control::Radio { options } => radio_group(field, path, options, theme, on_msg), Control::ColorPicker => color_control(field, path, state, theme, on_msg), Control::TextInput => text_control(field, path, state, theme, on_msg, false), Control::Password => text_control(field, path, state, theme, on_msg, true), Control::List { item_label } => { list_control(field, path, item_label, state, theme, on_msg) } Control::Table { columns } => table_control(field, path, columns, state, theme, on_msg), Control::InlineActions { chips, buttons } => { inline_actions_control(field, path, chips, buttons, theme, on_msg) } Control::MultiSelect { options } => { multiselect_control(field, path, options, state, theme, on_msg) } Control::Swatches { colors } => swatches_control(field, path, colors, theme, on_msg), Control::Display => return display_row(field, theme), // El botón ES la fila completa (lleva su rótulo dentro): no le ponemos // un label arriba como a los controles escalares. Control::Button => return button_control(field, path, theme, on_msg), }; // Regla estética del panel: los controles ESCALARES (opciones) van en DOS // COLUMNAS — título a la izquierda, control a la derecha. Los agregados // (lista/tabla), el color-picker (alto) y los ya-especiales (display/botón, // que retornaron antes) conservan el ancho completo. let dos_columnas = matches!( field.control, Control::Toggle | Control::Slider { .. } | Control::Dropdown { .. } | Control::Radio { .. } | Control::TextInput | Control::ColorPicker | Control::InlineActions { .. } | Control::Swatches { .. } ); if dos_columnas { let etiqueta = View::new(Style { size: Size { width: length(150.0_f32), height: Dimension::auto() }, flex_shrink: 0.0, ..Default::default() }) .text_aligned(field.label.clone(), 12.0, theme.fg_text, Alignment::Start); let control_col = View::new(Style { flex_grow: 1.0, size: Size { width: length(0.0_f32), height: Dimension::auto() }, ..Default::default() }) .children(vec![control]); let fila = View::new(Style { flex_direction: FlexDirection::Row, size: Size { width: percent(1.0_f32), height: Dimension::auto() }, align_items: Some(AlignItems::CENTER), gap: Size { width: length(14.0_f32), height: length(0.0_f32) }, ..Default::default() }) .children(vec![etiqueta, control_col]); if field.help.is_empty() { return fila; } return View::new(field_col_style()).children(vec![fila, help_view(&field.help, theme)]); } let mut kids = vec![label_view(&field.label, theme), control]; if !field.help.is_empty() { kids.push(help_view(&field.help, theme)); } View::new(field_col_style()).children(kids) } /// Fila de sólo lectura: rótulo a la izquierda, valor a la derecha. Para items /// de información del sistema (no editables). fn display_row(field: &Field, theme: &Theme) -> View { let label = View::new(Style { size: Size { width: length(150.0_f32), height: length(20.0_f32), }, flex_shrink: 0.0, ..Default::default() }) .text_aligned(field.label.clone(), 12.0, theme.fg_muted, Alignment::Start); let value = View::new(Style { size: Size { width: Dimension::auto(), height: length(20.0_f32), }, flex_grow: 1.0, ..Default::default() }) .text_aligned( field.value.as_str().unwrap_or("").to_string(), 12.5, theme.fg_text, Alignment::Start, ); View::new(Style { flex_direction: FlexDirection::Row, size: Size { width: percent(1.0_f32), height: length(22.0_f32), }, align_items: Some(AlignItems::CENTER), ..Default::default() }) .children(vec![label, value]) } // ===================================================================== // Controles por tipo // ===================================================================== fn toggle_control(field: &Field, path: FieldPath, theme: &Theme, on_msg: F) -> View where Msg: Clone + Send + Sync + 'static, F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static, { let cur = field.value.as_bool().unwrap_or(false); let progress = if cur { 1.0 } else { 0.0 }; let msg = on_msg(AllichayMsg::Change(path, FieldValue::Bool(!cur))); switch_view(progress, msg, &SwitchPalette::from_theme(theme)) } /// Botón de acción: una fila clickeable con el rótulo del campo. Al clickear /// emite `Change(path, Bool(true))` — un pulso. Sin estado on/off (a diferencia /// del [`toggle_control`], que era lo que antes simulaba las acciones). fn button_control(field: &Field, path: FieldPath, theme: &Theme, on_msg: F) -> View where Msg: Clone + Send + Sync + 'static, F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static, { let msg = on_msg(AllichayMsg::Change(path, FieldValue::Bool(true))); let lbl = View::new(Style { size: Size { width: percent(1.0_f32), height: length(28.0_f32) }, flex_grow: 1.0, align_items: Some(AlignItems::CENTER), justify_content: Some(JustifyContent::CENTER), ..Default::default() }) .text_aligned(field.label.clone(), 12.5, theme.fg_text, Alignment::Center) .ellipsis(1); View::new(Style { flex_direction: FlexDirection::Row, size: Size { width: percent(1.0_f32), height: length(30.0_f32) }, align_items: Some(AlignItems::CENTER), justify_content: Some(JustifyContent::CENTER), padding: Rect { left: length(10.0_f32), right: length(10.0_f32), top: length(0.0_f32), bottom: length(0.0_f32), }, ..Default::default() }) .radius(7.0) .fill(theme.bg_input) .border(1.0, theme.border) .hover_fill(theme.bg_row_hover) .on_click(msg) .children(vec![lbl]) } /// [`Control::InlineActions`]: chips seleccionables (el activo resaltado en acento) /// + botones-ícono de pulso, en una fila alineada a la derecha. Cada clic emite /// `Change(path, Enum(id))`. /// **La colección de colores** ([`Control::Swatches`]): un chip por color, el /// elegido con anillo. Un click elige — nada más. /// /// El anillo va por FUERA del color (un marco alrededor, no un borde encima): /// un borde comido al chip cambia cuánto color se ve, y con seis chips en fila /// el elegido se leía más chico en vez de más marcado. fn swatches_control( field: &Field, path: FieldPath, colors: &[String], theme: &Theme, on_msg: F, ) -> View where Msg: Clone + Send + Sync + 'static, F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static, { let cur = field.value.as_str().unwrap_or("").to_ascii_uppercase(); let mut kids: Vec> = Vec::new(); for hex in colors { let activo = hex.to_ascii_uppercase() == cur; let c = llimphi_widget_color_picker::parse_hex(hex, 255) .map(|[r, g, b, a]| Color::from_rgba8(r, g, b, a)) .unwrap_or(theme.bg_input); let msg = on_msg(AllichayMsg::Change(path.clone(), FieldValue::Enum(hex.clone()))); // Marco: el elegido lo lleva del color del texto (se ve sobre cualquier // fondo); el resto lo lleva transparente para no mover la geometría. let marco = View::new(Style { size: Size { width: length(24.0_f32), height: length(24.0_f32) }, align_items: Some(AlignItems::CENTER), justify_content: Some(JustifyContent::CENTER), flex_shrink: 0.0, ..Default::default() }) .radius(7.0) .fill(if activo { theme.fg_text } else { Color::TRANSPARENT }) .on_click(msg) .children(vec![ View::new(Style { size: Size { width: length(18.0_f32), height: length(18.0_f32) }, flex_shrink: 0.0, ..Default::default() }) .fill(c) .radius(5.0) // El borde tenue es lo que hace visible un chip casi del color del // fondo — sin él, un gris oscuro sobre el panel oscuro no existe. .border(1.0, theme.border), ]); kids.push(marco); } View::new(Style { flex_direction: FlexDirection::Row, flex_wrap: FlexWrap::Wrap, size: Size { width: percent(1.0_f32), height: Dimension::auto() }, align_items: Some(AlignItems::CENTER), gap: Size { width: length(2.0_f32), height: length(2.0_f32) }, ..Default::default() }) .children(kids) } fn inline_actions_control( field: &Field, path: FieldPath, chips: &[allichay::EnumOption], buttons: &[(String, String)], theme: &Theme, on_msg: F, ) -> View where Msg: Clone + Send + Sync + 'static, F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static, { let cur = field.value.as_str().unwrap_or(""); let mut kids: Vec> = Vec::new(); for c in chips { let activo = c.id == cur; let (fg, bg) = if activo { (theme.bg_panel, theme.accent) } else { (theme.fg_muted, theme.bg_panel_alt) }; let msg = on_msg(AllichayMsg::Change(path.clone(), FieldValue::Enum(c.id.clone()))); kids.push( View::new(Style { size: Size { width: Dimension::auto(), height: length(24.0_f32) }, align_items: Some(AlignItems::CENTER), justify_content: Some(JustifyContent::CENTER), flex_shrink: 0.0, padding: Rect { left: length(9.0_f32), right: length(9.0_f32), top: length(0.0_f32), bottom: length(0.0_f32), }, ..Default::default() }) .fill(bg) .radius(5.0) .hover_fill(theme.bg_row_hover) .on_click(msg) .text(c.label.clone(), 11.0, fg), ); } for (glifo, action) in buttons { let msg = on_msg(AllichayMsg::Change(path.clone(), FieldValue::Enum(action.clone()))); kids.push( View::new(Style { size: Size { width: length(26.0_f32), height: length(24.0_f32) }, align_items: Some(AlignItems::CENTER), justify_content: Some(JustifyContent::CENTER), flex_shrink: 0.0, ..Default::default() }) .fill(theme.bg_panel_alt) .radius(5.0) .hover_fill(theme.bg_button_hover) .on_click(msg) .text(glifo.clone(), 13.0, theme.fg_text), ); } View::new(Style { flex_direction: FlexDirection::Row, size: Size { width: percent(1.0_f32), height: length(26.0_f32) }, align_items: Some(AlignItems::CENTER), justify_content: Some(JustifyContent::END), gap: Size { width: length(4.0_f32), height: length(0.0_f32) }, ..Default::default() }) .children(kids) } fn slider_control( field: &Field, path: FieldPath, min: f64, max: f64, step: f64, theme: &Theme, on_msg: F, ) -> View where Msg: Clone + Send + Sync + 'static, F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static, { let is_int = matches!(field.value, FieldValue::Int(_)); let cur = field.value.as_float().unwrap_or(0.0); let palette = SliderPalette::from_theme(theme); slider_view( String::new(), cur as f32, min as f32, max as f32, &palette, move |phase, dv| match phase { DragPhase::Move => { let mut nv = (cur + dv as f64).clamp(min, max); if step > 0.0 { nv = (nv / step).round() * step; } let value = if is_int { FieldValue::Int(nv as i64) } else { FieldValue::Float(nv) }; Some(on_msg(AllichayMsg::Change(path.clone(), value))) } DragPhase::End => None, }, ) } /// **Multi-select con tags**: los ids elegidos van como *chips* (tag + ×) que se /// envuelven a varias líneas, y un disparador «+» despliega el menú de opciones /// (lo pinta [`dropdown_overlay`], que ya sabe togglear la pertenencia). La × de /// un chip emite `Change(path, List(resto))`. fn multiselect_control( field: &Field, path: FieldPath, options: &[allichay::EnumOption], state: &AllichayState, theme: &Theme, on_msg: F, ) -> View where Msg: Clone + Send + Sync + 'static, F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static, { let selected: Vec = field.value.as_list().map(|s| s.to_vec()).unwrap_or_default(); let label_of = |id: &str| { options.iter().find(|o| o.id == id).map(|o| o.label.clone()).unwrap_or_else(|| id.to_string()) }; let mut kids: Vec> = Vec::new(); for id in &selected { // La × quita este id de la lista (emite la lista sin él). let resto: Vec = selected.iter().filter(|x| *x != id).cloned().collect(); let rm = on_msg(AllichayMsg::Change(path.clone(), FieldValue::List(resto))); let equis = View::new(Style { size: Size { width: length(16.0_f32), height: length(18.0_f32) }, align_items: Some(AlignItems::CENTER), justify_content: Some(JustifyContent::CENTER), flex_shrink: 0.0, ..Default::default() }) .radius(4.0) .hover_fill(theme.bg_button_hover) .on_click(rm) .text_aligned("✕".to_string(), 10.0, theme.fg_muted, Alignment::Center); let chip = View::new(Style { flex_direction: FlexDirection::Row, size: Size { width: Dimension::auto(), height: length(24.0_f32) }, align_items: Some(AlignItems::CENTER), flex_shrink: 0.0, gap: Size { width: length(4.0_f32), height: length(0.0_f32) }, padding: Rect { left: length(9.0_f32), right: length(4.0_f32), top: length(0.0_f32), bottom: length(0.0_f32), }, ..Default::default() }) .fill(theme.bg_panel_alt) .radius(5.0) .children(vec![ View::new(Style { size: Size { width: Dimension::auto(), height: Dimension::auto() }, ..Default::default() }) .text(label_of(id), 11.0, theme.fg_text), equis, ]); kids.push(chip); } // Disparador «+ agregar…»: abre el menú (lo pinta `dropdown_overlay`). let open = state.is_dropdown_open(&path); let p = path.clone(); let trigger = select_trigger_view( None, "+ agregar…", open, Some(160.0), &SelectPalette::from_theme(theme), move |x, y, w, h| on_msg(AllichayMsg::ToggleDropdown(p.clone(), x, y + h, w)), ); kids.push(trigger); View::new(Style { flex_direction: FlexDirection::Row, flex_wrap: FlexWrap::Wrap, size: Size { width: percent(1.0_f32), height: Dimension::auto() }, align_items: Some(AlignItems::CENTER), gap: Size { width: length(6.0_f32), height: length(6.0_f32) }, ..Default::default() }) .children(kids) } fn dropdown_control( field: &Field, path: FieldPath, options: &[allichay::EnumOption], state: &AllichayState, theme: &Theme, on_msg: F, ) -> View where Msg: Clone + Send + Sync + 'static, F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static, { // SELECT desplegable de verdad: SÓLO el disparador (opción actual + chevron). // El menú lo pinta [`dropdown_overlay`] en la capa `view_overlay`, ANCLADO al // click y encima de todo (no inline, no empuja los campos de abajo). El click // del disparador manda las coords absolutas vía `on_click_screen`. let cur = field.value.as_str().unwrap_or(""); let open = state.is_dropdown_open(&path); let sel = options.iter().find(|o| o.id == cur).map(|o| SelectItem::new(o.label.clone())); let p = path; select_trigger_view( sel.as_ref(), "Elige…", open, None, &SelectPalette::from_theme(theme), // Ancla = esquina INFERIOR-izquierda del disparador (`y + h`); ancho = el // del disparador (`w`) → el menú sale de abajo y copia el ancho del input. move |x, y, w, h| on_msg(AllichayMsg::ToggleDropdown(p.clone(), x, y + h, w)), ) } /// **Dónde nace el menú de un select y cuán ancho es**, dados el borde izquierdo /// del disparador (`ax`) y su ancho (`w`). Devuelve `(x, ancho)`. /// /// El menú se alinea con SU botón. Con un disparador ancho —el de un campo de /// formulario— el menú le copia el ancho y calza exacto debajo. Con uno COMPACTO /// —los 38 px del combobox de una fila del árbol del sidebar de wawapanel, que no /// muestra ningún valor: es un chevron a secas— el menú es forzosamente más ancho /// que el botón, y anclado por la IZQUIERDA se derramaba hacia la derecha: nacía /// en el botón y terminaba sobre el canvas, sin leerse como «el menú de este /// botón». Cuando el menú es más ancho, se alinea por el borde **derecho**, que /// es el canto que los dos comparten. /// /// El piso del ancho también depende de qué tan compacto sea el disparador: uno /// que no muestra ningún valor no puede dictar el ancho del menú (no hay texto /// suyo con el que empatar), y con los 96 px de siempre «windows-xp» salía /// partido en dos renglones. Ahí el ancho lo pide el contenido del menú. pub fn caja_del_menu(ax: f32, w: f32) -> (f32, f32) { let piso = if w < 60.0 { 180.0 } else { 96.0 }; let ancho = w.max(piso); let x = if ancho > w { (ax + w - ancho).max(4.0) } else { ax }; (x, ancho) } /// El **menú desplegado** del select abierto, para apilarlo en `view_overlay` /// (encima de todo). `None` si no hay ninguno abierto. Busca el campo abierto en /// `schema` por su path (guardado en el estado con su ancla) y pinta su lista /// anclada. Elegir una opción manda `Change`; el click-fuera (scrim) la cierra. pub fn dropdown_overlay( schema: &Schema, state: &AllichayState, theme: &Theme, viewport: (f32, f32), on_msg: F, ) -> Option> where Msg: Clone + Send + Sync + 'static, F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static, { let (path, ax, ay, w) = state.open_dropdown_anchor()?; let field = find_dropdown_field(schema, path)?; // El menú lo abren tanto el Dropdown (elige uno) como el MultiSelect (togglea // pertenencia). Sacamos las opciones y qué está elegido según el control. let options = match &field.control { Control::Dropdown { options } | Control::MultiSelect { options } => options, _ => return None, }; let es_multi = matches!(field.control, Control::MultiSelect { .. }); let items: Vec = options.iter().map(|o| SelectItem::new(o.label.clone())).collect(); let visible: Vec = (0..items.len()).collect(); // Marcados: en Dropdown, la opción actual; en MultiSelect, todas las elegidas. let selected: Vec = if es_multi { let chosen = field.value.as_list().map(|s| s.to_vec()).unwrap_or_default(); options.iter().enumerate().filter(|(_, o)| chosen.contains(&o.id)).map(|(i, _)| i).collect() } else { let cur = field.value.as_str().unwrap_or(""); options.iter().position(|o| o.id == cur).into_iter().collect() }; let ids: Vec = options.iter().map(|o| o.id.clone()).collect(); let chosen_now: Vec = field.value.as_list().map(|s| s.to_vec()).unwrap_or_default(); let p_pick = path.clone(); let om = on_msg.clone(); let on_pick = std::sync::Arc::new(move |i: usize| { if es_multi { // Togglea el id en la lista y emite la lista nueva. let id = &ids[i]; let mut nueva = chosen_now.clone(); if let Some(pos) = nueva.iter().position(|x| x == id) { nueva.remove(pos); } else { nueva.push(id.clone()); } om(AllichayMsg::Change(p_pick.clone(), FieldValue::List(nueva))) } else { om(AllichayMsg::Change(p_pick.clone(), FieldValue::Enum(ids[i].clone()))) } }); let pal = SelectPalette::from_theme(theme); let (ax, ancho) = caja_del_menu(ax, w); let spec = SelectMenuSpec { anchor: (ax, ay), viewport, width: ancho, phase: SelectPhase::Ready(&items), visible: &visible, active: usize::MAX, selected: &selected, query: "", searchable: false, empty_text: "", appear: 1.0, on_pick, on_hover: None, on_dismiss: on_msg(AllichayMsg::ToggleDropdown(path.clone(), ax, ay, w)), on_retry: None, palette: &pal, }; // Sale de abajo del disparador: nace pegado a su borde y se desliza hacia // abajo (+ fade del enter). Key estable por path para no re-animar al re-render. let mut hasher = std::collections::hash_map::DefaultHasher::new(); std::hash::Hash::hash(&path.to_string(), &mut hasher); let key = std::hash::Hasher::finish(&hasher); Some(select_menu_view(spec).animated_enter_from( key, std::time::Duration::from_millis(150), Affine::translate((0.0, -10.0)), )) } /// Busca el `Field` de un `Control::Dropdown` por su path (`[section.id, /// field.id]`), recorriendo secciones y subsecciones. fn find_dropdown_field<'a>(schema: &'a Schema, path: &FieldPath) -> Option<&'a Field> { for sec in &schema.sections { for f in &sec.fields { if FieldPath(vec![sec.id.clone(), f.id.clone()]) == *path { return Some(f); } } for sub in &sec.subsections { for f in &sub.fields { if FieldPath(vec![sec.id.clone(), f.id.clone()]) == *path { return Some(f); } } } } None } /// Lista vertical de radios (un círculo por opción). Es el cuerpo de /// [`Control::Radio`] y la rama de muchas opciones del dropdown. Cada fila /// emite el mismo `Change` — sin overlay, sin estado, sin tocar el `update`. fn radio_group( field: &Field, path: FieldPath, options: &[allichay::EnumOption], theme: &Theme, on_msg: F, ) -> View where Msg: Clone + Send + Sync + 'static, F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static, { let cur = field.value.as_str().unwrap_or(""); let rows: Vec> = options .iter() .map(|o| { let selected = o.id == cur; let msg = on_msg(AllichayMsg::Change(path.clone(), FieldValue::Enum(o.id.clone()))); radio_row(&o.label, selected, msg, theme) }) .collect(); View::new(Style { flex_direction: FlexDirection::Column, size: Size { width: percent(1.0_f32), height: Dimension::auto(), }, gap: Size { width: length(0.0_f32), height: length(2.0_f32), }, ..Default::default() }) .children(rows) } /// Una fila de radio del dropdown largo: punto a la izquierda (relleno si está /// seleccionada) + rótulo. La fila entera es clickeable y resalta en hover; la /// seleccionada lleva fondo y rótulo en negrita. fn radio_row( label: &str, selected: bool, msg: Msg, theme: &Theme, ) -> View { let dot_inner = if selected { vec![View::new(Style { size: Size { width: length(8.0_f32), height: length(8.0_f32) }, ..Default::default() }) .radius(4.0) .fill(theme.accent)] } else { Vec::new() }; let dot = View::new(Style { size: Size { width: length(16.0_f32), height: length(16.0_f32) }, align_items: Some(AlignItems::CENTER), justify_content: Some(JustifyContent::CENTER), flex_shrink: 0.0, ..Default::default() }) .radius(8.0) .border(1.5, if selected { theme.accent } else { theme.border }) .children(dot_inner); let mut lbl = View::new(Style { size: Size { width: Dimension::auto(), height: length(20.0_f32) }, flex_grow: 1.0, ..Default::default() }) .text_aligned( label.to_string(), 12.5, if selected { theme.fg_text } else { theme.fg_muted }, Alignment::Start, ) .ellipsis(1); if selected { lbl = lbl.bold(); } let mut row = View::new(Style { flex_direction: FlexDirection::Row, size: Size { width: percent(1.0_f32), height: length(28.0_f32) }, align_items: Some(AlignItems::CENTER), gap: Size { width: length(8.0_f32), height: length(0.0_f32) }, padding: Rect { left: length(6.0_f32), right: length(6.0_f32), top: length(0.0_f32), bottom: length(0.0_f32), }, ..Default::default() }) .radius(6.0) .hover_fill(theme.bg_row_hover) .on_click(msg) .children(vec![dot, lbl]); if selected { row = row.fill(theme.bg_selected); } row } /// Sólo el **swatch disparador** del color (compacto): al clickearlo abre el /// POPUP anclado (`colorpicker_overlay`) en vez de pintar el picker inline /// ocupando la ventana. El swatch manda sus coords absolutas vía `on_click_screen` /// (igual que el disparador del select). fn color_control( field: &Field, path: FieldPath, _state: &AllichayState, theme: &Theme, on_msg: F, ) -> View where Msg: Clone + Send + Sync + 'static, F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static, { let cur = field.value.as_color().unwrap_or([0, 0, 0, 255]); let pal = ColorPopupPalette::from_theme(theme); let p = path; // Swatch 40×24 (mismo look que `color_trigger_view`) pero con coords: ancla el // popup a la esquina inferior-izquierda del swatch (`y + h`). View::new(Style { size: Size { width: length(40.0_f32), height: length(24.0_f32) }, flex_shrink: 0.0, ..Default::default() }) .fill(Color::from_rgba8(cur[0], cur[1], cur[2], cur[3])) .radius(5.0) .border(1.0, pal.border) .on_click_screen(move |x, y, w, h| Some(on_msg(AllichayMsg::ToggleColorPicker(p.clone(), x, y + h, w)))) } /// El POPUP de un color-picker abierto (`Control::ColorPicker`), para pintarlo en /// `view_overlay` ANCLADO al swatch y encima de todo (espeja [`dropdown_overlay`]). /// `None` si no hay ninguno abierto o el campo no está en `schema`. pub fn colorpicker_overlay( schema: &Schema, state: &AllichayState, theme: &Theme, viewport: (f32, f32), on_msg: F, ) -> Option> where Msg: Clone + Send + Sync + 'static, F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static, { let (path, ax, ay, w) = state.open_colorpicker_anchor()?; let field = find_color_field(schema, path)?; let cur = field.value.as_color().unwrap_or([0, 0, 0, 255]); let hex_on = on_msg.clone(); let hex_path = path.clone(); let hex = HexField { focused: state.is_editing_hex(path), state: state.input(path), on_focus: on_msg(AllichayMsg::FocusHex(path.clone())), on_input: std::sync::Arc::new(move |ev| hex_on(AllichayMsg::Input(hex_path.clone(), ev))), }; let change_path = path.clone(); let om = on_msg.clone(); let orbit_msg = on_msg.clone(); let dismiss = on_msg(AllichayMsg::ToggleColorPicker(path.clone(), ax, ay, w)); let pal = ColorPopupPalette::from_theme(theme); let spec = ColorPopupSpec { anchor: (ax, ay), viewport, rgba: cur, orbit: state.cone_orbit(), recent: &[], swatches: DEFAULT_SWATCHES, hex: Some(hex), palette: &pal, on_change: std::sync::Arc::new(move |rgba| { om(AllichayMsg::Change(change_path.clone(), FieldValue::Color(rgba))) }), on_orbit: std::sync::Arc::new(move |dy, dp| orbit_msg(AllichayMsg::ColorOrbit(dy, dp))), on_dismiss: dismiss, }; Some(color_popup_view(spec)) } /// Busca el campo `Control::ColorPicker` en `path` dentro del schema (reusa el /// mismo recorrido que el select, y filtra por tipo de control). fn find_color_field<'a>(schema: &'a Schema, path: &FieldPath) -> Option<&'a Field> { let field = find_dropdown_field(schema, path)?; matches!(field.control, Control::ColorPicker).then_some(field) } fn text_control( field: &Field, path: FieldPath, state: &AllichayState, theme: &Theme, on_msg: F, secreto: bool, ) -> View where Msg: Clone + Send + Sync + 'static, F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static, { let cur = field.value.as_str().unwrap_or(""); let palette = TextInputPalette::from_theme(theme); let focused = state.is_focused(&path); // Estado a pintar: el REAL si el campo está focado; si no, uno temporal sembrado // con el valor actual (sólo para MOSTRAR el texto y medir la x del click). // El enmascarado se decide ACÁ, al pintar, y no en `focus`: así vale para // cualquier estado, venga de donde venga, y ningún host puede olvidarlo. let mut tmp = if secreto { TextInputState::masked() } else { TextInputState::new() }; let secreto_focado; let st: &TextInputState = match (focused, state.input(&path)) { (true, Some(real)) if secreto => { // El estado real se clona sólo para ponerle la máscara. El `box_w` // viaja en un `Arc`, así que el scroll horizontal sigue siendo el // mismo; lo único que cambia es cómo se dibuja. secreto_focado = { let mut c = real.clone(); c.set_masked(true); c }; &secreto_focado } (true, Some(real)) => real, _ => { // Un campo secreto NO se siembra con su valor: ni siquiera los // puntitos, porque su largo ya dice algo. Vacío hasta que lo tipeen. if !secreto { tmp.set_text(cur); } &tmp } }; // SIEMPRE el camino COMPLETO (no el básico): el box emite un `Press` posicionado // desde el 1er click. El host, ante un `Press` sobre un campo no focado, lo // enfoca+siembra y recién aplica — así el PRIMER click llega al estado y el // doble-click (palabra) cuenta desde el toque uno. Teclas/copiar-pegar: apply_key. let ip = path.clone(); text_input_view_full(st, "", focused, &palette, move |ev| { on_msg(AllichayMsg::Input(ip.clone(), ev)) }) } // ===================================================================== // Agregados: lista y tabla // ===================================================================== /// Delega en `list_view` del widget agnóstico `llimphi-widget-table`. El foco de /// celda lo posee el estado ([`AllichayState::focused_cell_of`]/`input_cell`); /// cada alta/baja/edición emite el [`FieldValue::List`] entero (protocolo "valor /// entero"). fn list_control( field: &Field, path: FieldPath, item_label: &str, state: &AllichayState, theme: &Theme, on_msg: F, ) -> View where Msg: Clone + Send + Sync + 'static, F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static, { let items: Vec = field.value.as_list().map(<[String]>::to_vec).unwrap_or_default(); let value = field.value.clone(); let focused = state.focused_cell_of(&path); let focused_state = focused.and_then(|(r, c)| state.input_cell(&path, r, c)); let add_label = if item_label.is_empty() { "Agregar".to_string() } else { format!("+ {item_label}") }; let (p_focus, p_remove, p_add) = (path.clone(), path.clone(), path); let (m_focus, m_remove, m_add) = (on_msg.clone(), on_msg.clone(), on_msg); let v_remove = value.clone(); list_view( &items, focused.map(|(r, _)| r), focused_state, &add_label, &TablePalette::from_theme(theme), move |r| m_focus(AllichayMsg::FocusCell(p_focus.clone(), r, 0)), move |r| m_remove(AllichayMsg::Change(p_remove.clone(), v_remove.with_row_removed(r))), move || m_add(AllichayMsg::Change(p_add.clone(), value.with_row_pushed(1))), ) } /// Delega en `table_view` del widget agnóstico `llimphi-widget-table`, igual que /// [`list_control`] pero con encabezados por columna. Cada cambio emite el /// [`FieldValue::Table`] entero. fn table_control( field: &Field, path: FieldPath, columns: &[allichay::Column], state: &AllichayState, theme: &Theme, on_msg: F, ) -> View where Msg: Clone + Send + Sync + 'static, F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static, { let headers: Vec = columns.iter().map(|c| c.label.clone()).collect(); let rows: Vec> = field .value .as_table() .map(<[Vec]>::to_vec) .unwrap_or_default(); let ncols = columns.len(); let value = field.value.clone(); let focused = state.focused_cell_of(&path); let focused_state = focused.and_then(|(r, c)| state.input_cell(&path, r, c)); let (p_focus, p_remove, p_add) = (path.clone(), path.clone(), path); let (m_focus, m_remove, m_add) = (on_msg.clone(), on_msg.clone(), on_msg); let v_remove = value.clone(); table_view( &headers, &rows, focused, focused_state, "+ fila", &TablePalette::from_theme(theme), move |r, c| m_focus(AllichayMsg::FocusCell(p_focus.clone(), r, c)), move |r| m_remove(AllichayMsg::Change(p_remove.clone(), v_remove.with_row_removed(r))), move || m_add(AllichayMsg::Change(p_add.clone(), value.with_row_pushed(ncols))), ) } // ===================================================================== // Tests del estado del renderizador // ===================================================================== // // Cubren la lógica con estado de `AllichayState` (foco de campo escalar, de // celda de agregado y de hex; reconstrucción del `FieldValue` entero en cada // tecla; selección de diente que reinicia scroll + foco). El vocabulario en sí // (`Schema`/`Field`/`FieldValue`) se testea en el crate `allichay`. #[cfg(test)] mod pruebas_de_alto { use super::*; use allichay::Section; fn seccion(con_ayuda: bool) -> Schema { let mut s = Section::new("s", "Sección"); if con_ayuda { s = s.help("qué hace esta sección"); } for i in 0..8 { let f = Field::toggle(format!("t{i}"), format!("Perilla {i}"), false); s = s.field(if con_ayuda { f.help("qué prende esta perilla") } else { f }); } Schema { sections: vec![s] } } /// La ayuda **se pinta y por lo tanto se mide**. Antes no se contaba, y como /// `scroll_y` sólo dibuja la barra si hay desborde, un panel subestimado /// quedaba sin barra y con el final inalcanzable a la vez. #[test] fn la_ayuda_de_cada_campo_entra_en_el_alto() { let con = estimate_height(&seccion(true)); let sin = estimate_height(&seccion(false)); // 8 campos + la propia sección = 9 líneas de ayuda. assert_eq!(con - sin, 9.0 * ALTO_AYUDA, "con={con} sin={sin}"); } /// Que el número no se despegue del que pinta `help_view`: si alguien cambia /// el alto de la línea, tiene que cambiar en los dos lados a la vez. #[test] fn el_alto_de_ayuda_es_uno_solo() { assert_eq!(ayuda_h("algo"), ALTO_AYUDA); assert_eq!(ayuda_h(""), 0.0); } } #[cfg(test)] mod tests { use super::*; use llimphi_compositor::{Key, KeyState, Modifiers}; /// Un evento de tecleo de un carácter (con su `text`, que es de donde el /// editor inserta — respeta IME + modifiers). fn evtext(s: &str) -> KeyEvent { KeyEvent { key: Key::Character(s.into()), state: KeyState::Pressed, text: Some(s.to_owned()), modifiers: Modifiers::default(), repeat: false, } } /// **El menú y su botón comparten un canto.** Un disparador ancho manda el /// ancho y el menú calza exacto debajo (mismo borde izquierdo); uno compacto /// —el chevron a secas de una fila del árbol del sidebar— no puede mandarlo, /// así que el menú es más ancho y se alinea por el borde DERECHO. #[test] fn el_menu_comparte_un_canto_con_su_boton() { // Campo de formulario: 136 px de disparador en x=511. let (x, ancho) = caja_del_menu(511.0, 136.0); assert_eq!((x, ancho), (511.0, 136.0), "el menú le copia ancho y borde izquierdo"); // Combobox compacto del árbol: 38 px en x=202 (medido en la captura del // sidebar de wawapanel). El menú es más ancho, así que empata por la derecha. let (x, ancho) = caja_del_menu(202.0, 38.0); assert_eq!(ancho, 180.0, "un chevron a secas no dicta el ancho del menú"); assert_eq!(x + ancho, 202.0 + 38.0, "mismo borde derecho que el botón"); // Contra el borde izquierdo de la ventana el menú no se sale de la pantalla. let (x, _) = caja_del_menu(10.0, 38.0); assert!(x >= 4.0, "no se va de la pantalla por la izquierda: {x}"); } /// Un evento de tecla nombrada (Backspace/Enter/Escape…), sin texto. fn evkey(k: NamedKey) -> KeyEvent { KeyEvent { key: Key::Named(k), state: KeyState::Pressed, text: None, modifiers: Modifiers::default(), repeat: false, } } #[test] fn buscador_enfocado_las_teclas_editan_el_filtro() { let mut st = AllichayState::new(); st.sidebar.search_focused = true; // Con el buscador enfocado las teclas editan el filtro, NO los campos. assert_eq!(st.apply_key(&evtext("h")), None); st.apply_key(&evtext("o")); st.apply_key(&evtext("l")); st.apply_key(&evtext("a")); assert_eq!(st.sidebar.search, "hola"); st.apply_key(&evkey(NamedKey::Backspace)); assert_eq!(st.sidebar.search, "hol"); st.apply_key(&evkey(NamedKey::Enter)); assert!(!st.sidebar.search_focused, "Enter suelta el buscador"); } #[test] fn apply_sidebar_mantiene_diente_abierto_en_la_seccion_activa() { let mut st = AllichayState::new(); st.select(2); assert_eq!(st.sidebar.open, Some(2)); // Un mensaje del sidebar (cambiar un eje) NO colapsa el panel: el diente // abierto se re-fija en la sección seleccionada (selector fijo). st.apply_sidebar(RagSidebarMsg::SetDocked(false)); assert_eq!(st.sidebar.open, Some(2), "el panel nunca colapsa"); assert_eq!(st.sidebar.selected, Some(2)); assert!(!st.sidebar.docked, "el eje sí se aplicó"); } #[test] fn field_matches_filtra_por_label_case_insensitive() { let f = Field::toggle("k", "Modo Oscuro", true); assert!(field_matches(&f, ""), "sin query pasa todo"); assert!(field_matches(&f, "oscuro")); assert!(field_matches(&f, "modo")); assert!(!field_matches(&f, "idioma")); } #[test] fn select_reinicia_scroll_y_foco() { let mut st = AllichayState::new(); st.set_scroll(120.0); st.focus(&"a.b".into(), "x"); assert!(st.is_editing()); st.select(3); assert_eq!(st.selected(), 3); assert_eq!(st.scroll(), 0.0); assert!(!st.is_editing(), "cambiar de diente debe soltar el foco"); } #[test] fn focus_escalar_y_blur() { let mut st = AllichayState::new(); let path: FieldPath = "editor.nombre".into(); st.focus(&path, "hola"); assert!(st.is_focused(&path)); assert!(!st.is_focused(&"editor.otro".into())); st.blur(); assert!(!st.is_editing()); assert!(!st.is_focused(&path)); } #[test] fn apply_key_campo_escalar_emite_texto_entero() { let mut st = AllichayState::new(); let path: FieldPath = "editor.nombre".into(); st.focus(&path, "abc"); // el caret queda al final let r = st.apply_key(&evtext("d")); assert_eq!(r, Some((path, FieldValue::Text("abcd".into())))); } #[test] fn apply_key_sin_foco_no_emite() { let mut st = AllichayState::new(); assert_eq!(st.apply_key(&evtext("x")), None); } /// PRUEBA del doble-click: dos `Press` sobre el MISMO estado (como los rutea el /// host tras el enfoque-al-primer-Press) seleccionan la palabra. Antes fallaba /// porque el 1er click caía en el camino básico (sólo foco) y el estado sólo veía /// el segundo. Con el campo SIEMPRE-full + focus-on-press, ambos clicks llegan. #[test] fn apply_input_doble_press_selecciona_palabra() { let mut st = AllichayState::new(); let path: FieldPath = "editor.nombre".into(); // El host, ante el 1er Press sobre un campo no focado, enfoca+siembra: st.focus(&path, "hola mundo"); // x dentro de "mundo" (col ~7; char_w = 13*0.5 = 6.5; x = 11 + 7*6.5). let x = 11.0 + 7.0 * 6.5; // Un Press NO cambia el texto → NO debe emitir Change (si no, el host // persistiría y re-sembraría el campo entre clicks, reseteando el contador). assert_eq!(st.apply_input(TextInputEvent::Press(x)), None, "click 1: sin Change"); assert_eq!(st.apply_input(TextInputEvent::Press(x)), None, "click 2: sin Change"); let buf = st.input(&path).expect("buffer focado existe"); assert_eq!( buf.editor().selected_text().as_deref(), Some("mundo"), "el doble-click debe seleccionar la palabra bajo el cursor" ); } #[test] fn menu_edicion_abre_con_context_y_aplica_accion() { let mut st = AllichayState::new(); let path: FieldPath = "editor.nombre".into(); st.focus(&path, "hola mundo"); // Sin menú al empezar. assert_eq!(st.edit_menu_anchor(), None); // Click derecho abre el menú en las coords dadas (no cambia el texto). assert_eq!(st.apply_input(TextInputEvent::Context { x: 40.0, y: 12.0 }), None); assert_eq!(st.edit_menu_anchor(), Some((40.0, 12.0))); // Seleccionar todo desde el menú (no cambia el texto → sin Change) y cierra. assert_eq!(st.apply_edit_action(EditAction::SelectAll), None); assert_eq!(st.edit_menu_anchor(), None, "aplicar una acción cierra el menú"); let buf = st.input(&path).expect("buffer focado"); assert_eq!(buf.editor().selected_text().as_deref(), Some("hola mundo")); // Cortar borra la selección → emite Change con el texto vacío. let cambio = st.apply_edit_action(EditAction::Cut); assert_eq!(cambio, Some((path.clone(), FieldValue::Text(String::new())))); } #[test] fn context_sin_foco_no_abre_menu() { // La vista full sólo emite eventos focada, pero por robustez: sin foco el // Context no arma el menú (no habría campo sobre el que operar). let mut st = AllichayState::new(); assert_eq!(st.apply_input(TextInputEvent::Context { x: 5.0, y: 5.0 }), None); assert_eq!(st.edit_menu_anchor(), None); } #[test] fn close_edit_menu_cierra() { let mut st = AllichayState::new(); let path: FieldPath = "editor.nombre".into(); st.focus(&path, "hola"); st.apply_input(TextInputEvent::Context { x: 1.0, y: 2.0 }); assert!(st.edit_menu_anchor().is_some()); st.close_edit_menu(); assert_eq!(st.edit_menu_anchor(), None); } #[test] fn focus_cell_lista_reconstruye_el_agregado() { let mut st = AllichayState::new(); let path: FieldPath = "rutas".into(); let base = FieldValue::List(vec!["a".into(), "b".into()]); st.focus_cell(&path, base, 0, 0); // siembra "a", caret al final assert!(st.is_focused_cell(&path, 0, 0)); assert!(!st.is_focused_cell(&path, 1, 0)); let r = st.apply_key(&evtext("x")); assert_eq!( r, Some((path, FieldValue::List(vec!["ax".into(), "b".into()]))) ); } #[test] fn focus_cell_tabla_solo_toca_su_celda() { let mut st = AllichayState::new(); let path: FieldPath = "menu".into(); let base = FieldValue::Table(vec![ vec!["A".into(), "a".into()], vec!["B".into(), "b".into()], ]); st.focus_cell(&path, base, 1, 1); // siembra "b" let r = st.apply_key(&evtext("z")); assert_eq!( r, Some(( path, FieldValue::Table(vec![ vec!["A".into(), "a".into()], vec!["B".into(), "bz".into()], ]) )) ); } #[test] fn focus_cell_fuera_de_rango_no_enfoca() { let mut st = AllichayState::new(); let path: FieldPath = "rutas".into(); let base = FieldValue::List(vec!["a".into()]); st.focus_cell(&path, base, 5, 0); // coordenada inexistente assert!(!st.is_editing(), "no debe enfocar una celda fuera de rango"); } #[test] fn apply_key_hex_emite_color_solo_si_parsea() { let mut st = AllichayState::new(); let path: FieldPath = "apariencia.acento".into(); // Semilla de 5 dígitos: incompleta, no parsea todavía. st.focus_hex(&path, "#11223"); assert!(st.is_editing_hex(&path)); // Al completar el 6º dígito el hex parsea → emite el Color. let r = st.apply_key(&evtext("3")); assert_eq!(r, Some((path, FieldValue::Color([0x11, 0x22, 0x33, 255])))); } #[test] fn focos_son_mutuamente_excluyentes() { let mut st = AllichayState::new(); let path: FieldPath = "a.b".into(); st.focus_cell(&path, FieldValue::List(vec!["v".into()]), 0, 0); assert!(st.is_focused_cell(&path, 0, 0)); // Pasar a editar el hex de otro campo descarta el foco de celda. let hex_path: FieldPath = "a.color".into(); st.focus_hex(&hex_path, "#000000"); assert!(st.is_editing_hex(&hex_path)); assert!(!st.is_focused_cell(&path, 0, 0)); } }