//! El **scroll real** de `list` y `tree`, medido sobre el árbol montado. //! //! Hasta acá las dos sólo recortaban (`clip`): una lista más larga que su panel //! se cortaba y no había forma de alcanzar el resto sin que el host //! virtualizara por su cuenta. Lo que se certifica acá es que el cuerpo se //! desplaza de verdad, que los rótulos **no** se van con él, y que el `Default` //! no cambió nada para quien ya las usaba. //! //! Sin GPU (CLAUDE.md §8). use llimphi_test::query::{Sel, Ui}; use llimphi_ui::Role; use llimphi_ui::View; use llimphi_widget_scroll::DesplazamientoV; #[derive(Clone, Debug, PartialEq)] enum Msg { Fila(usize), Toggle(usize), Scroll(i32), } const ALTO_FILA: f32 = 22.0; const FILAS: usize = 60; const VIEWPORT: f32 = 200.0; fn desplazamiento(offset: f32, viewport_len: f32) -> DesplazamientoV { DesplazamientoV { offset, viewport_len, on_scroll: std::sync::Arc::new(|d: f32| Msg::Scroll(d as i32)), } } /// Y del nodo que lleva ese texto (la fila, o su label — los dos comparten Y). fn y_de(ui: &Ui, etiqueta: &str) -> f32 { ui.find_all(Sel::new().label(etiqueta)) .first() .unwrap_or_else(|| panic!("no encontré «{etiqueta}»:\n{}", ui.dump())) .rect() .y } // ── list ──────────────────────────────────────────────────────────────── fn lista(scroll: Option>) -> Ui { use llimphi_widget_list::{list_view_ext, ListOpciones, ListPalette, ListRow, ListSpec}; let v: View = list_view_ext( ListSpec { rows: (0..FILAS) .map(|i| ListRow { label: format!("item-{i}"), selected: false, on_click: Msg::Fila(i), }) .collect(), total: FILAS, caption: Some("60 entradas".into()), truncated_hint: None, row_height: ALTO_FILA, palette: ListPalette::default(), }, ListOpciones { scroll, ..Default::default() }, ); Ui::new(v, 300.0, VIEWPORT + 40.0) } #[test] fn la_lista_desplaza_las_filas_y_deja_el_caption_quieto() { let quieta = lista(Some(desplazamiento(0.0, VIEWPORT))); let corrida = lista(Some(desplazamiento(80.0, VIEWPORT))); let dy = y_de(&quieta, "item-0") - y_de(&corrida, "item-0"); assert!((dy - 80.0).abs() < 0.5, "el cuerpo tendría que correrse 80 px, se corrió {dy}"); // El caption es un rótulo DE la lista, no contenido de ella: se queda. assert_eq!( y_de(&quieta, "60 entradas"), y_de(&corrida, "60 entradas"), "el caption se fue con las filas" ); } #[test] fn la_lista_sin_opciones_sigue_clickeable_y_no_aplasta_las_filas() { let plana = lista(None); // Las filas siguen emitiendo su Msg (el cuerpo aparte no se comió el click). assert_eq!( plana.get(Sel::new().role(Role::ListItem).label("item-3")).click(), Some(Msg::Fila(3)) ); // Y miden lo que declaró el caller. Sin `flex_shrink: 0` el flex repartía // el faltante entre TODAS las filas cuando la lista desbordaba: 60 filas de // 22 px se pintaban a 14 (medido), o sea que el widget no recortaba como // decía su doc, achicaba. El alto de fila es un dato, no una sugerencia. let alto = plana .find_all(Sel::new().label("item-0")) .first() .expect("la fila 0 existe") .rect() .h; assert!((alto - ALTO_FILA).abs() < 0.5, "la fila mide {alto} y se declaró {ALTO_FILA}"); } // ── tree ──────────────────────────────────────────────────────────────── fn arbol(scroll: Option>) -> Ui { use llimphi_widget_tree::{tree_view_ext, TreeOpciones, TreePalette, TreeRow, TreeSpec}; let v: View = tree_view_ext( TreeSpec { rows: (0..FILAS) .map(|i| { TreeRow::new(format!("nodo-{i}"), 0, false, false, false, Msg::Toggle(i), Msg::Fila(i)) }) .collect(), row_height: ALTO_FILA, indent_px: 14.0, palette: TreePalette::default(), guides: false, }, TreeOpciones { scroll, ..Default::default() }, ); Ui::new(v, 300.0, VIEWPORT + 40.0) } #[test] fn el_arbol_desplaza_sus_filas() { let quieto = arbol(Some(desplazamiento(0.0, VIEWPORT))); let corrido = arbol(Some(desplazamiento(80.0, VIEWPORT))); let dy = y_de(&quieto, "nodo-0") - y_de(&corrido, "nodo-0"); assert!((dy - 80.0).abs() < 0.5, "el árbol tendría que correrse 80 px, se corrió {dy}"); // Y las filas siguen siendo filas de árbol. El `on_click` NO vive en el // nodo del rol sino en el del label (así lo activa el lector de pantalla, // está documentado en el widget), así que se busca por ahí. let fila = corrido.get(Sel::new().role(Role::TreeItem).label("nodo-5")); assert_eq!(fila.rect().h, ALTO_FILA, "la fila del árbol no debe aplastarse"); let clickeable = corrido .find_all(Sel::new().label("nodo-5").clickable(true)) .first() .expect("alguien tiene que llevar el click de la fila") .click(); assert_eq!(clickeable, Some(Msg::Fila(5))); } #[test] fn sin_alto_medido_todavia_ninguna_de_las_dos_salta() { // El primer cuadro (`viewport_len = 0`) con un offset guardado: se pintan // desde arriba en vez de saltar a un lugar que aún no se puede validar. assert_eq!( y_de(&lista(Some(desplazamiento(0.0, VIEWPORT))), "item-0"), y_de(&lista(Some(desplazamiento(80.0, 0.0))), "item-0") ); assert_eq!( y_de(&arbol(Some(desplazamiento(0.0, VIEWPORT))), "nodo-0"), y_de(&arbol(Some(desplazamiento(80.0, 0.0))), "nodo-0") ); }