ashlar-ui 0.1.0

ashlar UI components: theme tokens, controls, layout, docking, icons
Documentation
use egui::accesskit::Role;
use egui::{Color32, Key, Shape, pos2, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::{NodeT, Queryable};

use super::*;

fn total(fit: &TabFit) -> f32 {
    fit.shown.iter().map(|&(_, w)| w).sum::<f32>() + fit.overflow_width
}

fn shown(fit: &TabFit) -> Vec<usize> {
    fit.shown.iter().map(|&(i, _)| i).collect()
}

#[test]
fn natural_widths_when_they_fit_and_the_widest_shrinks_first() {
    let f = fit(&[60.0, 70.0, 80.0], 0, 400.0, 40.0, 22.0);
    assert_eq!(f.shown, vec![(0, 60.0), (1, 70.0), (2, 80.0)]);
    assert!(f.overflow.is_empty());

    let f = fit(&[50.0, 70.0, 120.0], 1, 200.0, 40.0, 22.0);
    assert!(f.overflow.is_empty());
    assert_eq!((f.shown[0], f.shown[1]), ((0, 50.0), (1, 70.0)));
    assert!((f.shown[2].1 - 80.0).abs() < 0.01, "{:?}", f.shown);
    assert!((total(&f) - 200.0).abs() < 0.01);
}

#[test]
fn the_selected_tab_is_always_shown() {
    let natural = [80.0, 90.0, 85.0, 95.0];
    for selected in [0, 1, 2, 3, 4, usize::MAX] {
        for available in (0..=500).step_by(5) {
            let available = available as f32;
            let f = fit(&natural, selected, available, 40.0, 22.0);
            let selected = selected.min(3);
            assert!(shown(&f).contains(&selected) && !f.overflow.contains(&selected), "{selected} {available} {f:?}");
            assert!(shown(&f).windows(2).all(|w| w[0] < w[1]) && f.overflow.windows(2).all(|w| w[0] < w[1]));
            let mut all = shown(&f);
            all.extend(&f.overflow);
            all.sort_unstable();
            assert_eq!(all, vec![0, 1, 2, 3]);
            assert!(total(&f) <= available + 0.01, "{available} {f:?}");
        }
    }
}

#[test]
fn a_single_tab_takes_whatever_there_is() {
    assert_eq!(fit(&[80.0], 99, 10.0, 40.0, 22.0), TabFit { shown: vec![(0, 10.0)], overflow: vec![], overflow_width: 0.0 });
}

#[test]
fn hostile_dimensions_stay_finite_and_bounded() {
    let hostile = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -5.0, 0.0, f32::MAX, 1e20, 120.0];
    let inputs: [&[f32]; 4] = [&[], &[60.0, f32::NAN, 70.0], &[f32::MAX, -5.0, f32::INFINITY, 30.0], &[50.0; 9]];
    for natural in inputs {
        for &available in &hostile {
            for &minimum in &hostile {
                for &overflow in &hostile {
                    let f = fit(natural, 1, available, minimum, overflow);
                    assert!(f.shown.iter().all(|&(_, w)| w.is_finite() && w >= 0.0), "{f:?}");
                    assert!(f.overflow_width.is_finite() && f.overflow_width >= 0.0);
                    assert!(total(&f) <= clean(available) + 0.125, "{natural:?} {available} {minimum} {overflow} {f:?}");
                    assert_eq!(f.shown.len() + f.overflow.len(), natural.len());
                }
            }
        }
    }
}

#[test]
fn ten_thousand_tabs_are_fast() {
    let natural = vec![60.0; 10_000];
    let start = std::time::Instant::now();
    let f = fit(&natural, 9_999, 240.0, 40.0, 22.0);
    assert!(start.elapsed() < std::time::Duration::from_secs(1));
    let last = f.shown.iter().find(|&&(i, _)| i == 9_999).unwrap();
    assert!(last.1 > 0.0);
    assert_eq!(f.shown.len() + f.overflow.len(), 10_000);
    assert!(total(&f) <= 240.0 + 0.01);
}

const FOCUS: Color32 = Color32::from_rgb(220, 38, 38);

#[derive(Default)]
struct Strip {
    labels: Vec<String>,
    selected: usize,
    enabled: bool,
    drag: bool,
    clicks: Vec<usize>,
    drag_started: bool,
    dragged: bool,
    drag_stopped: bool,
    ids: Vec<Id>,
    rects: Vec<Rect>,
}

fn strip(labels: &[&str]) -> Harness<'static, Strip> {
    let state = Strip { labels: labels.iter().map(|s| s.to_string()).collect(), enabled: true, ..Default::default() };
    Harness::builder().with_size(vec2(400.0, 120.0)).build_ui_state(
        |ui, s: &mut Strip| {
            s.ids.clear();
            s.rects.clear();
            let enabled = s.enabled;
            ui.add_enabled_ui(enabled, |ui| {
                for (i, label) in s.labels.clone().iter().enumerate() {
                    let rect = Rect::from_min_size(pos2(10.0 + 80.0 * i as f32, 10.0), vec2(76.0, 26.0));
                    let mut tab = Tab::new(Id::new(("tab", i)), label, i == s.selected).focus_stroke(Stroke::new(1.0, FOCUS));
                    if s.drag {
                        tab = tab.sense(Sense::click_and_drag());
                    }
                    let response = tab.show_at(ui, rect, |ui, r| {
                        ui.painter().rect_filled(r.rect, 0.0, Color32::from_gray(if r.hovered() { 90 } else { 60 }));
                    });
                    if response.clicked() {
                        s.clicks.push(i);
                    }
                    s.drag_started |= response.drag_started();
                    s.dragged |= response.dragged();
                    s.drag_stopped |= response.drag_stopped();
                    s.ids.push(response.id);
                    s.rects.push(rect);
                }
            });
        },
        state,
    )
}

