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