From d0a9ca09dd0c4d146aae3a12a71c2babd42c5f6f Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Wed, 14 Oct 2026 17:37:54 -0400 Subject: [PATCH] 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 --- crates/ui-egui/src/button.rs | 389 +++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/panels/dialogs.rs | 52 ++-- crates/ui-egui/src/tests_button.rs | 159 ++++++++++ crates/ui-egui/src/tests_dialog_fit.rs | 5 +- docs/whats-new.md | 6 + 6 files changed, 583 insertions(+), 31 deletions(-) create mode 100644 crates/ui-egui/src/button.rs create mode 100644 crates/ui-egui/src/tests_button.rs diff --git a/crates/ui-egui/src/button.rs b/crates/ui-egui/src/button.rs new file mode 100644 index 000000000..f46156ed9 --- /dev/null +++ b/crates/ui-egui/src/button.rs @@ -0,0 +1,389 @@ +//! One button for the whole interface: what it is for ([`Kind`]: Primary, Secondary, Danger, +//! Ghost), how big ([`Size`]: Small, Medium, Large), and what it shows (text, an icon and text, +//! or an icon alone, which then needs a name for screen readers). It looks the same everywhere, +//! answers hover, press and the keyboard the same way, and its text always reads at WCAG AA on +//! its fill. +//! +//! ```ignore +//! if Button::new("Import Photos…").primary().widget("button:emptyImportPhotos").show(ui).clicked() { … } +//! Button::icon_only(Icon::Trash, "Delete mask").ghost().small().show(ui); +//! Button::new("Remove").icon(Icon::Trash).danger().show(ui); +//! ``` + +use egui::{Color32, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2, pos2, vec2}; + +use crate::icons::{Icon, paint}; +use crate::theme::Tokens; + +/// What a button is for, which decides how loud it is. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Kind { + /// The step to take: filled with the accent. One per place (a dialog's confirm, the welcome's + /// Import Photos…). + Primary, + /// Any other action: a neutral fill with a border. + #[default] + Secondary, + /// A step that removes or erases something, when it is the one being asked for: filled red. + Danger, + /// A quiet action among content (a toolbar, a banner's Hide): no fill until hovered. + Ghost, +} + +/// How big a button is: its height, padding, text and icon size. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Size { + /// 24 points: in toolbars, banners and dense rows. + Small, + /// 30 points: dialogs and panels. + #[default] + Medium, + /// 36 points: a page's one main action (the welcome, About's Discord link). + Large, +} + +impl Size { + pub const fn height(self) -> f32 { + match self { + Size::Small => 24.0, + Size::Medium => 30.0, + Size::Large => 36.0, + } + } + fn padding(self) -> f32 { + match self { + Size::Small => 8.0, + Size::Medium => 12.0, + Size::Large => 16.0, + } + } + fn text(self) -> f32 { + match self { + Size::Small => 12.0, + Size::Medium => 13.0, + Size::Large => 14.0, + } + } + fn icon(self) -> f32 { + match self { + Size::Small => 14.0, + Size::Medium => 16.0, + Size::Large => 18.0, + } + } + fn gap(self) -> f32 { + match self { + Size::Small => 4.0, + Size::Medium => 6.0, + Size::Large => 8.0, + } + } + fn radius(self) -> f32 { + match self { + Size::Small => 4.0, + Size::Medium => 5.0, + Size::Large => 6.0, + } + } +} + +/// Where the pointer and the keyboard are, for the colours. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum State { + Idle, + Hovered, + Pressed, + Disabled, +} + +/// The colours a button of `kind` takes in `state`: fill (if any), border (if any), and what is +/// drawn on it (text and icon). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Look { + pub fill: Option, + pub border: Option, + pub content: Color32, +} + +/// WCAG 2 contrast of two opaque colours. +pub fn contrast(a: Color32, b: Color32) -> f32 { + let lum = |c: Color32| { + let ch = |v: u8| { + let v = f32::from(v) / 255.0; + if v <= 0.03928 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } + }; + 0.2126 * ch(c.r()) + 0.7152 * ch(c.g()) + 0.0722 * ch(c.b()) + }; + let (x, y) = (lum(a), lum(b)); + (x.max(y) + 0.05) / (x.min(y) + 0.05) +} + +/// `c` toward black by `amount` (0 to 1). +fn darker(c: Color32, amount: f32) -> Color32 { + crate::widgets::lerp(c, Color32::BLACK, amount) +} + +/// `fill`, darkened as little as needed for white to read on it at 4.5:1 (a light theme's or a +/// bright red's fill would otherwise be too pale for white text). +fn fill_for_white(fill: Color32) -> Color32 { + let mut f = fill; + for _ in 0..20 { + if contrast(Color32::WHITE, f) >= 4.5 { + break; + } + f = darker(f, 0.06); + } + f +} + +/// The colours of a `kind` button in `state` with the theme `t`. +pub fn look(t: &Tokens, kind: Kind, state: State) -> Look { + let filled = |base: Color32| { + let base = fill_for_white(base); + // hover and press go darker: white on them only reads better + let fill = match state { + State::Idle | State::Disabled => base, + State::Hovered => darker(base, 0.12), + State::Pressed => darker(base, 0.22), + }; + Look { fill: Some(fill), border: None, content: Color32::WHITE } + }; + let look = match kind { + Kind::Primary => filled(t.accent), + Kind::Danger => filled(t.reject), + Kind::Secondary => Look { + fill: Some(match state { + State::Idle | State::Disabled => t.button, + State::Hovered => t.hover, + State::Pressed => t.pressed, + }), + border: Some(t.button_border), + content: t.text, + }, + Kind::Ghost => Look { + fill: match state { + State::Idle | State::Disabled => None, + State::Hovered => Some(t.hover), + State::Pressed => Some(t.pressed), + }, + border: None, + content: t.text, + }, + }; + if state == State::Disabled { + let fade = |c: Color32| c.gamma_multiply(0.45); + Look { fill: look.fill.map(fade), border: look.border.map(fade), content: fade(look.content) } + } else { + look + } +} + +/// A button, built and then shown with [`Button::show`] (in the layout) or [`Button::show_at`] +/// (in a given rectangle). +#[derive(Clone, Debug)] +pub struct Button<'a> { + text: Option<&'a str>, + icon: Option, + name: Option<&'a str>, + kind: Kind, + size: Size, + enabled: bool, + widget: Option, + tooltip: Option<&'a str>, + min_width: f32, + id_salt: Option, +} + +impl<'a> Button<'a> { + /// A button with `text` (an English string, translated when drawn). + pub fn new(text: &'a str) -> Self { + Button { + text: Some(text), + icon: None, + name: None, + kind: Kind::default(), + size: Size::default(), + enabled: true, + widget: None, + tooltip: None, + min_width: 0.0, + id_salt: None, + } + } + + /// A button showing `icon` alone; `name` is what a screen reader says and the tooltip shows. + pub fn icon_only(icon: Icon, name: &'a str) -> Self { + Button { text: None, icon: Some(icon), name: Some(name), ..Button::new("") } + } + + /// An icon before the text. + pub fn icon(mut self, icon: Icon) -> Self { + self.icon = Some(icon); + self + } + + pub fn kind(mut self, kind: Kind) -> Self { + self.kind = kind; + self + } + pub fn primary(self) -> Self { + self.kind(Kind::Primary) + } + pub fn secondary(self) -> Self { + self.kind(Kind::Secondary) + } + pub fn danger(self) -> Self { + self.kind(Kind::Danger) + } + pub fn ghost(self) -> Self { + self.kind(Kind::Ghost) + } + + pub fn size(mut self, size: Size) -> Self { + self.size = size; + self + } + pub fn small(self) -> Self { + self.size(Size::Small) + } + pub fn large(self) -> Self { + self.size(Size::Large) + } + + /// Off: drawn faded, and a click does nothing. + pub fn enabled(mut self, enabled: bool) -> Self { + self.enabled = enabled; + self + } + + /// Registered under this id (`button:…`) for scripts, tests and `ui.clickWidget`; unique on + /// screen, as every registered id is. + pub fn widget(mut self, id: impl Into) -> Self { + self.widget = Some(id.into()); + self + } + + /// Shown on hover (an icon-only button shows its name without one). + pub fn tooltip(mut self, text: &'a str) -> Self { + self.tooltip = Some(text); + self + } + + /// Told apart from buttons of the same widget id in other places (each dialog's confirm is + /// `button:dialogOk`: salted with the dialog, the keyboard's place doesn't carry from one + /// dialog to the next). + pub fn id_salt(mut self, salt: impl std::hash::Hash + std::fmt::Debug) -> Self { + self.id_salt = Some(egui::Id::NULL.with(salt)); + self + } + + /// At least this wide (a row of buttons of one width). + pub fn min_width(mut self, width: f32) -> Self { + self.min_width = width; + self + } + + fn font(&self, t: &Tokens) -> egui::FontId { + // the loud kinds speak a little bolder + match self.kind { + Kind::Primary | Kind::Danger => t.semibold(self.size.text()), + Kind::Secondary | Kind::Ghost => t.font(self.size.text()), + } + } + + fn galley(&self, ui: &Ui) -> Option> { + let t = Tokens::get(ui.ctx()); + self.text.filter(|s| !s.is_empty()).map(|s| ui.painter().layout_no_wrap(crate::i18n::tr(s).to_string(), self.font(&t), Color32::WHITE)) + } + + /// How big it is drawn. + pub fn desired_size(&self, ui: &Ui) -> Vec2 { + let h = self.size.height(); + let text_w = self.galley(ui).map_or(0.0, |g| g.size().x); + let w = match (self.icon.is_some(), text_w > 0.0) { + // an icon alone: a square + (true, false) => h, + (true, true) => self.size.padding() * 2.0 + self.size.icon() + self.size.gap() + text_w, + (false, _) => self.size.padding() * 2.0 + text_w, + }; + vec2(w.max(self.min_width), h) + } + + /// Show it where the layout puts it. + pub fn show(self, ui: &mut Ui) -> Response { + let size = self.desired_size(ui); + let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); + self.show_at(ui, rect) + } + + /// Show it in `rect` (sized by the caller, e.g. a row of equal buttons). + pub fn show_at(self, ui: &mut Ui, rect: Rect) -> Response { + let t = Tokens::get(ui.ctx()); + // stable across frames and moves: its widget id (with its salt) when it has one, else its + // place in the ui (moved on, so the next button in the same ui gets another) + let id = match &self.widget { + Some(w) => ui.id().with(("lc-button", w.as_str(), self.id_salt)), + None => { + let id = ui.auto_id_with(("lc-button", self.text.or(self.name), self.id_salt)); + ui.skip_ahead_auto_ids(1); + id + } + }; + // a button in a disabled part of the window is disabled too + let enabled = self.enabled && ui.is_enabled(); + let resp = ui.interact(rect, id, if enabled { Sense::click() } else { Sense::hover() }); + let words = crate::i18n::tr(self.name.or(self.text).unwrap_or_default()).to_string(); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, words.clone())); + if let Some(w) = &self.widget { + crate::widgets::register(ui.ctx(), w.clone(), rect); + } + let state = if !enabled { + State::Disabled + } else if resp.is_pointer_button_down_on() { + State::Pressed + } else if resp.hovered() { + State::Hovered + } else { + State::Idle + }; + let lk = look(&t, self.kind, state); + // (its own rectangle and its focus ring: a long text never runs over its neighbours) + let p = &ui.painter_at(rect.expand(5.0)); + let radius = self.size.radius(); + if let Some(fill) = lk.fill { + p.rect_filled(rect, radius, fill); + } + if let Some(border) = lk.border { + p.rect_stroke(rect, radius, Stroke::new(1.0, border), StrokeKind::Inside); + } + // where the keyboard is: an accent ring around it + if resp.has_focus() { + p.rect_stroke(rect.expand(2.0), radius + 2.0, Stroke::new(2.0, t.accent), StrokeKind::Outside); + } + let galley = self.galley(ui); + let icon_s = self.size.icon(); + let content_w = match (&galley, self.icon) { + (Some(g), Some(_)) => icon_s + self.size.gap() + g.size().x, + (Some(g), None) => g.size().x, + (None, Some(_)) => icon_s, + (None, None) => 0.0, + }; + let mut x = rect.center().x - content_w / 2.0; + if let Some(icon) = self.icon { + paint(p, Rect::from_center_size(pos2(x + icon_s / 2.0, rect.center().y), vec2(icon_s, icon_s)), icon, lk.content); + x += icon_s + self.size.gap(); + } + if let Some(g) = galley { + let at = pos2(x, rect.center().y - g.size().y / 2.0); + p.galley_with_override_text_color(at, g, lk.content); + } + // the tooltip, or an icon's name, also while disabled (what the button would do). egui + // calls a response disabled only in a disabled ui: a button turned off on its own still + // answers hover, and takes the ordinary tooltip + match self.tooltip.or(if self.text.is_none() { self.name } else { None }) { + Some(tip) if resp.enabled() => resp.on_hover_text(crate::i18n::tr(tip)), + Some(tip) => resp.on_disabled_hover_text(crate::i18n::tr(tip)), + None => resp, + } + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 544724671..5fb1f4e71 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -8,6 +8,7 @@ pub mod album_picker; pub mod brush_live; +pub mod button; mod command_error; pub mod control; pub mod credits; @@ -49,6 +50,8 @@ mod tests_appearance; #[cfg(test)] mod tests_before_after; #[cfg(test)] +mod tests_button; +#[cfg(test)] mod tests_compare; #[cfg(test)] mod tests_copy_paste_dialogs; diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index c91266a26..743d6becd 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -1169,11 +1169,13 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { crate::theme::font_credits(app.chinese_font.is_some()) )); ui.add_space(10.0); - let discord = egui::Button::new(egui::RichText::new(crate::i18n::tr("Join the ArtCraft Discord")).font(t.semibold(15.0)).color(egui::Color32::WHITE)) - .fill(t.accent) - .min_size(egui::vec2(260.0, 34.0)); - let r = ui.add(discord).on_hover_text(crate::links::DISCORD); - crate::widgets::register(ui.ctx(), "button:aboutDiscord", r.rect); + let r = crate::button::Button::new("Join the ArtCraft Discord") + .primary() + .large() + .min_width(260.0) + .widget("button:aboutDiscord") + .tooltip(crate::links::DISCORD) + .show(ui); if r.clicked() { let _ = crate::links::open(app, crate::links::DISCORD); } @@ -1254,39 +1256,31 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { // smart-album rules that can't mean anything wait to be fixed && !matches!(&dlg, Dialog::SmartRules { id, rules, .. } if !rules.check_for(&app.session.catalog, id.map(lightcraft_catalog::AlbumId)).is_empty()); let primary = !informational && can_confirm && confirm_style(&dlg) == ConfirmStyle::Primary; + use crate::button::{Button, Kind}; + // (each kind of dialog its own buttons: the keyboard's place doesn't carry from a + // dialog to the one it opens) + let kind = std::mem::discriminant(&dlg); + let cancel_button = Button::new(cancel).widget("button:dialogCancel").id_salt(kind).min_width(72.0); + let ok_button = Button::new(ok) + .kind(if primary { Kind::Primary } else { Kind::Secondary }) + .enabled(can_confirm) + .widget("button:dialogOk") + .id_salt(kind) + .min_width(72.0); // (Cancel first, then the confirm: the keyboard goes through them as the eye does) - let width_of = |label: &str| { - let text = egui::WidgetText::from(crate::i18n::tr(label)) - .into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Button) - .size() - .x; - (text + 2.0 * ui.spacing().button_padding.x).max(ui.spacing().interact_size.x) - }; let gap = ui.spacing().item_spacing.x; let mut buttons_w = 0.0; if !informational { - buttons_w += width_of(cancel) + gap; + buttons_w += cancel_button.desired_size(ui).x + gap; } if !ok.is_empty() { - buttons_w += width_of(ok); + buttons_w += ok_button.desired_size(ui).x; } ui.add_space((ui.available_width() - buttons_w).max(0.0)); - if !informational { - let r = ui.button(crate::i18n::tr(cancel)); - crate::widgets::register(ui.ctx(), "button:dialogCancel", r.rect); - if r.clicked() { - close = true; - } - } - let button = if primary { - egui::Button::new(egui::RichText::new(crate::i18n::tr(ok)).color(egui::Color32::WHITE)).fill(t.accent) - } else { - egui::Button::new(crate::i18n::tr(ok)) - }; - let r = (!ok.is_empty()).then(|| ui.add_enabled(can_confirm, button)); - if let Some(r) = &r { - crate::widgets::register(ui.ctx(), "button:dialogOk", r.rect); + if !informational && cancel_button.show(ui).clicked() { + close = true; } + let r = (!ok.is_empty()).then(|| ok_button.show(ui)); if r.is_some_and(|r| r.clicked()) { if informational { close = true; diff --git a/crates/ui-egui/src/tests_button.rs b/crates/ui-egui/src/tests_button.rs new file mode 100644 index 000000000..e06948fff --- /dev/null +++ b/crates/ui-egui/src/tests_button.rs @@ -0,0 +1,159 @@ +//! 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(events: Vec, 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 = 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"); +} diff --git a/crates/ui-egui/src/tests_dialog_fit.rs b/crates/ui-egui/src/tests_dialog_fit.rs index 29d0401c2..1185e94e5 100644 --- a/crates/ui-egui/src/tests_dialog_fit.rs +++ b/crates/ui-egui/src/tests_dialog_fit.rs @@ -95,8 +95,9 @@ fn export_dialog_is_unchanged_in_a_tall_window() { let dialog = rect(&h, "dialog:window"); // (846 tall as before #781: the whole dialog, nothing scrolled away; 615 wide since the // format row gained JPEG XL, #780. On macOS the dialog has one more row, "Add to Apple Photos" - // (#236), so it is 876 tall there) - let height = if cfg!(target_os = "macos") { 876.0 } else { 846.0 }; + // (#236), so it is 876 tall there. 8 more since its footer's buttons are the button + // component's medium size, 30 points, as the welcome's and every dialog's) + let height = if cfg!(target_os = "macos") { 884.0 } else { 854.0 }; assert!((dialog.height() - height).abs() <= 1.0, "{dialog:?}"); assert!((dialog.width() - 615.2).abs() <= 1.0, "{dialog:?}"); } diff --git a/docs/whats-new.md b/docs/whats-new.md index d8611d7a6..054105e2e 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,12 @@ ## 2026-10-14 +### One kind of button +- Dialogs' buttons and About's Discord link are drawn by one button component: primary (filled with the accent), + secondary, danger (filled red) and ghost, in three sizes, with an icon or without, and an icon alone (named for + screen readers). Each darkens a little when hovered and more when pressed, shows a ring where the keyboard is, and + its text reads at 4.5:1 or more on its fill in every theme. Dialog buttons are 30 points tall, like the welcome's. + ### A dialog's confirm button says it's the next step - In dialogs, the confirm button is on the right with Cancel to its left, and filled with the accent when it makes or brings in something (Import N Photos, Merge, Save, Create, Download…). Delete and Remove stay plain, so a destructive