Compare commits

...
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 12ab5e58a2 Dialog buttons follow the OS order; Windows/Linux unsaved prompt asks Yes / No / Cancel (#627, #776)
One shared helper, widgets::dialog_buttons, now draws every modal's button
row 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 (Don't Save, Cancel, Save). Buttons are laid out left to
right, so Tab walks them in reading order without the old manual focus code.

The unsaved-changes prompt answers in the platform's words: Yes / No /
Cancel with Y, N and Esc on Windows and Linux (as Photoshop on Windows),
Don't Save / Cancel / Save on macOS. Enter takes the default answer unless
Tab has focused another button.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 04:28:50 +07:00
22 changed files with 340 additions and 95 deletions

No files matched your search

+8 -5
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);
}
});
});
+9 -2
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;
}
});
+8 -5
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)"));
+15 -9
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 => {}
}
}
});
+109 -46
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();
+8 -5
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);
+5 -5
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);
}
});
});
+2
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.
+2
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.
+2
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.
+2
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.
+2
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.
+2
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.
+2
View File
@@ -944,6 +944,8 @@
Divide… 나누기…
Document 문서
Don't Save 저장 안 함
No 아니요
Yes 예
Done 완료
Dots 점
Drag a rectangle around the object 오브젝트를 둘러싸는 사각형을 드래그하세요
1 # Korean (ko) catalog. UTF-8: context<TAB>English source<TAB>translation.
944
945
946
947
948
949
950
951
+2
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.
+2
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.
+2
View File
@@ -966,6 +966,8 @@
Divide… 划分…
Document 文档
Don't Save 不保存
No 否
Yes 是
Done 完成
Dots 点
Drag a rectangle around the object 围绕对象拖出矩形
1 # Simplified Chinese (zh-hans) catalog. UTF-8, TAB-separated columns:
966
967
968
969
970
971
972
973
+2
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.
+8 -5
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()));
+8 -5
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));
});
+132 -3
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");
+8 -5
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);
}
});
});