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#![no_std]
2#![forbid(unsafe_code)]
3
4extern crate alloc;
5
6// =============================================================================
7// llimphi-entrada :: el modelo de entrada, como dato (W4.7 de PLAN-LLIMPHI)
8// -----------------------------------------------------------------------------
9// Hasta acá los tipos de entrada —`KeyEvent`, `ImeEvent`, `Modifiers`— vivían
10// en `llimphi-ui`, y `Key`/`NamedKey` eran directamente los de winit. La
11// consecuencia medida: los cinco widgets del editor (`text-editor`,
12// `text-input`, `text-area`, `edit-menu`, `gallery`) no podían cruzar a una
13// jaula WASM ni al kernel, porque procesar UNA TECLA arrastraba el windowing
14// de escritorio entero.
15//
16// El error de diseño era de dirección: el input no es del shell, es del
17// MODELO. Un `KeyEvent` es un dato — quién lo produjo (winit, el canal de
18// scancodes del kernel, un test) es asunto del sustrato, igual que quién
19// presenta el framebuffer. Este módulo lo pone donde el resto del modelo ya
20// vive, y cada shell CONVIERTE en su frontera:
21//
22// winit ──convierte──► input::KeyEvent ◄──convierte── sys_get_scancode
23// (llimphi-ui) (la jaula, W7)
24//
25// ## `NamedKey`: el subconjunto REAL, no el catálogo de winit
26//
27// winit modela cientos de teclas (media keys, teclado japonés, botones de
28// navegador). Se contó qué usa el repo entero: **31 variantes** — navegación,
29// edición, F1-F12 y modificadores — y puriy, que traduce teclas a nombres DOM
30// para el navegador, usa exactamente las mismas. Eso es la interfaz real, y es
31// lo que este enum nombra, con los MISMOS nombres que winit para que ni un
32// widget ni una app cambien un `match`. Lo no mapeado cae a `Unidentified`,
33// que todo `match` existente ya absorbe en su brazo `_`.
34// =============================================================================
35
36use alloc::string::String;
37
38// Cada tipo lleva el derive de serde detrás de la feature. Va acá y no en un
39// espejo del otro lado del cable porque `NamedKey` tiene 31 variantes: una copia
40// se desincroniza en la primera tecla que alguien agregue, y el síntoma sería una
41// tecla que funciona suelta y no enjaulada.
42
43
44/// Una tecla con nombre. Espeja el subconjunto de `winit::keyboard::NamedKey`
45/// que el repo usa — mismos nombres, así el código existente no cambia.
46#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
48pub enum NamedKey {
49 Enter,
50 Escape,
51 Backspace,
52 Tab,
53 Space,
54 Delete,
55 Insert,
56 Home,
57 End,
58 PageUp,
59 PageDown,
60 ArrowUp,
61 ArrowDown,
62 ArrowLeft,
63 ArrowRight,
64 Shift,
65 Control,
66 Alt,
67 Meta,
68 CapsLock,
69 F1,
70 F2,
71 F3,
72 F4,
73 F5,
74 F6,
75 F7,
76 F8,
77 F9,
78 F10,
79 F11,
80 F12,
81 /// Cualquier tecla con nombre que el modelo no distingue. Existe para que
82 /// la conversión desde un sustrato rico (winit) sea total sin arrastrar su
83 /// catálogo: una media key llega como esto y el brazo `_` de todo `match`
84 /// existente la absorbe, que es lo que ya pasaba.
85 Unidentified,
86}
87
88/// Una tecla: con nombre, o el carácter que produce.
89///
90/// Espeja la forma de `winit::keyboard::Key` en sus dos variantes reales — el
91/// repo no usa `Dead` ni `Unidentified(NativeKey)` (verificado), así que no
92/// existen acá. `Character` lleva `String` y no el `SmolStr` de winit: mismo
93/// `.as_str()`, mismas comparaciones con `==`, sin la dependencia.
94#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
95#[derive(Debug, Clone, PartialEq, Eq, Hash)]
96pub enum Key {
97 /// Tecla con nombre (Enter, flechas, F5…).
98 Named(NamedKey),
99 /// Tecla que produce texto: el carácter ya con layout aplicado.
100 Character(String),
101}
102
103/// Estado de una tecla en un evento.
104#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum KeyState {
107 Pressed,
108 Released,
109}
110
111/// Modificadores activos en el momento del evento.
112#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
113#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
114pub struct Modifiers {
115 pub shift: bool,
116 pub ctrl: bool,
117 pub alt: bool,
118 pub meta: bool,
119}
120
121/// Evento de teclado normalizado.
122///
123/// La MISMA forma que tenía en `llimphi-ui`, movida acá sin un campo de
124/// diferencia: era un dato puro desde el principio — sólo estaba mal vecindado.
125#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
126#[derive(Debug, Clone, PartialEq)]
127pub struct KeyEvent {
128 pub key: Key,
129 pub state: KeyState,
130 /// Texto resultante (con modifiers e IME aplicados). Útil para inserción
131 /// directa; `None` para teclas que no producen texto (flechas, etc.).
132 pub text: Option<String>,
133 pub modifiers: Modifiers,
134 pub repeat: bool,
135}
136
137/// Evento de IME normalizado. Ver `App::on_ime` en el shell para el flujo
138/// Enabled → Preedit* → Commit/Disabled.
139#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub enum ImeEvent {
142 /// El IME se activó para esta ventana.
143 Enabled,
144 /// Texto en composición (aún no confirmado). `cursor` es el rango
145 /// `(inicio, fin)` en bytes a resaltar dentro de `text`, si el IME lo
146 /// reporta. Cada `Preedit` reemplaza al anterior; uno con `text`
147 /// vacío cierra la preedición sin confirmar.
148 Preedit {
149 text: String,
150 cursor: Option<(usize, usize)>,
151 },
152 /// Texto confirmado: insertarlo como si se hubiera tecleado.
153 Commit(String),
154 /// El IME se desactivó (perder foco, cambiar de método).
155 Disabled,
156}
157
158/// Delta de rueda en "líneas" lógicas (normalizado a través de backends).
159/// Convención CSS: positivo = scroll **hacia abajo** (contenido sube).
160/// `x` similar para scroll horizontal (touchpads, ratones de 2 ejes).
161#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
162#[derive(Debug, Clone, Copy, Default, PartialEq)]
163pub struct WheelDelta {
164 pub x: f32,
165 pub y: f32,
166}
167
168// =============================================================================
169// El SENTIDO de la rueda, y por qué va por clase de dispositivo
170// -----------------------------------------------------------------------------
171// «Scroll natural» fue siempre UN interruptor para toda la máquina, y esa es la
172// forma equivocada: la rueda de un ratón y dos dedos sobre un pad no son el
173// mismo gesto. En el pad el contenido *se agarra* y sigue al dedo —invertido se
174// siente como arrastrar un papel—; en la rueda no hay papel que agarrar, y la
175// misma inversión se siente al revés. Quien usa las dos cosas en la misma
176// máquina (portátil con ratón enchufado) quiere una en natural y la otra no, y
177// con un solo interruptor tiene que elegir cuál de los dos sufre.
178//
179// Por eso son DOS sentidos, no uno, y por eso la clase de dispositivo viaja con
180// el delta ([`FuenteRueda`]) hasta la frontera que lo convierte. Cada sustrato
181// sabe distinguirlas con lo que ya le llega:
182//
183// winit LineDelta = ratón · PixelDelta = touchpad
184// wayland discrete≠0 = ratón · sólo `absolute` = touchpad
185//
186// La política es un GLOBAL de proceso a propósito: la decide la sesión (el
187// panel la escribe, el runtime la siembra al arrancar) y la consultan las dos
188// o tres fronteras que fabrican un `WheelDelta`. Pasarla por parámetro obligaría
189// a que cada `App` la conociera, y una `App` no tiene por qué saber si el humano
190// invierte su pad.
191// =============================================================================
192
193use core::sync::atomic::{AtomicU8, Ordering};
194
195/// De qué clase de dispositivo viene una rueda. La distinción no es cosmética:
196/// es la única forma de que ratón y touchpad puedan ir en sentidos distintos.
197#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199pub enum FuenteRueda {
200 /// Rueda física de un ratón: clicks discretos.
201 Raton,
202 /// Dos dedos sobre un touchpad (o una rueda de precisión, que entrega
203 /// píxeles y no clicks).
204 Touchpad,
205}
206
207/// Sentido de la rueda para UNA clase de dispositivo.
208///
209/// El punto de partida (`Clasico`) es la convención que ya normaliza Llimphi:
210/// positivo = scroll hacia abajo = el contenido sube. `Natural` invierte los dos
211/// ejes, que es exactamente lo que hace `natural_scroll` de libinput.
212#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
213#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
214pub enum SentidoRueda {
215 /// **No reverso**: el delta pasa tal cual llega.
216 #[default]
217 Clasico,
218 /// **Reverso**: el contenido sigue al dedo/rueda (se invierten `x` e `y`).
219 Natural,
220}
221
222impl SentidoRueda {
223 /// Id estable para config y para el panel.
224 pub const fn as_str(self) -> &'static str {
225 match self {
226 Self::Clasico => "clasico",
227 Self::Natural => "natural",
228 }
229 }
230
231 /// Lee un id de config. Acepta los sinónimos con los que el humano lo
232 /// nombra (`"reverso"`, `"invertido"`, `"no-reverso"`) para que un archivo
233 /// escrito a mano no caiga en silencio al default.
234 pub fn desde_id(s: &str) -> Option<Self> {
235 match s {
236 "natural" | "reverso" | "invertido" | "invertida" => Some(Self::Natural),
237 "clasico" | "clásico" | "no-reverso" | "no_reverso" | "directo" => Some(Self::Clasico),
238 _ => None,
239 }
240 }
241
242 /// `true` si invierte (equivalente al `natural_scroll` de libinput).
243 pub const fn es_natural(self) -> bool {
244 matches!(self, Self::Natural)
245 }
246
247 /// Desde el booleano histórico (`natural_scroll`).
248 pub const fn desde_natural(natural: bool) -> Self {
249 if natural { Self::Natural } else { Self::Clasico }
250 }
251}
252
253/// Los dos sentidos de la sesión: uno por clase de dispositivo.
254#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
255#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
256pub struct PoliticaRueda {
257 /// Sentido de la rueda del ratón.
258 pub raton: SentidoRueda,
259 /// Sentido del deslizamiento de dos dedos en el touchpad.
260 pub touchpad: SentidoRueda,
261}
262
263impl PoliticaRueda {
264 /// El sentido que le toca a `fuente`.
265 pub const fn sentido(&self, fuente: FuenteRueda) -> SentidoRueda {
266 match fuente {
267 FuenteRueda::Raton => self.raton,
268 FuenteRueda::Touchpad => self.touchpad,
269 }
270 }
271
272 /// Aplica el sentido al delta ya normalizado a la convención de Llimphi.
273 pub fn aplicar(&self, d: WheelDelta, fuente: FuenteRueda) -> WheelDelta {
274 if self.sentido(fuente).es_natural() {
275 WheelDelta { x: -d.x, y: -d.y }
276 } else {
277 d
278 }
279 }
280}
281
282// El global de proceso. Dos bytes: `0` = clásico, `1` = natural — el mismo
283// mapeo que `SentidoRueda as u8`, pero explícito para que agregar una variante
284// no cambie en silencio lo que hay guardado.
285static RUEDA_RATON: AtomicU8 = AtomicU8::new(0);
286static RUEDA_TOUCHPAD: AtomicU8 = AtomicU8::new(0);
287
288const fn codigo(s: SentidoRueda) -> u8 {
289 match s {
290 SentidoRueda::Clasico => 0,
291 SentidoRueda::Natural => 1,
292 }
293}
294
295const fn desde_codigo(c: u8) -> SentidoRueda {
296 if c == 1 { SentidoRueda::Natural } else { SentidoRueda::Clasico }
297}
298
299/// La política vigente en este proceso. Default: clásico en las dos clases —
300/// o sea, el delta pasa tal cual, que es lo que Llimphi hizo siempre.
301pub fn politica_rueda() -> PoliticaRueda {
302 PoliticaRueda {
303 raton: desde_codigo(RUEDA_RATON.load(Ordering::Relaxed)),
304 touchpad: desde_codigo(RUEDA_TOUCHPAD.load(Ordering::Relaxed)),
305 }
306}
307
308/// Fija la política del proceso. La llama el runtime al arrancar (sembrada del
309/// bus de config) y cada vez que el panel la cambia en caliente.
310pub fn fijar_politica_rueda(p: PoliticaRueda) {
311 RUEDA_RATON.store(codigo(p.raton), Ordering::Relaxed);
312 RUEDA_TOUCHPAD.store(codigo(p.touchpad), Ordering::Relaxed);
313}
314
315// =============================================================================
316// navegación por teclado — EL atajo, uno solo para toda la suite
317// -----------------------------------------------------------------------------
318// Cada app Llimphi tiene las mismas dos escalas: los **dientes** (el rail: las
319// secciones grandes) y los **tabs** (las sub-pestañas del canvas). Hasta acá se
320// navegaban las dos sólo con el mouse, y cada app que quisiera teclado iba a
321// elegir su propia tecla — que es como se termina con tres atajos distintos
322// para el mismo gesto en tres apps de la MISMA suite (ya pasó con los verbos de
323// los CLIs: regla 7).
324//
325// Por qué estas teclas y no otras:
326//
327// * El compositor (`mirada_brain::action::default_keymap`) se reserva `Super+*`,
328// `Alt+Tab`, `Ctrl+Alt+*`, `Ctrl+Print` y `F12`. `Ctrl+Tab`, `Ctrl+PageUp/Down`
329// y `Ctrl+<dígito>` están **libres**: nunca los agarra el escritorio, así que
330// llegan enteros a la app enfocada.
331// * `Ctrl+Tab` para tabs es el atajo que ya tiene todo el mundo en las manos
332// (navegador, editor, terminal), y `Ctrl+PageUp/PageDown` es el otro clásico
333// de «cambiar de sección» (GTK notebook, Firefox, LibreOffice). Al ser teclas
334// distintas, las dos escalas nunca compiten por el mismo gesto.
335// * Los **dígitos van a los dientes** y no a los tabs: el diente es la
336// navegación primaria (es la lista de secciones de la app), y los tabs son
337// pocos y locales. `Ctrl+0` va al último, como el `Ctrl+9` del navegador.
338//
339// Queda afuera **pata**: su barra es layer-shell y con el cajón cerrado corre
340// con `KeyboardInteractivity::None`, así que un atajo con Ctrl no le llega
341// nunca — sus teclas viven en el compositor (es la razón por la que `F12` está
342// en el keymap del escritorio y no en la barra).
343// =============================================================================
344
345/// Un gesto de navegación de la suite. Ver [`navegacion`].
346#[derive(Clone, Copy, Debug, PartialEq, Eq)]
347pub enum Navegacion {
348 /// `Ctrl+PageDown` — el diente siguiente (cíclico, lo decide la app).
349 DienteSiguiente,
350 /// `Ctrl+PageUp` — el diente anterior.
351 DienteAnterior,
352 /// `Ctrl+1`…`Ctrl+9` — el diente N (base 0 acá: `Ctrl+1` → `Diente(0)`).
353 /// `Ctrl+0` pide **el último**, y por eso viaja como [`Navegacion::DienteUltimo`].
354 Diente(usize),
355 /// `Ctrl+0` — el último diente.
356 DienteUltimo,
357 /// `Ctrl+Tab` — el tab siguiente del canvas.
358 TabSiguiente,
359 /// `Ctrl+Shift+Tab` — el tab anterior.
360 TabAnterior,
361}
362
363/// A qué diente lleva un gesto, dado el actual y cuántos hay. `None` si el
364/// gesto es de **tabs** (esos los resuelve la app: el número de tabs depende de
365/// qué diente esté abierto) o si no hay ningún diente.
366///
367/// Existe para que la aritmética no se copie una vez por app. Es cíclica a
368/// propósito —del último se pasa al primero— y `Ctrl+<dígito>` **satura** en vez
369/// de no hacer nada: con seis dientes, `Ctrl+9` lleva al sexto. Un atajo que no
370/// hace nada se lee como que el teclado no anda; uno que lleva al último se lee
371/// como lo que es.
372pub fn destino_diente(nav: Navegacion, actual: usize, n: usize) -> Option<usize> {
373 if n == 0 {
374 return None;
375 }
376 Some(match nav {
377 Navegacion::DienteSiguiente => (actual + 1) % n,
378 Navegacion::DienteAnterior => (actual + n - 1) % n,
379 Navegacion::Diente(i) => i.min(n - 1),
380 Navegacion::DienteUltimo => n - 1,
381 Navegacion::TabSiguiente | Navegacion::TabAnterior => return None,
382 })
383}
384
385/// Lee un evento de teclado como un gesto de navegación de la suite/// Lee un evento de teclado como un gesto de navegación de la suite, o `None`
386/// si no lo es.
387///
388/// Sólo mira el **Pressed**: un atajo se ejecuta una vez, y con el `Released`
389/// haría dos saltos por pulsación. Y exige que **no** haya `alt` ni `meta`
390/// puestos: `Ctrl+Alt+…` es territorio del compositor, y tomarlo acá le robaría
391/// el atajo al escritorio en la app enfocada.
392pub fn navegacion(ev: &KeyEvent) -> Option<Navegacion> {
393 if ev.state != KeyState::Pressed || !ev.modifiers.ctrl || ev.modifiers.alt || ev.modifiers.meta
394 {
395 return None;
396 }
397 match &ev.key {
398 Key::Named(NamedKey::Tab) => Some(if ev.modifiers.shift {
399 Navegacion::TabAnterior
400 } else {
401 Navegacion::TabSiguiente
402 }),
403 Key::Named(NamedKey::PageDown) => Some(Navegacion::DienteSiguiente),
404 Key::Named(NamedKey::PageUp) => Some(Navegacion::DienteAnterior),
405 // Los dígitos no llevan shift: `Ctrl+Shift+1` es otro gesto (y en varios
406 // teclados ni siquiera produce un dígito).
407 Key::Character(c) if !ev.modifiers.shift => match c.as_str() {
408 "0" => Some(Navegacion::DienteUltimo),
409 d if d.len() == 1 => {
410 let n = d.as_bytes()[0];
411 (n.is_ascii_digit()).then(|| Navegacion::Diente((n - b'1') as usize))
412 }
413 _ => None,
414 },
415 _ => None,
416 }
417}
418
419#[cfg(test)]
420mod tests_rueda {
421 use super::*;
422
423 #[test]
424 fn clasico_no_toca_el_delta() {
425 let p = PoliticaRueda::default();
426 let d = WheelDelta { x: 1.0, y: -3.0 };
427 assert_eq!(p.aplicar(d, FuenteRueda::Raton), d);
428 assert_eq!(p.aplicar(d, FuenteRueda::Touchpad), d);
429 }
430
431 #[test]
432 fn cada_clase_va_por_su_lado() {
433 // El caso que motiva todo esto: portátil con ratón enchufado, pad en
434 // natural y rueda en clásico. Un solo interruptor no puede expresarlo.
435 let p = PoliticaRueda {
436 raton: SentidoRueda::Clasico,
437 touchpad: SentidoRueda::Natural,
438 };
439 let d = WheelDelta { x: 2.0, y: 3.0 };
440 assert_eq!(p.aplicar(d, FuenteRueda::Raton), d);
441 assert_eq!(
442 p.aplicar(d, FuenteRueda::Touchpad),
443 WheelDelta { x: -2.0, y: -3.0 }
444 );
445 }
446
447 #[test]
448 fn el_global_arranca_neutro_y_se_puede_fijar() {
449 assert_eq!(politica_rueda(), PoliticaRueda::default());
450 let p = PoliticaRueda {
451 raton: SentidoRueda::Natural,
452 touchpad: SentidoRueda::Clasico,
453 };
454 fijar_politica_rueda(p);
455 assert_eq!(politica_rueda(), p);
456 fijar_politica_rueda(PoliticaRueda::default());
457 }
458
459 #[test]
460 fn los_ids_van_y_vuelven() {
461 for s in [SentidoRueda::Clasico, SentidoRueda::Natural] {
462 assert_eq!(SentidoRueda::desde_id(s.as_str()), Some(s));
463 }
464 assert_eq!(SentidoRueda::desde_id("reverso"), Some(SentidoRueda::Natural));
465 assert_eq!(SentidoRueda::desde_id("no-reverso"), Some(SentidoRueda::Clasico));
466 assert_eq!(SentidoRueda::desde_id("nada"), None);
467 }
468}
469
470#[cfg(test)]
471mod navegacion_tests {
472 use super::*;
473
474 fn ev(key: Key, ctrl: bool, shift: bool) -> KeyEvent {
475 KeyEvent {
476 key,
477 state: KeyState::Pressed,
478 text: None,
479 modifiers: Modifiers { shift, ctrl, alt: false, meta: false },
480 repeat: false,
481 }
482 }
483
484 #[test]
485 fn las_dos_escalas_no_comparten_tecla() {
486 assert_eq!(
487 navegacion(&ev(Key::Named(NamedKey::Tab), true, false)),
488 Some(Navegacion::TabSiguiente)
489 );
490 assert_eq!(
491 navegacion(&ev(Key::Named(NamedKey::Tab), true, true)),
492 Some(Navegacion::TabAnterior)
493 );
494 assert_eq!(
495 navegacion(&ev(Key::Named(NamedKey::PageDown), true, false)),
496 Some(Navegacion::DienteSiguiente)
497 );
498 assert_eq!(
499 navegacion(&ev(Key::Named(NamedKey::PageUp), true, false)),
500 Some(Navegacion::DienteAnterior)
501 );
502 }
503
504 #[test]
505 fn los_digitos_van_a_los_dientes_y_el_cero_al_ultimo() {
506 assert_eq!(
507 navegacion(&ev(Key::Character("1".into()), true, false)),
508 Some(Navegacion::Diente(0))
509 );
510 assert_eq!(
511 navegacion(&ev(Key::Character("9".into()), true, false)),
512 Some(Navegacion::Diente(8))
513 );
514 assert_eq!(
515 navegacion(&ev(Key::Character("0".into()), true, false)),
516 Some(Navegacion::DienteUltimo)
517 );
518 }
519
520 /// Sin Ctrl no es un atajo: es tecleo. Un `Tab` pelado tiene que seguir
521 /// llegando al foco de los campos, y una `1` al campo de texto.
522 #[test]
523 fn sin_ctrl_no_hay_navegacion() {
524 assert_eq!(navegacion(&ev(Key::Named(NamedKey::Tab), false, false)), None);
525 assert_eq!(navegacion(&ev(Key::Character("1".into()), false, false)), None);
526 }
527
528 /// `Ctrl+Alt+…` y `Super+…` son del compositor: no se los robamos.
529 #[test]
530 fn los_atajos_del_escritorio_no_se_tocan() {
531 let mut e = ev(Key::Named(NamedKey::PageDown), true, false);
532 e.modifiers.alt = true;
533 assert_eq!(navegacion(&e), None);
534 let mut e = ev(Key::Named(NamedKey::Tab), true, false);
535 e.modifiers.meta = true;
536 assert_eq!(navegacion(&e), None);
537 }
538
539 /// El `Released` no navega: con él cada pulsación saltaría dos dientes.
540 #[test]
541 fn solo_el_pressed_navega() {
542 let mut e = ev(Key::Named(NamedKey::PageDown), true, false);
543 e.state = KeyState::Released;
544 assert_eq!(navegacion(&e), None);
545 }
546
547 /// La aritmética de los dientes, una sola vez para toda la suite: cíclica en
548 /// los extremos y saturante en los dígitos.
549 #[test]
550 fn el_destino_es_ciclico_y_el_digito_satura() {
551 use Navegacion::*;
552 assert_eq!(destino_diente(DienteSiguiente, 4, 5), Some(0), "del último al primero");
553 assert_eq!(destino_diente(DienteAnterior, 0, 5), Some(4), "del primero al último");
554 assert_eq!(destino_diente(Diente(2), 0, 5), Some(2));
555 assert_eq!(destino_diente(Diente(8), 0, 5), Some(4), "Ctrl+9 con cinco → el quinto");
556 assert_eq!(destino_diente(DienteUltimo, 0, 5), Some(4));
557 // Los gestos de tab no son de diente: los resuelve la app.
558 assert_eq!(destino_diente(TabSiguiente, 0, 5), None);
559 // Sin dientes no hay a dónde ir (y no se divide por cero).
560 assert_eq!(destino_diente(DienteSiguiente, 0, 0), None);
561 }
562}