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

⎇ e548698fc442f53d1977ef2838ef4dea94d545e2 modules/allichay/src/lib.rs Rust · 1296 líneas · 45.3 KB Historia Quién tocó qué crudo

refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel ccab39f14 · 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 **rail de dientes**
5//! (`llimphi-widget-dock-rail`) para las secciones top-level y controles
6//! escalares para los campos (toggle, slider, dropdown, color, texto). Los
7//! cambios salen como un [`AllichayMsg`] que el host mapea a su propio `Msg`.
8//!
9//! Es un módulo al estilo de `command-palette`: aporta `state + Msg + apply_key
10//! + view`. El host:
11//!
12//! 1. guarda un [`AllichayState`] en su modelo;
13//! 2. llama [`allichay_view`] en su `view`, mapeando [`AllichayMsg`] → su `Msg`;
14//! 3. enruta las teclas a [`AllichayState::apply_key`] cuando hay un campo de
15//!    texto focado;
16//! 4. ante un [`AllichayMsg::Change`], aplica el `(FieldPath, FieldValue)` a su
17//!    config (vía [`allichay::Configurable::apply`]) y la persiste con su propio
18//!    `save()` — allichay no toca el disco.
19//!
20//! Subsecciones: en v1 se pintan como grupos con encabezado dentro del panel de
21//! la sección activa (un solo nivel de dientes). El panel central combina las
22//! apps en un esquema donde cada app es una sección-diente y sus secciones
23//! reales bajan a subsecciones — así un mismo rail navega "apps → secciones".
24
25#![forbid(unsafe_code)]
26
27use allichay::{Control, Field, FieldPath, FieldValue, Schema};
28
29use llimphi_ui::llimphi_layout::taffy::{
30    prelude::{length, percent, Dimension, FlexDirection, Size, Style},
31    AlignItems, JustifyContent, Rect,
32};
33use llimphi_ui::llimphi_raster::peniko::Color;
34use llimphi_ui::llimphi_text::Alignment;
35use llimphi_ui::{DragPhase, KeyEvent, View};
36use llimphi_theme::Theme;
37
38use llimphi_widget_color_picker::{
39    color_picker_height, color_picker_view, parse_hex, ColorPickerPalette, HexField,
40    DEFAULT_SWATCHES,
41};
42
43/// Re-exportado para que los hosts siembren el buffer del campo hex sin duplicar
44/// el formato `#RRGGBB` (lo usan al manejar [`AllichayMsg::FocusHex`]).
45pub use llimphi_widget_color_picker::rgba_to_hex as color_hex;
46use llimphi_widget_dock_rail::{dock_rail_view, DockRailItem, DockRailPalette};
47use llimphi_widget_modal::{modal_view, ModalButton, ModalPalette, ModalSpec};
48use llimphi_widget_scroll::{clamp_offset, scroll_y, ScrollPalette};
49use llimphi_widget_segmented::{segmented_view, SegmentedPalette};
50use llimphi_widget_slider::{slider_view, SliderPalette};
51use llimphi_widget_switch::{switch_view, SwitchPalette};
52use llimphi_widget_table::{list_view, table_view, table_height, TablePalette};
53use llimphi_widget_text_input::{text_input_view, TextInputPalette, TextInputState};
54
55use std::collections::BTreeMap;
56
57/// Ancho del rail de dientes (px). El diente es la pestañita que sobresale: un
58/// icono, no un rótulo (el nombre lo lleva el encabezado del panel desplegado).
59const RAIL_W: f32 = 52.0;
60
61/// Cuántas opciones de un [`Control::Dropdown`] caben cómodas como botones
62/// segmentados en una fila. Por encima de esto el segmented se amontona y
63/// desborda (p. ej. 7 modos de tiling), así que el renderer cae a un
64/// **radio-group vertical** (una fila seleccionable por opción).
65const SEGMENTED_MAX: usize = 5;
66
67// =====================================================================
68// Mensajes del módulo
69// =====================================================================
70
71/// Lo que emite el renderizador. El host lo envuelve en su propio `Msg`
72/// (típicamente `Msg::Allichay(AllichayMsg)`) y lo resuelve en su `update`.
73#[derive(Debug, Clone, PartialEq)]
74pub enum AllichayMsg {
75    /// Se clickeó el diente de la sección `índice`.
76    SelectSection(usize),
77    /// Se enfocó un campo de texto (para empezar a teclear sobre él).
78    Focus(FieldPath),
79    /// Se enfocó la celda `(row, col)` de un agregado (lista/tabla) en `path`.
80    /// El host siembra el buffer con [`AllichayState::focus_cell`] pasándole el
81    /// [`FieldValue`] actual del campo.
82    FocusCell(FieldPath, usize, usize),
83    /// Se enfocó el campo **hex** de un [`Control::ColorPicker`] en `path`. El
84    /// host siembra el buffer con [`AllichayState::focus_hex`].
85    FocusHex(FieldPath),
86    /// Un campo cambió de valor. El host lo aplica a su config y persiste.
87    Change(FieldPath, FieldValue),
88    /// El panel se desplazó: nuevo offset absoluto (ya clampeado).
89    ScrollTo(f32),
90}
91
92// =====================================================================
93// Estado del módulo
94// =====================================================================
95
96/// El estado que el host guarda en su modelo: qué diente está activo y los
97/// buffers de edición de los campos de texto (con su foco).
98#[derive(Debug, Clone, Default)]
99pub struct AllichayState {
100    selected: usize,
101    /// Buffers de los campos de texto, por clave de foco. Para un campo escalar
102    /// la clave es su `FieldPath` serializado; para una celda de agregado es una
103    /// clave compuesta (ver [`cell_key`]). Sólo existe entrada para lo que se
104    /// está editando.
105    inputs: BTreeMap<String, TextInputState>,
106    /// La clave del campo/celda de texto focado, si hay uno.
107    focused: Option<String>,
108    /// Contexto de edición cuando lo focado es una celda de agregado: el campo,
109    /// su valor base (la lista/tabla completa, fija durante la edición) y la
110    /// coordenada. `None` cuando se edita un campo de texto escalar.
111    edit_cell: Option<EditCell>,
112    /// `true` cuando lo focado es el campo **hex** de un `ColorPicker` (la clave
113    /// focada es el `FieldPath` del campo Color). Mutuamente excluyente con
114    /// `edit_cell` y con la edición de texto escalar.
115    edit_hex: bool,
116    /// Desplazamiento vertical (px) del panel activo, si su contenido excede el
117    /// viewport. Se reinicia al cambiar de diente.
118    scroll: f32,
119}
120
121/// Lo que el estado guarda mientras se teclea una celda de lista/tabla: con esto
122/// reconstruye el [`FieldValue`] entero y nuevo en cada tecla (protocolo "valor
123/// entero"), sin que el host tenga que conocer las coordenadas.
124#[derive(Debug, Clone)]
125struct EditCell {
126    path: FieldPath,
127    base: FieldValue,
128    row: usize,
129    col: usize,
130}
131
132/// La clave de buffer de una celda de agregado. Usa separadores que no aparecen
133/// en un `FieldPath` (sus segmentos van con `.`).
134fn cell_key(path: &FieldPath, row: usize, col: usize) -> String {
135    format!("{path}\u{1}{row}\u{1}{col}")
136}
137
138impl AllichayState {
139    /// Estado inicial: primera sección, sin foco.
140    pub fn new() -> Self {
141        Self::default()
142    }
143
144    /// Índice de la sección activa.
145    pub fn selected(&self) -> usize {
146        self.selected
147    }
148
149    /// Selecciona la sección `i`. Limpia el foco de texto y el scroll (cambiar
150    /// de diente arranca arriba, sin edición en curso).
151    pub fn select(&mut self, i: usize) {
152        self.selected = i;
153        self.scroll = 0.0;
154        self.blur();
155    }
156
157    /// Offset de scroll actual del panel (px).
158    pub fn scroll(&self) -> f32 {
159        self.scroll
160    }
161
162    /// Fija el offset de scroll (el valor ya viene clampeado por el renderer).
163    pub fn set_scroll(&mut self, offset: f32) {
164        self.scroll = offset;
165    }
166
167    /// Enfoca un campo de texto, sembrando su buffer con el valor actual `seed`.
168    pub fn focus(&mut self, path: &FieldPath, seed: &str) {
169        let key = path.to_string();
170        let mut st = TextInputState::new();
171        st.set_text(seed);
172        self.inputs.insert(key.clone(), st);
173        self.focused = Some(key);
174        self.edit_cell = None;
175        self.edit_hex = false;
176    }
177
178    /// Enfoca el campo hex de un `ColorPicker`. `seed` es el hex actual
179    /// (`#RRGGBB`). La clave focada es el `FieldPath` del campo Color.
180    pub fn focus_hex(&mut self, path: &FieldPath, seed: &str) {
181        let key = path.to_string();
182        let mut st = TextInputState::new();
183        st.set_text(seed);
184        self.inputs.insert(key.clone(), st);
185        self.focused = Some(key);
186        self.edit_cell = None;
187        self.edit_hex = true;
188    }
189
190    /// Enfoca la celda `(row, col)` de un agregado. El host le pasa el
191    /// [`FieldValue`] actual del campo (`value`); el estado lee de ahí el texto
192    /// inicial de la celda y guarda el valor como base para reconstruir el
193    /// cambio entero en cada tecla. Si la coordenada no corresponde a una celda,
194    /// no enfoca nada.
195    pub fn focus_cell(&mut self, path: &FieldPath, value: FieldValue, row: usize, col: usize) {
196        let Some(seed) = value.cell(row, col).map(str::to_string) else {
197            return;
198        };
199        let key = cell_key(path, row, col);
200        let mut st = TextInputState::new();
201        st.set_text(&seed);
202        self.inputs.insert(key.clone(), st);
203        self.focused = Some(key);
204        self.edit_cell = Some(EditCell {
205            path: path.clone(),
206            base: value,
207            row,
208            col,
209        });
210        self.edit_hex = false;
211    }
212
213    /// Quita el foco de texto (sin descartar nada — el valor ya viajó por
214    /// `Change` en cada tecla).
215    pub fn blur(&mut self) {
216        self.focused = None;
217        self.edit_cell = None;
218        self.edit_hex = false;
219        self.inputs.clear();
220    }
221
222    /// `true` si hay un campo de texto en edición (para que el host enrute las
223    /// teclas a [`AllichayState::apply_key`] sólo cuando hace falta).
224    pub fn is_editing(&self) -> bool {
225        self.focused.is_some()
226    }
227
228    /// `true` si `path` es el campo de texto focado.
229    pub fn is_focused(&self, path: &FieldPath) -> bool {
230        self.focused.as_deref() == Some(path.to_string().as_str())
231    }
232
233    /// `true` si la celda `(row, col)` de `path` es la focada.
234    pub fn is_focused_cell(&self, path: &FieldPath, row: usize, col: usize) -> bool {
235        self.focused.as_deref() == Some(cell_key(path, row, col).as_str())
236    }
237
238    /// `true` si el campo hex de `path` (un `ColorPicker`) está en edición.
239    pub fn is_editing_hex(&self, path: &FieldPath) -> bool {
240        self.edit_hex && self.focused.as_deref() == Some(path.to_string().as_str())
241    }
242
243    /// Enruta una tecla al campo/celda de texto focado. Devuelve el cambio
244    /// resultante (`FieldPath`, [`FieldValue`]) si el contenido cambió, para que
245    /// el host lo aplique y persista. Para un campo escalar es un
246    /// [`FieldValue::Text`]; para una celda de agregado es el agregado entero
247    /// con esa celda reemplazada. `None` si no hay foco o la tecla no editó.
248    pub fn apply_key(&mut self, event: &KeyEvent) -> Option<(FieldPath, FieldValue)> {
249        let key = self.focused.clone()?;
250        let st = self.inputs.get_mut(&key)?;
251        if !st.apply_key(event) {
252            return None;
253        }
254        let text = st.text();
255        if let Some(c) = &self.edit_cell {
256            return Some((c.path.clone(), c.base.clone().with_cell(c.row, c.col, &text)));
257        }
258        if self.edit_hex {
259            // El buffer es un hex; sólo emitimos cuando parsea a un color válido
260            // (mientras se escribe a medias no se aplica, pero el buffer se
261            // conserva). El alfa actual no está acá → 255; el slider A lo ajusta.
262            let path = FieldPath::from(key.as_str());
263            return parse_hex(&text, 255).map(|rgba| (path, FieldValue::Color(rgba)));
264        }
265        Some((FieldPath::from(key.as_str()), FieldValue::Text(text)))
266    }
267
268    /// Acceso interno al buffer de un campo escalar focado para pintarlo.
269    fn input(&self, path: &FieldPath) -> Option<&TextInputState> {
270        self.inputs.get(&path.to_string())
271    }
272
273    /// Acceso interno al buffer de una celda focada para pintarla.
274    fn input_cell(&self, path: &FieldPath, row: usize, col: usize) -> Option<&TextInputState> {
275        self.inputs.get(&cell_key(path, row, col))
276    }
277
278    /// La celda focada `(row, col)` si pertenece a `path`; `None` si el foco está
279    /// en otro campo o no hay foco de celda. Para pasarle al widget de tabla cuál
280    /// celda de ESTE agregado está en edición.
281    fn focused_cell_of(&self, path: &FieldPath) -> Option<(usize, usize)> {
282        self.edit_cell
283            .as_ref()
284            .filter(|c| &c.path == path)
285            .map(|c| (c.row, c.col))
286    }
287}
288
289// =====================================================================
290// Vista
291// =====================================================================
292
293/// Pinta el esquema completo: rail de dientes (secciones) + panel de la sección
294/// activa. `on_msg` mapea los [`AllichayMsg`] del módulo al `Msg` del host.
295pub fn allichay_view<Msg, F>(
296    schema: &Schema,
297    state: &AllichayState,
298    theme: &Theme,
299    on_msg: F,
300) -> View<Msg>
301where
302    Msg: Clone + Send + Sync + 'static,
303    F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static,
304{
305    let rail = build_rail(schema, state, theme, on_msg.clone());
306    let panel = build_panel(schema, state, theme, on_msg);
307
308    View::new(Style {
309        flex_direction: FlexDirection::Row,
310        size: Size {
311            width: percent(1.0_f32),
312            height: percent(1.0_f32),
313        },
314        gap: Size {
315            width: length(8.0_f32),
316            height: length(0.0_f32),
317        },
318        ..Default::default()
319    })
320    .children(vec![rail, panel])
321}
322
323/// Pinta la configuración como un **modal centrado** (scrim + card), para
324/// **embeber** el panel dentro de una app con ventana propia (media, nada…): la
325/// app guarda un [`AllichayState`], abre el overlay con una tecla y lo pinta en
326/// su `view_overlay`, enrutando los [`AllichayMsg`] como siempre. Reusa
327/// `llimphi-widget-modal` (scrim + card + título + botón Cerrar) con
328/// [`allichay_view`] como cuerpo. `on_dismiss` se emite al clickear el scrim o
329/// el botón Cerrar (la app maneja Esc en su `on_key`).
330#[allow(clippy::too_many_arguments)]
331pub fn settings_overlay<Msg, F>(
332    title: impl Into<String>,
333    close_label: impl Into<String>,
334    schema: &Schema,
335    state: &AllichayState,
336    theme: &Theme,
337    viewport: (f32, f32),
338    on_msg: F,
339    on_dismiss: Msg,
340) -> View<Msg>
341where
342    Msg: Clone + Send + Sync + 'static,
343    F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static,
344{
345    let body = allichay_view(schema, state, theme, on_msg);
346    modal_view(ModalSpec {
347        title: title.into(),
348        body,
349        buttons: vec![ModalButton::cancel(close_label, on_dismiss.clone())],
350        size: (640.0, 460.0),
351        viewport,
352        on_dismiss,
353        palette: ModalPalette::from_theme(theme),
354    })
355}
356
357/// Rail de **dientes** (el widget `dock-rail`): una sección = un diente. El
358/// diente sobresale del rail, lleva su icono y se arrastra; al activarse muestra
359/// su panel (las demás secciones quedan ocultas hasta que se las elige).
360fn build_rail<Msg, F>(schema: &Schema, state: &AllichayState, theme: &Theme, on_msg: F) -> View<Msg>
361where
362    Msg: Clone + Send + Sync + 'static,
363    F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static,
364{
365    let sel = state.selected.min(schema.sections.len().saturating_sub(1));
366    let items: Vec<DockRailItem> = schema
367        .sections
368        .iter()
369        .enumerate()
370        .map(|(i, _)| DockRailItem {
371            id: i as u64,
372            active: i == sel,
373        })
374        .collect();
375    let icons: Vec<String> = schema.sections.iter().map(|s| s.icon.clone()).collect();
376    let activate = on_msg;
377    dock_rail_view(
378        &items,
379        RAIL_W,
380        &DockRailPalette::from_theme(theme),
381        move |id, size, color| tooth_icon(icons.get(id as usize).cloned(), size, color),
382        move |id| activate(AllichayMsg::SelectSection(id as usize)),
383        |_| None,
384    )
385}
386
387/// Dibuja el icono de un diente (un glifo emoji que la fuente tenga), con el
388/// color ya resuelto por el widget según el estado activo/inactivo.
389fn tooth_icon<Msg: Clone + 'static>(glyph: Option<String>, size: f32, color: Color) -> View<Msg> {
390    View::new(Style {
391        size: Size {
392            width: length(size),
393            height: length(size),
394        },
395        align_items: Some(AlignItems::Center),
396        justify_content: Some(JustifyContent::Center),
397        ..Default::default()
398    })
399    .text_aligned(
400        glyph.unwrap_or_else(|| "•".to_string()),
401        size * 0.9,
402        color,
403        Alignment::Center,
404    )
405}
406
407fn build_panel<Msg, F>(schema: &Schema, state: &AllichayState, theme: &Theme, on_msg: F) -> View<Msg>
408where
409    Msg: Clone + Send + Sync + 'static,
410    F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static,
411{
412    if schema.sections.is_empty() {
413        return View::new(panel_style()).text_aligned(
414            "Sin configuración".to_string(),
415            13.0,
416            theme.fg_muted,
417            Alignment::Start,
418        );
419    }
420    let sel = state.selected.min(schema.sections.len() - 1);
421    let section = &schema.sections[sel];
422    // La ruta base = id de la sección; los campos cuelgan de ahí.
423    let base = FieldPath::empty().push(section.id.clone());
424    section_view(section, &base, state, theme, on_msg)
425}
426
427/// Pinta el panel de **una** sección: su encabezado, sus campos y sus
428/// subsecciones (un nivel), construyendo cada `FieldPath` a partir de `base`
429/// (la ruta que ya incluye el id de la sección — p. ej. `["mirada::teselado"]`
430/// cuando el host compone varias apps en un solo rail).
431///
432/// Lo expone aparte de [`allichay_view`] para que un host con su propio rail
433/// (el panel de control central, que mezcla dientes de varias apps) reutilice
434/// el renderizado de campos sin el rail interno del módulo.
435pub fn section_view<Msg, F>(
436    section: &allichay::Section,
437    base: &FieldPath,
438    state: &AllichayState,
439    theme: &Theme,
440    on_msg: F,
441) -> View<Msg>
442where
443    Msg: Clone + Send + Sync + 'static,
444    F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static,
445{
446    View::new(panel_style()).children(section_items(section, base, state, theme, on_msg))
447}
448
449/// Los views de una sección (encabezado + campos + subsecciones), sin el
450/// contenedor — para apilar varias secciones en un mismo panel ([`schema_panel`]).
451fn section_items<Msg, F>(
452    section: &allichay::Section,
453    base: &FieldPath,
454    state: &AllichayState,
455    theme: &Theme,
456    on_msg: F,
457) -> Vec<View<Msg>>
458where
459    Msg: Clone + Send + Sync + 'static,
460    F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static,
461{
462    let mut children: Vec<View<Msg>> = Vec::new();
463    children.push(section_head(&section.title, &section.help, theme));
464
465    for field in &section.fields {
466        let path = base.clone().push(field.id.clone());
467        children.push(field_row(field, path, state, theme, on_msg.clone()));
468    }
469
470    for sub in &section.subsections {
471        children.push(subsection_head(&sub.title, theme));
472        let sub_base = base.clone().push(sub.id.clone());
473        for field in &sub.fields {
474            let path = sub_base.clone().push(field.id.clone());
475            children.push(field_row(field, path, state, theme, on_msg.clone()));
476        }
477    }
478    children
479}
480
481/// Pinta el panel de un diente: **todas** las secciones de `schema` apiladas
482/// (cada una con su encabezado de grupo), con scroll vertical si el contenido
483/// excede `viewport_h`. Es el panel de un diente del panel de control — el id
484/// de cada sección es el prefijo de ruteo (`"app::seccion"`), así que cada
485/// campo emite su `FieldPath` completo.
486///
487/// `state.scroll()` lleva el offset; el renderer emite [`AllichayMsg::ScrollTo`]
488/// con el offset ya clampeado para que el host sólo lo guarde.
489pub fn schema_panel<Msg, F>(
490    schema: &Schema,
491    state: &AllichayState,
492    theme: &Theme,
493    viewport_h: f32,
494    on_msg: F,
495) -> View<Msg>
496where
497    Msg: Clone + Send + Sync + 'static,
498    F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static,
499{
500    let mut items: Vec<View<Msg>> = Vec::new();
501    for section in &schema.sections {
502        let base = FieldPath::empty().push(section.id.clone());
503        items.extend(section_items(section, &base, state, theme, on_msg.clone()));
504    }
505
506    let content = View::new(Style {
507        flex_direction: FlexDirection::Column,
508        size: Size {
509            width: percent(1.0_f32),
510            height: Dimension::auto(),
511        },
512        gap: Size {
513            width: length(0.0_f32),
514            height: length(10.0_f32),
515        },
516        ..Default::default()
517    })
518    .children(items);
519
520    let content_len = estimate_height(schema);
521    let offset = state.scroll().min(content_len);
522    let on_scroll = on_msg;
523    let scroller = scroll_y(
524        offset,
525        content_len,
526        viewport_h,
527        content,
528        move |delta| {
529            on_scroll(AllichayMsg::ScrollTo(clamp_offset(
530                offset + delta,
531                content_len,
532                viewport_h,
533            )))
534        },
535        &ScrollPalette::from_theme(theme),
536    );
537
538    View::new(panel_style()).children(vec![scroller])
539}
540
541/// Estimación (generosa) del alto del contenido de un schema, para el scroll.
542fn estimate_height(schema: &Schema) -> f32 {
543    let mut h = 0.0_f32;
544    for section in &schema.sections {
545        h += 44.0; // encabezado de sección
546        for f in &section.fields {
547            h += field_height(f) + 13.0; // + label/gap/separación
548        }
549        for sub in &section.subsections {
550            h += 26.0; // encabezado de subsección
551            for f in &sub.fields {
552                h += field_height(f) + 13.0;
553            }
554        }
555    }
556    h + 28.0 // padding del panel
557}
558
559/// Alto del control de un campo (px). Para los agregados (lista/tabla) depende
560/// del número de filas, por eso toma el [`Field`] entero y no sólo su control.
561fn field_height(field: &Field) -> f32 {
562    match &field.control {
563        Control::Toggle => 22.0,
564        Control::Slider { .. } => 28.0,
565        // Segmented (1 fila) para pocas opciones; radio-group (N filas) si son
566        // muchas. Ver [`dropdown_control`].
567        Control::Dropdown { options } => {
568            if options.len() <= SEGMENTED_MAX {
569                28.0
570            } else {
571                options.len() as f32 * 30.0
572            }
573        }
574        // Radio: siempre lista vertical, una fila por opción.
575        Control::Radio { options } => options.len().max(1) as f32 * 30.0,
576        Control::TextInput => 34.0,
577        Control::ColorPicker => color_picker_height(true),
578        Control::List { .. } => table_height(field.value.row_count(), false),
579        Control::Table { .. } => table_height(field.value.row_count(), true),
580        Control::Display => 8.0, // fila compacta sin label arriba
581        Control::Button => 34.0, // fila compacta: el botón ES la fila
582    }
583}
584
585fn panel_style() -> Style {
586    Style {
587        flex_direction: FlexDirection::Column,
588        size: Size {
589            width: Dimension::auto(),
590            height: percent(1.0_f32),
591        },
592        flex_grow: 1.0,
593        padding: Rect {
594            left: length(20.0_f32),
595            right: length(20.0_f32),
596            top: length(14.0_f32),
597            bottom: length(14.0_f32),
598        },
599        gap: Size {
600            width: length(0.0_f32),
601            height: length(10.0_f32),
602        },
603        ..Default::default()
604    }
605}
606
607// =====================================================================
608// Encabezados y campos
609// =====================================================================
610
611fn section_head<Msg: Clone + 'static>(title: &str, help: &str, theme: &Theme) -> View<Msg> {
612    let title_v = View::new(Style {
613        size: Size {
614            width: percent(1.0_f32),
615            height: length(22.0_f32),
616        },
617        ..Default::default()
618    })
619    .text_aligned(title.to_string(), 16.0, theme.fg_text, Alignment::Start);
620    let mut kids = vec![title_v];
621    if !help.is_empty() {
622        kids.push(
623            View::new(Style {
624                size: Size {
625                    width: percent(1.0_f32),
626                    height: length(16.0_f32),
627                },
628                ..Default::default()
629            })
630            .text_aligned(help.to_string(), 11.0, theme.fg_muted, Alignment::Start),
631        );
632    }
633    kids.push(
634        View::new(Style {
635            size: Size {
636                width: percent(1.0_f32),
637                height: length(1.0_f32),
638            },
639            margin: Rect {
640                left: length(0.0_f32),
641                right: length(0.0_f32),
642                top: length(6.0_f32),
643                bottom: length(0.0_f32),
644            },
645            ..Default::default()
646        })
647        .fill(theme.border),
648    );
649    View::new(Style {
650        flex_direction: FlexDirection::Column,
651        size: Size {
652            width: percent(1.0_f32),
653            height: Dimension::auto(),
654        },
655        ..Default::default()
656    })
657    .children(kids)
658}
659
660fn subsection_head<Msg: Clone + 'static>(title: &str, theme: &Theme) -> View<Msg> {
661    View::new(Style {
662        size: Size {
663            width: percent(1.0_f32),
664            height: length(20.0_f32),
665        },
666        margin: Rect {
667            left: length(0.0_f32),
668            right: length(0.0_f32),
669            top: length(6.0_f32),
670            bottom: length(0.0_f32),
671        },
672        ..Default::default()
673    })
674    .text_aligned(title.to_string(), 12.5, theme.accent, Alignment::Start)
675}
676
677fn label_view<Msg: Clone + 'static>(label: &str, theme: &Theme) -> View<Msg> {
678    View::new(Style {
679        size: Size {
680            width: percent(1.0_f32),
681            height: length(16.0_f32),
682        },
683        ..Default::default()
684    })
685    .text_aligned(label.to_string(), 11.5, theme.fg_muted, Alignment::Start)
686}
687
688fn help_view<Msg: Clone + 'static>(help: &str, theme: &Theme) -> View<Msg> {
689    View::new(Style {
690        size: Size {
691            width: percent(1.0_f32),
692            height: length(14.0_f32),
693        },
694        ..Default::default()
695    })
696    .text_aligned(help.to_string(), 10.0, theme.fg_placeholder, Alignment::Start)
697}
698
699fn field_col_style() -> Style {
700    Style {
701        flex_direction: FlexDirection::Column,
702        size: Size {
703            width: percent(1.0_f32),
704            height: Dimension::auto(),
705        },
706        gap: Size {
707            width: length(0.0_f32),
708            height: length(3.0_f32),
709        },
710        ..Default::default()
711    }
712}
713
714/// Una fila de campo: rótulo arriba, control abajo, ayuda opcional.
715fn field_row<Msg, F>(
716    field: &Field,
717    path: FieldPath,
718    state: &AllichayState,
719    theme: &Theme,
720    on_msg: F,
721) -> View<Msg>
722where
723    Msg: Clone + Send + Sync + 'static,
724    F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static,
725{
726    let control = match &field.control {
727        Control::Toggle => toggle_control(field, path, theme, on_msg),
728        Control::Slider { min, max, step } => {
729            slider_control(field, path, *min, *max, *step, theme, on_msg)
730        }
731        Control::Dropdown { options } => dropdown_control(field, path, options, theme, on_msg),
732        Control::Radio { options } => radio_group(field, path, options, theme, on_msg),
733        Control::ColorPicker => color_control(field, path, state, theme, on_msg),
734        Control::TextInput => text_control(field, path, state, theme, on_msg),
735        Control::List { item_label } => {
736            list_control(field, path, item_label, state, theme, on_msg)
737        }
738        Control::Table { columns } => table_control(field, path, columns, state, theme, on_msg),
739        Control::Display => return display_row(field, theme),
740        // El botón ES la fila completa (lleva su rótulo dentro): no le ponemos
741        // un label arriba como a los controles escalares.
742        Control::Button => return button_control(field, path, theme, on_msg),
743    };
744
745    let mut kids = vec![label_view(&field.label, theme), control];
746    if !field.help.is_empty() {
747        kids.push(help_view(&field.help, theme));
748    }
749    View::new(field_col_style()).children(kids)
750}
751
752/// Fila de sólo lectura: rótulo a la izquierda, valor a la derecha. Para items
753/// de información del sistema (no editables).
754fn display_row<Msg: Clone + 'static>(field: &Field, theme: &Theme) -> View<Msg> {
755    let label = View::new(Style {
756        size: Size {
757            width: length(150.0_f32),
758            height: length(20.0_f32),
759        },
760        flex_shrink: 0.0,
761        ..Default::default()
762    })
763    .text_aligned(field.label.clone(), 12.0, theme.fg_muted, Alignment::Start);
764    let value = View::new(Style {
765        size: Size {
766            width: Dimension::auto(),
767            height: length(20.0_f32),
768        },
769        flex_grow: 1.0,
770        ..Default::default()
771    })
772    .text_aligned(
773        field.value.as_str().unwrap_or("").to_string(),
774        12.5,
775        theme.fg_text,
776        Alignment::Start,
777    );
778    View::new(Style {
779        flex_direction: FlexDirection::Row,
780        size: Size {
781            width: percent(1.0_f32),
782            height: length(22.0_f32),
783        },
784        align_items: Some(AlignItems::Center),
785        ..Default::default()
786    })
787    .children(vec![label, value])
788}
789
790// =====================================================================
791// Controles por tipo
792// =====================================================================
793
794fn toggle_control<Msg, F>(field: &Field, path: FieldPath, theme: &Theme, on_msg: F) -> View<Msg>
795where
796    Msg: Clone + Send + Sync + 'static,
797    F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static,
798{
799    let cur = field.value.as_bool().unwrap_or(false);
800    let progress = if cur { 1.0 } else { 0.0 };
801    let msg = on_msg(AllichayMsg::Change(path, FieldValue::Bool(!cur)));
802    switch_view(progress, msg, &SwitchPalette::from_theme(theme))
803}
804
805/// Botón de acción: una fila clickeable con el rótulo del campo. Al clickear
806/// emite `Change(path, Bool(true))` — un pulso. Sin estado on/off (a diferencia
807/// del [`toggle_control`], que era lo que antes simulaba las acciones).
808fn button_control<Msg, F>(field: &Field, path: FieldPath, theme: &Theme, on_msg: F) -> View<Msg>
809where
810    Msg: Clone + Send + Sync + 'static,
811    F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static,
812{
813    let msg = on_msg(AllichayMsg::Change(path, FieldValue::Bool(true)));
814    let lbl = View::new(Style {
815        size: Size { width: percent(1.0_f32), height: length(28.0_f32) },
816        flex_grow: 1.0,
817        align_items: Some(AlignItems::Center),
818        justify_content: Some(JustifyContent::Center),
819        ..Default::default()
820    })
821    .text_aligned(field.label.clone(), 12.5, theme.fg_text, Alignment::Center)
822    .ellipsis(1);
823    View::new(Style {
824        flex_direction: FlexDirection::Row,
825        size: Size { width: percent(1.0_f32), height: length(30.0_f32) },
826        align_items: Some(AlignItems::Center),
827        justify_content: Some(JustifyContent::Center),
828        padding: Rect {
829            left: length(10.0_f32),
830            right: length(10.0_f32),
831            top: length(0.0_f32),
832            bottom: length(0.0_f32),
833        },
834        ..Default::default()
835    })
836    .radius(7.0)
837    .fill(theme.bg_input)
838    .border(1.0, theme.border)
839    .hover_fill(theme.bg_row_hover)
840    .on_click(msg)
841    .children(vec![lbl])
842}
843
844fn slider_control<Msg, F>(
845    field: &Field,
846    path: FieldPath,
847    min: f64,
848    max: f64,
849    step: f64,
850    theme: &Theme,
851    on_msg: F,
852) -> View<Msg>
853where
854    Msg: Clone + Send + Sync + 'static,
855    F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static,
856{
857    let is_int = matches!(field.value, FieldValue::Int(_));
858    let cur = field.value.as_float().unwrap_or(0.0);
859    let palette = SliderPalette::from_theme(theme);
860    slider_view(
861        String::new(),
862        cur as f32,
863        min as f32,
864        max as f32,
865        &palette,
866        move |phase, dv| match phase {
867            DragPhase::Move => {
868                let mut nv = (cur + dv as f64).clamp(min, max);
869                if step > 0.0 {
870                    nv = (nv / step).round() * step;
871                }
872                let value = if is_int {
873                    FieldValue::Int(nv as i64)
874                } else {
875                    FieldValue::Float(nv)
876                };
877                Some(on_msg(AllichayMsg::Change(path.clone(), value)))
878            }
879            DragPhase::End => None,
880        },
881    )
882}
883
884fn dropdown_control<Msg, F>(
885    field: &Field,
886    path: FieldPath,
887    options: &[allichay::EnumOption],
888    theme: &Theme,
889    on_msg: F,
890) -> View<Msg>
891where
892    Msg: Clone + Send + Sync + 'static,
893    F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static,
894{
895    let cur = field.value.as_str().unwrap_or("");
896    // Pocas opciones: botones segmentados (compacto, una fila).
897    if options.len() <= SEGMENTED_MAX {
898        let labels: Vec<&str> = options.iter().map(|o| o.label.as_str()).collect();
899        let selected = options.iter().position(|o| o.id == cur).unwrap_or(0);
900        let ids: Vec<String> = options.iter().map(|o| o.id.clone()).collect();
901        return segmented_view(
902            &labels,
903            selected,
904            move |i| on_msg(AllichayMsg::Change(path.clone(), FieldValue::Enum(ids[i].clone()))),
905            &SegmentedPalette::from_theme(theme),
906        );
907    }
908    // Muchas opciones: radio-group vertical (reusa el render compartido).
909    radio_group(field, path, options, theme, on_msg)
910}
911
912/// Lista vertical de radios (un círculo por opción). Es el cuerpo de
913/// [`Control::Radio`] y la rama de muchas opciones del dropdown. Cada fila
914/// emite el mismo `Change` — sin overlay, sin estado, sin tocar el `update`.
915fn radio_group<Msg, F>(
916    field: &Field,
917    path: FieldPath,
918    options: &[allichay::EnumOption],
919    theme: &Theme,
920    on_msg: F,
921) -> View<Msg>
922where
923    Msg: Clone + Send + Sync + 'static,
924    F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static,
925{
926    let cur = field.value.as_str().unwrap_or("");
927    let rows: Vec<View<Msg>> = options
928        .iter()
929        .map(|o| {
930            let selected = o.id == cur;
931            let msg = on_msg(AllichayMsg::Change(path.clone(), FieldValue::Enum(o.id.clone())));
932            radio_row(&o.label, selected, msg, theme)
933        })
934        .collect();
935    View::new(Style {
936        flex_direction: FlexDirection::Column,
937        size: Size {
938            width: percent(1.0_f32),
939            height: Dimension::auto(),
940        },
941        gap: Size {
942            width: length(0.0_f32),
943            height: length(2.0_f32),
944        },
945        ..Default::default()
946    })
947    .children(rows)
948}
949
950/// Una fila de radio del dropdown largo: punto a la izquierda (relleno si está
951/// seleccionada) + rótulo. La fila entera es clickeable y resalta en hover; la
952/// seleccionada lleva fondo y rótulo en negrita.
953fn radio_row<Msg: Clone + 'static>(
954    label: &str,
955    selected: bool,
956    msg: Msg,
957    theme: &Theme,
958) -> View<Msg> {
959    let dot_inner = if selected {
960        vec![View::new(Style {
961            size: Size { width: length(8.0_f32), height: length(8.0_f32) },
962            ..Default::default()
963        })
964        .radius(4.0)
965        .fill(theme.accent)]
966    } else {
967        Vec::new()
968    };
969    let dot = View::new(Style {
970        size: Size { width: length(16.0_f32), height: length(16.0_f32) },
971        align_items: Some(AlignItems::Center),
972        justify_content: Some(JustifyContent::Center),
973        flex_shrink: 0.0,
974        ..Default::default()
975    })
976    .radius(8.0)
977    .border(1.5, if selected { theme.accent } else { theme.border })
978    .children(dot_inner);
979
980    let mut lbl = View::new(Style {
981        size: Size { width: Dimension::auto(), height: length(20.0_f32) },
982        flex_grow: 1.0,
983        ..Default::default()
984    })
985    .text_aligned(
986        label.to_string(),
987        12.5,
988        if selected { theme.fg_text } else { theme.fg_muted },
989        Alignment::Start,
990    )
991    .ellipsis(1);
992    if selected {
993        lbl = lbl.bold();
994    }
995
996    let mut row = View::new(Style {
997        flex_direction: FlexDirection::Row,
998        size: Size { width: percent(1.0_f32), height: length(28.0_f32) },
999        align_items: Some(AlignItems::Center),
1000        gap: Size { width: length(8.0_f32), height: length(0.0_f32) },
1001        padding: Rect {
1002            left: length(6.0_f32),
1003            right: length(6.0_f32),
1004            top: length(0.0_f32),
1005            bottom: length(0.0_f32),
1006        },
1007        ..Default::default()
1008    })
1009    .radius(6.0)
1010    .hover_fill(theme.bg_row_hover)
1011    .on_click(msg)
1012    .children(vec![dot, lbl]);
1013    if selected {
1014        row = row.fill(theme.bg_selected);
1015    }
1016    row
1017}
1018
1019/// Delega en el widget agnóstico `llimphi-widget-color-picker`, traduciendo su
1020/// `[u8;4]` a `AllichayMsg::Change(path, Color(..))`. El campo hex usa el foco
1021/// del estado ([`AllichayState::is_editing_hex`]/`input`); el tecleo lo enruta
1022/// el host a `apply_key`, que parsea el hex a Color.
1023fn color_control<Msg, F>(
1024    field: &Field,
1025    path: FieldPath,
1026    state: &AllichayState,
1027    theme: &Theme,
1028    on_msg: F,
1029) -> View<Msg>
1030where
1031    Msg: Clone + Send + Sync + 'static,
1032    F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static,
1033{
1034    let cur = field.value.as_color().unwrap_or([0, 0, 0, 255]);
1035    let hex = HexField {
1036        focused: state.is_editing_hex(&path),
1037        state: state.input(&path),
1038        on_focus: on_msg(AllichayMsg::FocusHex(path.clone())),
1039    };
1040    let change_path = path;
1041    color_picker_view(
1042        cur,
1043        DEFAULT_SWATCHES,
1044        &ColorPickerPalette::from_theme(theme),
1045        Some(hex),
1046        move |rgba| on_msg(AllichayMsg::Change(change_path.clone(), FieldValue::Color(rgba))),
1047    )
1048}
1049
1050fn text_control<Msg, F>(
1051    field: &Field,
1052    path: FieldPath,
1053    state: &AllichayState,
1054    theme: &Theme,
1055    on_msg: F,
1056) -> View<Msg>
1057where
1058    Msg: Clone + Send + Sync + 'static,
1059    F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static,
1060{
1061    let cur = field.value.as_str().unwrap_or("");
1062    let palette = TextInputPalette::from_theme(theme);
1063    let focus_msg = on_msg(AllichayMsg::Focus(path.clone()));
1064
1065    if state.is_focused(&path) {
1066        if let Some(st) = state.input(&path) {
1067            return text_input_view(st, "", true, &palette, focus_msg);
1068        }
1069    }
1070    let mut tmp = TextInputState::new();
1071    tmp.set_text(cur);
1072    text_input_view(&tmp, "", false, &palette, focus_msg)
1073}
1074
1075// =====================================================================
1076// Agregados: lista y tabla
1077// =====================================================================
1078
1079/// Delega en `list_view` del widget agnóstico `llimphi-widget-table`. El foco de
1080/// celda lo posee el estado ([`AllichayState::focused_cell_of`]/`input_cell`);
1081/// cada alta/baja/edición emite el [`FieldValue::List`] entero (protocolo "valor
1082/// entero").
1083fn list_control<Msg, F>(
1084    field: &Field,
1085    path: FieldPath,
1086    item_label: &str,
1087    state: &AllichayState,
1088    theme: &Theme,
1089    on_msg: F,
1090) -> View<Msg>
1091where
1092    Msg: Clone + Send + Sync + 'static,
1093    F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static,
1094{
1095    let items: Vec<String> = field.value.as_list().map(<[String]>::to_vec).unwrap_or_default();
1096    let value = field.value.clone();
1097    let focused = state.focused_cell_of(&path);
1098    let focused_state = focused.and_then(|(r, c)| state.input_cell(&path, r, c));
1099    let add_label = if item_label.is_empty() {
1100        "Agregar".to_string()
1101    } else {
1102        format!("+ {item_label}")
1103    };
1104
1105    let (p_focus, p_remove, p_add) = (path.clone(), path.clone(), path);
1106    let (m_focus, m_remove, m_add) = (on_msg.clone(), on_msg.clone(), on_msg);
1107    let v_remove = value.clone();
1108    list_view(
1109        &items,
1110        focused.map(|(r, _)| r),
1111        focused_state,
1112        &add_label,
1113        &TablePalette::from_theme(theme),
1114        move |r| m_focus(AllichayMsg::FocusCell(p_focus.clone(), r, 0)),
1115        move |r| m_remove(AllichayMsg::Change(p_remove.clone(), v_remove.with_row_removed(r))),
1116        move || m_add(AllichayMsg::Change(p_add.clone(), value.with_row_pushed(1))),
1117    )
1118}
1119
1120/// Delega en `table_view` del widget agnóstico `llimphi-widget-table`, igual que
1121/// [`list_control`] pero con encabezados por columna. Cada cambio emite el
1122/// [`FieldValue::Table`] entero.
1123fn table_control<Msg, F>(
1124    field: &Field,
1125    path: FieldPath,
1126    columns: &[allichay::Column],
1127    state: &AllichayState,
1128    theme: &Theme,
1129    on_msg: F,
1130) -> View<Msg>
1131where
1132    Msg: Clone + Send + Sync + 'static,
1133    F: Fn(AllichayMsg) -> Msg + Clone + Send + Sync + 'static,
1134{
1135    let headers: Vec<String> = columns.iter().map(|c| c.label.clone()).collect();
1136    let rows: Vec<Vec<String>> = field
1137        .value
1138        .as_table()
1139        .map(<[Vec<String>]>::to_vec)
1140        .unwrap_or_default();
1141    let ncols = columns.len();
1142    let value = field.value.clone();
1143    let focused = state.focused_cell_of(&path);
1144    let focused_state = focused.and_then(|(r, c)| state.input_cell(&path, r, c));
1145
1146    let (p_focus, p_remove, p_add) = (path.clone(), path.clone(), path);
1147    let (m_focus, m_remove, m_add) = (on_msg.clone(), on_msg.clone(), on_msg);
1148    let v_remove = value.clone();
1149    table_view(
1150        &headers,
1151        &rows,
1152        focused,
1153        focused_state,
1154        "+ fila",
1155        &TablePalette::from_theme(theme),
1156        move |r, c| m_focus(AllichayMsg::FocusCell(p_focus.clone(), r, c)),
1157        move |r| m_remove(AllichayMsg::Change(p_remove.clone(), v_remove.with_row_removed(r))),
1158        move || m_add(AllichayMsg::Change(p_add.clone(), value.with_row_pushed(ncols))),
1159    )
1160}
1161
1162// =====================================================================
1163// Tests del estado del renderizador
1164// =====================================================================
1165//
1166// Cubren la lógica con estado de `AllichayState` (foco de campo escalar, de
1167// celda de agregado y de hex; reconstrucción del `FieldValue` entero en cada
1168// tecla; selección de diente que reinicia scroll + foco). El vocabulario en sí
1169// (`Schema`/`Field`/`FieldValue`) se testea en el crate `allichay`.
1170#[cfg(test)]
1171mod tests {
1172    use super::*;
1173    use llimphi_ui::{Key, KeyState, Modifiers};
1174
1175    /// Un evento de tecleo de un carácter (con su `text`, que es de donde el
1176    /// editor inserta — respeta IME + modifiers).
1177    fn evtext(s: &str) -> KeyEvent {
1178        KeyEvent {
1179            key: Key::Character(s.into()),
1180            state: KeyState::Pressed,
1181            text: Some(s.to_owned()),
1182            modifiers: Modifiers::default(),
1183            repeat: false,
1184        }
1185    }
1186
1187    #[test]
1188    fn select_reinicia_scroll_y_foco() {
1189        let mut st = AllichayState::new();
1190        st.set_scroll(120.0);
1191        st.focus(&"a.b".into(), "x");
1192        assert!(st.is_editing());
1193        st.select(3);
1194        assert_eq!(st.selected(), 3);
1195        assert_eq!(st.scroll(), 0.0);
1196        assert!(!st.is_editing(), "cambiar de diente debe soltar el foco");
1197    }
1198
1199    #[test]
1200    fn focus_escalar_y_blur() {
1201        let mut st = AllichayState::new();
1202        let path: FieldPath = "editor.nombre".into();
1203        st.focus(&path, "hola");
1204        assert!(st.is_focused(&path));
1205        assert!(!st.is_focused(&"editor.otro".into()));
1206        st.blur();
1207        assert!(!st.is_editing());
1208        assert!(!st.is_focused(&path));
1209    }
1210
1211    #[test]
1212    fn apply_key_campo_escalar_emite_texto_entero() {
1213        let mut st = AllichayState::new();
1214        let path: FieldPath = "editor.nombre".into();
1215        st.focus(&path, "abc"); // el caret queda al final
1216        let r = st.apply_key(&evtext("d"));
1217        assert_eq!(r, Some((path, FieldValue::Text("abcd".into()))));
1218    }
1219
1220    #[test]
1221    fn apply_key_sin_foco_no_emite() {
1222        let mut st = AllichayState::new();
1223        assert_eq!(st.apply_key(&evtext("x")), None);
1224    }
1225
1226    #[test]
1227    fn focus_cell_lista_reconstruye_el_agregado() {
1228        let mut st = AllichayState::new();
1229        let path: FieldPath = "rutas".into();
1230        let base = FieldValue::List(vec!["a".into(), "b".into()]);
1231        st.focus_cell(&path, base, 0, 0); // siembra "a", caret al final
1232        assert!(st.is_focused_cell(&path, 0, 0));
1233        assert!(!st.is_focused_cell(&path, 1, 0));
1234        let r = st.apply_key(&evtext("x"));
1235        assert_eq!(
1236            r,
1237            Some((path, FieldValue::List(vec!["ax".into(), "b".into()])))
1238        );
1239    }
1240
1241    #[test]
1242    fn focus_cell_tabla_solo_toca_su_celda() {
1243        let mut st = AllichayState::new();
1244        let path: FieldPath = "menu".into();
1245        let base = FieldValue::Table(vec![
1246            vec!["A".into(), "a".into()],
1247            vec!["B".into(), "b".into()],
1248        ]);
1249        st.focus_cell(&path, base, 1, 1); // siembra "b"
1250        let r = st.apply_key(&evtext("z"));
1251        assert_eq!(
1252            r,
1253            Some((
1254                path,
1255                FieldValue::Table(vec![
1256                    vec!["A".into(), "a".into()],
1257                    vec!["B".into(), "bz".into()],
1258                ])
1259            ))
1260        );
1261    }
1262
1263    #[test]
1264    fn focus_cell_fuera_de_rango_no_enfoca() {
1265        let mut st = AllichayState::new();
1266        let path: FieldPath = "rutas".into();
1267        let base = FieldValue::List(vec!["a".into()]);
1268        st.focus_cell(&path, base, 5, 0); // coordenada inexistente
1269        assert!(!st.is_editing(), "no debe enfocar una celda fuera de rango");
1270    }
1271
1272    #[test]
1273    fn apply_key_hex_emite_color_solo_si_parsea() {
1274        let mut st = AllichayState::new();
1275        let path: FieldPath = "apariencia.acento".into();
1276        // Semilla de 5 dígitos: incompleta, no parsea todavía.
1277        st.focus_hex(&path, "#11223");
1278        assert!(st.is_editing_hex(&path));
1279        // Al completar el 6º dígito el hex parsea → emite el Color.
1280        let r = st.apply_key(&evtext("3"));
1281        assert_eq!(r, Some((path, FieldValue::Color([0x11, 0x22, 0x33, 255]))));
1282    }
1283
1284    #[test]
1285    fn focos_son_mutuamente_excluyentes() {
1286        let mut st = AllichayState::new();
1287        let path: FieldPath = "a.b".into();
1288        st.focus_cell(&path, FieldValue::List(vec!["v".into()]), 0, 0);
1289        assert!(st.is_focused_cell(&path, 0, 0));
1290        // Pasar a editar el hex de otro campo descarta el foco de celda.
1291        let hex_path: FieldPath = "a.color".into();
1292        st.focus_hex(&hex_path, "#000000");
1293        assert!(st.is_editing_hex(&hex_path));
1294        assert!(!st.is_focused_cell(&path, 0, 0));
1295    }
1296}