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