gioser

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Definiciones

⎇ c5a232cf24788ab75a850ffe81eca5a7af50e2f8 modules/allichay/src/lib.rs Rust · 2201 líneas · 85.8 KB Historia Quién tocó qué crudo

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