tawasuyu

tawasuyu/llimphi

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

Rust 229TOML 101Markdown 82Shell 24052 Definitions

⎇ main llimphi-test/tests/detail_table_cabecera.rs Rust · 210 lines · 7.4 KB History Who touched what raw

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

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}