Merge PR #1810 (rebased)

# Conflicts:
#	docs/whats-new.md
This commit is contained in:
Brandon Thomas
2026-10-15 00:56:51 -04:00
6 changed files with 583 additions and 31 deletions
+389
View File
@@ -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,
}
}
}
+3
View File
@@ -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;
+23 -29
View File
@@ -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;
+159
View File
@@ -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");
}
+3 -2
View File
@@ -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:?}");
}
+6
View File
@@ -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