tawasuyu

tawasuyu/llimphi

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

Rust 229TOML 101Markdown 82Shell 24052 Definiciones

⎇ main llimphi-test/tests/list_tree_scroll.rs Rust · 156 líneas · 5.9 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros d251eb32b · Sergio ·

1//! El **scroll real** de `list` y `tree`, medido sobre el árbol montado.
2//!
3//! Hasta acá las dos sólo recortaban (`clip`): una lista más larga que su panel
4//! se cortaba y no había forma de alcanzar el resto sin que el host
5//! virtualizara por su cuenta. Lo que se certifica acá es que el cuerpo se
6//! desplaza de verdad, que los rótulos **no** se van con él, y que el `Default`
7//! no cambió nada para quien ya las usaba.
8//!
9//! Sin GPU (CLAUDE.md §8).
10
11use llimphi_test::query::{Sel, Ui};
12use llimphi_ui::Role;
13use llimphi_ui::View;
14use llimphi_widget_scroll::DesplazamientoV;
15
16#[derive(Clone, Debug, PartialEq)]
17enum Msg {
18    Fila(usize),
19    Toggle(usize),
20    Scroll(i32),
21}
22
23const ALTO_FILA: f32 = 22.0;
24const FILAS: usize = 60;
25const VIEWPORT: f32 = 200.0;
26
27fn desplazamiento(offset: f32, viewport_len: f32) -> DesplazamientoV<Msg> {
28    DesplazamientoV {
29        offset,
30        viewport_len,
31        on_scroll: std::sync::Arc::new(|d: f32| Msg::Scroll(d as i32)),
32    }
33}
34
35/// Y del nodo que lleva ese texto (la fila, o su label — los dos comparten Y).
36fn y_de(ui: &Ui<Msg>, etiqueta: &str) -> f32 {
37    ui.find_all(Sel::new().label(etiqueta))
38        .first()
39        .unwrap_or_else(|| panic!("no encontré «{etiqueta}»:\n{}", ui.dump()))
40        .rect()
41        .y
42}
43
44// ── list ────────────────────────────────────────────────────────────────
45
46fn lista(scroll: Option<DesplazamientoV<Msg>>) -> Ui<Msg> {
47    use llimphi_widget_list::{list_view_ext, ListOpciones, ListPalette, ListRow, ListSpec};
48    let v: View<Msg> = list_view_ext(
49        ListSpec {
50            rows: (0..FILAS)
51                .map(|i| ListRow {
52                    label: format!("item-{i}"),
53                    selected: false,
54                    on_click: Msg::Fila(i),
55                })
56                .collect(),
57            total: FILAS,
58            caption: Some("60 entradas".into()),
59            truncated_hint: None,
60            row_height: ALTO_FILA,
61            palette: ListPalette::default(),
62        },
63        ListOpciones { scroll, ..Default::default() },
64    );
65    Ui::new(v, 300.0, VIEWPORT + 40.0)
66}
67
68#[test]
69fn la_lista_desplaza_las_filas_y_deja_el_caption_quieto() {
70    let quieta = lista(Some(desplazamiento(0.0, VIEWPORT)));
71    let corrida = lista(Some(desplazamiento(80.0, VIEWPORT)));
72
73    let dy = y_de(&quieta, "item-0") - y_de(&corrida, "item-0");
74    assert!((dy - 80.0).abs() < 0.5, "el cuerpo tendría que correrse 80 px, se corrió {dy}");
75    // El caption es un rótulo DE la lista, no contenido de ella: se queda.
76    assert_eq!(
77        y_de(&quieta, "60 entradas"),
78        y_de(&corrida, "60 entradas"),
79        "el caption se fue con las filas"
80    );
81}
82
83#[test]
84fn la_lista_sin_opciones_sigue_clickeable_y_no_aplasta_las_filas() {
85    let plana = lista(None);
86    // Las filas siguen emitiendo su Msg (el cuerpo aparte no se comió el click).
87    assert_eq!(
88        plana.get(Sel::new().role(Role::ListItem).label("item-3")).click(),
89        Some(Msg::Fila(3))
90    );
91    // Y miden lo que declaró el caller. Sin `flex_shrink: 0` el flex repartía
92    // el faltante entre TODAS las filas cuando la lista desbordaba: 60 filas de
93    // 22 px se pintaban a 14 (medido), o sea que el widget no recortaba como
94    // decía su doc, achicaba. El alto de fila es un dato, no una sugerencia.
95    let alto = plana
96        .find_all(Sel::new().label("item-0"))
97        .first()
98        .expect("la fila 0 existe")
99        .rect()
100        .h;
101    assert!((alto - ALTO_FILA).abs() < 0.5, "la fila mide {alto} y se declaró {ALTO_FILA}");
102}
103
104// ── tree ────────────────────────────────────────────────────────────────
105
106fn arbol(scroll: Option<DesplazamientoV<Msg>>) -> Ui<Msg> {
107    use llimphi_widget_tree::{tree_view_ext, TreeOpciones, TreePalette, TreeRow, TreeSpec};
108    let v: View<Msg> = tree_view_ext(
109        TreeSpec {
110            rows: (0..FILAS)
111                .map(|i| {
112                    TreeRow::new(format!("nodo-{i}"), 0, false, false, false, Msg::Toggle(i), Msg::Fila(i))
113                })
114                .collect(),
115            row_height: ALTO_FILA,
116            indent_px: 14.0,
117            palette: TreePalette::default(),
118            guides: false,
119        },
120        TreeOpciones { scroll, ..Default::default() },
121    );
122    Ui::new(v, 300.0, VIEWPORT + 40.0)
123}
124
125#[test]
126fn el_arbol_desplaza_sus_filas() {
127    let quieto = arbol(Some(desplazamiento(0.0, VIEWPORT)));
128    let corrido = arbol(Some(desplazamiento(80.0, VIEWPORT)));
129    let dy = y_de(&quieto, "nodo-0") - y_de(&corrido, "nodo-0");
130    assert!((dy - 80.0).abs() < 0.5, "el árbol tendría que correrse 80 px, se corrió {dy}");
131    // Y las filas siguen siendo filas de árbol. El `on_click` NO vive en el
132    // nodo del rol sino en el del label (así lo activa el lector de pantalla,
133    // está documentado en el widget), así que se busca por ahí.
134    let fila = corrido.get(Sel::new().role(Role::TreeItem).label("nodo-5"));
135    assert_eq!(fila.rect().h, ALTO_FILA, "la fila del árbol no debe aplastarse");
136    let clickeable = corrido
137        .find_all(Sel::new().label("nodo-5").clickable(true))
138        .first()
139        .expect("alguien tiene que llevar el click de la fila")
140        .click();
141    assert_eq!(clickeable, Some(Msg::Fila(5)));
142}
143
144#[test]
145fn sin_alto_medido_todavia_ninguna_de_las_dos_salta() {
146    // El primer cuadro (`viewport_len = 0`) con un offset guardado: se pintan
147    // desde arriba en vez de saltar a un lugar que aún no se puede validar.
148    assert_eq!(
149        y_de(&lista(Some(desplazamiento(0.0, VIEWPORT))), "item-0"),
150        y_de(&lista(Some(desplazamiento(80.0, 0.0))), "item-0")
151    );
152    assert_eq!(
153        y_de(&arbol(Some(desplazamiento(0.0, VIEWPORT))), "nodo-0"),
154        y_de(&arbol(Some(desplazamiento(80.0, 0.0))), "nodo-0")
155    );
156}