Dialog buttons follow the OS order; Windows/Linux unsaved prompt asks Yes / No / Cancel (#627, #776) (#826)

Dialog buttons follow the OS order through one shared widgets::dialog_buttons helper (fixes #776, #627). Supersedes #802, whose reordering of the unsaved-changes and Revert prompts this carries to every dialog.

Co-authored-by: Luna <luna161@users.noreply.github.com>
This commit is contained in:
BFlat 2026-10-08 04:45:40 +07:00 committed by GitHub
parent 41e9c742dc
commit 03ffca0d54
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 340 additions and 95 deletions

View File

@ -823,11 +823,14 @@ fn dialog(app: &mut PhotocraftApp, ctx: &egui::Context) {
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if crate::widgets::primary_button(ui, tl!("OK"), 70.0).clicked() {
result = Some(true);
}
if crate::widgets::secondary_button(ui, tl!("Cancel"), 70.0).clicked() {
result = Some(false);
if let Some(role) = crate::widgets::dialog_buttons(
ui,
&[
crate::widgets::DialogButton::new(crate::widgets::ButtonRole::Default, tl!("OK"), 70.0),
crate::widgets::DialogButton::new(crate::widgets::ButtonRole::Cancel, tl!("Cancel"), 70.0),
],
) {
result = Some(role == crate::widgets::ButtonRole::Default);
}
});
});

View File

@ -270,10 +270,17 @@ fn rename_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, acts: &mut Vec<Action>
resp.request_focus();
}
let enter = resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
if widgets::primary_button(ui, tl!("OK"), 52.0).clicked() || enter {
let clicked = widgets::dialog_buttons(
ui,
&[
widgets::DialogButton::new(widgets::ButtonRole::Default, tl!("OK"), 52.0),
widgets::DialogButton::new(widgets::ButtonRole::Cancel, tl!("Cancel"), 60.0),
],
);
if clicked == Some(widgets::ButtonRole::Default) || enter {
acts.push(Action::Rename(r.clone()));
}
if widgets::secondary_button(ui, tl!("Cancel"), 60.0).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
if clicked == Some(widgets::ButtonRole::Cancel) || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
cancel = true;
}
});

View File

@ -671,11 +671,14 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
painter.rect_filled(foot, 0.0, t.dock);
painter.line_segment([foot.left_top(), foot.right_top()], Stroke::new(1.0, t.separator));
let mut fu = ui.new_child(egui::UiBuilder::new().max_rect(foot.shrink2(vec2(16.0, 9.0))).layout(egui::Layout::right_to_left(egui::Align::Center)));
if widgets::primary_button(&mut fu, tl!("OK"), 90.0).clicked() {
action = Some("ok");
}
if widgets::secondary_button(&mut fu, tl!("Cancel"), 90.0).clicked() {
action = Some("cancel");
if let Some(role) = widgets::dialog_buttons(
&mut fu,
&[
widgets::DialogButton::new(widgets::ButtonRole::Default, tl!("OK"), 90.0),
widgets::DialogButton::new(widgets::ButtonRole::Cancel, tl!("Cancel"), 90.0),
],
) {
action = Some(if role == widgets::ButtonRole::Default { "ok" } else { "cancel" });
}
fu.add_space(12.0);
widgets::checkbox(&mut fu, &mut d.show_before, tl!("Before (Y)"));

View File

@ -5,6 +5,7 @@ use serde_json::{Value, json};
use crate::PhotocraftApp;
use crate::state::{Dialog, DialogKind};
use crate::widgets::{ButtonRole, DialogButton, dialog_buttons};
/// Where this frame's dialogs are on screen (the canvas reads last frame's: it draws first).
const RECTS: &str = "pc-dialog-rects";
@ -169,7 +170,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
ui.spacing_mut().item_spacing.x = 10.0;
if matches!(d.kind, DialogKind::About | DialogKind::Error) {
if crate::widgets::primary_button(ui, tl!("OK"), 84.0).clicked() {
if dialog_buttons(ui, &[DialogButton::new(ButtonRole::Default, tl!("OK"), 84.0)]).is_some() {
outcome = Some(false);
}
} else {
@ -180,15 +181,20 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
} else {
crate::file_ui::ok_label(&d.fields).unwrap_or(tl!("OK"))
};
if crate::widgets::primary_button(ui, ok_label, 84.0).clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) {
outcome = Some(true);
}
if d.kind == DialogKind::Command && crate::prefs_ui::is_preferences(&fields) {
let ok = DialogButton::new(ButtonRole::Default, ok_label, 84.0);
let cancel = DialogButton::new(ButtonRole::Cancel, if d.kind == DialogKind::NewDocument { tl!("Close") } else { tl!("Cancel") }, 84.0);
let clicked = if d.kind == DialogKind::Command && crate::prefs_ui::is_preferences(&fields) {
let changed = crate::prefs_ui::preferences_changed(app, &fields);
apply_requested = ui.add_enabled_ui(changed, |ui| crate::widgets::secondary_button(ui, tl!("Apply"), 84.0)).inner.clicked();
}
if crate::widgets::secondary_button(ui, if d.kind == DialogKind::NewDocument { tl!("Close") } else { tl!("Cancel") }, 84.0).clicked() {
outcome = Some(false);
dialog_buttons(ui, &[ok, cancel, DialogButton::new(ButtonRole::Apply, tl!("Apply"), 84.0).enabled(changed)])
} else {
dialog_buttons(ui, &[ok, cancel])
};
match clicked {
Some(ButtonRole::Cancel) => outcome = Some(false),
Some(ButtonRole::Apply) => apply_requested = true,
Some(_) => outcome = Some(true),
None if ui.input(|i| i.key_pressed(egui::Key::Enter)) => outcome = Some(true),
None => {}
}
}
});

