feat(ui): confirm before discarding unsaved changes (#31)
Close, Close All, Close Others, Revert, Exit and the window close button now ask Save / Don't Save / Cancel when a document has unsaved changes. The action is parked until each dirty document has been answered, then re-run. Window close requests are cancelled while the prompt is open. Closes #9
This commit is contained in:
parent
6f1d0dc080
commit
29506f3e93
|
|
@ -502,7 +502,7 @@ fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||||
app.session.set_active(i);
|
app.session.set_active(i);
|
||||||
}
|
}
|
||||||
if let Some(i) = close {
|
if let Some(i) = close {
|
||||||
let _ = app.run("file.close", json!({"document": i}));
|
let _ = crate::menus::invoke(app, ui.ctx(), "file.close", json!({"document": i}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -549,7 +549,7 @@ fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||||
app.session.set_active(i);
|
app.session.set_active(i);
|
||||||
}
|
}
|
||||||
if let Some(i) = close {
|
if let Some(i) = close {
|
||||||
let _ = app.run("file.close", json!({"document": i}));
|
let _ = crate::menus::invoke(app, ui.ctx(), "file.close", json!({"document": i}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -313,6 +313,7 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest
|
||||||
},
|
},
|
||||||
"app.save" => wrap(app.save_as(s("path").map(str::to_string)).map(|p| json!({"path": p}))),
|
"app.save" => wrap(app.save_as(s("path").map(str::to_string)).map(|p| json!({"path": p}))),
|
||||||
"app.quit" => {
|
"app.quit" => {
|
||||||
|
app.allow_close = true;
|
||||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||||
ok(Value::Null)
|
ok(Value::Null)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,316 @@
|
||||||
|
//! Save / Don't Save / Cancel prompt for actions that would throw away unsaved changes: closing
|
||||||
|
//! documents, Revert, and leaving the app (File › Exit or the window's close button).
|
||||||
|
//!
|
||||||
|
//! egui is immediate-mode, so the action is parked in [`Prompt`] while the modal is up and re-run
|
||||||
|
//! once every affected document has been answered. Cancel at any point drops it. Documents are
|
||||||
|
//! tracked by id, not tab index, so closing one elsewhere while the prompt is up can't retarget it.
|
||||||
|
|
||||||
|
use photocraft_doc::DocId;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use crate::PhotocraftApp;
|
||||||
|
|
||||||
|
const EXIT: &str = "file.exit";
|
||||||
|
|
||||||
|
/// An action waiting on the user, and the dirty documents still to ask about.
|
||||||
|
pub struct Prompt {
|
||||||
|
id: String,
|
||||||
|
params: Value,
|
||||||
|
/// The document the command was aimed at (`document` param, else the active one), if it has one.
|
||||||
|
target: Option<DocId>,
|
||||||
|
docs: Vec<DocId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn index_of(app: &PhotocraftApp, id: DocId) -> Option<usize> {
|
||||||
|
app.session.documents().iter().position(|d| d.doc.id == id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which documents a command discards, relative to its target (the `document` param, else the active one).
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum Reach {
|
||||||
|
Target,
|
||||||
|
AllButTarget,
|
||||||
|
All,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every command that can throw away unsaved changes. A new one only needs a row here.
|
||||||
|
const DISCARDING: &[(&str, Reach)] = &[
|
||||||
|
("file.close", Reach::Target),
|
||||||
|
("file.revert", Reach::Target),
|
||||||
|
("file.closeOthers", Reach::AllButTarget),
|
||||||
|
("file.closeAll", Reach::All),
|
||||||
|
(EXIT, Reach::All),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The command's target document and the unsaved documents it would discard (empty for commands
|
||||||
|
/// that discard nothing).
|
||||||
|
fn discarded(app: &PhotocraftApp, id: &str, params: &Value) -> (Option<DocId>, Vec<DocId>) {
|
||||||
|
let docs = app.session.documents();
|
||||||
|
let target = params.get("document").and_then(Value::as_u64).map(|i| i as usize).or(app.session.active_index());
|
||||||
|
let affected: Vec<usize> = match DISCARDING.iter().find(|(c, _)| *c == id) {
|
||||||
|
Some((_, Reach::Target)) => target.into_iter().collect(),
|
||||||
|
Some((_, Reach::AllButTarget)) => (0..docs.len()).filter(|&i| Some(i) != target).collect(),
|
||||||
|
Some((_, Reach::All)) => (0..docs.len()).collect(),
|
||||||
|
None => Vec::new(),
|
||||||
|
};
|
||||||
|
let dirty = affected.into_iter().filter_map(|i| docs.get(i)).filter(|d| d.is_dirty()).map(|d| d.doc.id).collect();
|
||||||
|
(target.and_then(|i| docs.get(i)).map(|d| d.doc.id), dirty)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Park `id` behind a prompt if it would discard unsaved work. Returns whether it did.
|
||||||
|
pub fn intercept(app: &mut PhotocraftApp, id: &str, params: &Value) -> bool {
|
||||||
|
let (target, docs) = discarded(app, id, params);
|
||||||
|
if docs.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let prompt = Prompt { id: id.to_string(), params: params.clone(), target, docs };
|
||||||
|
match &app.discard {
|
||||||
|
None => app.discard = Some(prompt),
|
||||||
|
// Quitting overrides whatever is pending: it covers every document, so nothing is lost.
|
||||||
|
Some(open) if id == EXIT && open.id != EXIT => app.discard = Some(prompt),
|
||||||
|
// Repeated quit requests (the X pressed again) keep the prompt, and the answers so far.
|
||||||
|
Some(open) if open.id == id => {}
|
||||||
|
Some(_) => {
|
||||||
|
app.ui.status = "Answer the unsaved-changes prompt first".into();
|
||||||
|
app.ui.status_error = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called once per frame: holds back a window close request while there is unsaved work.
|
||||||
|
pub fn guard_window_close(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||||
|
if app.allow_close || !ctx.input(|i| i.viewport().close_requested()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if intercept(app, EXIT, &Value::Null) {
|
||||||
|
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moves on to the next document, or runs the parked action once none are left.
|
||||||
|
fn advance(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||||
|
let Some(p) = app.discard.as_mut() else { return };
|
||||||
|
if !p.docs.is_empty() {
|
||||||
|
p.docs.remove(0);
|
||||||
|
}
|
||||||
|
if !p.docs.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(Prompt { id, mut params, target, .. }) = app.discard.take() else { return };
|
||||||
|
if id == EXIT {
|
||||||
|
app.allow_close = true;
|
||||||
|
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(target) = target {
|
||||||
|
// The tab may have moved since the command was issued; aim it at the same document.
|
||||||
|
let Some(i) = index_of(app, target) else { return };
|
||||||
|
params = json!({"document": i});
|
||||||
|
}
|
||||||
|
if let Err(e) = crate::menus::invoke_unguarded(app, ctx, &id, params) {
|
||||||
|
app.ui.status = e;
|
||||||
|
app.ui.status_error = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves `doc` in place; false when it is gone, the save failed or its file dialog was cancelled.
|
||||||
|
fn save(app: &mut PhotocraftApp, ctx: &egui::Context, doc: DocId) -> bool {
|
||||||
|
let Some(i) = index_of(app, doc) else { return false };
|
||||||
|
app.session.set_active(i);
|
||||||
|
match crate::menus::invoke_unguarded(app, ctx, "file.save", json!({})) {
|
||||||
|
Ok(_) => true,
|
||||||
|
// Backing out of the file dialog is the user's choice, not an error.
|
||||||
|
Err(e) if e == "cancelled" => false,
|
||||||
|
Err(e) => {
|
||||||
|
app.ui.status = format!("Couldn't save: {e}");
|
||||||
|
app.ui.status_error = true;
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||||
|
let Some(p) = &app.discard else { return };
|
||||||
|
let Some(&doc) = p.docs.first() else { return };
|
||||||
|
let (exits, reverts) = (p.id == EXIT, p.id == "file.revert");
|
||||||
|
let Some(name) = index_of(app, doc).map(|i| app.session.documents()[i].doc.name.clone()) else {
|
||||||
|
// Closed by something else while the prompt was up: nothing left to ask about.
|
||||||
|
advance(app, ctx);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (title, message) = if reverts {
|
||||||
|
("Revert", format!("Revert “{name}” to the last saved version? Your changes will be lost."))
|
||||||
|
} else {
|
||||||
|
let verb = if exits { "quitting" } else { "closing" };
|
||||||
|
("Unsaved changes", format!("Do you want to save the changes you made to “{name}” before {verb}?"))
|
||||||
|
};
|
||||||
|
let (mut save_it, mut discard_it, mut cancel) = (false, false, false);
|
||||||
|
let modal = egui::Modal::new(egui::Id::new("discard-prompt")).show(ctx, |ui| {
|
||||||
|
ui.set_max_width(420.0);
|
||||||
|
ui.label(egui::RichText::new(title).font(crate::theme::semibold(15.0)));
|
||||||
|
ui.add_space(4.0);
|
||||||
|
crate::widgets::hairline(ui);
|
||||||
|
ui.add_space(8.0);
|
||||||
|
ui.label(message);
|
||||||
|
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;
|
||||||
|
if reverts {
|
||||||
|
discard_it = crate::widgets::primary_button(ui, "Revert", 84.0).clicked();
|
||||||
|
cancel = crate::widgets::secondary_button(ui, "Cancel", 84.0).clicked();
|
||||||
|
} else {
|
||||||
|
save_it = crate::widgets::primary_button(ui, "Save", 84.0).clicked();
|
||||||
|
cancel = crate::widgets::secondary_button(ui, "Cancel", 84.0).clicked();
|
||||||
|
discard_it = crate::widgets::secondary_button(ui, "Don't Save", 100.0).clicked();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
cancel |= modal.should_close();
|
||||||
|
if cancel {
|
||||||
|
app.discard = None;
|
||||||
|
} else if discard_it || (save_it && save(app, ctx, doc)) {
|
||||||
|
advance(app, ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn app_with_docs(n: usize) -> PhotocraftApp {
|
||||||
|
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||||
|
for _ in 0..n {
|
||||||
|
app.run("file.new", json!({"width": 8, "height": 8})).unwrap();
|
||||||
|
}
|
||||||
|
app.sync_views();
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_dirty(app: &mut PhotocraftApp, doc: usize) {
|
||||||
|
app.session.set_active(doc);
|
||||||
|
app.run("layer.new.layer", json!({})).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn doc_id(app: &PhotocraftApp, i: usize) -> DocId {
|
||||||
|
app.session.documents()[i].doc.id
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clean_documents_close_without_asking() {
|
||||||
|
let mut app = app_with_docs(2);
|
||||||
|
let ctx = egui::Context::default();
|
||||||
|
crate::menus::invoke(&mut app, &ctx, "file.close", json!({})).unwrap();
|
||||||
|
assert!(app.discard.is_none());
|
||||||
|
assert_eq!(app.session.documents().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn closing_a_dirty_document_waits_for_an_answer() {
|
||||||
|
let mut app = app_with_docs(2);
|
||||||
|
let ctx = egui::Context::default();
|
||||||
|
make_dirty(&mut app, 1);
|
||||||
|
crate::menus::invoke(&mut app, &ctx, "file.close", json!({"document": 1})).unwrap();
|
||||||
|
assert_eq!(app.session.documents().len(), 2, "nothing closes before the user answers");
|
||||||
|
assert!(app.discard.is_some());
|
||||||
|
advance(&mut app, &ctx);
|
||||||
|
assert!(app.discard.is_none());
|
||||||
|
assert_eq!(app.session.documents().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_documents_that_would_be_discarded_are_asked_about() {
|
||||||
|
let mut app = app_with_docs(3);
|
||||||
|
make_dirty(&mut app, 0);
|
||||||
|
make_dirty(&mut app, 2);
|
||||||
|
let ids = [doc_id(&app, 0), doc_id(&app, 2)];
|
||||||
|
assert_eq!(discarded(&app, "file.closeOthers", &json!({"document": 0})), (Some(ids[0]), vec![ids[1]]));
|
||||||
|
assert_eq!(discarded(&app, "file.closeAll", &json!({})).1, ids);
|
||||||
|
assert_eq!(discarded(&app, EXIT, &json!({})).1, ids);
|
||||||
|
assert!(discarded(&app, "view.zoomIn", &json!({})).1.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn close_all_asks_per_dirty_document_then_runs() {
|
||||||
|
let mut app = app_with_docs(3);
|
||||||
|
let ctx = egui::Context::default();
|
||||||
|
make_dirty(&mut app, 0);
|
||||||
|
make_dirty(&mut app, 2);
|
||||||
|
crate::menus::invoke(&mut app, &ctx, "file.closeAll", json!({})).unwrap();
|
||||||
|
advance(&mut app, &ctx);
|
||||||
|
assert_eq!(app.session.documents().len(), 3, "still waiting on the second document");
|
||||||
|
advance(&mut app, &ctx);
|
||||||
|
assert!(app.session.documents().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_parked_close_still_hits_its_document_after_tabs_move() {
|
||||||
|
let mut app = app_with_docs(3);
|
||||||
|
let ctx = egui::Context::default();
|
||||||
|
make_dirty(&mut app, 2);
|
||||||
|
let doomed = doc_id(&app, 2);
|
||||||
|
crate::menus::invoke(&mut app, &ctx, "file.close", json!({"document": 2})).unwrap();
|
||||||
|
// Another document closes while the prompt is up, shifting the tab to index 1.
|
||||||
|
app.run("file.close", json!({"document": 0})).unwrap();
|
||||||
|
advance(&mut app, &ctx);
|
||||||
|
assert_eq!(app.session.documents().len(), 1);
|
||||||
|
assert!(index_of(&app, doomed).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quitting_replaces_a_pending_close_but_other_requests_are_refused() {
|
||||||
|
let mut app = app_with_docs(2);
|
||||||
|
make_dirty(&mut app, 0);
|
||||||
|
make_dirty(&mut app, 1);
|
||||||
|
assert!(intercept(&mut app, "file.close", &json!({"document": 0})));
|
||||||
|
assert!(intercept(&mut app, "file.closeAll", &Value::Null));
|
||||||
|
assert_eq!(app.discard.as_ref().map(|p| (p.id.as_str(), p.docs.len())), Some(("file.close", 1)));
|
||||||
|
assert!(app.ui.status_error);
|
||||||
|
assert!(intercept(&mut app, EXIT, &Value::Null));
|
||||||
|
assert_eq!(app.discard.as_ref().map(|p| (p.id.as_str(), p.docs.len())), Some((EXIT, 2)));
|
||||||
|
// Pressing the window's X again must not restart the questions.
|
||||||
|
app.discard.as_mut().unwrap().docs.remove(0);
|
||||||
|
assert!(intercept(&mut app, EXIT, &Value::Null));
|
||||||
|
assert_eq!(app.discard.as_ref().unwrap().docs.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exit_is_held_back_until_confirmed() {
|
||||||
|
let mut app = app_with_docs(1);
|
||||||
|
let ctx = egui::Context::default();
|
||||||
|
make_dirty(&mut app, 0);
|
||||||
|
assert!(intercept(&mut app, EXIT, &Value::Null));
|
||||||
|
assert!(!app.allow_close);
|
||||||
|
advance(&mut app, &ctx);
|
||||||
|
assert!(app.allow_close);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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();
|
||||||
|
info.events.push(egui::ViewportEvent::Close);
|
||||||
|
let mut input = egui::RawInput::default();
|
||||||
|
input.viewports.insert(egui::ViewportId::ROOT, info);
|
||||||
|
let mut out = egui::Context::default().run_ui(input, |ui| guard_window_close(app, ui.ctx()));
|
||||||
|
out.textures_delta.clear();
|
||||||
|
out.viewport_output[&egui::ViewportId::ROOT].commands.iter().any(|c| matches!(c, egui::ViewportCommand::CancelClose))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn window_close_is_cancelled_while_work_is_unsaved() {
|
||||||
|
let mut app = app_with_docs(1);
|
||||||
|
make_dirty(&mut app, 0);
|
||||||
|
assert!(press_window_close(&mut app));
|
||||||
|
assert!(app.discard.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn window_close_goes_through_when_nothing_is_unsaved_or_it_was_confirmed() {
|
||||||
|
let mut app = app_with_docs(1);
|
||||||
|
assert!(!press_window_close(&mut app));
|
||||||
|
make_dirty(&mut app, 0);
|
||||||
|
app.allow_close = true;
|
||||||
|
assert!(!press_window_close(&mut app));
|
||||||
|
assert!(app.discard.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,7 @@ pub mod color_picker_ui;
|
||||||
pub mod comps_ui;
|
pub mod comps_ui;
|
||||||
pub mod control;
|
pub mod control;
|
||||||
pub mod dialogs;
|
pub mod dialogs;
|
||||||
|
pub mod discard_ui;
|
||||||
pub mod distort_ui;
|
pub mod distort_ui;
|
||||||
pub mod doc_props_ui;
|
pub mod doc_props_ui;
|
||||||
pub mod enable_rules;
|
pub mod enable_rules;
|
||||||
|
|
@ -221,6 +222,10 @@ pub struct PhotocraftApp {
|
||||||
pub perf: gpu_canvas::Perf,
|
pub perf: gpu_canvas::Perf,
|
||||||
/// Preferences, autosave and snapping runtime state (see `prefs_ui`, `snap_ui`).
|
/// Preferences, autosave and snapping runtime state (see `prefs_ui`, `snap_ui`).
|
||||||
pub(crate) prefs_rt: prefs_ui::Runtime,
|
pub(crate) prefs_rt: prefs_ui::Runtime,
|
||||||
|
/// Close, Revert or Exit parked behind the unsaved-changes prompt (see `discard_ui`).
|
||||||
|
pub(crate) discard: Option<discard_ui::Prompt>,
|
||||||
|
/// Set once the user has agreed to quit, so the resulting close request goes through.
|
||||||
|
pub(crate) allow_close: bool,
|
||||||
#[cfg(all(debug_assertions, not(target_arch = "wasm32")))]
|
#[cfg(all(debug_assertions, not(target_arch = "wasm32")))]
|
||||||
live_tokens: theme::live::LiveTokens,
|
live_tokens: theme::live::LiveTokens,
|
||||||
}
|
}
|
||||||
|
|
@ -270,6 +275,8 @@ impl PhotocraftApp {
|
||||||
gpu: None,
|
gpu: None,
|
||||||
perf: Default::default(),
|
perf: Default::default(),
|
||||||
prefs_rt: Default::default(),
|
prefs_rt: Default::default(),
|
||||||
|
discard: None,
|
||||||
|
allow_close: false,
|
||||||
#[cfg(all(debug_assertions, not(target_arch = "wasm32")))]
|
#[cfg(all(debug_assertions, not(target_arch = "wasm32")))]
|
||||||
live_tokens: theme::live::LiveTokens::from_env(),
|
live_tokens: theme::live::LiveTokens::from_env(),
|
||||||
};
|
};
|
||||||
|
|
@ -493,6 +500,7 @@ impl eframe::App for PhotocraftApp {
|
||||||
self.collect_screenshots(ctx);
|
self.collect_screenshots(ctx);
|
||||||
self.issue_screenshots(ctx);
|
self.issue_screenshots(ctx);
|
||||||
prefs_ui::tick(self, ctx);
|
prefs_ui::tick(self, ctx);
|
||||||
|
discard_ui::guard_window_close(self, ctx);
|
||||||
shortcuts::handle(self, ctx);
|
shortcuts::handle(self, ctx);
|
||||||
let arrived: Vec<(String, Vec<u8>)> =
|
let arrived: Vec<(String, Vec<u8>)> =
|
||||||
self.services.inbox.as_ref().map(|q| std::mem::take(&mut *q.lock().unwrap_or_else(|e| e.into_inner()))).unwrap_or_default();
|
self.services.inbox.as_ref().map(|q| std::mem::take(&mut *q.lock().unwrap_or_else(|e| e.into_inner()))).unwrap_or_default();
|
||||||
|
|
@ -568,6 +576,7 @@ impl eframe::App for PhotocraftApp {
|
||||||
workspace_ui::windows(self, &ctx);
|
workspace_ui::windows(self, &ctx);
|
||||||
palette::show(self, &ctx);
|
palette::show(self, &ctx);
|
||||||
dialogs::show(self, &ctx);
|
dialogs::show(self, &ctx);
|
||||||
|
discard_ui::show(self, &ctx);
|
||||||
distort_ui::show(self, &ctx);
|
distort_ui::show(self, &ctx);
|
||||||
camera_raw_ui::show(self, &ctx);
|
camera_raw_ui::show(self, &ctx);
|
||||||
wide_angle_ui::show(self, &ctx);
|
wide_angle_ui::show(self, &ctx);
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,14 @@ fn workspace_name(id: &str) -> Option<&'static str> {
|
||||||
|
|
||||||
/// Run a command id from any source (menu, shortcut, palette, automation).
|
/// Run a command id from any source (menu, shortcut, palette, automation).
|
||||||
pub fn invoke(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result<Value, String> {
|
pub fn invoke(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result<Value, String> {
|
||||||
|
if crate::discard_ui::intercept(app, id, ¶ms) {
|
||||||
|
return Ok(Value::Null);
|
||||||
|
}
|
||||||
|
invoke_unguarded(app, ctx, id, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`invoke`] without the unsaved-changes prompt, for once the user has already answered it.
|
||||||
|
pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result<Value, String> {
|
||||||
// Help › Discord, website, GitHub, Report an Issue.
|
// Help › Discord, website, GitHub, Report an Issue.
|
||||||
if let Some(url) = crate::links::url_for(id) {
|
if let Some(url) = crate::links::url_for(id) {
|
||||||
return Ok(crate::links::open(app, ctx, url));
|
return Ok(crate::links::open(app, ctx, url));
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ pub fn pretty(s: &str) -> String {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||||
if ctx.egui_wants_keyboard_input() || !app.ui.dialogs.is_empty() {
|
if ctx.egui_wants_keyboard_input() || !app.ui.dialogs.is_empty() || app.discard.is_some() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Liquify / Puppet Warp / Perspective Warp: ↩ commits, Esc cancels.
|
// Liquify / Puppet Warp / Perspective Warp: ↩ commits, Esc cancels.
|
||||||
|
|
|
||||||
|
|
@ -164,11 +164,11 @@ fn engine_commands_open_their_dialogs() {
|
||||||
assert!(app.run("type.info", json!({})).unwrap()["warp"]["style"] == "warpArc");
|
assert!(app.run("type.info", json!({})).unwrap()["warp"]["style"] == "warpArc");
|
||||||
// Batch needs a recorded action.
|
// Batch needs a recorded action.
|
||||||
assert!(menu(&mut app, &ctx, "file.automate.batch", json!({})).is_err());
|
assert!(menu(&mut app, &ctx, "file.automate.batch", json!({})).is_err());
|
||||||
// Close Others keeps the active document's view.
|
// Close Others keeps the active document's view (the documents are dirty; the unsaved-changes prompt is covered in discard_ui).
|
||||||
app.ui.views[1].zoom = 4.0;
|
app.ui.views[1].zoom = 4.0;
|
||||||
menu(&mut app, &ctx, "file.closeOthers", json!({})).unwrap();
|
crate::menus::invoke_unguarded(&mut app, &ctx, "file.closeOthers", json!({})).unwrap();
|
||||||
assert_eq!(app.ui.views.len(), 1);
|
assert_eq!(app.ui.views.len(), 1);
|
||||||
assert_eq!(app.ui.views[0].zoom, 4.0);
|
assert_eq!(app.ui.views[0].zoom, 4.0);
|
||||||
menu(&mut app, &ctx, "file.closeAll", json!({})).unwrap();
|
crate::menus::invoke_unguarded(&mut app, &ctx, "file.closeAll", json!({})).unwrap();
|
||||||
assert!(app.ui.views.is_empty() && app.session.documents().is_empty());
|
assert!(app.ui.views.is_empty() && app.session.documents().is_empty());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue