mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
@@ -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<Color32>,
|
||||
pub border: Option<Color32>,
|
||||
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<Icon>,
|
||||
name: Option<&'a str>,
|
||||
kind: Kind,
|
||||
size: Size,
|
||||
enabled: bool,
|
||||
widget: Option<String>,
|
||||
tooltip: Option<&'a str>,
|
||||
min_width: f32,
|
||||
id_salt: Option<egui::Id>,
|
||||
}
|
||||
|
||||
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<String>) -> 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<std::sync::Arc<egui::Galley>> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
pub mod album_picker;
|
||||
pub mod brush_live;
|
||||
pub mod button;
|
||||
pub mod canvas_keys;
|
||||
mod command_error;
|
||||
pub mod control;
|
||||
@@ -58,6 +59,8 @@ mod tests_before_after;
|
||||
#[cfg(test)]
|
||||
mod tests_browse_active;
|
||||
#[cfg(test)]
|
||||
mod tests_button;
|
||||
#[cfg(test)]
|
||||
mod tests_canvas_keys;
|
||||
#[cfg(test)]
|
||||
mod tests_compare;
|
||||
|
||||
@@ -1280,11 +1280,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);
|
||||
}
|
||||
@@ -1380,39 +1382,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;
|
||||
|
||||
@@ -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<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");
|
||||
}
|
||||
@@ -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, plus the Export to row
|
||||
// since #956; 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 30 taller there)
|
||||
let height = if cfg!(target_os = "macos") { 908.0 } else { 878.0 };
|
||||
// more row, "Add to Apple Photos" (#236), so it is 30 taller 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") { 916.0 } else { 886.0 };
|
||||
assert!((dialog.height() - height).abs() <= 1.0, "{dialog:?}");
|
||||
assert!((dialog.width() - 615.2).abs() <= 1.0, "{dialog:?}");
|
||||
}
|
||||
|
||||
@@ -130,6 +130,12 @@
|
||||
script, the control channel or MCP is now refused (issue #1506). It used to act on the selected photos instead,
|
||||
or on the part of the list it could read, so a mistyped id could rate, move or delete the wrong photos.
|
||||
|
||||
### 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
|
||||
|
||||
Reference in New Issue
Block a user