tawasuyu

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 660Markdown 302TOML 157RON 312382 Definitions

⎇ main modules/allichay/src/lib.rs Rust · 2399 lines · 94.8 KB History Who touched what raw

chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual d8edd9a9c · Sergio ·

1//! `llimphi-module-allichay` — el renderizador único de la configuración
2//! declarativa.
3//!
4//! Toma un [`allichay::Schema`] y lo pinta con el **sidebar unificado**
5//! (`llimphi-widget-rag-sidebar`): un diente por sección top-level y, en el panel
6//! del diente activo, controles escalares para los campos (toggle, slider,
7//! dropdown, color, texto). El rail, el panel, el header uniforme (título de la
8//! sección), el buscador y el control de disposición los pinta UN solo widget —
9//! allichay no re-deriva chrome. Los cambios salen como un [`AllichayMsg`] que el
10//! host mapea a su propio `Msg`.
11//!
12//! El cuerpo schema-driven ([`section_view`]/[`schema_panel`]) queda expuesto
13//! aparte para hosts que ya tienen su propio rail (p. ej. `wawa-panel`): esos
14//! reutilizan el renderizado de campos sin el sidebar interno.
15//!
16//! Es un módulo al estilo de `command-palette`: aporta `state + Msg + apply_key
17//! + view`. El host:
18//!
19//! 1. guarda un [`AllichayState`] en su modelo;
20//! 2. llama [`allichay_view`] en su `view`, mapeando [`AllichayMsg`] → su `Msg`;
21//! 3. enruta las teclas a [`AllichayState::apply_key`] cuando hay un campo de
22//!    texto focado;
23//! 4. ante un [`AllichayMsg::Change`], aplica el `(FieldPath, FieldValue)` a su
24//!    config (vía [`allichay::Configurable::apply`]) y la persiste con su propio
25//!    `save()` — allichay no toca el disco.
26//!
27//! Subsecciones: en v1 se pintan como grupos con encabezado dentro del panel de
28//! la sección activa (un solo nivel de dientes). El panel central combina las
29//! apps en un esquema donde cada app es una sección-diente y sus secciones
30//! reales bajan a subsecciones — así un mismo rail navega "apps → secciones".
31
32#![forbid(unsafe_code)]
33
34use allichay::{Control, Field, FieldPath, FieldValue, Schema};
35
36use llimphi_compositor::llimphi_layout::taffy::{
37    prelude::{length, percent, Dimension, FlexDirection, Size, Style},
38    AlignItems, FlexWrap, JustifyContent, Rect,
39};
40use llimphi_compositor::peniko::kurbo::Affine;
41use llimphi_compositor::peniko::Color;
42use llimphi_compositor::llimphi_text::Alignment;
43use llimphi_compositor::{DragPhase, Key, KeyEvent, KeyState, NamedKey, View};
44use llimphi_theme::Theme;
45
46use std::sync::Arc;
47
48use llimphi_widget_color_picker::{
49    color_popup_view, parse_hex, ColorPopupPalette, ColorPopupSpec, ConeOrbit, HexField,
50    DEFAULT_SWATCHES,
51};
52
53/// Re-exportado para que los hosts siembren el buffer del campo hex sin duplicar
54/// el formato `#RRGGBB` (lo usan al manejar [`AllichayMsg::FocusHex`]).
55pub use llimphi_widget_color_picker::rgba_to_hex as color_hex;
56use llimphi_widget_modal::{modal_view, ModalButton, ModalPalette, ModalSpec};
57use llimphi_widget_rag_sidebar::{
58    rag_sidebar_view, RagSidebarPalette, RagSidebarView, RagSide, RagTooth,
59};
60/// Re-exportados para que el host enrute [`AllichayMsg::Sidebar`] y guarde el
61/// estado del sidebar unificado sin depender del crate del widget a mano.
62pub use llimphi_widget_rag_sidebar::{RagSidebarMsg, RagSidebarState};
63use llimphi_widget_scroll::{clamp_offset, scroll_y, ScrollPalette};
64use llimphi_widget_select::{
65    select_menu_view, select_trigger_view, SelectItem, SelectMenuSpec, SelectPalette, SelectPhase,
66};
67use llimphi_widget_slider::{slider_view, SliderPalette};
68use llimphi_widget_switch::{switch_view, SwitchPalette};
69use llimphi_widget_table::{list_view, table_view, table_height, TablePalette};
70use llimphi_widget_text_input::{
71    float_target, text_input_field_view, text_input_view_full, MemClipboard, TextInputEvent,
72    TextInputPalette, TextInputState,
73};
74use llimphi_widget_edit_menu::{self as editmenu, EditFlags};
75pub use llimphi_widget_edit_menu::EditAction;
76use llimphi_widget_context_menu::context_menu_view;
77
78use std::collections::BTreeMap;
79
80/// Ancho del rail de dientes (px). El diente es la pestañita que sobresale: un
81/// icono, no un rótulo (el nombre lo lleva el encabezado del panel desplegado).
82const RAIL_W: f32 = 52.0;
83
84// =====================================================================
85// Mensajes del módulo
86// =====================================================================
87
88/// Lo que emite el renderizador. El host lo envuelve en su propio `Msg`
89/// (típicamente `Msg::Allichay(AllichayMsg)`) y lo resuelve en su `update`.
90// Sin `PartialEq`: `Input` lleva un `TextInputEvent` (con `KeyEvent`, que no es
91// comparable). No se compara `AllichayMsg` en ningún lado; los `Msg` de los hosts
92// solo derivan `Clone`.
93#[derive(Debug, Clone)]
94pub enum AllichayMsg {
95    /// Se clickeó el diente de la sección `índice`.
96    SelectSection(usize),
97    /// Mensaje del **sidebar unificado** (`rag-sidebar`): ejes de disposición
98    /// (flota/fijo/autoesconde/un-dos-pasos), buscador y control. El host lo
99    /// enruta a [`AllichayState::apply_sidebar`]. El `Activate` del rail NO llega
100    /// aquí: la vista lo intercepta y lo convierte en [`AllichayMsg::SelectSection`]
101    /// (selector fijo — el panel de la sección activa nunca colapsa).
102    Sidebar(RagSidebarMsg),
103    /// Se enfocó un campo de texto (para empezar a teclear sobre él).
104    Focus(FieldPath),
105    /// Se enfocó la celda `(row, col)` de un agregado (lista/tabla) en `path`.
106    /// El host siembra el buffer con [`AllichayState::focus_cell`] pasándole el
107    /// [`FieldValue`] actual del campo.
108    FocusCell(FieldPath, usize, usize),
109    /// Se enfocó el campo **hex** de un [`Control::ColorPicker`] en `path`. El
110    /// host siembra el buffer con [`AllichayState::focus_hex`].
111    FocusHex(FieldPath),
112    /// Un campo cambió de valor. El host lo aplica a su config y persiste.
113    Change(FieldPath, FieldValue),
114    /// El panel se desplazó: nuevo offset absoluto (ya clampeado).
115    ScrollTo(f32),
116    /// Un **evento de mouse** del campo `path` (posicionar caret / doble-click =
117    /// palabra / triple = todo / arrastre = selección), emitido por el
118    /// `text_input_view_full`. El host, ante un `Press` sobre un campo **no focado**,
119    /// lo enfoca+siembra primero (así el PRIMER click llega al estado y el
120    /// doble-click cuenta desde ahí), y luego aplica con [`AllichayState::apply_input`]
121    /// (que usa el buffer focado). El `path` es para ese enfoque-al-Press.
122    Input(FieldPath, TextInputEvent),
123    /// Se eligió una acción del **menú de edición** (click derecho → cortar/
124    /// copiar/pegar/…). El host la aplica con [`AllichayState::apply_edit_action`]
125    /// y persiste el cambio si lo hubo.
126    EditAction(EditAction),
127    /// Se cerró el menú de edición (click-fuera o Esc). El host llama a
128    /// [`AllichayState::close_edit_menu`].
129    CloseEditMenu,
130    /// Se clickeó el disparador de un **select** (`Control::Dropdown`): abre su
131    /// menú anclado en `(x, y)` (esquina inferior-izquierda del disparador, coords
132    /// absolutas) con ancho `w` (= el del disparador), o lo cierra si ya estaba
133    /// abierto. El host llama a [`AllichayState::toggle_dropdown`].
134    ToggleDropdown(FieldPath, f32, f32, f32),
135    /// Se clickeó el disparador (swatch) de un **color-picker** (`Control::ColorPicker`):
136    /// abre su POPUP anclado en `(x, y)` (esquina inferior-izquierda del swatch) con
137    /// ancho `w`, o lo cierra si ya estaba abierto. Así el picker NO ocupa la ventana
138    /// inline. El host llama a [`AllichayState::toggle_colorpicker`].
139    ToggleColorPicker(FieldPath, f32, f32, f32),
140    /// Arrastre sobre el cono HSV 3D del popup de color: `(d_yaw, d_pitch)` en
141    /// radianes. El host llama a [`AllichayState::orbit_cone`].
142    ColorOrbit(f32, f32),
143}
144
145// =====================================================================
146// Estado del módulo
147// =====================================================================
148
149/// El estado que el host guarda en su modelo: qué diente está activo y los
150/// buffers de edición de los campos de texto (con su foco).
151#[derive(Debug, Clone, Default)]
152pub struct AllichayState {
153    selected: usize,
154    /// Buffers de los campos de texto, por clave de foco. Para un campo escalar
155    /// la clave es su `FieldPath` serializado; para una celda de agregado es una
156    /// clave compuesta (ver [`cell_key`]). Sólo existe entrada para lo que se
157    /// está editando.
158    inputs: BTreeMap<String, TextInputState>,
159    /// La clave del campo/celda de texto focado, si hay uno.
160    focused: Option<String>,
161    /// Contexto de edición cuando lo focado es una celda de agregado: el campo,
162    /// su valor base (la lista/tabla completa, fija durante la edición) y la
163    /// coordenada. `None` cuando se edita un campo de texto escalar.
164    edit_cell: Option<EditCell>,
165    /// `true` cuando lo focado es el campo **hex** de un `ColorPicker` (la clave
166    /// focada es el `FieldPath` del campo Color). Mutuamente excluyente con
167    /// `edit_cell` y con la edición de texto escalar.
168    edit_hex: bool,
169    /// Desplazamiento vertical (px) del panel activo, si su contenido excede el
170    /// viewport. Se reinicia al cambiar de diente.
171    scroll: f32,
172    /// Clipboard en memoria para copiar/cortar/pegar dentro del campo focado.
173    clipboard: MemClipboard,
174    /// Ancla (coords de ventana) del **menú de edición** abierto con click
175    /// derecho sobre un campo, o `None` si está cerrado. El host lo monta en su
176    /// `view_overlay` con [`edit_menu_overlay`]. `None` por [`Default`].
177    edit_menu: Option<(f32, f32)>,
178    /// El **select desplegado** (`Control::Dropdown` abierto): su path + el
179    /// **ancla** `(x, y)` (esquina inferior-izquierda del disparador, coords
180    /// absolutas) + el **ancho** del disparador, para pintar el menú en
181    /// `view_overlay` con el mismo ancho y saliendo de abajo. `None` = todos
182    /// cerrados. Sólo uno a la vez; al elegir/cambiar se cierra.
183    open_dropdown: Option<(FieldPath, f32, f32, f32)>,
184    /// El **color-picker desplegado** (`Control::ColorPicker` abierto): su path +
185    /// ancla `(x, y)` + ancho del swatch, para pintar el POPUP en `view_overlay`
186    /// con [`colorpicker_overlay`]. `None` = cerrado. Sólo uno a la vez.
187    open_colorpicker: Option<(FieldPath, f32, f32, f32)>,
188    /// Órbita del cono HSV 3D del popup de color (la mantiene el estado para que
189    /// el arrastre persista entre re-renders).
190    cone_orbit: ConeOrbit,
191    /// Estado del **sidebar unificado** (widget `rag-sidebar`): los cuatro ejes
192    /// de disposición, el buscador y el diente abierto. En allichay es un
193    /// **selector fijo**: el diente abierto sigue a [`Self::selected`] y nunca
194    /// colapsa (lo re-fija [`Self::select`]/[`Self::apply_sidebar`] y la vista).
195    /// Los hosts con su propio rail (wawa-panel) no tocan este campo: su
196    /// [`RagSidebarState::search`] queda vacío, así que el cuerpo compartido
197    /// (`schema_panel`) no filtra nada.
198    pub sidebar: RagSidebarState,
199}
200
201/// Lo que el estado guarda mientras se teclea una celda de lista/tabla: con esto
202/// reconstruye el [`FieldValue`] entero y nuevo en cada tecla (protocolo "valor
203/// entero"), sin que el host tenga que conocer las coordenadas.
204#[derive(Debug, Clone)]
205struct EditCell {
206    path: FieldPath,
207    base: FieldValue,
208    row: usize,
209    col: usize,
210}
211
212/// La clave de buffer de una celda de agregado. Usa separadores que no aparecen
213/// en un `FieldPath` (sus segmentos van con `.`).
214fn cell_key(path: &FieldPath, row: usize, col: usize) -> String {
215    format!("{path}\u{1}{row}\u{1}{col}")
216}
217
218impl AllichayState {
219    /// Estado inicial: primera sección, sin foco.
220    pub fn new() -> Self {
221        Self::default()
222    }
223
224    /// Índice de la sección activa.
225    pub fn selected(&self) -> usize {
226        self.selected
227    }
228
229    /// Selecciona la sección `i`. Limpia el foco de texto, el scroll y el filtro
230    /// del buscador (cambiar de diente arranca arriba, sin edición en curso), y
231    /// re-fija el diente abierto del sidebar unificado en la sección elegida (el
232    /// panel de la sección activa nunca colapsa).
233    pub fn select(&mut self, i: usize) {
234        self.selected = i;
235        self.scroll = 0.0;
236        self.sidebar.open = Some(i as u64);
237        self.sidebar.selected = Some(i as u64);
238        self.sidebar.search.clear();
239        self.blur();
240    }
241
242    /// Enruta un [`RagSidebarMsg`] del sidebar unificado (ejes de disposición,
243    /// buscador, control) al estado. El `Activate` del rail NO llega aquí: la
244    /// vista lo intercepta como [`AllichayMsg::SelectSection`]. Tras reducir los
245    /// ejes/buscador, re-fija el diente abierto en la sección seleccionada para
246    /// que el panel **nunca colapse** (selector fijo).
247    pub fn apply_sidebar(&mut self, msg: RagSidebarMsg) {
248        self.sidebar.update(msg);
249        self.sidebar.open = Some(self.selected as u64);
250        self.sidebar.selected = Some(self.selected as u64);
251    }
252
253    /// Offset de scroll actual del panel (px).
254    pub fn scroll(&self) -> f32 {
255        self.scroll
256    }
257
258    /// Fija el offset de scroll (el valor ya viene clampeado por el renderer).
259    pub fn set_scroll(&mut self, offset: f32) {
260        self.scroll = offset;
261    }
262
263    /// Enfoca un campo de texto, sembrando su buffer con el valor actual `seed`.
264    ///
265    /// NO recibe si el campo es secreto, y es deliberado: el enmascarado lo
266    /// garantiza el RENDER, que es el único que ve el [`Control`] del campo. Si
267    /// dependiera de que cada host se acuerde de pedirlo acá, alcanzaría con
268    /// que un panel nuevo lo olvidara para dejar una contraseña a la vista.
269    pub fn focus(&mut self, path: &FieldPath, seed: &str) {
270        let key = path.to_string();
271        let mut st = TextInputState::new();
272        st.set_text(seed);
273        self.inputs.insert(key.clone(), st);
274        self.focused = Some(key);
275        self.edit_cell = None;
276        self.edit_hex = false;
277    }
278
279    /// Enfoca el campo hex de un `ColorPicker`. `seed` es el hex actual
280    /// (`#RRGGBB`). La clave focada es el `FieldPath` del campo Color.
281    pub fn focus_hex(&mut self, path: &FieldPath, seed: &str) {
282        let key = path.to_string();
283        let mut st = TextInputState::new();
284        st.set_text(seed);
285        self.inputs.insert(key.clone(), st);
286        self.focused = Some(key);
287        self.edit_cell = None;
288        self.edit_hex = true;
289    }
290
291    /// Enfoca la celda `(row, col)` de un agregado. El host le pasa el
292    /// [`FieldValue`] actual del campo (`value`); el estado lee de ahí el texto
293    /// inicial de la celda y guarda el valor como base para reconstruir el
294    /// cambio entero en cada tecla. Si la coordenada no corresponde a una celda,
295    /// no enfoca nada.
296    pub fn focus_cell(&mut self, path: &FieldPath, value: FieldValue, row: usize, col: usize) {
297        let Some(seed) = value.cell(row, col).map(str::to_string) else {
298            return;
299        };
300        let key = cell_key(path, row, col);
301        let mut st = TextInputState::new();
302        st.set_text(&seed);
303        self.inputs.insert(key.clone(), st);
304        self.focused = Some(key);
305        self.edit_cell = Some(EditCell {
306            path: path.clone(),
307            base: value,
308            row,
309            col,
310        });
311        self.edit_hex = false;
312    }
313
314    /// Quita el foco de texto (sin descartar nada — el valor ya viajó por
315    /// `Change` en cada tecla).
316    pub fn blur(&mut self) {
317        self.focused = None;
318        self.edit_cell = None;
319        self.edit_hex = false;
320        self.inputs.clear();
321    }
322
323    /// `true` si hay un campo de texto en edición (para que el host enrute las
324    /// teclas a [`AllichayState::apply_key`] sólo cuando hace falta).
325    pub fn is_editing(&self) -> bool {
326        self.focused.is_some()
327    }
328
329    /// `true` si `path` es el campo de texto focado.
330    pub fn is_focused(&self, path: &FieldPath) -> bool {
331        self.focused.as_deref() == Some(path.to_string().as_str())
332    }
333
334    /// `true` si la celda `(row, col)` de `path` es la focada.
335    pub fn is_focused_cell(&self, path: &FieldPath, row: usize, col: usize) -> bool {
336        self.focused.as_deref() == Some(cell_key(path, row, col).as_str())
337    }
338
339    /// `true` si el campo hex de `path` (un `ColorPicker`) está en edición.
340    pub fn is_editing_hex(&self, path: &FieldPath) -> bool {
341        self.edit_hex && self.focused.as_deref() == Some(path.to_string().as_str())
342    }
343
344    /// Enruta una tecla al campo/celda de texto focado. Devuelve el cambio
345    /// resultante (`FieldPath`, [`FieldValue`]) si el contenido cambió, para que
346    /// el host lo aplique y persista. Para un campo escalar es un
347    /// [`FieldValue::Text`]; para una celda de agregado es el agregado entero
348    /// con esa celda reemplazada. `None` si no hay foco o la tecla no editó.
349    pub fn apply_key(&mut self, event: &KeyEvent) -> Option<(FieldPath, FieldValue)> {
350        // Buscador del sidebar unificado con foco: las teclas editan el filtro
351        // (no los campos). El módulo no recibe teclado propio, así que el host
352        // rutea CADA tecla aquí mientras el panel está abierto; este atajo la
353        // resuelve sin que el host tenga que conocer el buscador. No cambia
354        // ningún campo → devuelve `None`.
355        if self.sidebar.search_focused {
356            self.apply_search_key(event);
357            return None;
358        }
359        let key = self.focused.clone()?;
360        let st = self.inputs.get_mut(&key)?;
361        if !st.apply_key_with_clipboard(event, &mut self.clipboard) {
362            return None;
363        }
364        let text = st.text();
365        self.change_from_focused(&key, text)
366    }
367
368    /// Aplica una tecla al **buscador** del sidebar unificado (cuando tiene el
369    /// foco): Esc/Enter lo sueltan, Backspace borra, el resto inserta el texto
370    /// tipeado. Espeja el ruteo canónico de agora/nakui.
371    fn apply_search_key(&mut self, event: &KeyEvent) {
372        if event.state != KeyState::Pressed {
373            return;
374        }
375        match &event.key {
376            Key::Named(NamedKey::Escape) | Key::Named(NamedKey::Enter) => {
377                self.sidebar.search_focused = false;
378            }
379            Key::Named(NamedKey::Backspace) => {
380                self.sidebar.search.pop();
381            }
382            _ => {
383                if let Some(t) = &event.text {
384                    if !t.is_empty() && !t.chars().any(char::is_control) {
385                        self.sidebar.search.push_str(t);
386                    }
387                }
388            }
389        }
390    }
391
392    /// Arma el `(FieldPath, FieldValue)` del campo focado a partir de su texto
393    /// actual — según sea celda de agregado, hex, o escalar. Compartido por
394    /// [`Self::apply_key`], [`Self::apply_input`] y [`Self::apply_edit_action`]
395    /// (un solo lugar decide la forma del cambio). El hex sólo emite si parsea.
396    fn change_from_focused(&self, key: &str, text: String) -> Option<(FieldPath, FieldValue)> {
397        if let Some(c) = &self.edit_cell {
398            return Some((c.path.clone(), c.base.clone().with_cell(c.row, c.col, &text)));
399        }
400        if self.edit_hex {
401            // El buffer es un hex; sólo emitimos cuando parsea a un color válido
402            // (mientras se escribe a medias no se aplica, pero el buffer se
403            // conserva). El alfa actual no está aquí → 255; el slider A lo ajusta.
404            let path = FieldPath::from(key);
405            return parse_hex(&text, 255).map(|rgba| (path, FieldValue::Color(rgba)));
406        }
407        Some((FieldPath::from(key), FieldValue::Text(text)))
408    }
409
410    /// Aplica un **evento de mouse** ([`AllichayMsg::Input`]) al campo focado:
411    /// posiciona el caret / selecciona palabra (doble-click) / todo (triple) /
412    /// arrastra la selección. Devuelve `Some((path, value))` sólo si el texto
413    /// cambió (raro para gestos de mouse; el pegado va por `apply_key`); el resto
414    /// muta la selección/caret y el host re-pinta igual. Requiere el campo focado
415    /// (su buffer existe): el `text_input_view_full` sólo emite eventos cuando lo
416    /// está.
417    pub fn apply_input(&mut self, ev: TextInputEvent) -> Option<(FieldPath, FieldValue)> {
418        // Click derecho: abre el menú de edición en `(x, y)` en vez de tocar el
419        // texto. Requiere foco (la vista full sólo emite eventos focada; el host
420        // enfoca al Press/Context de un campo no focado antes de llamar aquí).
421        if let TextInputEvent::Context { x, y } = ev {
422            if self.focused.is_some() {
423                self.edit_menu = Some((x, y));
424            }
425            return None;
426        }
427        // Usa el buffer FOCADO (`self.focused`), no un path: así vale igual para un
428        // campo escalar, el hex, o una **celda** (cuya clave es `cell_key`). Espeja
429        // a `apply_key`.
430        let key = self.focused.clone()?;
431        let st = self.inputs.get_mut(&key)?;
432        let antes = st.text();
433        if !st.handle(ev, &mut self.clipboard) {
434            return None;
435        }
436        let text = st.text();
437        // Los gestos de MOUSE (Press/Drag) mueven caret/selección pero NO cambian el
438        // texto — `handle` devuelve `true` igual (hay que repintar el caret). Si
439        // emitiéramos un `Change` con el mismo texto en cada Press, el host haría
440        // route_change→persistir→re-sembrar el campo ENTRE clicks, reseteando el
441        // contador de multi-click (era el bug del "doble-click a los 16 clicks").
442        if text == antes {
443            return None;
444        }
445        self.change_from_focused(&key, text)
446    }
447
448    // =================================================================
449    // Menú de edición (click derecho sobre un campo)
450    // =================================================================
451
452    /// Ancla (coords de ventana) del menú de edición abierto, o `None`. El host
453    /// lo consulta en su `view_overlay` para montar [`edit_menu_overlay`].
454    pub fn edit_menu_anchor(&self) -> Option<(f32, f32)> {
455        self.edit_menu
456    }
457
458    /// Cierra el menú de edición (click-fuera, Esc, o tras elegir una acción).
459    pub fn close_edit_menu(&mut self) {
460        self.edit_menu = None;
461    }
462
463    /// Abre/cierra el select (`Control::Dropdown`) en `path`, anclando su menú en
464    /// `(x, y)` (inferior-izquierda del disparador) con ancho `w`. Uno a la vez.
465    pub fn toggle_dropdown(&mut self, path: &FieldPath, x: f32, y: f32, w: f32) {
466        if self.open_dropdown.as_ref().map(|(p, _, _, _)| p) == Some(path) {
467            self.open_dropdown = None;
468        } else {
469            self.open_dropdown = Some((path.clone(), x, y, w));
470        }
471    }
472
473    /// `true` si el select en `path` está desplegado.
474    pub fn is_dropdown_open(&self, path: &FieldPath) -> bool {
475        self.open_dropdown.as_ref().map(|(p, _, _, _)| p) == Some(path)
476    }
477
478    /// El path + ancla `(x, y)` + ancho del select abierto, para su menú overlay.
479    pub fn open_dropdown_anchor(&self) -> Option<(&FieldPath, f32, f32, f32)> {
480        self.open_dropdown.as_ref().map(|(p, x, y, w)| (p, *x, *y, *w))
481    }
482
483    /// Cierra cualquier select abierto (tras elegir una opción / click-fuera).
484    pub fn close_dropdown(&mut self) {
485        self.open_dropdown = None;
486    }
487
488    /// Abre/cierra el POPUP de color (`Control::ColorPicker`) en `path`, anclado en
489    /// `(x, y)` (inferior-izquierda del swatch) con ancho `w`. Uno a la vez; abrir
490    /// uno cierra cualquier select. Al abrir, siembra el color actual en recientes.
491    pub fn toggle_colorpicker(&mut self, path: &FieldPath, x: f32, y: f32, w: f32) {
492        if self.open_colorpicker.as_ref().map(|(p, _, _, _)| p) == Some(path) {
493            self.open_colorpicker = None;
494        } else {
495            self.open_dropdown = None;
496            self.open_colorpicker = Some((path.clone(), x, y, w));
497        }
498    }
499
500    /// `true` si el color-picker en `path` está desplegado.
501    pub fn is_colorpicker_open(&self, path: &FieldPath) -> bool {
502        self.open_colorpicker.as_ref().map(|(p, _, _, _)| p) == Some(path)
503    }
504
505    /// El path + ancla + ancho del color-picker abierto, para su popup overlay.
506    pub fn open_colorpicker_anchor(&self) -> Option<(&FieldPath, f32, f32, f32)> {
507        self.open_colorpicker.as_ref().map(|(p, x, y, w)| (p, *x, *y, *w))
508    }
509
510    /// La órbita actual del cono HSV 3D del popup.
511    pub fn cone_orbit(&self) -> ConeOrbit {
512        self.cone_orbit
513    }
514
515    /// Aplica un arrastre `(d_yaw, d_pitch)` (rad) al cono HSV del popup.
516    pub fn orbit_cone(&mut self, d_yaw: f32, d_pitch: f32) {
517        self.cone_orbit.orbit(d_yaw, d_pitch);
518    }
519
520    /// Banderas de habilitación de los ítems del menú, derivadas del campo
521    /// focado (sin selección → Cortar/Copiar grises; masked → sin Copiar; etc).
522    /// [`EditFlags::default`] si no hay foco.
523    pub fn edit_flags(&self) -> EditFlags {
524        match self.focused.as_ref().and_then(|k| self.inputs.get(k)) {
525            Some(st) => EditFlags::from_editor(st.editor(), st.is_masked()),
526            None => EditFlags::default(),
527        }
528    }
529
530    /// Aplica una [`EditAction`] del menú al campo focado (reusa el mismo
531    /// `apply` del atajo de teclado, vía [`llimphi_widget_edit_menu::apply`]),
532    /// cierra el menú y devuelve el cambio si el texto cambió (para persistir).
533    /// Espeja [`Self::apply_key`].
534    pub fn apply_edit_action(&mut self, action: EditAction) -> Option<(FieldPath, FieldValue)> {
535        self.edit_menu = None;
536        let key = self.focused.clone()?;
537        let st = self.inputs.get_mut(&key)?;
538        let antes = st.text();
539        editmenu::apply(st.editor_mut(), action, &mut self.clipboard);
540        let text = st.text();
541        if text == antes {
542            return None;
543        }
544        self.change_from_focused(&key, text)
545    }
546
547    /// Acceso interno al buffer de un campo escalar focado para pintarlo.
548    fn input(&self, path: &FieldPath) -> Option<&TextInputState> {
549        self.inputs.get(&path.to_string())
550    }
551
552    /// Acceso interno al buffer de una celda focada para pintarla.
553    fn input_cell(&self, path: &FieldPath, row: usize, col: usize) -> Option<&TextInputState> {
554        self.inputs.get(&cell_key(path, row, col))
555    }
556
557    /// La celda focada `(row, col)` si pertenece a `path`; `None` si el foco está
558    /// en otro campo o no hay foco de celda. Para pasarle al widget de tabla cuál
559    /// celda de ESTE agregado está en edición.
560    fn focused_cell_of(&self, path: &FieldPath) -> Option<(usize, usize)> {
561        self.edit_cell
562            .as_ref()
563            .filter(|c| &c.path == path)
564            .map(|c| (c.row, c.col))
565    }
566}
567
568// =====================================================================
569// Vista
570// =====================================================================
571
572/// Pinta el esquema completo con el **sidebar unificado** (`rag-sidebar`): un
573/// diente por sección del schema y, en el panel del diente activo, el cuerpo
574/// schema-driven de la sección seleccionada. El rail, el panel, el **header
575/// uniforme** (título de la sección), el **buscador** y el control de disposición
576/// los pinta UN solo widget — allichay no re-deriva ninguno.
577///
578/// Es un **selector fijo**: clickear un diente selecciona su sección (nunca
579/// colapsa el panel). `on_msg` mapea los [`AllichayMsg`] del módulo al `Msg` del
580/// host; el host resuelve `SelectSection` con [`AllichayState::select`] y el
581/// resto de ejes/buscador con [`AllichayState::apply_sidebar`].
582pub fn allichay_view<Msg, F>(
583    schema: &Schema,
584    state: &AllichayState,
585    theme: &Theme,
586    on_msg: F,
587) -> View<Msg>
588where
589    Msg: Clone + Send + Sync + 'static,
590    F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static,
591{
592    // Sin secciones: sólo el cuerpo vacío (no hay dientes que pintar).
593    if schema.sections.is_empty() {
594        return build_panel(schema, state, theme, on_msg);
595    }
596    let sel = state.selected.min(schema.sections.len() - 1);
597
598    // Un diente por sección; el icono sale del glifo declarado en `Section.icon`.
599    let teeth: Vec<RagTooth<Msg>> = schema
600        .sections
601        .iter()
602        .enumerate()
603        .map(|(i, s)| {
604            let glyph = s.icon.clone();
605            RagTooth::new(
606                i as u64,
607                s.title.clone(),
608                Arc::new(move |size, color| tooth_icon(Some(glyph.clone()), size, color)),
609            )
610        })
611        .collect();
612
613    // Cuerpo = los campos de la sección seleccionada (filtrados por el buscador).
614    // El TÍTULO de la sección lo pone el cabezal uniforme del widget — el cuerpo
615    // NO repite la franja-header (por eso ya no hay `section_head`).
616    let body = build_panel(schema, state, theme, on_msg.clone());
617
618    // Selector FIJO: el diente abierto es SIEMPRE la sección seleccionada (nunca
619    // colapsa). El resto de ejes (buscador, control, ancho, flota/fijo) salen del
620    // estado real del sidebar.
621    let mut sb = state.sidebar.clone();
622    sb.open = Some(sel as u64);
623    sb.selected = Some(sel as u64);
624
625    // ¿El buscador tiene coincidencias en la sección activa? (tiñe el control).
626    let q = state.sidebar.search.trim().to_lowercase();
627    let sec = &schema.sections[sel];
628    let hits = !q.is_empty()
629        && sec
630            .fields
631            .iter()
632            .chain(sec.subsections.iter().flat_map(|s| s.fields.iter()))
633            .any(|f| field_matches(f, &q));
634
635    let spec = RagSidebarView {
636        teeth,
637        body,
638        accessory: None,
639        search_hits: hits,
640        t: 0.0,
641        rail_w: RAIL_W,
642        // Click en un diente = seleccionar la sección (nunca colapsa); el resto de
643        // mensajes (ejes de disposición, buscador, control) van al estado.
644        map: Arc::new(move |rm| match rm {
645            RagSidebarMsg::Activate(id) => on_msg(AllichayMsg::SelectSection(id as usize)),
646            other => on_msg(AllichayMsg::Sidebar(other)),
647        }),
648        on_drop: None,
649        on_reorder: None,
650        grow: None,
651    };
652    rag_sidebar_view(&sb, spec, RagSide::Left, &RagSidebarPalette::from_theme(theme))
653}
654
655/// Monta el **menú de edición** (cortar/copiar/pegar/deshacer/…) si está abierto
656/// (click derecho sobre un campo → [`AllichayState::edit_menu_anchor`]). El host
657/// lo devuelve **apilado** desde su `view_overlay`, encima del formulario. Los
658/// ítems se habilitan según el estado real del campo focado (sin selección →
659/// Cortar/Copiar grises; masked → sin Copiar). `viewport` = `(ancho, alto)` de
660/// la ventana para clampear el menú al borde. `on_msg` mapea
661/// [`AllichayMsg::EditAction`]/[`AllichayMsg::CloseEditMenu`] al `Msg` del host,
662/// que resuelve con [`AllichayState::apply_edit_action`]/[`AllichayState::close_edit_menu`].
663/// Devuelve `None` cuando el menú está cerrado (el host apila lo que ya tenía).
664pub fn edit_menu_overlay<Msg, F>(
665    state: &AllichayState,
666    theme: &Theme,
667    viewport: (f32, f32),
668    on_msg: F,
669) -> Option<View<Msg>>
670where
671    Msg: Clone + 'static,
672    F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static,
673{
674    let anchor = state.edit_menu_anchor()?;
675    let flags = state.edit_flags();
676    let act = on_msg.clone();
677    let spec = editmenu::edit_context_menu(
678        anchor,
679        viewport,
680        theme,
681        flags,
682        move |a| act(AllichayMsg::EditAction(a)),
683        on_msg(AllichayMsg::CloseEditMenu),
684    );
685    Some(context_menu_view(spec))
686}
687
688/// Pinta la configuración como un **modal centrado** (scrim + card), para
689/// **embeber** el panel dentro de una app con ventana propia (media, nada…): la
690/// app guarda un [`AllichayState`], abre el overlay con una tecla y lo pinta en
691/// su `view_overlay`, enrutando los [`AllichayMsg`] como siempre. Reusa
692/// `llimphi-widget-modal` (scrim + card + título + botón Cerrar) con
693/// [`allichay_view`] como cuerpo. `on_dismiss` se emite al clickear el scrim o
694/// el botón Cerrar (la app maneja Esc en su `on_key`).
695#[allow(clippy::too_many_arguments)]
696pub fn settings_overlay<Msg, F>(
697    title: impl Into<String>,
698    close_label: impl Into<String>,
699    schema: &Schema,
700    state: &AllichayState,
701    theme: &Theme,
702    viewport: (f32, f32),
703    on_msg: F,
704    on_dismiss: Msg,
705) -> View<Msg>
706where
707    Msg: Clone + Send + Sync + 'static,
708    F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static,
709{
710    let body = allichay_view(schema, state, theme, on_msg.clone());
711    let modal = modal_view(ModalSpec {
712        title: title.into(),
713        body,
714        buttons: vec![ModalButton::cancel(close_label, on_dismiss.clone())],
715        size: (640.0, 460.0),
716        viewport,
717        on_dismiss,
718        palette: ModalPalette::from_theme(theme),
719        body_scroll: None,
720    });
721    // Menú del select abierto (si hay), ENCIMA del modal.
722    match dropdown_overlay(schema, state, theme, viewport, on_msg) {
723        Some(menu) => View::new(Style {
724            size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
725            ..Default::default()
726        })
727        .children(vec![modal, menu]),
728        None => modal,
729    }
730}
731
732/// Dibuja el icono de un diente. El glifo declarado en `Section.icon` se
733/// resuelve a un **ícono vectorial** de `llimphi-icons` (determinista en toda
734/// máquina); si el glifo no está en el catálogo, cae a texto. El color lo
735/// resuelve el widget según el estado activo/inactivo.
736fn tooth_icon<Msg: Clone + 'static>(glyph: Option<String>, size: f32, color: Color) -> View<Msg> {
737    let g = glyph.unwrap_or_else(|| "•".to_string());
738    View::new(Style {
739        size: Size {
740            width: length(size),
741            height: length(size),
742        },
743        align_items: Some(AlignItems::CENTER),
744        justify_content: Some(JustifyContent::CENTER),
745        ..Default::default()
746    })
747    .children(vec![llimphi_icons::glyph_or_text_view(&g, size * 0.9, color, 1.8)])
748}
749
750fn build_panel<Msg, F>(schema: &Schema, state: &AllichayState, theme: &Theme, on_msg: F) -> View<Msg>
751where
752    Msg: Clone + Send + Sync + 'static,
753    F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static,
754{
755    if schema.sections.is_empty() {
756        return View::new(panel_style()).text_aligned(
757            "Sin configuración".to_string(),
758            13.0,
759            theme.fg_muted,
760            Alignment::Start,
761        );
762    }
763    let sel = state.selected.min(schema.sections.len() - 1);
764    let section = &schema.sections[sel];
765    // La ruta base = id de la sección; los campos cuelgan de ahí.
766    let base = FieldPath::empty().push(section.id.clone());
767    section_view(section, &base, state, theme, on_msg)
768}
769
770/// Pinta el panel de **una** sección: su encabezado, sus campos y sus
771/// subsecciones (un nivel), construyendo cada `FieldPath` a partir de `base`
772/// (la ruta que ya incluye el id de la sección — p. ej. `["mirada::teselado"]`
773/// cuando el host compone varias apps en un solo rail).
774///
775/// Lo expone aparte de [`allichay_view`] para que un host con su propio rail
776/// (el panel de control central, que mezcla dientes de varias apps) reutilice
777/// el renderizado de campos sin el rail interno del módulo.
778pub fn section_view<Msg, F>(
779    section: &allichay::Section,
780    base: &FieldPath,
781    state: &AllichayState,
782    theme: &Theme,
783    on_msg: F,
784) -> View<Msg>
785where
786    Msg: Clone + Send + Sync + 'static,
787    F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static,
788{
789    View::new(panel_style()).children(section_items(section, base, state, theme, on_msg))
790}
791
792/// Los views de una sección (campos + subsecciones), sin el contenedor — para
793/// apilar varias secciones en un mismo panel ([`schema_panel`]).
794///
795/// **Sin franja-header propia:** el título de la sección lo pone el cabezal
796/// uniforme del widget `rag-sidebar` (o el canvas del host); aquí sólo se foldea
797/// la descripción (`help`) como primera línea tenue del cuerpo.
798///
799/// **Buscador real:** cuando [`RagSidebarState::search`] (en `state.sidebar`)
800/// tiene texto, se filtran los campos por su `label` (substring
801/// case-insensitive); una subsección sin campos coincidentes se omite entera.
802/// Los hosts con rail propio (wawa-panel) dejan ese buscador vacío → no filtra.
803fn section_items<Msg, F>(
804    section: &allichay::Section,
805    base: &FieldPath,
806    state: &AllichayState,
807    theme: &Theme,
808    on_msg: F,
809) -> Vec<View<Msg>>
810where
811    Msg: Clone + Send + Sync + 'static,
812    F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static,
813{
814    let q = state.sidebar.search.trim().to_lowercase();
815    let mut children: Vec<View<Msg>> = Vec::new();
816
817    // Descripción de la sección foldeada como primera línea tenue (sólo en
818    // reposo; durante una búsqueda se omite para no separar los resultados).
819    if q.is_empty() && !section.help.is_empty() {
820        children.push(help_view(&section.help, theme));
821    }
822
823    for field in &section.fields {
824        if !field_matches(field, &q) {
825            continue;
826        }
827        let path = base.clone().push(field.id.clone());
828        children.push(field_row(field, path, state, theme, on_msg.clone()));
829    }
830
831    for sub in &section.subsections {
832        let matching: Vec<&Field> = sub.fields.iter().filter(|f| field_matches(f, &q)).collect();
833        if matching.is_empty() {
834            continue;
835        }
836        children.push(subsection_head(&sub.title, theme));
837        let sub_base = base.clone().push(sub.id.clone());
838        for field in matching {
839            let path = sub_base.clone().push(field.id.clone());
840            children.push(field_row(field, path, state, theme, on_msg.clone()));
841        }
842    }
843    children
844}
845
846/// `true` si el campo coincide con el filtro del buscador: query vacío = todos;
847/// si no, su `label` (case-insensitive) contiene la query. `query` ya viene en
848/// minúsculas y sin espacios de borde.
849fn field_matches(field: &Field, query: &str) -> bool {
850    query.is_empty() || field.label.to_lowercase().contains(query)
851}
852
853/// Pinta el panel de un diente: **todas** las secciones de `schema` apiladas
854/// (cada una con su encabezado de grupo), con scroll vertical si el contenido
855/// excede `viewport_h`. Es el panel de un diente del panel de control — el id
856/// de cada sección es el prefijo de ruteo (`"app::seccion"`), así que cada
857/// campo emite su `FieldPath` completo.
858///
859/// `state.scroll()` lleva el offset; el renderer emite [`AllichayMsg::ScrollTo`]
860/// con el offset ya clampeado para que el host sólo lo guarde.
861pub fn schema_panel<Msg, F>(
862    schema: &Schema,
863    state: &AllichayState,
864    theme: &Theme,
865    viewport_h: f32,
866    on_msg: F,
867) -> View<Msg>
868where
869    Msg: Clone + Send + Sync + 'static,
870    F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static,
871{
872    let mut items: Vec<View<Msg>> = Vec::new();
873    for section in &schema.sections {
874        let base = FieldPath::empty().push(section.id.clone());
875        items.extend(section_items(section, &base, state, theme, on_msg.clone()));
876    }
877
878    let content = View::new(Style {
879        flex_direction: FlexDirection::Column,
880        size: Size {
881            width: percent(1.0_f32),
882            height: Dimension::auto(),
883        },
884        gap: Size {
885            width: length(0.0_f32),
886            height: length(10.0_f32),
887        },
888        ..Default::default()
889    })
890    .children(items);
891
892    let content_len = estimate_height(schema);
893    let offset = state.scroll().min(content_len);
894    let on_scroll = on_msg;
895    let scroller = scroll_y(
896        offset,
897        content_len,
898        viewport_h,
899        content,
900        move |delta| {
901            on_scroll(AllichayMsg::ScrollTo(clamp_offset(
902                offset + delta,
903                content_len,
904                viewport_h,
905            )))
906        },
907        &ScrollPalette::from_theme(theme),
908    );
909
910    View::new(panel_style()).children(vec![scroller])
911}
912
913/// Como [`schema_panel`] pero con una **vista al pie** (`footer`, de alto `footer_h`)
914/// DEBAJO de los campos, todo en UN mismo scroll. Para poner un gráfico/editor
915/// interactivo bajo un formulario chico y que scrolleen juntos (p. ej. el editor de
916/// recorrido Prezi bajo su formulario).
917pub fn schema_panel_with_footer<Msg, F>(
918    schema: &Schema,
919    footer: View<Msg>,
920    footer_h: f32,
921    state: &AllichayState,
922    theme: &Theme,
923    viewport_h: f32,
924    on_msg: F,
925) -> View<Msg>
926where
927    Msg: Clone + Send + Sync + 'static,
928    F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static,
929{
930    let mut items: Vec<View<Msg>> = Vec::new();
931    for section in &schema.sections {
932        let base = FieldPath::empty().push(section.id.clone());
933        items.extend(section_items(section, &base, state, theme, on_msg.clone()));
934    }
935    // El pie va en un contenedor de alto fijo para que el scroll lo cuente entero.
936    items.push(
937        View::new(Style {
938            size: Size { width: percent(1.0_f32), height: length(footer_h) },
939            flex_shrink: 0.0,
940            ..Default::default()
941        })
942        .children(vec![footer]),
943    );
944
945    let content = View::new(Style {
946        flex_direction: FlexDirection::Column,
947        size: Size { width: percent(1.0_f32), height: Dimension::auto() },
948        gap: Size { width: length(0.0_f32), height: length(10.0_f32) },
949        ..Default::default()
950    })
951    .children(items);
952
953    let content_len = estimate_height(schema) + footer_h + 10.0;
954    let offset = state.scroll().min(content_len);
955    let on_scroll = on_msg;
956    let scroller = scroll_y(
957        offset,
958        content_len,
959        viewport_h,
960        content,
961        move |delta| {
962            on_scroll(AllichayMsg::ScrollTo(clamp_offset(offset + delta, content_len, viewport_h)))
963        },
964        &ScrollPalette::from_theme(theme),
965    );
966    View::new(panel_style()).children(vec![scroller])
967}
968
969/// Un **título editable** ligado a `path` (sin label ni chrome de campo), para
970/// usar como encabezado de un canvas — p. ej. el nombre de una instancia de
971/// perfil, arriba de sus tabs. Reusa el MISMO mecanismo de foco/edición que los
972/// campos (`AllichayState` + `text_input_view_full`): el host lo cablea con los
973/// mismos `AllichayMsg` (Focus/Input/Change) que ya maneja para el resto. `valor`
974/// es el texto actual (se muestra cuando el título no está focado).
975pub fn titulo_editable_view<Msg, F>(
976    path: FieldPath,
977    valor: &str,
978    state: &AllichayState,
979    theme: &Theme,
980    on_msg: F,
981) -> View<Msg>
982where
983    Msg: Clone + Send + Sync + 'static,
984    F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static,
985{
986    // Input con **label flotante estilo Flutter** («Nombre» arriba del campo, que
987    // sube al enfocar o cuando hay texto). Reusa el foco/edición de los campos.
988    let palette = TextInputPalette::from_theme(theme);
989    let focused = state.is_focused(&path);
990    let mut tmp = TextInputState::new();
991    let st: &TextInputState = match (focused, state.input(&path)) {
992        (true, Some(real)) => real,
993        _ => {
994            tmp.set_text(valor);
995            &tmp
996        }
997    };
998    let float_t = float_target(focused, st);
999    let ip = path.clone();
1000    text_input_field_view(st, "Nombre", focused, float_t, &palette, move |ev| {
1001        on_msg(AllichayMsg::Input(ip.clone(), ev))
1002    })
1003}
1004
1005/// Estimación (generosa) del alto del contenido de un schema, para el scroll.
1006fn estimate_height(schema: &Schema) -> f32 {
1007    let mut h = 0.0_f32;
1008    for section in &schema.sections {
1009        h += 44.0; // encabezado de sección
1010        h += ayuda_h(&section.help);
1011        for f in &section.fields {
1012            h += field_height(f) + 13.0 + ayuda_h(&f.help); // + label/gap/separación
1013        }
1014        for sub in &section.subsections {
1015            h += 26.0; // encabezado de subsección
1016            for f in &sub.fields {
1017                h += field_height(f) + 13.0 + ayuda_h(&f.help);
1018            }
1019        }
1020    }
1021    h + 28.0 // padding del panel
1022}
1023
1024/// El alto que se lleva una línea de `help` — el mismo que fija [`help_view`].
1025///
1026/// **No contarla partía el scroll de una forma que no se veía**: `scroll_y`
1027/// sólo pinta la barra `if max_offset > 0`, así que un panel subestimado se
1028/// queda a la vez SIN barra y con el final inalcanzable — los dos síntomas de
1029/// «el formulario sale cortado» salen de este único número faltante. Una
1030/// sección con ayuda en cada campo perdía ~14 px por campo (126 en una de ocho),
1031/// suficiente para que el panel se creyera más corto que su viewport.
1032///
1033/// Se cuenta la ayuda de la sección siempre, aunque `section_items` la oculte
1034/// cuando hay filtro: el estimate no conoce el filtro, y **sobrar** alto sólo
1035/// deja un poco de scroll de más, mientras que faltar recorta contenido.
1036fn ayuda_h(help: &str) -> f32 {
1037    if help.is_empty() {
1038        0.0
1039    } else {
1040        ALTO_AYUDA
1041    }
1042}
1043
1044/// Alto fijo de una línea de ayuda. Vive acá para que [`help_view`] y
1045/// [`estimate_height`] no puedan discrepar en silencio.
1046const ALTO_AYUDA: f32 = 14.0;
1047
1048/// Alto del control de un campo (px). Para los agregados (lista/tabla) depende
1049/// del número de filas, por eso toma el [`Field`] entero y no sólo su control.
1050fn field_height(field: &Field) -> f32 {
1051    match &field.control {
1052        Control::Toggle => 22.0,
1053        Control::Slider { .. } => 28.0,
1054        // Select: sólo el disparador (1 fila). El menú se pinta como overlay
1055        // (`dropdown_overlay`), fuera del flujo — no ocupa alto del panel.
1056        Control::Dropdown { .. } => 34.0,
1057        // Radio: siempre lista vertical, una fila por opción.
1058        Control::Radio { options } => options.len().max(1) as f32 * 30.0,
1059        Control::TextInput | Control::Password => 34.0,
1060        // Color: sólo el swatch disparador (1 fila). El picker se pinta como POPUP
1061        // (`colorpicker_overlay`), fuera del flujo — no ocupa alto del panel.
1062        Control::ColorPicker => 28.0,
1063        // Chips + botones-ícono en una sola línea.
1064        Control::InlineActions { .. } => 28.0,
1065        // Chips de color: ~6 por línea a 26 px de paso.
1066        Control::Swatches { colors } => (colors.len().max(1).div_ceil(6)) as f32 * 26.0 + 2.0,
1067        // Multi-select: los chips (elegidos) + el «+» se envuelven; estimamos
1068        // ~3 por línea a 30px. Como mínimo una línea (sólo el disparador).
1069        Control::MultiSelect { .. } => {
1070            let n = field.value.as_list().map(|s| s.len()).unwrap_or(0) + 1;
1071            (n.div_ceil(3)).max(1) as f32 * 30.0 + 2.0
1072        }
1073        Control::List { .. } => table_height(field.value.row_count(), false),
1074        Control::Table { .. } => table_height(field.value.row_count(), true),
1075        Control::Display => 8.0, // fila compacta sin label arriba
1076        Control::Button => 34.0, // fila compacta: el botón ES la fila
1077    }
1078}
1079
1080fn panel_style() -> Style {
1081    Style {
1082        flex_direction: FlexDirection::Column,
1083        size: Size {
1084            width: Dimension::auto(),
1085            height: percent(1.0_f32),
1086        },
1087        flex_grow: 1.0,
1088        padding: Rect {
1089            left: length(20.0_f32),
1090            right: length(20.0_f32),
1091            top: length(14.0_f32),
1092            bottom: length(14.0_f32),
1093        },
1094        gap: Size {
1095            width: length(0.0_f32),
1096            height: length(10.0_f32),
1097        },
1098        ..Default::default()
1099    }
1100}
1101
1102// =====================================================================
1103// Encabezados y campos
1104// =====================================================================
1105
1106fn subsection_head<Msg: Clone + 'static>(title: &str, theme: &Theme) -> View<Msg> {
1107    View::new(Style {
1108        size: Size {
1109            width: percent(1.0_f32),
1110            height: length(20.0_f32),
1111        },
1112        margin: Rect {
1113            left: length(0.0_f32),
1114            right: length(0.0_f32),
1115            top: length(6.0_f32),
1116            bottom: length(0.0_f32),
1117        },
1118        ..Default::default()
1119    })
1120    .text_aligned(title.to_string(), 12.5, theme.accent, Alignment::Start)
1121}
1122
1123fn label_view<Msg: Clone + 'static>(label: &str, theme: &Theme) -> View<Msg> {
1124    View::new(Style {
1125        size: Size {
1126            width: percent(1.0_f32),
1127            height: length(16.0_f32),
1128        },
1129        ..Default::default()
1130    })
1131    .text_aligned(label.to_string(), 11.5, theme.fg_muted, Alignment::Start)
1132}
1133
1134fn help_view<Msg: Clone + 'static>(help: &str, theme: &Theme) -> View<Msg> {
1135    View::new(Style {
1136        size: Size {
1137            width: percent(1.0_f32),
1138            height: length(ALTO_AYUDA),
1139        },
1140        ..Default::default()
1141    })
1142    .text_aligned(help.to_string(), 10.0, theme.fg_placeholder, Alignment::Start)
1143}
1144
1145fn field_col_style() -> Style {
1146    Style {
1147        flex_direction: FlexDirection::Column,
1148        size: Size {
1149            width: percent(1.0_f32),
1150            height: Dimension::auto(),
1151        },
1152        gap: Size {
1153            width: length(0.0_f32),
1154            height: length(3.0_f32),
1155        },
1156        ..Default::default()
1157    }
1158}
1159
1160/// Una fila de campo: rótulo arriba, control abajo, ayuda opcional.
1161fn field_row<Msg, F>(
1162    field: &Field,
1163    path: FieldPath,
1164    state: &AllichayState,
1165    theme: &Theme,
1166    on_msg: F,
1167) -> View<Msg>
1168where
1169    Msg: Clone + Send + Sync + 'static,
1170    F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static,
1171{
1172    let control = match &field.control {
1173        Control::Toggle => toggle_control(field, path, theme, on_msg),
1174        Control::Slider { min, max, step } => {
1175            slider_control(field, path, *min, *max, *step, theme, on_msg)
1176        }
1177        Control::Dropdown { options } => dropdown_control(field, path, options, state, theme, on_msg),
1178        Control::Radio { options } => radio_group(field, path, options, theme, on_msg),
1179        Control::ColorPicker => color_control(field, path, state, theme, on_msg),
1180        Control::TextInput => text_control(field, path, state, theme, on_msg, false),
1181        Control::Password => text_control(field, path, state, theme, on_msg, true),
1182        Control::List { item_label } => {
1183            list_control(field, path, item_label, state, theme, on_msg)
1184        }
1185        Control::Table { columns } => table_control(field, path, columns, state, theme, on_msg),
1186        Control::InlineActions { chips, buttons } => {
1187            inline_actions_control(field, path, chips, buttons, theme, on_msg)
1188        }
1189        Control::MultiSelect { options } => {
1190            multiselect_control(field, path, options, state, theme, on_msg)
1191        }
1192        Control::Swatches { colors } => swatches_control(field, path, colors, theme, on_msg),
1193        Control::Display => return display_row(field, theme),
1194        // El botón ES la fila completa (lleva su rótulo dentro): no le ponemos
1195        // un label arriba como a los controles escalares.
1196        Control::Button => return button_control(field, path, theme, on_msg),
1197    };
1198
1199    // Regla estética del panel: los controles ESCALARES (opciones) van en DOS
1200    // COLUMNAS — título a la izquierda, control a la derecha. Los agregados
1201    // (lista/tabla), el color-picker (alto) y los ya-especiales (display/botón,
1202    // que retornaron antes) conservan el ancho completo.
1203    let dos_columnas = matches!(
1204        field.control,
1205        Control::Toggle
1206            | Control::Slider { .. }
1207            | Control::Dropdown { .. }
1208            | Control::Radio { .. }
1209            | Control::TextInput
1210            | Control::ColorPicker
1211            | Control::InlineActions { .. }
1212            | Control::Swatches { .. }
1213    );
1214    if dos_columnas {
1215        let etiqueta = View::new(Style {
1216            size: Size { width: length(150.0_f32), height: Dimension::auto() },
1217            flex_shrink: 0.0,
1218            ..Default::default()
1219        })
1220        .text_aligned(field.label.clone(), 12.0, theme.fg_text, Alignment::Start);
1221        let control_col = View::new(Style {
1222            flex_grow: 1.0,
1223            size: Size { width: length(0.0_f32), height: Dimension::auto() },
1224            ..Default::default()
1225        })
1226        .children(vec![control]);
1227        let fila = View::new(Style {
1228            flex_direction: FlexDirection::Row,
1229            size: Size { width: percent(1.0_f32), height: Dimension::auto() },
1230            align_items: Some(AlignItems::CENTER),
1231            gap: Size { width: length(14.0_f32), height: length(0.0_f32) },
1232            ..Default::default()
1233        })
1234        .children(vec![etiqueta, control_col]);
1235        if field.help.is_empty() {
1236            return fila;
1237        }
1238        return View::new(field_col_style()).children(vec![fila, help_view(&field.help, theme)]);
1239    }
1240
1241    let mut kids = vec![label_view(&field.label, theme), control];
1242    if !field.help.is_empty() {
1243        kids.push(help_view(&field.help, theme));
1244    }
1245    View::new(field_col_style()).children(kids)
1246}
1247
1248/// Fila de sólo lectura: rótulo a la izquierda, valor a la derecha. Para items
1249/// de información del sistema (no editables).
1250fn display_row<Msg: Clone + 'static>(field: &Field, theme: &Theme) -> View<Msg> {
1251    let label = View::new(Style {
1252        size: Size {
1253            width: length(150.0_f32),
1254            height: length(20.0_f32),
1255        },
1256        flex_shrink: 0.0,
1257        ..Default::default()
1258    })
1259    .text_aligned(field.label.clone(), 12.0, theme.fg_muted, Alignment::Start);
1260    let value = View::new(Style {
1261        size: Size {
1262            width: Dimension::auto(),
1263            height: length(20.0_f32),
1264        },
1265        flex_grow: 1.0,
1266        ..Default::default()
1267    })
1268    .text_aligned(
1269        field.value.as_str().unwrap_or("").to_string(),
1270        12.5,
1271        theme.fg_text,
1272        Alignment::Start,
1273    );
1274    View::new(Style {
1275        flex_direction: FlexDirection::Row,
1276        size: Size {
1277            width: percent(1.0_f32),
1278            height: length(22.0_f32),
1279        },
1280        align_items: Some(AlignItems::CENTER),
1281        ..Default::default()
1282    })
1283    .children(vec![label, value])
1284}
1285
1286// =====================================================================
1287// Controles por tipo
1288// =====================================================================
1289
1290fn toggle_control<Msg, F>(field: &Field, path: FieldPath, theme: &Theme, on_msg: F) -> View<Msg>
1291where
1292    Msg: Clone + Send + Sync + 'static,
1293    F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static,
1294{
1295    let cur = field.value.as_bool().unwrap_or(false);
1296    let progress = if cur { 1.0 } else { 0.0 };
1297    let msg = on_msg(AllichayMsg::Change(path, FieldValue::Bool(!cur)));
1298    switch_view(progress, msg, &SwitchPalette::from_theme(theme))
1299}
1300
1301/// Botón de acción: una fila clickeable con el rótulo del campo. Al clickear
1302/// emite `Change(path, Bool(true))` — un pulso. Sin estado on/off (a diferencia
1303/// del [`toggle_control`], que era lo que antes simulaba las acciones).
1304fn button_control<Msg, F>(field: &Field, path: FieldPath, theme: &Theme, on_msg: F) -> View<Msg>
1305where
1306    Msg: Clone + Send + Sync + 'static,
1307    F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static,
1308{
1309    let msg = on_msg(AllichayMsg::Change(path, FieldValue::Bool(true)));
1310    let lbl = View::new(Style {
1311        size: Size { width: percent(1.0_f32), height: length(28.0_f32) },
1312        flex_grow: 1.0,
1313        align_items: Some(AlignItems::CENTER),
1314        justify_content: Some(JustifyContent::CENTER),
1315        ..Default::default()
1316    })
1317    .text_aligned(field.label.clone(), 12.5, theme.fg_text, Alignment::Center)
1318    .ellipsis(1);
1319    View::new(Style {
1320        flex_direction: FlexDirection::Row,
1321        size: Size { width: percent(1.0_f32), height: length(30.0_f32) },
1322        align_items: Some(AlignItems::CENTER),
1323        justify_content: Some(JustifyContent::CENTER),
1324        padding: Rect {
1325            left: length(10.0_f32),
1326            right: length(10.0_f32),
1327            top: length(0.0_f32),
1328            bottom: length(0.0_f32),
1329        },
1330        ..Default::default()
1331    })
1332    .radius(7.0)
1333    .fill(theme.bg_input)
1334    .border(1.0, theme.border)
1335    .hover_fill(theme.bg_row_hover)
1336    .on_click(msg)
1337    .children(vec![lbl])
1338}
1339
1340/// [`Control::InlineActions`]: chips seleccionables (el activo resaltado en acento)
1341/// + botones-ícono de pulso, en una fila alineada a la derecha. Cada clic emite
1342/// `Change(path, Enum(id))`.
1343/// **La colección de colores** ([`Control::Swatches`]): un chip por color, el
1344/// elegido con anillo. Un click elige — nada más.
1345///
1346/// El anillo va por FUERA del color (un marco alrededor, no un borde encima):
1347/// un borde comido al chip cambia cuánto color se ve, y con seis chips en fila
1348/// el elegido se leía más chico en vez de más marcado.
1349fn swatches_control<Msg, F>(
1350    field: &Field,
1351    path: FieldPath,
1352    colors: &[String],
1353    theme: &Theme,
1354    on_msg: F,
1355) -> View<Msg>
1356where
1357    Msg: Clone + Send + Sync + 'static,
1358    F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static,
1359{
1360    let cur = field.value.as_str().unwrap_or("").to_ascii_uppercase();
1361    let mut kids: Vec<View<Msg>> = Vec::new();
1362    for hex in colors {
1363        let activo = hex.to_ascii_uppercase() == cur;
1364        let c = llimphi_widget_color_picker::parse_hex(hex, 255)
1365            .map(|[r, g, b, a]| Color::from_rgba8(r, g, b, a))
1366            .unwrap_or(theme.bg_input);
1367        let msg = on_msg(AllichayMsg::Change(path.clone(), FieldValue::Enum(hex.clone())));
1368        // Marco: el elegido lo lleva del color del texto (se ve sobre cualquier
1369        // fondo); el resto lo lleva transparente para no mover la geometría.
1370        let marco = View::new(Style {
1371            size: Size { width: length(24.0_f32), height: length(24.0_f32) },
1372            align_items: Some(AlignItems::CENTER),
1373            justify_content: Some(JustifyContent::CENTER),
1374            flex_shrink: 0.0,
1375            ..Default::default()
1376        })
1377        .radius(7.0)
1378        .fill(if activo { theme.fg_text } else { Color::TRANSPARENT })
1379        .on_click(msg)
1380        .children(vec![
1381            View::new(Style {
1382                size: Size { width: length(18.0_f32), height: length(18.0_f32) },
1383                flex_shrink: 0.0,
1384                ..Default::default()
1385            })
1386            .fill(c)
1387            .radius(5.0)
1388            // El borde tenue es lo que hace visible un chip casi del color del
1389            // fondo — sin él, un gris oscuro sobre el panel oscuro no existe.
1390            .border(1.0, theme.border),
1391        ]);
1392        kids.push(marco);
1393    }
1394    View::new(Style {
1395        flex_direction: FlexDirection::Row,
1396        flex_wrap: FlexWrap::Wrap,
1397        size: Size { width: percent(1.0_f32), height: Dimension::auto() },
1398        align_items: Some(AlignItems::CENTER),
1399        gap: Size { width: length(2.0_f32), height: length(2.0_f32) },
1400        ..Default::default()
1401    })
1402    .children(kids)
1403}
1404
1405fn inline_actions_control<Msg, F>(
1406    field: &Field,
1407    path: FieldPath,
1408    chips: &[allichay::EnumOption],
1409    buttons: &[(String, String)],
1410    theme: &Theme,
1411    on_msg: F,
1412) -> View<Msg>
1413where
1414    Msg: Clone + Send + Sync + 'static,
1415    F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static,
1416{
1417    let cur = field.value.as_str().unwrap_or("");
1418    let mut kids: Vec<View<Msg>> = Vec::new();
1419    for c in chips {
1420        let activo = c.id == cur;
1421        let (fg, bg) = if activo {
1422            (theme.bg_panel, theme.accent)
1423        } else {
1424            (theme.fg_muted, theme.bg_panel_alt)
1425        };
1426        let msg = on_msg(AllichayMsg::Change(path.clone(), FieldValue::Enum(c.id.clone())));
1427        kids.push(
1428            View::new(Style {
1429                size: Size { width: Dimension::auto(), height: length(24.0_f32) },
1430                align_items: Some(AlignItems::CENTER),
1431                justify_content: Some(JustifyContent::CENTER),
1432                flex_shrink: 0.0,
1433                padding: Rect {
1434                    left: length(9.0_f32),
1435                    right: length(9.0_f32),
1436                    top: length(0.0_f32),
1437                    bottom: length(0.0_f32),
1438                },
1439                ..Default::default()
1440            })
1441            .fill(bg)
1442            .radius(5.0)
1443            .hover_fill(theme.bg_row_hover)
1444            .on_click(msg)
1445            .text(c.label.clone(), 11.0, fg),
1446        );
1447    }
1448    for (glifo, action) in buttons {
1449        let msg = on_msg(AllichayMsg::Change(path.clone(), FieldValue::Enum(action.clone())));
1450        kids.push(
1451            View::new(Style {
1452                size: Size { width: length(26.0_f32), height: length(24.0_f32) },
1453                align_items: Some(AlignItems::CENTER),
1454                justify_content: Some(JustifyContent::CENTER),
1455                flex_shrink: 0.0,
1456                ..Default::default()
1457            })
1458            .fill(theme.bg_panel_alt)
1459            .radius(5.0)
1460            .hover_fill(theme.bg_button_hover)
1461            .on_click(msg)
1462            .text(glifo.clone(), 13.0, theme.fg_text),
1463        );
1464    }
1465    View::new(Style {
1466        flex_direction: FlexDirection::Row,
1467        size: Size { width: percent(1.0_f32), height: length(26.0_f32) },
1468        align_items: Some(AlignItems::CENTER),
1469        justify_content: Some(JustifyContent::END),
1470        gap: Size { width: length(4.0_f32), height: length(0.0_f32) },
1471        ..Default::default()
1472    })
1473    .children(kids)
1474}
1475
1476fn slider_control<Msg, F>(
1477    field: &Field,
1478    path: FieldPath,
1479    min: f64,
1480    max: f64,
1481    step: f64,
1482    theme: &Theme,
1483    on_msg: F,
1484) -> View<Msg>
1485where
1486    Msg: Clone + Send + Sync + 'static,
1487    F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static,
1488{
1489    let is_int = matches!(field.value, FieldValue::Int(_));
1490    let cur = field.value.as_float().unwrap_or(0.0);
1491    let palette = SliderPalette::from_theme(theme);
1492    slider_view(
1493        String::new(),
1494        cur as f32,
1495        min as f32,
1496        max as f32,
1497        &palette,
1498        move |phase, dv| match phase {
1499            DragPhase::Move => {
1500                let mut nv = (cur + dv as f64).clamp(min, max);
1501                if step > 0.0 {
1502                    nv = (nv / step).round() * step;
1503                }
1504                let value = if is_int {
1505                    FieldValue::Int(nv as i64)
1506                } else {
1507                    FieldValue::Float(nv)
1508                };
1509                Some(on_msg(AllichayMsg::Change(path.clone(), value)))
1510            }
1511            DragPhase::End => None,
1512        },
1513    )
1514}
1515
1516/// **Multi-select con tags**: los ids elegidos van como *chips* (tag + ×) que se
1517/// envuelven a varias líneas, y un disparador «+» despliega el menú de opciones
1518/// (lo pinta [`dropdown_overlay`], que ya sabe togglear la pertenencia). La × de
1519/// un chip emite `Change(path, List(resto))`.
1520fn multiselect_control<Msg, F>(
1521    field: &Field,
1522    path: FieldPath,
1523    options: &[allichay::EnumOption],
1524    state: &AllichayState,
1525    theme: &Theme,
1526    on_msg: F,
1527) -> View<Msg>
1528where
1529    Msg: Clone + Send + Sync + 'static,
1530    F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static,
1531{
1532    let selected: Vec<String> = field.value.as_list().map(|s| s.to_vec()).unwrap_or_default();
1533    let label_of = |id: &str| {
1534        options.iter().find(|o| o.id == id).map(|o| o.label.clone()).unwrap_or_else(|| id.to_string())
1535    };
1536    let mut kids: Vec<View<Msg>> = Vec::new();
1537    for id in &selected {
1538        // La × quita este id de la lista (emite la lista sin él).
1539        let resto: Vec<String> = selected.iter().filter(|x| *x != id).cloned().collect();
1540        let rm = on_msg(AllichayMsg::Change(path.clone(), FieldValue::List(resto)));
1541        let equis = View::new(Style {
1542            size: Size { width: length(16.0_f32), height: length(18.0_f32) },
1543            align_items: Some(AlignItems::CENTER),
1544            justify_content: Some(JustifyContent::CENTER),
1545            flex_shrink: 0.0,
1546            ..Default::default()
1547        })
1548        .radius(4.0)
1549        .hover_fill(theme.bg_button_hover)
1550        .on_click(rm)
1551        .text_aligned("✕".to_string(), 10.0, theme.fg_muted, Alignment::Center);
1552        let chip = View::new(Style {
1553            flex_direction: FlexDirection::Row,
1554            size: Size { width: Dimension::auto(), height: length(24.0_f32) },
1555            align_items: Some(AlignItems::CENTER),
1556            flex_shrink: 0.0,
1557            gap: Size { width: length(4.0_f32), height: length(0.0_f32) },
1558            padding: Rect {
1559                left: length(9.0_f32),
1560                right: length(4.0_f32),
1561                top: length(0.0_f32),
1562                bottom: length(0.0_f32),
1563            },
1564            ..Default::default()
1565        })
1566        .fill(theme.bg_panel_alt)
1567        .radius(5.0)
1568        .children(vec![
1569            View::new(Style {
1570                size: Size { width: Dimension::auto(), height: Dimension::auto() },
1571                ..Default::default()
1572            })
1573            .text(label_of(id), 11.0, theme.fg_text),
1574            equis,
1575        ]);
1576        kids.push(chip);
1577    }
1578    // Disparador «+ agregar…»: abre el menú (lo pinta `dropdown_overlay`).
1579    let open = state.is_dropdown_open(&path);
1580    let p = path.clone();
1581    let trigger = select_trigger_view(
1582        None,
1583        "+ agregar…",
1584        open,
1585        Some(160.0),
1586        &SelectPalette::from_theme(theme),
1587        move |x, y, w, h| on_msg(AllichayMsg::ToggleDropdown(p.clone(), x, y + h, w)),
1588    );
1589    kids.push(trigger);
1590    View::new(Style {
1591        flex_direction: FlexDirection::Row,
1592        flex_wrap: FlexWrap::Wrap,
1593        size: Size { width: percent(1.0_f32), height: Dimension::auto() },
1594        align_items: Some(AlignItems::CENTER),
1595        gap: Size { width: length(6.0_f32), height: length(6.0_f32) },
1596        ..Default::default()
1597    })
1598    .children(kids)
1599}
1600
1601fn dropdown_control<Msg, F>(
1602    field: &Field,
1603    path: FieldPath,
1604    options: &[allichay::EnumOption],
1605    state: &AllichayState,
1606    theme: &Theme,
1607    on_msg: F,
1608) -> View<Msg>
1609where
1610    Msg: Clone + Send + Sync + 'static,
1611    F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static,
1612{
1613    // SELECT desplegable de verdad: SÓLO el disparador (opción actual + chevron).
1614    // El menú lo pinta [`dropdown_overlay`] en la capa `view_overlay`, ANCLADO al
1615    // click y encima de todo (no inline, no empuja los campos de abajo). El click
1616    // del disparador manda las coords absolutas vía `on_click_screen`.
1617    let cur = field.value.as_str().unwrap_or("");
1618    let open = state.is_dropdown_open(&path);
1619    let sel = options.iter().find(|o| o.id == cur).map(|o| SelectItem::new(o.label.clone()));
1620    let p = path;
1621    select_trigger_view(
1622        sel.as_ref(),
1623        "Elige…",
1624        open,
1625        None,
1626        &SelectPalette::from_theme(theme),
1627        // Ancla = esquina INFERIOR-izquierda del disparador (`y + h`); ancho = el
1628        // del disparador (`w`) → el menú sale de abajo y copia el ancho del input.
1629        move |x, y, w, h| on_msg(AllichayMsg::ToggleDropdown(p.clone(), x, y + h, w)),
1630    )
1631}
1632
1633/// **Dónde nace el menú de un select y cuán ancho es**, dados el borde izquierdo
1634/// del disparador (`ax`) y su ancho (`w`). Devuelve `(x, ancho)`.
1635///
1636/// El menú se alinea con SU botón. Con un disparador ancho —el de un campo de
1637/// formulario— el menú le copia el ancho y calza exacto debajo. Con uno COMPACTO
1638/// —los 38 px del combobox de una fila del árbol del sidebar de wawapanel, que no
1639/// muestra ningún valor: es un chevron a secas— el menú es forzosamente más ancho
1640/// que el botón, y anclado por la IZQUIERDA se derramaba hacia la derecha: nacía
1641/// en el botón y terminaba sobre el canvas, sin leerse como «el menú de este
1642/// botón». Cuando el menú es más ancho, se alinea por el borde **derecho**, que
1643/// es el canto que los dos comparten.
1644///
1645/// El piso del ancho también depende de qué tan compacto sea el disparador: uno
1646/// que no muestra ningún valor no puede dictar el ancho del menú (no hay texto
1647/// suyo con el que empatar), y con los 96 px de siempre «windows-xp» salía
1648/// partido en dos renglones. Ahí el ancho lo pide el contenido del menú.
1649pub fn caja_del_menu(ax: f32, w: f32) -> (f32, f32) {
1650    let piso = if w < 60.0 { 180.0 } else { 96.0 };
1651    let ancho = w.max(piso);
1652    let x = if ancho > w { (ax + w - ancho).max(4.0) } else { ax };
1653    (x, ancho)
1654}
1655
1656/// El **menú desplegado** del select abierto, para apilarlo en `view_overlay`
1657/// (encima de todo). `None` si no hay ninguno abierto. Busca el campo abierto en
1658/// `schema` por su path (guardado en el estado con su ancla) y pinta su lista
1659/// anclada. Elegir una opción manda `Change`; el click-fuera (scrim) la cierra.
1660pub fn dropdown_overlay<Msg, F>(
1661    schema: &Schema,
1662    state: &AllichayState,
1663    theme: &Theme,
1664    viewport: (f32, f32),
1665    on_msg: F,
1666) -> Option<View<Msg>>
1667where
1668    Msg: Clone + Send + Sync + 'static,
1669    F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static,
1670{
1671    let (path, ax, ay, w) = state.open_dropdown_anchor()?;
1672    let field = find_dropdown_field(schema, path)?;
1673    // El menú lo abren tanto el Dropdown (elige uno) como el MultiSelect (togglea
1674    // pertenencia). Sacamos las opciones y qué está elegido según el control.
1675    let options = match &field.control {
1676        Control::Dropdown { options } | Control::MultiSelect { options } => options,
1677        _ => return None,
1678    };
1679    let es_multi = matches!(field.control, Control::MultiSelect { .. });
1680    let items: Vec<SelectItem> = options.iter().map(|o| SelectItem::new(o.label.clone())).collect();
1681    let visible: Vec<usize> = (0..items.len()).collect();
1682    // Marcados: en Dropdown, la opción actual; en MultiSelect, todas las elegidas.
1683    let selected: Vec<usize> = if es_multi {
1684        let chosen = field.value.as_list().map(|s| s.to_vec()).unwrap_or_default();
1685        options.iter().enumerate().filter(|(_, o)| chosen.contains(&o.id)).map(|(i, _)| i).collect()
1686    } else {
1687        let cur = field.value.as_str().unwrap_or("");
1688        options.iter().position(|o| o.id == cur).into_iter().collect()
1689    };
1690    let ids: Vec<String> = options.iter().map(|o| o.id.clone()).collect();
1691    let chosen_now: Vec<String> = field.value.as_list().map(|s| s.to_vec()).unwrap_or_default();
1692    let p_pick = path.clone();
1693    let om = on_msg.clone();
1694    let on_pick = std::sync::Arc::new(move |i: usize| {
1695        if es_multi {
1696            // Togglea el id en la lista y emite la lista nueva.
1697            let id = &ids[i];
1698            let mut nueva = chosen_now.clone();
1699            if let Some(pos) = nueva.iter().position(|x| x == id) {
1700                nueva.remove(pos);
1701            } else {
1702                nueva.push(id.clone());
1703            }
1704            om(AllichayMsg::Change(p_pick.clone(), FieldValue::List(nueva)))
1705        } else {
1706            om(AllichayMsg::Change(p_pick.clone(), FieldValue::Enum(ids[i].clone())))
1707        }
1708    });
1709    let pal = SelectPalette::from_theme(theme);
1710    let (ax, ancho) = caja_del_menu(ax, w);
1711    let spec = SelectMenuSpec {
1712        anchor: (ax, ay),
1713        viewport,
1714        width: ancho,
1715        phase: SelectPhase::Ready(&items),
1716        visible: &visible,
1717        active: usize::MAX,
1718        selected: &selected,
1719        query: "",
1720        searchable: false,
1721        empty_text: "",
1722        appear: 1.0,
1723        on_pick,
1724        on_hover: None,
1725        on_dismiss: on_msg(AllichayMsg::ToggleDropdown(path.clone(), ax, ay, w)),
1726        on_retry: None,
1727        palette: &pal,
1728    };
1729    // Sale de abajo del disparador: nace pegado a su borde y se desliza hacia
1730    // abajo (+ fade del enter). Key estable por path para no re-animar al re-render.
1731    let mut hasher = std::collections::hash_map::DefaultHasher::new();
1732    std::hash::Hash::hash(&path.to_string(), &mut hasher);
1733    let key = std::hash::Hasher::finish(&hasher);
1734    Some(select_menu_view(spec).animated_enter_from(
1735        key,
1736        std::time::Duration::from_millis(150),
1737        Affine::translate((0.0, -10.0)),
1738    ))
1739}
1740
1741/// Busca el `Field` de un `Control::Dropdown` por su path (`[section.id,
1742/// field.id]`), recorriendo secciones y subsecciones.
1743fn find_dropdown_field<'a>(schema: &'a Schema, path: &FieldPath) -> Option<&'a Field> {
1744    for sec in &schema.sections {
1745        for f in &sec.fields {
1746            if FieldPath(vec![sec.id.clone(), f.id.clone()]) == *path {
1747                return Some(f);
1748            }
1749        }
1750        for sub in &sec.subsections {
1751            for f in &sub.fields {
1752                if FieldPath(vec![sec.id.clone(), f.id.clone()]) == *path {
1753                    return Some(f);
1754                }
1755            }
1756        }
1757    }
1758    None
1759}
1760
1761/// Lista vertical de radios (un círculo por opción). Es el cuerpo de
1762/// [`Control::Radio`] y la rama de muchas opciones del dropdown. Cada fila
1763/// emite el mismo `Change` — sin overlay, sin estado, sin tocar el `update`.
1764fn radio_group<Msg, F>(
1765    field: &Field,
1766    path: FieldPath,
1767    options: &[allichay::EnumOption],
1768    theme: &Theme,
1769    on_msg: F,
1770) -> View<Msg>
1771where
1772    Msg: Clone + Send + Sync + 'static,
1773    F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static,
1774{
1775    let cur = field.value.as_str().unwrap_or("");
1776    let rows: Vec<View<Msg>> = options
1777        .iter()
1778        .map(|o| {
1779            let selected = o.id == cur;
1780            let msg = on_msg(AllichayMsg::Change(path.clone(), FieldValue::Enum(o.id.clone())));
1781            radio_row(&o.label, selected, msg, theme)
1782        })
1783        .collect();
1784    View::new(Style {
1785        flex_direction: FlexDirection::Column,
1786        size: Size {
1787            width: percent(1.0_f32),
1788            height: Dimension::auto(),
1789        },
1790        gap: Size {
1791            width: length(0.0_f32),
1792            height: length(2.0_f32),
1793        },
1794        ..Default::default()
1795    })
1796    .children(rows)
1797}
1798
1799/// Una fila de radio del dropdown largo: punto a la izquierda (relleno si está
1800/// seleccionada) + rótulo. La fila entera es clickeable y resalta en hover; la
1801/// seleccionada lleva fondo y rótulo en negrita.
1802fn radio_row<Msg: Clone + 'static>(
1803    label: &str,
1804    selected: bool,
1805    msg: Msg,
1806    theme: &Theme,
1807) -> View<Msg> {
1808    let dot_inner = if selected {
1809        vec![View::new(Style {
1810            size: Size { width: length(8.0_f32), height: length(8.0_f32) },
1811            ..Default::default()
1812        })
1813        .radius(4.0)
1814        .fill(theme.accent)]
1815    } else {
1816        Vec::new()
1817    };
1818    let dot = View::new(Style {
1819        size: Size { width: length(16.0_f32), height: length(16.0_f32) },
1820        align_items: Some(AlignItems::CENTER),
1821        justify_content: Some(JustifyContent::CENTER),
1822        flex_shrink: 0.0,
1823        ..Default::default()
1824    })
1825    .radius(8.0)
1826    .border(1.5, if selected { theme.accent } else { theme.border })
1827    .children(dot_inner);
1828
1829    let mut lbl = View::new(Style {
1830        size: Size { width: Dimension::auto(), height: length(20.0_f32) },
1831        flex_grow: 1.0,
1832        ..Default::default()
1833    })
1834    .text_aligned(
1835        label.to_string(),
1836        12.5,
1837        if selected { theme.fg_text } else { theme.fg_muted },
1838        Alignment::Start,
1839    )
1840    .ellipsis(1);
1841    if selected {
1842        lbl = lbl.bold();
1843    }
1844
1845    let mut row = View::new(Style {
1846        flex_direction: FlexDirection::Row,
1847        size: Size { width: percent(1.0_f32), height: length(28.0_f32) },
1848        align_items: Some(AlignItems::CENTER),
1849        gap: Size { width: length(8.0_f32), height: length(0.0_f32) },
1850        padding: Rect {
1851            left: length(6.0_f32),
1852            right: length(6.0_f32),
1853            top: length(0.0_f32),
1854            bottom: length(0.0_f32),
1855        },
1856        ..Default::default()
1857    })
1858    .radius(6.0)
1859    .hover_fill(theme.bg_row_hover)
1860    .on_click(msg)
1861    .children(vec![dot, lbl]);
1862    if selected {
1863        row = row.fill(theme.bg_selected);
1864    }
1865    row
1866}
1867
1868/// Sólo el **swatch disparador** del color (compacto): al clickearlo abre el
1869/// POPUP anclado (`colorpicker_overlay`) en vez de pintar el picker inline
1870/// ocupando la ventana. El swatch manda sus coords absolutas vía `on_click_screen`
1871/// (igual que el disparador del select).
1872fn color_control<Msg, F>(
1873    field: &Field,
1874    path: FieldPath,
1875    _state: &AllichayState,
1876    theme: &Theme,
1877    on_msg: F,
1878) -> View<Msg>
1879where
1880    Msg: Clone + Send + Sync + 'static,
1881    F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static,
1882{
1883    let cur = field.value.as_color().unwrap_or([0, 0, 0, 255]);
1884    let pal = ColorPopupPalette::from_theme(theme);
1885    let p = path;
1886    // Swatch 40×24 (mismo look que `color_trigger_view`) pero con coords: ancla el
1887    // popup a la esquina inferior-izquierda del swatch (`y + h`).
1888    View::new(Style {
1889        size: Size { width: length(40.0_f32), height: length(24.0_f32) },
1890        flex_shrink: 0.0,
1891        ..Default::default()
1892    })
1893    .fill(Color::from_rgba8(cur[0], cur[1], cur[2], cur[3]))
1894    .radius(5.0)
1895    .border(1.0, pal.border)
1896    .on_click_screen(move |x, y, w, h| Some(on_msg(AllichayMsg::ToggleColorPicker(p.clone(), x, y + h, w))))
1897}
1898
1899/// El POPUP de un color-picker abierto (`Control::ColorPicker`), para pintarlo en
1900/// `view_overlay` ANCLADO al swatch y encima de todo (espeja [`dropdown_overlay`]).
1901/// `None` si no hay ninguno abierto o el campo no está en `schema`.
1902pub fn colorpicker_overlay<Msg, F>(
1903    schema: &Schema,
1904    state: &AllichayState,
1905    theme: &Theme,
1906    viewport: (f32, f32),
1907    on_msg: F,
1908) -> Option<View<Msg>>
1909where
1910    Msg: Clone + Send + Sync + 'static,
1911    F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static,
1912{
1913    let (path, ax, ay, w) = state.open_colorpicker_anchor()?;
1914    let field = find_color_field(schema, path)?;
1915    let cur = field.value.as_color().unwrap_or([0, 0, 0, 255]);
1916    let hex_on = on_msg.clone();
1917    let hex_path = path.clone();
1918    let hex = HexField {
1919        focused: state.is_editing_hex(path),
1920        state: state.input(path),
1921        on_focus: on_msg(AllichayMsg::FocusHex(path.clone())),
1922        on_input: std::sync::Arc::new(move |ev| hex_on(AllichayMsg::Input(hex_path.clone(), ev))),
1923    };
1924    let change_path = path.clone();
1925    let om = on_msg.clone();
1926    let orbit_msg = on_msg.clone();
1927    let dismiss = on_msg(AllichayMsg::ToggleColorPicker(path.clone(), ax, ay, w));
1928    let pal = ColorPopupPalette::from_theme(theme);
1929    let spec = ColorPopupSpec {
1930        anchor: (ax, ay),
1931        viewport,
1932        rgba: cur,
1933        orbit: state.cone_orbit(),
1934        recent: &[],
1935        swatches: DEFAULT_SWATCHES,
1936        hex: Some(hex),
1937        palette: &pal,
1938        on_change: std::sync::Arc::new(move |rgba| {
1939            om(AllichayMsg::Change(change_path.clone(), FieldValue::Color(rgba)))
1940        }),
1941        on_orbit: std::sync::Arc::new(move |dy, dp| orbit_msg(AllichayMsg::ColorOrbit(dy, dp))),
1942        on_dismiss: dismiss,
1943    };
1944    Some(color_popup_view(spec))
1945}
1946
1947/// Busca el campo `Control::ColorPicker` en `path` dentro del schema (reusa el
1948/// mismo recorrido que el select, y filtra por tipo de control).
1949fn find_color_field<'a>(schema: &'a Schema, path: &FieldPath) -> Option<&'a Field> {
1950    let field = find_dropdown_field(schema, path)?;
1951    matches!(field.control, Control::ColorPicker).then_some(field)
1952}
1953
1954fn text_control<Msg, F>(
1955    field: &Field,
1956    path: FieldPath,
1957    state: &AllichayState,
1958    theme: &Theme,
1959    on_msg: F,
1960    secreto: bool,
1961) -> View<Msg>
1962where
1963    Msg: Clone + Send + Sync + 'static,
1964    F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static,
1965{
1966    let cur = field.value.as_str().unwrap_or("");
1967    let palette = TextInputPalette::from_theme(theme);
1968    let focused = state.is_focused(&path);
1969    // Estado a pintar: el REAL si el campo está focado; si no, uno temporal sembrado
1970    // con el valor actual (sólo para MOSTRAR el texto y medir la x del click).
1971    // El enmascarado se decide ACÁ, al pintar, y no en `focus`: así vale para
1972    // cualquier estado, venga de donde venga, y ningún host puede olvidarlo.
1973    let mut tmp = if secreto { TextInputState::masked() } else { TextInputState::new() };
1974    let secreto_focado;
1975    let st: &TextInputState = match (focused, state.input(&path)) {
1976        (true, Some(real)) if secreto => {
1977            // El estado real se clona sólo para ponerle la máscara. El `box_w`
1978            // viaja en un `Arc`, así que el scroll horizontal sigue siendo el
1979            // mismo; lo único que cambia es cómo se dibuja.
1980            secreto_focado = {
1981                let mut c = real.clone();
1982                c.set_masked(true);
1983                c
1984            };
1985            &secreto_focado
1986        }
1987        (true, Some(real)) => real,
1988        _ => {
1989            // Un campo secreto NO se siembra con su valor: ni siquiera los
1990            // puntitos, porque su largo ya dice algo. Vacío hasta que lo tipeen.
1991            if !secreto {
1992                tmp.set_text(cur);
1993            }
1994            &tmp
1995        }
1996    };
1997    // SIEMPRE el camino COMPLETO (no el básico): el box emite un `Press` posicionado
1998    // desde el 1er click. El host, ante un `Press` sobre un campo no focado, lo
1999    // enfoca+siembra y recién aplica — así el PRIMER click llega al estado y el
2000    // doble-click (palabra) cuenta desde el toque uno. Teclas/copiar-pegar: apply_key.
2001    let ip = path.clone();
2002    text_input_view_full(st, "", focused, &palette, move |ev| {
2003        on_msg(AllichayMsg::Input(ip.clone(), ev))
2004    })
2005}
2006
2007// =====================================================================
2008// Agregados: lista y tabla
2009// =====================================================================
2010
2011/// Delega en `list_view` del widget agnóstico `llimphi-widget-table`. El foco de
2012/// celda lo posee el estado ([`AllichayState::focused_cell_of`]/`input_cell`);
2013/// cada alta/baja/edición emite el [`FieldValue::List`] entero (protocolo "valor
2014/// entero").
2015fn list_control<Msg, F>(
2016    field: &Field,
2017    path: FieldPath,
2018    item_label: &str,
2019    state: &AllichayState,
2020    theme: &Theme,
2021    on_msg: F,
2022) -> View<Msg>
2023where
2024    Msg: Clone + Send + Sync + 'static,
2025    F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static,
2026{
2027    let items: Vec<String> = field.value.as_list().map(<[String]>::to_vec).unwrap_or_default();
2028    let value = field.value.clone();
2029    let focused = state.focused_cell_of(&path);
2030    let focused_state = focused.and_then(|(r, c)| state.input_cell(&path, r, c));
2031    let add_label = if item_label.is_empty() {
2032        "Agregar".to_string()
2033    } else {
2034        format!("+ {item_label}")
2035    };
2036
2037    let (p_focus, p_remove, p_add) = (path.clone(), path.clone(), path);
2038    let (m_focus, m_remove, m_add) = (on_msg.clone(), on_msg.clone(), on_msg);
2039    let v_remove = value.clone();
2040    list_view(
2041        &items,
2042        focused.map(|(r, _)| r),
2043        focused_state,
2044        &add_label,
2045        &TablePalette::from_theme(theme),
2046        move |r| m_focus(AllichayMsg::FocusCell(p_focus.clone(), r, 0)),
2047        move |r| m_remove(AllichayMsg::Change(p_remove.clone(), v_remove.with_row_removed(r))),
2048        move || m_add(AllichayMsg::Change(p_add.clone(), value.with_row_pushed(1))),
2049    )
2050}
2051
2052/// Delega en `table_view` del widget agnóstico `llimphi-widget-table`, igual que
2053/// [`list_control`] pero con encabezados por columna. Cada cambio emite el
2054/// [`FieldValue::Table`] entero.
2055fn table_control<Msg, F>(
2056    field: &Field,
2057    path: FieldPath,
2058    columns: &[allichay::Column],
2059    state: &AllichayState,
2060    theme: &Theme,
2061    on_msg: F,
2062) -> View<Msg>
2063where
2064    Msg: Clone + Send + Sync + 'static,
2065    F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static,
2066{
2067    let headers: Vec<String> = columns.iter().map(|c| c.label.clone()).collect();
2068    let rows: Vec<Vec<String>> = field
2069        .value
2070        .as_table()
2071        .map(<[Vec<String>]>::to_vec)
2072        .unwrap_or_default();
2073    let ncols = columns.len();
2074    let value = field.value.clone();
2075    let focused = state.focused_cell_of(&path);
2076    let focused_state = focused.and_then(|(r, c)| state.input_cell(&path, r, c));
2077
2078    let (p_focus, p_remove, p_add) = (path.clone(), path.clone(), path);
2079    let (m_focus, m_remove, m_add) = (on_msg.clone(), on_msg.clone(), on_msg);
2080    let v_remove = value.clone();
2081    table_view(
2082        &headers,
2083        &rows,
2084        focused,
2085        focused_state,
2086        "+ fila",
2087        &TablePalette::from_theme(theme),
2088        move |r, c| m_focus(AllichayMsg::FocusCell(p_focus.clone(), r, c)),
2089        move |r| m_remove(AllichayMsg::Change(p_remove.clone(), v_remove.with_row_removed(r))),
2090        move || m_add(AllichayMsg::Change(p_add.clone(), value.with_row_pushed(ncols))),
2091    )
2092}
2093
2094// =====================================================================
2095// Tests del estado del renderizador
2096// =====================================================================
2097//
2098// Cubren la lógica con estado de `AllichayState` (foco de campo escalar, de
2099// celda de agregado y de hex; reconstrucción del `FieldValue` entero en cada
2100// tecla; selección de diente que reinicia scroll + foco). El vocabulario en sí
2101// (`Schema`/`Field`/`FieldValue`) se testea en el crate `allichay`.
2102#[cfg(test)]
2103mod pruebas_de_alto {
2104    use super::*;
2105    use allichay::Section;
2106
2107    fn seccion(con_ayuda: bool) -> Schema {
2108        let mut s = Section::new("s", "Sección");
2109        if con_ayuda {
2110            s = s.help("qué hace esta sección");
2111        }
2112        for i in 0..8 {
2113            let f = Field::toggle(format!("t{i}"), format!("Perilla {i}"), false);
2114            s = s.field(if con_ayuda { f.help("qué prende esta perilla") } else { f });
2115        }
2116        Schema { sections: vec![s] }
2117    }
2118
2119    /// La ayuda **se pinta y por lo tanto se mide**. Antes no se contaba, y como
2120    /// `scroll_y` sólo dibuja la barra si hay desborde, un panel subestimado
2121    /// quedaba sin barra y con el final inalcanzable a la vez.
2122    #[test]
2123    fn la_ayuda_de_cada_campo_entra_en_el_alto() {
2124        let con = estimate_height(&seccion(true));
2125        let sin = estimate_height(&seccion(false));
2126        // 8 campos + la propia sección = 9 líneas de ayuda.
2127        assert_eq!(con - sin, 9.0 * ALTO_AYUDA, "con={con} sin={sin}");
2128    }
2129
2130    /// Que el número no se despegue del que pinta `help_view`: si alguien cambia
2131    /// el alto de la línea, tiene que cambiar en los dos lados a la vez.
2132    #[test]
2133    fn el_alto_de_ayuda_es_uno_solo() {
2134        assert_eq!(ayuda_h("algo"), ALTO_AYUDA);
2135        assert_eq!(ayuda_h(""), 0.0);
2136    }
2137}
2138
2139#[cfg(test)]
2140mod tests {
2141    use super::*;
2142    use llimphi_compositor::{Key, KeyState, Modifiers};
2143
2144    /// Un evento de tecleo de un carácter (con su `text`, que es de donde el
2145    /// editor inserta — respeta IME + modifiers).
2146    fn evtext(s: &str) -> KeyEvent {
2147        KeyEvent {
2148            key: Key::Character(s.into()),
2149            state: KeyState::Pressed,
2150            text: Some(s.to_owned()),
2151            modifiers: Modifiers::default(),
2152            repeat: false,
2153        }
2154    }
2155
2156    /// **El menú y su botón comparten un canto.** Un disparador ancho manda el
2157    /// ancho y el menú calza exacto debajo (mismo borde izquierdo); uno compacto
2158    /// —el chevron a secas de una fila del árbol del sidebar— no puede mandarlo,
2159    /// así que el menú es más ancho y se alinea por el borde DERECHO.
2160    #[test]
2161    fn el_menu_comparte_un_canto_con_su_boton() {
2162        // Campo de formulario: 136 px de disparador en x=511.
2163        let (x, ancho) = caja_del_menu(511.0, 136.0);
2164        assert_eq!((x, ancho), (511.0, 136.0), "el menú le copia ancho y borde izquierdo");
2165
2166        // Combobox compacto del árbol: 38 px en x=202 (medido en la captura del
2167        // sidebar de wawapanel). El menú es más ancho, así que empata por la derecha.
2168        let (x, ancho) = caja_del_menu(202.0, 38.0);
2169        assert_eq!(ancho, 180.0, "un chevron a secas no dicta el ancho del menú");
2170        assert_eq!(x + ancho, 202.0 + 38.0, "mismo borde derecho que el botón");
2171
2172        // Contra el borde izquierdo de la ventana el menú no se sale de la pantalla.
2173        let (x, _) = caja_del_menu(10.0, 38.0);
2174        assert!(x >= 4.0, "no se va de la pantalla por la izquierda: {x}");
2175    }
2176
2177    /// Un evento de tecla nombrada (Backspace/Enter/Escape…), sin texto.
2178    fn evkey(k: NamedKey) -> KeyEvent {
2179        KeyEvent {
2180            key: Key::Named(k),
2181            state: KeyState::Pressed,
2182            text: None,
2183            modifiers: Modifiers::default(),
2184            repeat: false,
2185        }
2186    }
2187
2188    #[test]
2189    fn buscador_enfocado_las_teclas_editan_el_filtro() {
2190        let mut st = AllichayState::new();
2191        st.sidebar.search_focused = true;
2192        // Con el buscador enfocado las teclas editan el filtro, NO los campos.
2193        assert_eq!(st.apply_key(&evtext("h")), None);
2194        st.apply_key(&evtext("o"));
2195        st.apply_key(&evtext("l"));
2196        st.apply_key(&evtext("a"));
2197        assert_eq!(st.sidebar.search, "hola");
2198        st.apply_key(&evkey(NamedKey::Backspace));
2199        assert_eq!(st.sidebar.search, "hol");
2200        st.apply_key(&evkey(NamedKey::Enter));
2201        assert!(!st.sidebar.search_focused, "Enter suelta el buscador");
2202    }
2203
2204    #[test]
2205    fn apply_sidebar_mantiene_diente_abierto_en_la_seccion_activa() {
2206        let mut st = AllichayState::new();
2207        st.select(2);
2208        assert_eq!(st.sidebar.open, Some(2));
2209        // Un mensaje del sidebar (cambiar un eje) NO colapsa el panel: el diente
2210        // abierto se re-fija en la sección seleccionada (selector fijo).
2211        st.apply_sidebar(RagSidebarMsg::SetDocked(false));
2212        assert_eq!(st.sidebar.open, Some(2), "el panel nunca colapsa");
2213        assert_eq!(st.sidebar.selected, Some(2));
2214        assert!(!st.sidebar.docked, "el eje sí se aplicó");
2215    }
2216
2217    #[test]
2218    fn field_matches_filtra_por_label_case_insensitive() {
2219        let f = Field::toggle("k", "Modo Oscuro", true);
2220        assert!(field_matches(&f, ""), "sin query pasa todo");
2221        assert!(field_matches(&f, "oscuro"));
2222        assert!(field_matches(&f, "modo"));
2223        assert!(!field_matches(&f, "idioma"));
2224    }
2225
2226    #[test]
2227    fn select_reinicia_scroll_y_foco() {
2228        let mut st = AllichayState::new();
2229        st.set_scroll(120.0);
2230        st.focus(&"a.b".into(), "x");
2231        assert!(st.is_editing());
2232        st.select(3);
2233        assert_eq!(st.selected(), 3);
2234        assert_eq!(st.scroll(), 0.0);
2235        assert!(!st.is_editing(), "cambiar de diente debe soltar el foco");
2236    }
2237
2238    #[test]
2239    fn focus_escalar_y_blur() {
2240        let mut st = AllichayState::new();
2241        let path: FieldPath = "editor.nombre".into();
2242        st.focus(&path, "hola");
2243        assert!(st.is_focused(&path));
2244        assert!(!st.is_focused(&"editor.otro".into()));
2245        st.blur();
2246        assert!(!st.is_editing());
2247        assert!(!st.is_focused(&path));
2248    }
2249
2250    #[test]
2251    fn apply_key_campo_escalar_emite_texto_entero() {
2252        let mut st = AllichayState::new();
2253        let path: FieldPath = "editor.nombre".into();
2254        st.focus(&path, "abc"); // el caret queda al final
2255        let r = st.apply_key(&evtext("d"));
2256        assert_eq!(r, Some((path, FieldValue::Text("abcd".into()))));
2257    }
2258
2259    #[test]
2260    fn apply_key_sin_foco_no_emite() {
2261        let mut st = AllichayState::new();
2262        assert_eq!(st.apply_key(&evtext("x")), None);
2263    }
2264
2265    /// PRUEBA del doble-click: dos `Press` sobre el MISMO estado (como los rutea el
2266    /// host tras el enfoque-al-primer-Press) seleccionan la palabra. Antes fallaba
2267    /// porque el 1er click caía en el camino básico (sólo foco) y el estado sólo veía
2268    /// el segundo. Con el campo SIEMPRE-full + focus-on-press, ambos clicks llegan.
2269    #[test]
2270    fn apply_input_doble_press_selecciona_palabra() {
2271        let mut st = AllichayState::new();
2272        let path: FieldPath = "editor.nombre".into();
2273        // El host, ante el 1er Press sobre un campo no focado, enfoca+siembra:
2274        st.focus(&path, "hola mundo");
2275        // x dentro de "mundo" (col ~7; char_w = 13*0.5 = 6.5; x = 11 + 7*6.5).
2276        let x = 11.0 + 7.0 * 6.5;
2277        // Un Press NO cambia el texto → NO debe emitir Change (si no, el host
2278        // persistiría y re-sembraría el campo entre clicks, reseteando el contador).
2279        assert_eq!(st.apply_input(TextInputEvent::Press(x)), None, "click 1: sin Change");
2280        assert_eq!(st.apply_input(TextInputEvent::Press(x)), None, "click 2: sin Change");
2281        let buf = st.input(&path).expect("buffer focado existe");
2282        assert_eq!(
2283            buf.editor().selected_text().as_deref(),
2284            Some("mundo"),
2285            "el doble-click debe seleccionar la palabra bajo el cursor"
2286        );
2287    }
2288
2289    #[test]
2290    fn menu_edicion_abre_con_context_y_aplica_accion() {
2291        let mut st = AllichayState::new();
2292        let path: FieldPath = "editor.nombre".into();
2293        st.focus(&path, "hola mundo");
2294        // Sin menú al empezar.
2295        assert_eq!(st.edit_menu_anchor(), None);
2296        // Click derecho abre el menú en las coords dadas (no cambia el texto).
2297        assert_eq!(st.apply_input(TextInputEvent::Context { x: 40.0, y: 12.0 }), None);
2298        assert_eq!(st.edit_menu_anchor(), Some((40.0, 12.0)));
2299        // Seleccionar todo desde el menú (no cambia el texto → sin Change) y cierra.
2300        assert_eq!(st.apply_edit_action(EditAction::SelectAll), None);
2301        assert_eq!(st.edit_menu_anchor(), None, "aplicar una acción cierra el menú");
2302        let buf = st.input(&path).expect("buffer focado");
2303        assert_eq!(buf.editor().selected_text().as_deref(), Some("hola mundo"));
2304        // Cortar borra la selección → emite Change con el texto vacío.
2305        let cambio = st.apply_edit_action(EditAction::Cut);
2306        assert_eq!(cambio, Some((path.clone(), FieldValue::Text(String::new()))));
2307    }
2308
2309    #[test]
2310    fn context_sin_foco_no_abre_menu() {
2311        // La vista full sólo emite eventos focada, pero por robustez: sin foco el
2312        // Context no arma el menú (no habría campo sobre el que operar).
2313        let mut st = AllichayState::new();
2314        assert_eq!(st.apply_input(TextInputEvent::Context { x: 5.0, y: 5.0 }), None);
2315        assert_eq!(st.edit_menu_anchor(), None);
2316    }
2317
2318    #[test]
2319    fn close_edit_menu_cierra() {
2320        let mut st = AllichayState::new();
2321        let path: FieldPath = "editor.nombre".into();
2322        st.focus(&path, "hola");
2323        st.apply_input(TextInputEvent::Context { x: 1.0, y: 2.0 });
2324        assert!(st.edit_menu_anchor().is_some());
2325        st.close_edit_menu();
2326        assert_eq!(st.edit_menu_anchor(), None);
2327    }
2328
2329    #[test]
2330    fn focus_cell_lista_reconstruye_el_agregado() {
2331        let mut st = AllichayState::new();
2332        let path: FieldPath = "rutas".into();
2333        let base = FieldValue::List(vec!["a".into(), "b".into()]);
2334        st.focus_cell(&path, base, 0, 0); // siembra "a", caret al final
2335        assert!(st.is_focused_cell(&path, 0, 0));
2336        assert!(!st.is_focused_cell(&path, 1, 0));
2337        let r = st.apply_key(&evtext("x"));
2338        assert_eq!(
2339            r,
2340            Some((path, FieldValue::List(vec!["ax".into(), "b".into()])))
2341        );
2342    }
2343
2344    #[test]
2345    fn focus_cell_tabla_solo_toca_su_celda() {
2346        let mut st = AllichayState::new();
2347        let path: FieldPath = "menu".into();
2348        let base = FieldValue::Table(vec![
2349            vec!["A".into(), "a".into()],
2350            vec!["B".into(), "b".into()],
2351        ]);
2352        st.focus_cell(&path, base, 1, 1); // siembra "b"
2353        let r = st.apply_key(&evtext("z"));
2354        assert_eq!(
2355            r,
2356            Some((
2357                path,
2358                FieldValue::Table(vec![
2359                    vec!["A".into(), "a".into()],
2360                    vec!["B".into(), "bz".into()],
2361                ])
2362            ))
2363        );
2364    }
2365
2366    #[test]
2367    fn focus_cell_fuera_de_rango_no_enfoca() {
2368        let mut st = AllichayState::new();
2369        let path: FieldPath = "rutas".into();
2370        let base = FieldValue::List(vec!["a".into()]);
2371        st.focus_cell(&path, base, 5, 0); // coordenada inexistente
2372        assert!(!st.is_editing(), "no debe enfocar una celda fuera de rango");
2373    }
2374
2375    #[test]
2376    fn apply_key_hex_emite_color_solo_si_parsea() {
2377        let mut st = AllichayState::new();
2378        let path: FieldPath = "apariencia.acento".into();
2379        // Semilla de 5 dígitos: incompleta, no parsea todavía.
2380        st.focus_hex(&path, "#11223");
2381        assert!(st.is_editing_hex(&path));
2382        // Al completar el 6º dígito el hex parsea → emite el Color.
2383        let r = st.apply_key(&evtext("3"));
2384        assert_eq!(r, Some((path, FieldValue::Color([0x11, 0x22, 0x33, 255]))));
2385    }
2386
2387    #[test]
2388    fn focos_son_mutuamente_excluyentes() {
2389        let mut st = AllichayState::new();
2390        let path: FieldPath = "a.b".into();
2391        st.focus_cell(&path, FieldValue::List(vec!["v".into()]), 0, 0);
2392        assert!(st.is_focused_cell(&path, 0, 0));
2393        // Pasar a editar el hex de otro campo descarta el foco de celda.
2394        let hex_path: FieldPath = "a.color".into();
2395        st.focus_hex(&hex_path, "#000000");
2396        assert!(st.is_editing_hex(&hex_path));
2397        assert!(!st.is_focused_cell(&path, 0, 0));
2398    }
2399}