fix(ui): Keep Enter and Escape in the Save Document Preset popup; saved presets in the engine

Review follow-up. Enter in the Save Document Preset popup created the
document and Escape closed the whole dialog: the dialog footer took
both keys while a popup was open. The dialog now leaves Enter and
Escape to an open popup (read before it draws, as egui closes a popup
on Escape that frame): Enter in the preset name saves the preset,
Escape closes only the popup. Clicking the name field no longer closes
the popup either (menus close on any click; this one only outside), and
the field takes focus when the popup opens.

Saved presets lived only in the UI state, so `file.new {preset: name}`
refused them. They are now engine preferences (saved with prefs.json):
`file.savePreset {name, …the file.new settings}` replaces a preset of
that name and refuses built-in names, `file.deletePreset {name}` removes
one, `file.presets` lists them after the built-in ones (`saved: true`),
and `file.new {preset}` makes documents from them by name. The Saved tab
reads and writes them through those commands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
moppeeer
2026-10-12 18:29:17 +02:00
committed by paaspaas00
co-authored by Claude Opus 5.5
parent 564fc8cd98
commit 65ef8a1c70
7 changed files with 204 additions and 36 deletions
+108 -12
View File
@@ -13,10 +13,31 @@ use crate::{DocState, EngineError, Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(noundo "file.new", "Document…", ["File", "New"], Some("Cmd+N"),
"{preset?: \"Letter\"|\"A4\"|…, intent?: print|web|mobile, units?: points|picas|millimeters|…, width?, height?, pages?, startPage?, facingPages?, columns?, gutter?, margins?: number|{top,bottom,inside,outside}, bleed?, slug?: n | [top, bottom, inside, outside], title?}",
"{preset?: \"Letter\"|\"A4\"|… or a saved preset (file.savePreset), intent?: print|web|mobile, units?: points|picas|millimeters|…, width?, height?, pages?, startPage?, facingPages?, columns?, gutter?, margins?: number|{top,bottom,inside,outside}, bleed?, slug?: n | [top, bottom, inside, outside], title?}",
always, file_new),
cmd!(noundo "file.newSample", "Sample Document", ["Help"], None, "{} — a multi-page magazine sample", always, file_sample),
cmd!(query "file.presets", "Document Presets", [], None, "{}", always, |_, _| Ok(serde_json::to_value(PRESETS).unwrap_or_default())),
cmd!(query "file.presets", "Document Presets", [], None, "{} → the built-in presets [{name, intent, width, height, units}], then the saved ones (with `saved: true`)", always, |s, _| {
let mut presets: Vec<Value> = PRESETS.iter().map(|p| serde_json::to_value(p).unwrap_or_default()).collect();
presets.extend(
s.prefs
.document_presets
.iter()
.map(|p| json!({"name": p.title, "intent": p.intent, "width": p.width, "height": p.height, "units": p.units, "saved": true})),
);
Ok(Value::Array(presets))
}),
cmd!(noundo "file.savePreset", "Save Document Preset", [], None,
"{name, …the settings file.new takes} — kept with the preferences; `file.new {preset: name}` uses it. A saved preset of that name is replaced; built-in names are refused",
always, save_preset),
cmd!(noundo "file.deletePreset", "Delete Document Preset", [], None, "{name} — a saved document preset", always, |s, p| {
let name = str_param(p, "name").unwrap_or_default().trim().to_string();
let before = s.prefs.document_presets.len();
s.prefs.document_presets.retain(|x| !x.title.eq_ignore_ascii_case(&name));
if s.prefs.document_presets.len() == before {
return Err(bad("file.deletePreset", format!("no saved preset `{name}`")));
}
ok()
}),
cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"),
"{path} — .designcraft or .idml; the fonts in a `Document Fonts` folder beside it load first → {index, documentFonts: faces loaded, warnings: font files skipped (and, for .idml, package parts missing)}",
always, file_open),
@@ -115,11 +136,59 @@ pub fn from_bytes(b: &[u8]) -> Result<Document> {
designcraft_format::load(b).map_err(|e| EngineError::Other(e.to_string()))
}
/// The longest saved preset name, and how many presets are kept.
const MAX_PRESET_NAME: usize = 100;
const MAX_PRESETS: usize = 500;
/// A built-in document preset or a saved one, by name (case ignored).
fn find_preset(s: &Session, name: &str) -> Option<NewDocument> {
NewDocument::from_preset(name).or_else(|| s.prefs.document_presets.iter().find(|p| p.title.eq_ignore_ascii_case(name.trim())).cloned())
}
fn file_new(s: &mut Session, p: &Value) -> Result<Value> {
let mut nd = match str_param(p, "preset") {
Some(name) => NewDocument::from_preset(name).ok_or_else(|| bad("file.new", format!("unknown preset `{name}`")))?,
Some(name) => find_preset(s, name).ok_or_else(|| bad("file.new", format!("unknown preset `{name}`")))?,
None => NewDocument::default(),
};
apply_settings(&mut nd, p);
check_size("file.new", &nd)?;
s.untitled += 1;
nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| format!("Untitled-{}", s.untitled));
let d = Document::new(&nd);
let i = s.add_document(DocState::new(d, None));
Ok(json!({"index": i}))
}
fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad("file.savePreset", "missing `name`"))?.to_string();
if name.chars().count() > MAX_PRESET_NAME {
return Err(bad("file.savePreset", format!("a preset name has at most {MAX_PRESET_NAME} characters")));
}
if NewDocument::from_preset(&name).is_some() {
return Err(bad("file.savePreset", format!("`{name}` is a built-in preset")));
}
let mut nd = NewDocument::default();
apply_settings(&mut nd, p);
check_size("file.savePreset", &nd)?;
nd.title = name.clone();
let presets = &mut s.prefs.document_presets;
presets.retain(|x| !x.title.eq_ignore_ascii_case(&name));
if presets.len() >= MAX_PRESETS {
return Err(bad("file.savePreset", format!("at most {MAX_PRESETS} saved presets")));
}
presets.push(nd);
Ok(json!({"name": name}))
}
fn check_size(cmd: &str, nd: &NewDocument) -> Result<()> {
if !(nd.width > 0.0 && nd.height > 0.0 && nd.width <= 15552.0 && nd.height <= 15552.0) {
return Err(bad(cmd, "page size out of range (0 < size ≤ 216 in)"));
}
Ok(())
}
/// The document settings in `p` (as file.new takes them) over `nd`.
fn apply_settings(nd: &mut NewDocument, p: &Value) {
if let Some(intent) = p.get("intent").and_then(|v| serde_json::from_value(v.clone()).ok()) {
nd.intent = intent;
}
@@ -133,7 +202,7 @@ fn file_new(s: &mut Session, p: &Value) -> Result<Value> {
nd.facing_pages = p.get("facingPages").and_then(Value::as_bool).unwrap_or(nd.facing_pages);
nd.columns = p.get("columns").and_then(Value::as_u64).map(|v| v.clamp(1, 216) as u32).unwrap_or(nd.columns);
nd.gutter = f64_or(p, "gutter", nd.gutter);
nd.primary_text_frame = p.get("primaryTextFrame").and_then(Value::as_bool).unwrap_or(false);
nd.primary_text_frame = p.get("primaryTextFrame").and_then(Value::as_bool).unwrap_or(nd.primary_text_frame);
match p.get("margins") {
Some(Value::Number(n)) => nd.margins = designcraft_doc::Margins::uniform(n.as_f64().unwrap_or(36.0)),
Some(v @ Value::Object(_)) => {
@@ -149,14 +218,6 @@ fn file_new(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(b) = edges(p, "slug") {
nd.slug = b;
}
s.untitled += 1;
nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| format!("Untitled-{}", s.untitled));
if nd.width <= 0.0 || nd.height <= 0.0 || nd.width > 15552.0 || nd.height > 15552.0 {
return Err(bad("file.new", "page size out of range (0 < size ≤ 216 in)"));
}
let d = Document::new(&nd);
let i = s.add_document(DocState::new(d, None));
Ok(json!({"index": i}))
}
/// A bleed or slug parameter: one width for every edge, or [top, bottom, inside, outside].
@@ -689,6 +750,41 @@ mod new_document_tests {
use crate::Session;
#[test]
fn saved_presets_make_documents_by_name() {
let mut s = Session::new();
let newsletter = json!({"name": "Newsletter", "units": "millimeters", "width": 595.2755905511812, "height": 841.8897637795277,
"pages": 8, "facingPages": false, "columns": 3, "margins": {"top": 20, "bottom": 30, "inside": 40, "outside": 50},
"bleed": [9, 9, 0, 9], "primaryTextFrame": true});
assert_eq!(s.execute("file.savePreset", &newsletter).unwrap()["name"], "Newsletter");
let listed = s.execute("file.presets", &json!({})).unwrap();
let saved: Vec<_> = listed.as_array().unwrap().iter().filter(|p| p["saved"] == true).collect();
assert_eq!(saved.len(), 1);
assert_eq!((&saved[0]["name"], &saved[0]["units"]), (&json!("Newsletter"), &json!("millimeters")));
// By name, case ignored; parameters still override the preset.
s.execute("file.new", &json!({"preset": "newsletter", "pages": 4})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(d.page_count(), 4);
assert!(!d.settings.facing_pages && d.settings.primary_text_frame);
assert_eq!(d.settings.horizontal_units, designcraft_geom::Unit::Millimeters);
assert_eq!(d.settings.bleed, [9.0, 9.0, 0.0, 9.0]);
assert_eq!(d.page(0).unwrap().margins.outside, 50.0);
assert!(d.title.starts_with("Untitled-"), "the preset's name isn't the document's");
// The same name replaces it.
s.execute("file.savePreset", &json!({"name": "NEWSLETTER", "pages": 2})).unwrap();
assert_eq!(s.prefs.document_presets.len(), 1);
s.execute("file.new", &json!({"preset": "Newsletter"})).unwrap();
assert_eq!(s.doc().unwrap().doc.page_count(), 2);
// Built-in names, empty and overlong names and impossible sizes are refused.
for bad in [json!({"name": "a4"}), json!({"name": " "}), json!({}), json!({"name": "x".repeat(101)}), json!({"name": "Huge", "width": 1e9})]
{
assert!(s.execute("file.savePreset", &bad).is_err(), "{bad}");
}
s.execute("file.deletePreset", &json!({"name": "newsletter"})).unwrap();
assert!(s.execute("file.deletePreset", &json!({"name": "newsletter"})).is_err());
assert!(s.execute("file.new", &json!({"preset": "Newsletter"})).is_err());
}
#[test]
fn new_document_takes_intent_units_start_page_bleed_and_slug() {
let mut s = Session::new();
+3
View File
@@ -195,6 +195,8 @@ pub struct Prefs {
pub show_note_anchors: bool,
/// Preferences › File Handling: minutes between document recovery saves.
pub recovery_minutes: f64,
/// File › New › Document › Save Document Preset: saved document presets, by name (`title`).
pub document_presets: Vec<designcraft_doc::build::NewDocument>,
}
/// A starter autocorrect list (common English typing slips).
@@ -221,6 +223,7 @@ pub fn default_autocorrect() -> Vec<(String, String)> {
impl Default for Prefs {
fn default() -> Self {
Prefs {
document_presets: Vec::new(),
show_hidden_characters: false,
typographers_quotes: true,
polygon_sides: 6,
+5 -2
View File
@@ -1113,6 +1113,9 @@ fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
let Some(mut d) = app.ui.dialog.clone() else { return };
// Enter and Escape belong to an open popup (a menu, a name field) rather than the dialog.
// Read before the dialog draws: a popup closed by this frame's Escape is still open here.
let popup_open = egui::Popup::is_any_open(ctx);
let mut result: Option<bool> = None;
let alert_title = d.s("title");
let title = match d.id.as_str() {
@@ -1645,13 +1648,13 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
.fill(crate::theme::Tokens::get(ui.ctx()).accent_strong),
)
.clicked()
|| ui.input(|i| i.key_pressed(egui::Key::Enter))
|| (!popup_open && ui.input(|i| i.key_pressed(egui::Key::Enter)))
{
result = Some(true);
}
// An alert only has OK.
let cancel = d.id != "alert" && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, cancel_label))).clicked();
if cancel || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
if cancel || (!popup_open && ui.input(|i| i.key_pressed(egui::Key::Escape))) {
result = Some(false);
}
if d.id == "closeDocument" && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Don't Save"))).clicked() {
+1
View File
@@ -2867,6 +2867,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
"存储文档预设为:",
],
),
("Preset Name", ["Vorgabename", "Nom du paramètre prédéfini", "Nombre del ajuste preestablecido", "プリセット名", "预设名称"]),
("Save Preset", ["Vorgabe speichern", "Enregistrer le paramètre prédéfini", "Guardar ajuste preestablecido", "プリセットを保存", "存储预设"]),
("Start #", ["Startseitennr.", "N° de début", "N.º inicial", "開始ページ番号", "起始页码"]),
("Column Gutter", ["Spaltenabstand", "Gouttière", "Medianil", "列間隔", "栏间距"]),
+1
View File
@@ -1509,6 +1509,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Document Name", "Nome documento"),
("Save Document Preset", "Salva predefinito documento"),
("Save Document Preset As:", "Salva predefinito documento come:"),
("Preset Name", "Nome predefinito"),
("Save Preset", "Salva predefinito"),
("Start #", "N. iniziale"),
("Column Gutter", "Spazio tra colonne"),
-3
View File
@@ -244,8 +244,6 @@ pub struct UiState {
pub workspace: String,
/// Window › Workspace › New Workspace: saved panel arrangements.
pub custom_workspaces: Vec<SavedWorkspace>,
/// File › New › Document › Saved: document presets saved by name (`title`).
pub document_presets: Vec<designcraft_doc::build::NewDocument>,
/// File › New › Document › Recent: the settings of the last documents made, newest first.
pub recent_new_documents: Vec<designcraft_doc::build::NewDocument>,
/// Transform reference point (0..8, row-major; 0 = top-left).
@@ -345,7 +343,6 @@ impl Default for UiState {
units: Unit::Picas,
workspace: "Essentials".into(),
custom_workspaces: Vec::new(),
document_presets: Vec::new(),
recent_new_documents: Vec::new(),
ref_point: 0,
align_to: "selection".into(),
+86 -19
View File
@@ -139,18 +139,20 @@ pub(crate) fn remember(app: &mut DesignApp, p: &Value) {
recent.truncate(MAX_RECENT);
}
/// Save the details as the document preset `name` (replacing one of that name) and show it.
fn save_preset(app: &mut DesignApp, d: &mut Dialog, name: &str) {
/// Save the details as the document preset `name` (`file.savePreset`, which replaces one of that
/// name) and show it on the Saved tab.
fn save_preset(app: &mut DesignApp, d: &mut Dialog, name: &str) -> Result<(), String> {
let name = name.trim();
let Some(mut nd) = as_preset(&params(d)) else { return };
if name.is_empty() {
return;
let mut p = params(d);
if let Some(o) = p.as_object_mut() {
o.remove("title");
o.retain(|_, v| !v.is_null());
o.insert("name".into(), json!(name));
}
nd.title = name.to_string();
app.ui.document_presets.retain(|p| p.title != name);
app.ui.document_presets.push(nd);
app.run("file.savePreset", p)?;
d.fields.insert("tab".into(), json!("saved"));
d.fields.insert("preset".into(), json!(format!("saved:{name}")));
Ok(())
}
/// The built-in preset `nd` is the size of, if any.
@@ -184,7 +186,8 @@ fn cards(app: &DesignApp, tab: &str) -> Vec<Card> {
.collect()
}
"saved" => app
.ui
.session
.prefs
.document_presets
.iter()
.map(|nd| Card { key: format!("saved:{}", nd.title), label: nd.title.clone(), nd: nd.clone(), custom: preset_name(nd).is_none() })
@@ -366,8 +369,10 @@ fn presets(app: &mut DesignApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
}
});
if let Some(name) = delete {
app.ui.document_presets.retain(|p| p.title != name);
if let Some(name) = delete
&& let Err(e) = app.run("file.deletePreset", json!({"name": name}))
{
app.status(e);
}
create
}
@@ -586,17 +591,30 @@ fn details(app: &mut DesignApp, ui: &mut Ui, d: &mut Dialog) {
d.fields.insert("name".into(), json!(name));
}
let save = crate::widgets::icon_toggle_sized(ui, "preset-save", false, tr("Save Document Preset"), vec2(30.0, 26.0));
egui::Popup::menu(&save).show(|ui| {
save.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tr("Save Document Preset")));
// It holds a text field: clicks inside it don't close it.
egui::Popup::menu(&save).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
ui.set_min_width(220.0);
crate::rtl::label(ui, tr("Save Document Preset As:"));
let mut preset = d.s("presetName");
if ui.add(egui::TextEdit::singleline(&mut preset).hint_text(tr("Custom")).desired_width(200.0)).changed() {
let field = ui.add(egui::TextEdit::singleline(&mut preset).hint_text(tr("Custom")).desired_width(200.0));
field.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, true, tr("Preset Name")));
// Type the name as soon as the popup opens.
if save.clicked() {
field.request_focus();
}
if field.changed() {
d.fields.insert("presetName".into(), json!(preset));
}
if ui.button(crate::rtl::widget(ui, tr("Save Preset"))).clicked() {
// Enter in the name saves the preset (the dialog ignores keys while a popup is open).
let enter = field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
if ui.button(crate::rtl::widget(ui, tr("Save Preset"))).clicked() || enter {
let name = if preset.trim().is_empty() { tr("Custom").to_string() } else { preset };
save_preset(app, d, &name);
ui.close();
match save_preset(app, d, &name) {
Ok(()) => ui.close(),
// Refused (a built-in name…): stay open to be corrected.
Err(e) => app.status(e),
}
}
});
});
@@ -828,14 +846,63 @@ mod tests {
assert_eq!((p["gutter"].as_f64(), p["pages"].as_u64()), (Some(12.0), Some(1)));
}
/// The app's window (with a document) with the New Document dialog and its Save Document Preset
/// popup open.
fn window_with_preset_popup() -> egui_kittest::Harness<'static, crate::test_window::Window> {
use egui_kittest::kittest::Queryable as _;
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("app.newDocumentDialog", json!({})).unwrap();
let mut h = crate::test_window::open(app, vec2(1440.0, 900.0));
// The dialog takes a couple of frames to settle where it opens.
h.run_steps(4);
let button = h.get_by_label("Save Document Preset").rect().center();
crate::test_window::click_at(&mut h, button);
assert!(egui::Popup::is_any_open(&h.ctx), "the popup opened");
h
}
#[test]
fn enter_in_the_preset_popup_saves_the_preset_and_keeps_the_dialog() {
use egui_kittest::kittest::Queryable as _;
let mut h = window_with_preset_popup();
let field = h.get_by_label("Preset Name").rect().center();
crate::test_window::click_at(&mut h, field);
h.get_by_label("Preset Name").type_text("Brochure");
h.run_steps(2);
h.key_press(egui::Key::Enter);
h.run_steps(4);
let app = &mut h.state_mut().app;
let d = app.ui.dialog.as_ref().expect("Enter saves the preset; it doesn't create the document");
assert_eq!(app.session.documents().len(), 1, "no document was made (the window opens with one)");
assert_eq!(app.session.prefs.document_presets.iter().map(|p| p.title.as_str()).collect::<Vec<_>>(), ["Brochure"]);
assert_eq!((d.s("tab"), d.s("preset")), ("saved".into(), "saved:Brochure".into()));
// The saved preset makes documents by name, as file.new {preset} does from anywhere.
app.run("file.new", json!({"preset": "Brochure"})).unwrap();
assert!(!egui::Popup::is_any_open(&h.ctx), "the popup closed");
}
#[test]
fn escape_in_the_preset_popup_closes_only_the_popup() {
let mut h = window_with_preset_popup();
h.key_press(egui::Key::Escape);
h.run_steps(4);
assert!(!egui::Popup::is_any_open(&h.ctx), "Escape closed the popup");
assert!(h.state().app.ui.dialog.is_some(), "and left the dialog open");
// With no popup open, Escape closes the dialog as before.
h.key_press(egui::Key::Escape);
h.run_steps(4);
assert!(h.state().app.ui.dialog.is_none());
}
#[test]
fn presets_are_saved_by_name_and_recent_documents_once() {
let mut app = app();
let mut d = app.ui.dialog.clone().unwrap();
save_preset(&mut app, &mut d, " Newsletter ");
save_preset(&mut app, &mut d, " Newsletter ").unwrap();
d.fields.insert("pages".into(), json!(8));
save_preset(&mut app, &mut d, "Newsletter");
assert_eq!(app.ui.document_presets.len(), 1, "the same name replaces the preset");
save_preset(&mut app, &mut d, "Newsletter").unwrap();
assert_eq!(app.session.prefs.document_presets.len(), 1, "the same name replaces the preset");
assert!(save_preset(&mut app, &mut d, "Letter").is_err(), "a built-in name is refused");
assert_eq!((d.s("tab"), d.s("preset")), ("saved".into(), "saved:Newsletter".into()));
let saved = cards(&app, "saved");
assert_eq!((saved.len(), saved[0].label.as_str(), saved[0].nd.pages), (1, "Newsletter", 8));