View File

@ -10,6 +10,7 @@ use photocraft_doc::DocId;
use serde_json::{Value, json};
use crate::PhotocraftApp;
use crate::widgets::{ButtonRole, DialogButton};
const EXIT: &str = "file.exit";
@ -151,26 +152,25 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
};
(tl!("Unsaved changes"), crate::i18n::fmt(template, &[("name", &name)]))
};
// Left to right, as drawn: (label, key, primary, min width, answer).
let buttons: &[(&str, Key, bool, f32, Answer)] = if reverts {
&[("Cancel", Key::C, false, 84.0, Answer::Cancel), ("Revert", Key::R, true, 84.0, Answer::Discard)]
let mac = ctx.os() == egui::os::OperatingSystem::Mac;
// The answers in the platform's words; `dialog_buttons` puts them in its order. Windows (and
// Linux) ask Yes / No / Cancel with Y, N and Esc, as Photoshop does there; macOS asks
// Don't Save / Cancel / Save. Esc always cancels (the modal closes on it).
let cancel = (ButtonRole::Cancel, "Cancel", mac.then_some(Key::C), 84.0, Answer::Cancel);
let buttons = if reverts {
vec![(ButtonRole::Default, "Revert", Some(Key::R), 84.0, Answer::Discard), cancel]
} else if mac {
vec![
(ButtonRole::Default, "Save", Some(Key::S), 84.0, Answer::Save),
(ButtonRole::Alternate, "Don't Save", Some(Key::D), 100.0, Answer::Discard),
cancel,
]
} else {
&[("Don't Save", Key::D, false, 100.0, Answer::Discard), ("Cancel", Key::C, false, 84.0, Answer::Cancel), ("Save", Key::S, true, 84.0, Answer::Save)]
vec![(ButtonRole::Default, "Yes", Some(Key::Y), 84.0, Answer::Save), (ButtonRole::Alternate, "No", Some(Key::N), 84.0, Answer::Discard), cancel]
};
let mut answer = ctx.input_mut(|i| buttons.iter().find(|b| i.consume_key(egui::Modifiers::NONE, b.1)).map(|b| b.4));
// egui's Tab order follows the right-to-left layout below; walk the buttons left to right instead.
let step = ctx.input_mut(|i| {
if i.consume_key(egui::Modifiers::SHIFT, Key::Tab) {
-1
} else if i.consume_key(egui::Modifiers::NONE, Key::Tab) {
1
} else {
0
}
});
if step != 0 {
ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None));
}
let mut answer = ctx.input_mut(|i| buttons.iter().find(|b| b.2.is_some_and(|k| i.consume_key(egui::Modifiers::NONE, k))).map(|b| b.4));
let labels: Vec<String> = buttons.iter().map(|b| b.2.map_or_else(|| tl!(b.1).to_string(), |k| mnemonic(b.1, k))).collect();
let row: Vec<DialogButton> = buttons.iter().zip(&labels).map(|(b, label)| DialogButton::new(b.0, label, b.3)).collect();
let modal = egui::Modal::new(egui::Id::new("discard-prompt")).show(ctx, |ui| {
ui.set_max_width(420.0);
ui.label(egui::RichText::new(tl!(&title)).font(crate::theme::semibold(15.0)));
@ -181,31 +181,18 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
ui.add_space(12.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.spacing_mut().item_spacing.x = 10.0;
let mut drawn: Vec<_> = buttons
.iter()
.rev()
.map(|&(label, key, primary, width, a)| {
let label = mnemonic(label, key);
let r = if primary { crate::widgets::primary_button(ui, &label, width) } else { crate::widgets::secondary_button(ui, &label, width) };
if r.clicked() {
answer = Some(a);
}
r
})
.collect();
drawn.reverse();
if step != 0 {
let n = drawn.len() as i32;
let at = drawn.iter().position(|r| r.has_focus()).map_or(if step > 0 { -1 } else { n }, |i| i as i32);
if let Some(r) = drawn.get((at + step).rem_euclid(n) as usize) {
r.request_focus();
}
if let Some(role) = crate::widgets::dialog_buttons(ui, &row) {
answer = buttons.iter().find(|b| b.0 == role).map(|b| b.4);
}
});
});
if modal.should_close() {
answer = Some(Answer::Cancel);
}
// Enter takes the default answer, unless Tab focused a button: that one took the Enter as a click.
if answer.is_none() && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::Enter)) {
answer = buttons.iter().find(|b| b.0 == ButtonRole::Default).map(|b| b.4);
}
match answer {
Some(Answer::Cancel) => app.discard = None,
Some(Answer::Discard) => advance(app, ctx),
@ -348,34 +335,110 @@ mod tests {
assert_eq!(mnemonic("保存", Key::S), "保存 (S)");
}
#[test]
fn tab_walks_the_buttons_left_to_right_and_letters_answer() {
use egui_kittest::{Harness, kittest::Queryable};
type Prompted = egui_kittest::Harness<'static, PhotocraftApp>;
/// The unsaved-changes prompt for Close All over two dirty documents, as `os` draws it.
fn prompt_on(os: egui::os::OperatingSystem) -> Prompted {
let mut app = app_with_docs(2);
make_dirty(&mut app, 0);
make_dirty(&mut app, 1);
let mut h = Harness::builder().with_size(egui::vec2(800.0, 600.0)).build_ui_state(|ui, app| show(app, ui.ctx()), app);
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(800.0, 600.0)).build_ui_state(|ui, app| show(app, ui.ctx()), app);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
h.ctx.set_os(os);
assert!(intercept(h.state_mut(), "file.closeAll", &Value::Null));
h.run_steps(2);
let focused = |h: &Harness<'_, PhotocraftApp>| ["(D)on't Save", "(C)ancel", "(S)ave"].into_iter().find(|l| h.get_by_label(l).is_focused());
for want in ["(D)on't Save", "(C)ancel", "(S)ave", "(D)on't Save"] {
h
}
/// The buttons named `labels`, sorted by where they are drawn, left to right.
fn drawn_order<'a>(h: &Prompted, labels: [&'a str; 3]) -> Vec<&'a str> {
use egui_kittest::kittest::Queryable;
let mut v = labels.map(|l| (h.get_by_label(l).rect().left(), l)).to_vec();
v.sort_by(|a, b| a.0.total_cmp(&b.0));
v.into_iter().map(|(_, l)| l).collect()
}
/// Tab walks `labels` left to right and wraps; Shift+Tab steps back.
fn tab_walks(h: &mut Prompted, labels: [&str; 3]) {
use egui_kittest::kittest::Queryable;
let focused = |h: &Prompted| labels.into_iter().find(|l| h.get_by_label(l).is_focused());
for want in [labels[0], labels[1], labels[2], labels[0]] {
h.key_press(Key::Tab);
h.run_steps(2);
assert_eq!(focused(&h), Some(want));
assert_eq!(focused(h), Some(want));
}
h.key_press_modifiers(egui::Modifiers::SHIFT, Key::Tab);
h.run_steps(2);
assert_eq!(focused(&h), Some("(S)ave"));
assert_eq!(focused(h), Some(labels[2]));
}
fn docs_left(h: &Prompted) -> Option<usize> {
h.state().discard.as_ref().map(|p| p.docs.len())
}
#[test]
fn windows_and_linux_ask_yes_no_cancel_with_the_default_first() {
for os in [egui::os::OperatingSystem::Windows, egui::os::OperatingSystem::Nix] {
let mut h = prompt_on(os);
let labels = ["(Y)es", "(N)o", "Cancel"];
assert_eq!(drawn_order(&h, labels), labels, "{os:?}");
tab_walks(&mut h, labels);
h.key_press(Key::N);
h.run_steps(2);
assert_eq!(docs_left(&h), Some(1), "N answered the first document");
// Cancel has no letter here: C does nothing, Esc cancels.
h.key_press(Key::C);
h.run_steps(2);
assert_eq!(docs_left(&h), Some(1));
h.key_press(Key::Escape);
h.run_steps(2);
assert!(h.state().discard.is_none());
assert_eq!(h.state().session.documents().len(), 2, "Cancel closed nothing");
}
}
#[test]
fn macos_asks_dont_save_cancel_save_with_the_default_last() {
let mut h = prompt_on(egui::os::OperatingSystem::Mac);
let labels = ["(D)on't Save", "(C)ancel", "(S)ave"];
assert_eq!(drawn_order(&h, labels), labels);
tab_walks(&mut h, labels);
h.key_press(Key::D);
h.run_steps(2);
assert_eq!(h.state().discard.as_ref().map(|p| p.docs.len()), Some(1), "D answered the first document");
assert_eq!(docs_left(&h), Some(1), "D answered the first document");
h.key_press(Key::C);
h.run_steps(2);
assert!(h.state().discard.is_none());
assert_eq!(h.state().session.documents().len(), 2, "Cancel closed nothing");
}
#[test]
fn enter_takes_the_default_answer_or_the_focused_button() {
use std::{cell::Cell, rc::Rc};
let mut h = prompt_on(egui::os::OperatingSystem::Windows);
let asked = Rc::new(Cell::new(false));
let seen = asked.clone();
// Backing out of the save dialog keeps the prompt.
h.state_mut().services.pick_save = Some(Box::new(move |_| {
seen.set(true);
None
}));
h.key_press(Key::Enter);
h.run_steps(2);
assert!(asked.get(), "Enter answered Yes, which saves");
assert_eq!(docs_left(&h), Some(2));
// Tab to No: Enter now presses it.
for _ in 0..2 {
h.key_press(Key::Tab);
h.run_steps(2);
}
asked.set(false);
h.key_press(Key::Enter);
h.run_steps(2);
assert!(!asked.get());
assert_eq!(docs_left(&h), Some(1), "Enter pressed the focused No");
}
/// One frame with the window's close button pressed; whether the guard cancelled the close.
fn press_window_close(app: &mut PhotocraftApp) -> bool {
let mut info = egui::ViewportInfo::default();

View File

@ -457,11 +457,14 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
let mut ru = ui.new_child(egui::UiBuilder::new().max_rect(inner));
ru.spacing_mut().item_spacing.y = 6.0;
ru.horizontal(|ui| {
if widgets::primary_button(ui, tl!("OK"), 120.0).clicked() {
action = Some("ok");
}
if widgets::secondary_button(ui, tl!("Cancel"), 120.0).clicked() {
action = Some("cancel");
if let Some(role) = widgets::dialog_buttons(
ui,
&[
widgets::DialogButton::new(widgets::ButtonRole::Default, tl!("OK"), 120.0),
widgets::DialogButton::new(widgets::ButtonRole::Cancel, tl!("Cancel"), 120.0),
],
) {
action = Some(if role == widgets::ButtonRole::Default { "ok" } else { "cancel" });
}
});
ru.add_space(4.0);

View File

@ -129,11 +129,11 @@ pub fn show_fallback(app: &mut PhotocraftApp, ctx: &egui::Context) {
ui.label(tl!("Retry GPU requires a restart. Save your work first."));
ui.add_space(8.0);
ui.horizontal(|ui| {
if crate::widgets::primary_button(ui, tl!("Keep Using CPU"), 150.0).clicked() {
choice = Some(false);
}
if crate::widgets::secondary_button(ui, tl!("Retry GPU"), 120.0).clicked() {
choice = Some(true);
use crate::widgets::{ButtonRole, DialogButton};
let buttons =
[DialogButton::new(ButtonRole::Default, tl!("Keep Using CPU"), 150.0), DialogButton::new(ButtonRole::Alternate, tl!("Retry GPU"), 120.0)];
if let Some(role) = crate::widgets::dialog_buttons(ui, &buttons) {
choice = Some(role == ButtonRole::Alternate);
}
});
});

View File

@ -976,6 +976,8 @@
Divide… Rozdělit…
Document Dokument
Don't Save Neukládat
No Ne
Yes Ano
Done Hotovo
Dots Tečky
Drag a rectangle around the object Táhněte obdélník kolem objektu

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -957,6 +957,8 @@
Divide… Unterteilen…
Document Dokument
Don't Save Nicht speichern
No Nein
Yes Ja
Done Fertig
Dots Punkte
Drag a rectangle around the object Ziehe ein Rechteck um das Objekt

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -961,6 +961,8 @@
Divide… Dividir…
Document Documento
Don't Save No guardar
No No
Yes Sí
Done Hecho
Dots Puntos
Drag a rectangle around the object Arrastre un rectángulo alrededor del objeto

Can't render this file because it contains an unexpected character in line 4 and column 47.

View File

@ -951,6 +951,8 @@
Divide… Diviser…
Document Document
Don't Save Ne pas enregistrer
No Non
Yes Oui
Done Terminé
Dots Points
Drag a rectangle around the object Tracez un rectangle autour de l'objet

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -945,6 +945,8 @@
Divide… Bagi…
Document Dokumen
Don't Save Jangan Simpan
No Tidak
Yes Ya
Done Selesai
Dots Titik
Drag a rectangle around the object Seret persegi panjang di sekeliling objek

Can't render this file because it contains an unexpected character in line 4 and column 47.

View File

@ -966,6 +966,8 @@
Divide… 分割…
Document ドキュメント
Don't Save 保存しない
No いいえ
Yes はい
Done 完了
Dots ドット
Drag a rectangle around the object オブジェクトを囲む矩形をドラッグ

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -944,6 +944,8 @@
Divide… 나누기…
Document 문서
Don't Save 저장 안 함
No 아니요
Yes 예
Done 완료
Dots 점
Drag a rectangle around the object 오브젝트를 둘러싸는 사각형을 드래그하세요

Can't render this file because it has a wrong number of fields in line 10.

View File

@ -948,6 +948,8 @@
Divide… Dividir…
Document Documento
Don't Save Não salvar
No Não
Yes Sim
Done Concluído
Dots Pontos
Drag a rectangle around the object Arraste um retângulo ao redor do objeto

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -1548,6 +1548,8 @@
Answer the unsaved-changes prompt first Сначала ответьте на запрос о несохранённых изменениях
Unsaved changes Несохранённые изменения
Don't Save Не сохранять
No Нет
Yes Да
These settings aren't available in PhotoCraft yet. Эти параметры пока недоступны в PhotoCraft.
Shortcut removed. Комбинация клавиш удалена.
All shortcuts reset to Photoshop defaults. Все комбинации клавиш сброшены к стандартным Photoshop.

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -966,6 +966,8 @@
Divide… 划分…
Document 文档
Don't Save 不保存
No 否
Yes 是
Done 完成
Dots 点
Drag a rectangle around the object 围绕对象拖出矩形

Can't render this file because it has a wrong number of fields in line 14.

View File

@ -967,6 +967,8 @@
Divide… 分割…
Document 文件
Don't Save 不儲存
No 否
Yes 是
Done 完成
Dots 點
Drag a rectangle around the object 在物件周圍拖曳出矩形

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -676,11 +676,14 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
// Footer buttons.
let foot = ERect::from_min_size(pos2(right.left() + 14.0, right.bottom() - 48.0), vec2(RIGHT_W - 28.0, 32.0));
let mut fb = ui.new_child(egui::UiBuilder::new().max_rect(foot).layout(egui::Layout::right_to_left(egui::Align::Center)));
if widgets::primary_button(&mut fb, tl!("OK"), 84.0).clicked() {
action = Some("ok");
}
if widgets::secondary_button(&mut fb, tl!("Cancel"), 84.0).clicked() {
action = Some("cancel");
if let Some(role) = widgets::dialog_buttons(
&mut fb,
&[
widgets::DialogButton::new(widgets::ButtonRole::Default, tl!("OK"), 84.0),
widgets::DialogButton::new(widgets::ButtonRole::Cancel, tl!("Cancel"), 84.0),
],
) {
action = Some(if role == widgets::ButtonRole::Default { "ok" } else { "cancel" });
}
// Preview.
let area = ERect::from_min_max(pos2(left.right(), body.top()), pos2(right.left(), body.bottom()));

View File

@ -389,11 +389,14 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
let foot = ERect::from_min_max(pos2(full.left(), full.bottom() - footer_h), full.max);
painter.rect_filled(foot, 0.0, t.dock);
let mut fu = ui.new_child(egui::UiBuilder::new().max_rect(foot.shrink2(vec2(16.0, 9.0))).layout(egui::Layout::right_to_left(egui::Align::Center)));
if widgets::primary_button(&mut fu, tl!("OK"), 90.0).clicked() {
action = Some("ok");
}
if widgets::secondary_button(&mut fu, tl!("Cancel"), 90.0).clicked() {
action = Some("cancel");
if let Some(role) = widgets::dialog_buttons(
&mut fu,
&[
widgets::DialogButton::new(widgets::ButtonRole::Default, tl!("OK"), 90.0),
widgets::DialogButton::new(widgets::ButtonRole::Cancel, tl!("Cancel"), 90.0),
],
) {
action = Some(if role == widgets::ButtonRole::Default { "ok" } else { "cancel" });
}
fu.label(egui::RichText::new(format!("{:.0} ms · straightness {:.1} px", d.render_ms, d.residual)).color(t.text_faint));
});

View File

@ -316,11 +316,98 @@ pub fn secondary_button(ui: &mut Ui, label: &str, min_width: f32) -> Response {
button_impl(ui, label, min_width, t.field, t.text, false)
}
/// What a dialog button does, which decides where the platform's button order puts it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ButtonRole {
/// The default answer (OK, Save, Yes), drawn as the primary button.
Default,
/// Another answer that closes the dialog (Don't Save, No).
Alternate,
/// Closes the dialog without acting.
Cancel,
/// Acts but keeps the dialog open (Apply).
Apply,
}
impl ButtonRole {
/// Position from the left in the platform's order. Windows and Linux put the default action
/// first (OK Cancel Apply, Yes No Cancel); macOS puts it last, in the corner, with Cancel beside
/// it and the other answers further left (Don't Save, Cancel, Save).
fn slot(self, mac: bool) -> u8 {
match (self, mac) {
(Self::Default, false) | (Self::Alternate, true) => 0,
(Self::Alternate, false) | (Self::Cancel, true) => 1,
(Self::Cancel, false) | (Self::Apply, true) => 2,
(Self::Apply, false) | (Self::Default, true) => 3,
}
}
}
/// One button of a [`dialog_buttons`] row.
#[derive(Clone, Copy)]
pub struct DialogButton<'a> {
pub role: ButtonRole,
pub label: &'a str,
pub min_width: f32,
pub enabled: bool,
}
impl<'a> DialogButton<'a> {
pub fn new(role: ButtonRole, label: &'a str, min_width: f32) -> Self {
Self { role, label, min_width, enabled: true }
}
pub fn enabled(self, enabled: bool) -> Self {
Self { enabled, ..self }
}
}
/// A dialog's button row at the cursor, in the platform's order (see [`ButtonRole`]): every modal
/// draws its buttons through this, so they all agree. Inside a right-to-left row it sits at the
/// right edge. The buttons are laid out left to right, so Tab walks them in reading order.
/// Returns the role of the button clicked this frame.
pub fn dialog_buttons(ui: &mut Ui, buttons: &[DialogButton]) -> Option<ButtonRole> {
let mac = ui.ctx().os() == egui::os::OperatingSystem::Mac;
let gap = ui.spacing().item_spacing.x;
let size = buttons.iter().fold(Vec2::ZERO, |acc, b| {
let s = button_size(ui, b.label, b.min_width);
vec2(acc.x + s.x, acc.y.max(s.y))
}) + vec2(gap * buttons.len().saturating_sub(1) as f32, 0.0);
ui.allocate_ui_with_layout(size, egui::Layout::left_to_right(egui::Align::Center), |ui| {
let mut hit = None;
for slot in 0..4 {
for b in buttons.iter().filter(|b| b.role.slot(mac) == slot) {
let r = ui
.add_enabled_ui(b.enabled, |ui| {
if b.role == ButtonRole::Default { primary_button(ui, b.label, b.min_width) } else { secondary_button(ui, b.label, b.min_width) }
})
.inner;
if r.clicked() {
hit = Some(b.role);
}
}
}
hit
})
.inner
}
/// The size of a primary or secondary button: its label plus padding, at least `min_width` wide.
fn button_size(ui: &Ui, label: &str, min_width: f32) -> Vec2 {
button_layout(ui, label, min_width, Tokens::get(ui.ctx()).text).1
}
fn button_layout(ui: &Ui, label: &str, min_width: f32, fg: Color32) -> (std::sync::Arc<egui::Galley>, Vec2) {
let galley = ui.painter().layout_no_wrap(tl!(label).to_owned(), theme::medium(13.0), fg);
let h = if Tokens::get(ui.ctx()).pro { 28.0 } else { 30.0 };
let size = vec2((galley.size().x + 28.0).max(min_width), h);
(galley, size)
}
fn button_impl(ui: &mut Ui, label: &str, min_width: f32, bg: Color32, fg: Color32, primary: bool) -> Response {
let t = Tokens::get(ui.ctx());
let galley = ui.painter().layout_no_wrap(tl!(label).to_owned(), theme::medium(13.0), fg);
let h = if t.pro { 28.0 } else { 30.0 };
let size = vec2((galley.size().x + 28.0).max(min_width), h);
let (galley, size) = button_layout(ui, label, min_width, fg);
let h = size.y;
let (rect, resp) = ui.allocate_exact_size(size, Sense::click());
// Painted text: name the button for accessibility (and so tests and agents can find it).
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label));
@ -579,6 +666,48 @@ fn product(s: &str) -> Option<f64> {
#[cfg(test)]
mod tests {
/// A right-aligned OK / Cancel / Apply row as `os` draws it: labels left to right, and the
/// row's right edge with the window's.
fn button_row(os: egui::os::OperatingSystem) -> (Vec<String>, f32, f32) {
use super::{ButtonRole, DialogButton};
use egui_kittest::{Harness, kittest::Queryable};
// Drawn from the second frame, once the theme's fonts are bound.
let mut h = Harness::builder().with_size(egui::vec2(500.0, 80.0)).build_ui_state(
|ui, ready: &mut bool| {
if *ready {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
let row = [
DialogButton::new(ButtonRole::Default, "OK", 84.0),
DialogButton::new(ButtonRole::Cancel, "Cancel", 84.0),
DialogButton::new(ButtonRole::Apply, "Apply", 84.0),
];
super::dialog_buttons(ui, &row);
});
}
},
false,
);
crate::PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
h.ctx.set_os(os);
*h.state_mut() = true;
h.run();
let mut drawn: Vec<(f32, f32, String)> =
["OK", "Cancel", "Apply"].iter().map(|l| (h.get_by_label(l).rect().left(), h.get_by_label(l).rect().right(), l.to_string())).collect();
drawn.sort_by(|a, b| a.0.total_cmp(&b.0));
let right = drawn.last().map_or(0.0, |d| d.1);
(drawn.into_iter().map(|d| d.2).collect(), right, h.ctx.content_rect().right())
}
#[test]
fn dialog_buttons_follow_the_platform_order() {
use egui::os::OperatingSystem as Os;
for (os, want) in [(Os::Windows, ["OK", "Cancel", "Apply"]), (Os::Nix, ["OK", "Cancel", "Apply"]), (Os::Mac, ["Cancel", "Apply", "OK"])] {
let (order, right, edge) = button_row(os);
assert_eq!(order, want, "{os:?}");
assert!(edge - right < 20.0, "{os:?}: the row hugs the right edge ({right} of {edge})");
}
}
#[test]
fn two_decimal_numbers_trim_zeros() {
assert_eq!(super::fmt_num2(1.05), "1.05");

View File

@ -419,11 +419,14 @@ fn dialog(app: &mut PhotocraftApp, ctx: &egui::Context) {
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let ok = if kind == "deleteWorkspace" { tl!("Delete") } else { tl!("OK") };
if crate::widgets::primary_button(ui, ok, 70.0).clicked() {
result = Some(true);
}
if crate::widgets::secondary_button(ui, tl!("Cancel"), 70.0).clicked() {
result = Some(false);
if let Some(role) = crate::widgets::dialog_buttons(
ui,
&[
crate::widgets::DialogButton::new(crate::widgets::ButtonRole::Default, ok, 70.0),
crate::widgets::DialogButton::new(crate::widgets::ButtonRole::Cancel, tl!("Cancel"), 70.0),
],
) {
result = Some(role == crate::widgets::ButtonRole::Default);
}
});
});