Files
lightcraft/crates/ui-egui/src/tests_button.rs
T
Nathaniel LedfordandClaude Opus 5.5 d0a9ca09dd Buttons: one component with kinds, sizes and icons
Buttons were egui's plain one, a fill set by hand where one had to stand out (a dialog's confirm,
About's Discord link), with no hover or press shade on a filled one and no shared sizes.

- `crate::button::Button`: what it is for (`Kind`: Primary filled with the accent, Secondary
  with a neutral fill and a border, Danger filled red, Ghost with no fill until hovered), how
  big (`Size`: Small 24, Medium 30, Large 36 points, each with its padding, text and icon size),
  and what it shows (text, an icon before the text, or an icon alone, which takes a name for
  screen readers and its tooltip).
- Hover and press darken it; disabled (or in a disabled part of the window) fades it and takes
  no click, its tooltip or name still shown; a focused one shows an accent ring. A filled kind's
  fill is darkened as little as needed for white to read at 4.5:1 (the light themes' accent and
  the red would be too pale), in every state. Drawn within its own rectangle.
- Named for screen readers, registered for scripts and tests (`.widget(id)`). Its egui id comes
  from the widget id (salted with `.id_salt`, e.g. the dialog's kind, so the keyboard's place
  doesn't carry from one dialog to the next) or from its place in the ui, moved on so buttons
  with the same text side by side stay apart.
- Dialogs' footers (Cancel, the confirm; at least 72 points wide) and About's Discord link use
  it. The Export dialog's expected height in `tests_dialog_fit` grows by the 8 points of its
  footer's 30-point buttons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:57:58 -04:00

160 lines
6.9 KiB
Rust

//! The button component: its sizes, its looks in every theme and state, what screen readers hear,
//! and that a disabled one can't be clicked.
use egui::{Color32, Pos2, Rect, pos2, vec2};
use crate::button::{Button, Kind, Size, State, contrast, look};
use crate::icons::Icon;
use crate::theme::{ThemeKind, Tokens};
/// Run frames of `draw` (with pointer `events` in the second), returning the AccessKit nodes'
/// (role, name) and what `draw` reported.
fn frames<T: Default>(events: Vec<egui::Event>, mut draw: impl FnMut(&mut egui::Ui) -> T) -> (Vec<(String, String)>, T) {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
ctx.enable_accesskit();
let mut found = Vec::new();
let mut last = T::default();
for frame in 0..3 {
let raw = egui::RawInput {
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))),
events: if frame == 1 { events.clone() } else { Vec::new() },
..Default::default()
};
let mut out = ctx.run_ui(raw, |ui| last = draw(ui));
out.textures_delta.clear();
if let Some(update) = out.platform_output.accesskit_update.take() {
found = update.nodes.iter().map(|(_, n)| (format!("{:?}", n.role()), n.label().unwrap_or_default().to_string())).collect();
}
}
(found, last)
}
#[test]
fn the_three_sizes_and_an_icon_alone() {
let (_, sizes) = frames(Vec::new(), |ui| {
[Size::Small, Size::Medium, Size::Large]
.map(|s| (Button::new("Import Photos…").size(s).desired_size(ui), Button::icon_only(Icon::Trash, "Delete").size(s).desired_size(ui)))
.to_vec()
});
let heights: Vec<f32> = sizes.iter().map(|(text, _)| text.y).collect();
assert_eq!(heights, vec![24.0, 30.0, 36.0]);
for (text, icon) in &sizes {
assert_eq!(icon.x, icon.y, "an icon alone is square");
assert!(text.x > icon.x, "text makes it wider");
}
// an icon beside the text makes it wider still, and a minimum width holds
let (_, (plain, with_icon, wide)) = frames(Vec::new(), |ui| {
(
Button::new("Remove").desired_size(ui).x,
Button::new("Remove").icon(Icon::Trash).desired_size(ui).x,
Button::new("OK").min_width(120.0).desired_size(ui).x,
)
});
assert!(with_icon > plain);
assert_eq!(wide, 120.0);
}
#[test]
fn a_screen_reader_hears_every_button_by_its_name() {
let (found, _) = frames(Vec::new(), |ui| {
Button::new("Import Photos…").primary().show(ui);
Button::icon_only(Icon::Trash, "Delete mask").ghost().show(ui);
Button::new("Remove").icon(Icon::Trash).danger().enabled(false).show(ui);
});
let has = |label: &str| found.iter().any(|(r, l)| r == "Button" && l == label);
assert!(has("Import Photos…"), "{found:?}");
assert!(has("Delete mask"), "an icon alone is named: {found:?}");
assert!(has("Remove"), "a disabled one is still announced: {found:?}");
}
#[test]
fn text_on_a_fill_reads_at_aa_in_every_theme_and_state() {
for kind in ThemeKind::ALL {
let t = Tokens::for_kind(kind);
for k in [Kind::Primary, Kind::Danger, Kind::Secondary] {
for state in [State::Idle, State::Hovered, State::Pressed] {
let lk = look(&t, k, state);
let fill = lk.fill.unwrap_or(t.canvas);
let c = contrast(lk.content, fill);
assert!(c >= 4.5, "{kind:?} {k:?} {state:?}: {c:.2}:1");
}
}
// a ghost's text on the surfaces it sits on
let ghost = look(&t, Kind::Ghost, State::Idle);
assert!(ghost.fill.is_none());
assert!(contrast(ghost.content, t.chrome) >= 4.5, "{kind:?} ghost on chrome");
}
}
#[test]
fn hover_and_press_are_darker_and_disabled_is_faded() {
let t = Tokens::for_kind(ThemeKind::Charcoal);
let luma = |c: Color32| u32::from(c.r()) + u32::from(c.g()) + u32::from(c.b());
let fill = |s| look(&t, Kind::Primary, s).fill.map(luma).unwrap_or(0);
assert!(fill(State::Hovered) < fill(State::Idle));
assert!(fill(State::Pressed) < fill(State::Hovered));
let disabled = look(&t, Kind::Primary, State::Disabled);
assert!(disabled.content.a() < 255, "faded");
}
#[test]
fn a_disabled_button_cannot_be_clicked() {
let at = pos2(40.0, 15.0);
let click = vec![
egui::Event::PointerMoved(at),
egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() },
egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() },
];
let place = Rect::from_min_size(Pos2::ZERO, vec2(120.0, 30.0));
let mut disabled_clicked = false;
let _ = frames(click.clone(), |ui| {
if Button::new("Delete").danger().enabled(false).show_at(ui, place).clicked() {
disabled_clicked = true;
}
});
assert!(!disabled_clicked, "on any frame");
let mut seen = false;
let _ = frames(click, |ui| {
if Button::new("Delete").danger().show_at(ui, place).clicked() {
seen = true;
}
});
assert!(seen, "an enabled one is");
}
#[test]
fn buttons_in_one_place_with_the_same_text_are_told_apart() {
// two "Delete mask" buttons side by side, placed by the caller: two widgets, not one
let (found, _) = frames(Vec::new(), |ui| {
let a = Button::icon_only(Icon::Trash, "Delete mask").show_at(ui, Rect::from_min_size(pos2(0.0, 0.0), vec2(30.0, 30.0)));
let b = Button::icon_only(Icon::Trash, "Delete mask").show_at(ui, Rect::from_min_size(pos2(40.0, 0.0), vec2(30.0, 30.0)));
assert_ne!(a.id, b.id);
});
assert_eq!(found.iter().filter(|(r, l)| r == "Button" && l == "Delete mask").count(), 2, "{found:?}");
}
#[test]
fn a_button_in_a_disabled_part_of_the_window_says_it_is_disabled() {
// (egui refuses the click there anyway: what the button adds is looking and reading disabled)
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
ctx.enable_accesskit();
let mut disabled = None;
for _ in 0..3 {
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
ui.add_enabled_ui(false, |ui| {
let resp = Button::new("Delete").show_at(ui, Rect::from_min_size(Pos2::ZERO, vec2(120.0, 30.0)));
// drawn as disabled: it asks for hover only, as a button turned off does
assert!(!resp.sense.senses_click(), "drawn as a button that can be clicked");
});
});
out.textures_delta.clear();
if let Some(update) = out.platform_output.accesskit_update.take() {
disabled = update.nodes.iter().find(|(_, n)| n.label() == Some("Delete")).map(|(_, n)| n.is_disabled());
}
}
assert_eq!(disabled, Some(true), "a screen reader hears it disabled");
}