From 03ffca0d5458d6428518fd7290ae779897d0122f Mon Sep 17 00:00:00 2001 From: BFlat Date: Thu, 8 Oct 2026 04:45:40 +0700 Subject: [PATCH] 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 --- crates/ui-egui/src/analysis_ui.rs | 13 ++- crates/ui-egui/src/brushes_tab.rs | 11 +- crates/ui-egui/src/camera_raw_ui.rs | 13 ++- crates/ui-egui/src/dialogs.rs | 24 +++-- crates/ui-egui/src/discard_ui.rs | 155 +++++++++++++++++++--------- crates/ui-egui/src/gallery_ui.rs | 13 ++- crates/ui-egui/src/gpu_status.rs | 10 +- crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/liquify_ui.rs | 13 ++- crates/ui-egui/src/wide_angle_ui.rs | 13 ++- crates/ui-egui/src/widgets.rs | 135 +++++++++++++++++++++++- crates/ui-egui/src/workspace_ui.rs | 13 ++- 22 files changed, 340 insertions(+), 95 deletions(-) diff --git a/crates/ui-egui/src/analysis_ui.rs b/crates/ui-egui/src/analysis_ui.rs index 779f8795e5..f8791cdf9e 100644 --- a/crates/ui-egui/src/analysis_ui.rs +++ b/crates/ui-egui/src/analysis_ui.rs @@ -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); } }); }); diff --git a/crates/ui-egui/src/brushes_tab.rs b/crates/ui-egui/src/brushes_tab.rs index e84586cbda..4eddfad071 100644 --- a/crates/ui-egui/src/brushes_tab.rs +++ b/crates/ui-egui/src/brushes_tab.rs @@ -270,10 +270,17 @@ fn rename_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, acts: &mut Vec 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; } }); diff --git a/crates/ui-egui/src/camera_raw_ui.rs b/crates/ui-egui/src/camera_raw_ui.rs index 314ba30bd5..f64dab6ed0 100644 --- a/crates/ui-egui/src/camera_raw_ui.rs +++ b/crates/ui-egui/src/camera_raw_ui.rs @@ -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)")); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2a7f12cc3d..2386220754 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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 => {} } } }); diff --git a/crates/ui-egui/src/discard_ui.rs b/crates/ui-egui/src/discard_ui.rs index 2a4c499426..41b1776a74 100644 --- a/crates/ui-egui/src/discard_ui.rs +++ b/crates/ui-egui/src/discard_ui.rs @@ -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 = buttons.iter().map(|b| b.2.map_or_else(|| tl!(b.1).to_string(), |k| mnemonic(b.1, k))).collect(); + let row: Vec = 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 { + 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(); diff --git a/crates/ui-egui/src/gallery_ui.rs b/crates/ui-egui/src/gallery_ui.rs index f5c70f94cc..490771b99e 100644 --- a/crates/ui-egui/src/gallery_ui.rs +++ b/crates/ui-egui/src/gallery_ui.rs @@ -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); diff --git a/crates/ui-egui/src/gpu_status.rs b/crates/ui-egui/src/gpu_status.rs index 23b2be6f79..181ac01c13 100644 --- a/crates/ui-egui/src/gpu_status.rs +++ b/crates/ui-egui/src/gpu_status.rs @@ -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); } }); }); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 111b8ffc12..65adec88b7 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 229e084a8c..27f7919aac 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4beecc2c5e..9ab44f7329 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index afc121037a..3e6d07e99c 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 0c71e32ff9..0b36b2d9f8 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 685da685a0..d87b383bed 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -966,6 +966,8 @@ Divide… 分割… Document ドキュメント Don't Save 保存しない + No いいえ + Yes はい Done 完了 Dots ドット Drag a rectangle around the object オブジェクトを囲む矩形をドラッグ diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index aa60564f52..b8d6d35754 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -944,6 +944,8 @@ Divide… 나누기… Document 문서 Don't Save 저장 안 함 + No 아니요 + Yes 예 Done 완료 Dots 점 Drag a rectangle around the object 오브젝트를 둘러싸는 사각형을 드래그하세요 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 9bbca6391b..201f556a05 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 9847b08af1..7f47bc5a6a 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -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. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index a40041ee5e..3dca7a7151 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -966,6 +966,8 @@ Divide… 划分… Document 文档 Don't Save 不保存 + No 否 + Yes 是 Done 完成 Dots 点 Drag a rectangle around the object 围绕对象拖出矩形 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index b2d1accda1..f5db56555d 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -967,6 +967,8 @@ Divide… 分割… Document 文件 Don't Save 不儲存 + No 否 + Yes 是 Done 完成 Dots 點 Drag a rectangle around the object 在物件周圍拖曳出矩形 diff --git a/crates/ui-egui/src/liquify_ui.rs b/crates/ui-egui/src/liquify_ui.rs index fbd91e5cf6..7d938f3ae2 100644 --- a/crates/ui-egui/src/liquify_ui.rs +++ b/crates/ui-egui/src/liquify_ui.rs @@ -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())); diff --git a/crates/ui-egui/src/wide_angle_ui.rs b/crates/ui-egui/src/wide_angle_ui.rs index 9f568227c2..cd8bf0332c 100644 --- a/crates/ui-egui/src/wide_angle_ui.rs +++ b/crates/ui-egui/src/wide_angle_ui.rs @@ -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)); }); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index cab20ca404..bcefc2bb74 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -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 { + 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, 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 { #[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, 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"); diff --git a/crates/ui-egui/src/workspace_ui.rs b/crates/ui-egui/src/workspace_ui.rs index c8cb09323e..9d89dd263c 100644 --- a/crates/ui-egui/src/workspace_ui.rs +++ b/crates/ui-egui/src/workspace_ui.rs @@ -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); } }); });