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:
parent
41e9c742dc
commit
03ffca0d54
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)"));
|
||||
|
|
|
|||
|
|
@ -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 => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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()));
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue