tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ e548698fc442f53d1977ef2838ef4dea94d545e2
modules/allichay/src/lib.rs
Historia
Quién tocó qué
crudo
refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel
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(§ion.title, §ion.help, theme));
464
465 for field in §ion.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 §ion.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 §ion.fields {
547 h += field_height(f) + 13.0; // + label/gap/separación
548 }
549 for sub in §ion.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}