gioser

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 614Markdown 274TOML 149RON 310606 Sutichaykuna

⎇ main llimphi-entrada/src/lib.rs Rust · 562 siq'ikuna · 22.1 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual d8edd9a9c · Sergio ·

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}