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//! El **encabezado fijo** y los **handles de resize** de `detail-table`,
2//! medidos sobre el árbol montado.
3//!
4//! Las dos cosas se rompen en la junta, no en la aritmética: un encabezado
5//! «sticky» que en realidad viaja con el cuerpo, y un agarre de resize que
6//! además dispara el orden de la columna. Eso sólo se ve montando.
7//!
8//! Sin GPU (CLAUDE.md §8).
9
10use llimphi_test::query::{Sel, Ui};
11use llimphi_ui::Role;
12use llimphi_ui::View;
13use llimphi_widget_detail_table::{
14 detail_table_view, detail_table_view_ext, Column, DetailOpciones, DetailPalette, DetailRow,
15 DetailScroll, DetailSpec,
16};
17
18#[derive(Clone, Debug, PartialEq)]
19enum Msg {
20 Ordenar(usize),
21 Fila(usize),
22 Scroll(i32),
23 Ancho(usize, i32),
24}
25
26const ALTO_FILA: f32 = 22.0;
27/// Muchas más filas de las que entran: sin eso no hay nada que scrollear y el
28/// test no probaría nada.
29const FILAS: usize = 60;
30
31fn columnas() -> Vec<Column> {
32 vec![
33 Column::flex("Nombre", 1.0),
34 Column::fixed("Tamaño", 90.0).right(),
35 Column::fixed("Tipo", 80.0),
36 ]
37}
38
39fn spec<'a>(cols: &'a [Column]) -> DetailSpec<'a, Msg> {
40 DetailSpec {
41 columns: cols,
42 rows: (0..FILAS)
43 .map(|i| DetailRow {
44 cells: vec![format!("archivo-{i}.txt"), "1 KB".into(), "texto".into()],
45 selected: false,
46 accent: None,
47 icon: None,
48 on_click: Msg::Fila(i),
49 })
50 .collect(),
51 sort: None,
52 row_height: ALTO_FILA,
53 caption: None,
54 palette: DetailPalette::default(),
55 }
56}
57
58fn con_scroll(cols: &[Column], offset: f32, viewport_h: f32) -> Ui<Msg> {
59 let v: View<Msg> = detail_table_view_ext(
60 spec(cols),
61 Msg::Ordenar,
62 DetailOpciones {
63 scroll: Some(DetailScroll {
64 offset,
65 viewport_h,
66 on_scroll: std::sync::Arc::new(|d: f32| Msg::Scroll(d as i32)),
67 }),
68 ..Default::default()
69 },
70 );
71 Ui::new(v, 500.0, 300.0)
72}
73
74/// Y del encabezado (la fila que lleva los `ColumnHeader`).
75fn y_del_encabezado(ui: &Ui<Msg>) -> f32 {
76 ui.get(Sel::new().role(Role::ColumnHeader).label("Nombre")).rect().y
77}
78
79/// Y de la primera fila de datos, buscada **por su contenido**.
80///
81/// No sirve tomar el mínimo de los `Role::Row`: el encabezado también es una
82/// `Row`, así que con el cuerpo desplazado hacia arriba el mínimo pasa a ser el
83/// encabezado y la medición se vuelve la de otra cosa (probado: daba 80 en vez
84/// de 100).
85fn y_de_la_primera_fila(ui: &Ui<Msg>) -> f32 {
86 ui.find_all(Sel::new().label("archivo-0.txt"))
87 .first()
88 .expect("la primera fila tiene que existir")
89 .rect()
90 .y
91}
92
93#[test]
94fn el_encabezado_no_se_mueve_cuando_el_cuerpo_scrollea() {
95 let cols = columnas();
96 let quieto = con_scroll(&cols, 0.0, 200.0);
97 let corrido = con_scroll(&cols, 100.0, 200.0);
98
99 // El encabezado está en el mismo Y con y sin desplazamiento: es lo único
100 // que «sticky» significa acá — no viaja porque está fuera del cuerpo.
101 assert_eq!(
102 y_del_encabezado(&quieto),
103 y_del_encabezado(&corrido),
104 "el encabezado se movió con el scroll:\n{}",
105 corrido.dump()
106 );
107 // Y el cuerpo sí se movió: si no, el test de arriba pasaría por no estar
108 // scrolleando nada.
109 let dy = y_de_la_primera_fila(&quieto) - y_de_la_primera_fila(&corrido);
110 assert!((dy - 100.0).abs() < 0.5, "el cuerpo tendría que haberse corrido 100 px, se corrió {dy}");
111}
112
113#[test]
114fn sin_alto_medido_todavia_el_cuerpo_arranca_desde_arriba() {
115 // Primer cuadro (`viewport_h = 0`) con un offset guardado: se pinta desde
116 // arriba en vez de saltar a un lugar que aún no se puede validar.
117 let cols = columnas();
118 let primera = con_scroll(&cols, 0.0, 200.0);
119 let sin_medir = con_scroll(&cols, 100.0, 0.0);
120 assert_eq!(y_de_la_primera_fila(&primera), y_de_la_primera_fila(&sin_medir));
121}
122
123#[test]
124fn el_handle_de_resize_arrastra_sin_ordenar_la_columna() {
125 let cols = columnas();
126 let v: View<Msg> = detail_table_view_ext(
127 spec(&cols),
128 Msg::Ordenar,
129 DetailOpciones {
130 on_resize: Some(std::sync::Arc::new(|c: usize, d: f32| Msg::Ancho(c, d as i32))),
131 ..Default::default()
132 },
133 );
134 let ui = Ui::new(v, 500.0, 300.0);
135
136 // Un handle por junta entre columnas: la última no lleva (arrastrar el
137 // borde de la tabla no redimensiona nada).
138 let handles = ui.find_all(Role::Splitter);
139 assert_eq!(handles.len(), cols.len() - 1, "{}", ui.dump());
140 assert_eq!(handles[0].name(), Some("Ancho de la columna 1"));
141
142 // El handle NO es clickeable: el hit-test elige el nodo más interno con
143 // handler, y como el suyo es un drag, agarrarlo no dispara el `on_sort` del
144 // encabezado que tiene debajo. Ese es el bug clásico de esta UI: querés
145 // ensanchar una columna y la tabla se te reordena.
146 assert_eq!(handles[0].click(), None);
147 // Y el encabezado sigue ordenando por su cuenta.
148 assert_eq!(
149 ui.get(Sel::new().role(Role::ColumnHeader).label("Nombre")).click(),
150 Some(Msg::Ordenar(0))
151 );
152}
153
154#[test]
155fn sin_opciones_la_tabla_es_la_de_siempre() {
156 // Ni handles, ni cuerpo aparte: el `Default` no cambia el árbol.
157 let cols = columnas();
158 let ui = Ui::new(detail_table_view(spec(&cols), Msg::Ordenar), 500.0, 300.0);
159 assert!(ui.find(Role::Splitter).is_none(), "{}", ui.dump());
160 assert_eq!(
161 ui.get(Sel::new().role(Role::ColumnHeader).label("Tipo")).click(),
162 Some(Msg::Ordenar(2))
163 );
164}
165
166/// **Una celda que el host pinta** reemplaza al texto de esa columna, y sólo a
167/// esa.
168///
169/// Es lo que hace falta para una columna que no es texto —un ícono de estado,
170/// un botón con el gesto de la fila— y lo que se tiene que poder afirmar es que
171/// el texto que esa celda tapa **no se pinta igual debajo**: dos textos en el
172/// mismo rect salen uno encima del otro y el árbol queda correcto, así que
173/// ningún test estructural lo ve.
174#[test]
175fn una_celda_pintada_por_el_host_reemplaza_su_texto() {
176 let cols = columnas();
177 let v: View<Msg> = detail_table_view_ext(
178 spec(&cols),
179 Msg::Ordenar,
180 DetailOpciones {
181 // Sólo la columna 2 («Tipo»), y sólo en la fila 0.
182 celda_vista: Some(std::sync::Arc::new(|fila: usize, col: usize| {
183 (fila == 0 && col == 2).then(|| {
184 View::new(Default::default())
185 .role(Role::Button)
186 .aria_label("forjar".to_string())
187 .on_click(Msg::Fila(1000))
188 })
189 })),
190 ..Default::default()
191 },
192 );
193 let ui = Ui::new(v, 500.0, 300.0);
194
195 // La celda está, y es la que contesta el click (gana al `on_click` de la
196 // fila, que es el nodo de afuera).
197 let boton = ui.get(Sel::new().role(Role::Button).label("forjar"));
198 assert_eq!(boton.click(), Some(Msg::Fila(1000)), "{}", ui.dump());
199
200 // El texto «texto» de esa fila NO quedó debajo. El brazo de control son las
201 // OTRAS 59 filas, que lo siguen teniendo: si el conteo diera 0, lo que
202 // estaría roto es el instrumento.
203 let con_texto = ui.find_all(Sel::new().label("texto")).len();
204 assert_eq!(
205 con_texto,
206 FILAS - 1,
207 "la celda pintada por el host tiene que REEMPLAZAR el texto, no taparlo:\n{}",
208 ui.dump()
209 );
210}