#[test]
fn ids_come_from_the_caller_and_nodes_are_tabs() {
    let mut h = strip(&["Layers", "Layers"]);
    h.step();
    let ids = h.state().ids.clone();
    assert_ne!(ids[0], ids[1]);
    h.ctx.memory_mut(|m| m.request_focus(ids[0]));
    h.step();
    h.state_mut().labels[0] = "Ebenen".into();
    h.step();
    assert_eq!(h.state().ids[0], ids[0]);
    assert!(h.ctx.memory(|m| m.has_focus(ids[0])));
    let nodes: Vec<_> = h.get_all_by_role(Role::Tab).collect();
    assert_eq!(nodes.len(), 2);
    let selected: Vec<Option<bool>> = nodes.iter().map(|n| n.accesskit_node().is_selected()).collect();
    assert_eq!(selected, vec![Some(true), Some(false)]);
    assert!(nodes.iter().all(|n| n.accesskit_node().toggled().is_none()));
    assert_eq!(h.get_by_label("Ebenen").accesskit_node().role(), Role::Tab);
}

#[test]
fn keyboard_and_pointer_activate_once() {
    let mut h = strip(&["One", "Two"]);
    h.step();
    let id = h.state().ids[1];
    h.ctx.memory_mut(|m| m.request_focus(id));
    h.step();
    for key in [Key::Enter, Key::Space] {
        h.state_mut().clicks.clear();
        h.key_press(key);
        h.step();
        h.step();
        assert_eq!(h.state().clicks, vec![1], "{key:?}");
    }
    h.state_mut().clicks.clear();
    let c = h.state().rects[0].center();
    crate::test_support::click(&mut h, c);
    h.step();
    assert_eq!(h.state().clicks, vec![0]);
    assert_eq!(h.state().selected, 0);
}

#[test]
fn disabled_tabs_ignore_input() {
    let mut h = strip(&["One", "Two"]);
    h.state_mut().enabled = false;
    h.step();
    let id = h.state().ids[1];
    h.ctx.memory_mut(|m| m.request_focus(id));
    h.step();
    h.key_press(Key::Enter);
    h.step();
    let c = h.state().rects[0].center();
    crate::test_support::click(&mut h, c);
    h.step();
    assert!(h.state().clicks.is_empty());
    assert!(h.get_all_by_role(Role::Tab).all(|n| n.accesskit_node().is_disabled()));
}

#[test]
fn focus_outline_and_drags() {
    let mut h = strip(&["One", "Two"]);
    h.state_mut().drag = true;
    h.step();
    let id = h.state().ids[0];
    h.ctx.memory_mut(|m| m.request_focus(id));
    h.step();
    let inner = h.state().rects[0].shrink(2.0);
    let outlined = h.output().shapes.iter().any(|c| matches!(&c.shape, Shape::Rect(r) if r.rect == inner && r.stroke.color == FOCUS));
    assert!(outlined, "no focus outline at rect.shrink(2)");

    let c = h.state().rects[1].center();
    h.hover_at(c);
    h.step();
    h.drag_at(c);
    h.step();
    for dx in [10.0, 30.0, 60.0] {
        h.hover_at(c + vec2(dx, 0.0));
        h.step();
    }
    h.drop_at(c + vec2(60.0, 0.0));
    h.step();
    let s = h.state();
    assert!(s.drag_started && s.dragged && s.drag_stopped);
    assert!(s.clicks.is_empty());
    assert_eq!(s.selected, 0);
}

#[test]
fn invalid_rects_are_inert() {
    let bad = [
        Rect::NOTHING,
        Rect::EVERYTHING,
        Rect::from_min_size(pos2(0.0, 0.0), vec2(f32::NAN, 10.0)),
        Rect::from_min_size(pos2(0.0, 0.0), vec2(0.0, 10.0)),
        Rect::from_min_size(pos2(0.0, 0.0), vec2(1e20, 10.0)),
        Rect::from_min_max(pos2(50.0, 50.0), pos2(10.0, 10.0)),
    ];
    for rect in bad {
        let mut painted = false;
        let mut senses = false;
        let mut finite = true;
        let mut h = Harness::builder().with_size(vec2(200.0, 100.0)).build_ui(|ui| {
            let r = Tab::new(Id::new("bad"), "Bad", false).show_at(ui, rect, |_, _| painted = true);
            senses |= r.sense.senses_click();
            finite &= r.rect.is_finite();
        });
        h.step();
        crate::test_support::click(&mut h, pos2(5.0, 5.0));
        drop(h);
        assert!(!painted && !senses && finite, "{rect:?}");
    }
}