tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros
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}