diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 5dcd82297c..f496df68a3 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -13,6 +13,8 @@ use super::{ mod commands; mod control; mod files; +#[cfg(test)] +pub(crate) use files::suggested_stem; mod fonts; mod frame; mod input; diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index c545d131f4..64c38ee683 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -298,12 +298,16 @@ impl WordApp { } /// The name Save suggests: the open file's own Word format (so a .docm keeps its macros), - /// otherwise .docx. + /// otherwise .docx. A document never saved and without a title of its own is named after its + /// first words, as Word suggests them (#5793), instead of `Document1`. pub(crate) fn default_save_name(&self) -> String { let ext = self.session.path.as_ref().and_then(|p| p.extension()).map(|e| e.to_string_lossy().to_string()); + let unnamed = self.session.path.is_none() && self.session.file_name.is_none() && self.session.doc.core.title.trim().is_empty(); + let first = || self.session.doc.body.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).find_map(|t| suggested_stem(&t)); + let stem = unnamed.then(first).flatten().unwrap_or_else(|| self.title_stem()); match ext { - Some(e) if wordcraft_engine::io::is_word_package(&e) => format!("{}.{e}", self.title_stem()), - _ => format!("{}.docx", self.title_stem()), + Some(e) if wordcraft_engine::io::is_word_package(&e) => format!("{stem}.{e}"), + _ => format!("{stem}.docx"), } } @@ -463,3 +467,33 @@ impl WordApp { } } } + +/// The most characters a name taken from the text keeps ([`suggested_stem`]). +const SUGGESTED_MAX: usize = 50; + +/// A file name from a paragraph's text (#5793): up to its first sentence or clause end +/// (`.`, `!`, `?`, `;`, `:` and their full-width forms) or line break, without the characters a +/// file name can't hold (`\ / : * ? " < > |` and controls), white space collapsed, at most +/// [`SUGGESTED_MAX`] characters cut at a word's end, and no dot or space at either end (Windows +/// drops them). `None` when nothing is left, so the next paragraph, or `Document1`, is used. +pub(crate) fn suggested_stem(text: &str) -> Option { + let end = |c: char| matches!(c, '.' | '!' | '?' | ';' | ':' | '\n' | '\r' | '\u{0B}' | '\u{2029}' | '。' | '!' | '?' | ';' | ':'); + let head = text.split(end).next().unwrap_or(""); + let kept: String = head.chars().map(|c| if c.is_control() || matches!(c, '\\' | '/' | '*' | '"' | '<' | '>' | '|') { ' ' } else { c }).collect(); + let mut words = String::new(); + for w in kept.split_whitespace() { + let next = if words.is_empty() { w.chars().count() } else { words.chars().count() + 1 + w.chars().count() }; + if next > SUGGESTED_MAX { + if words.is_empty() { + words = w.chars().take(SUGGESTED_MAX).collect(); + } + break; + } + if !words.is_empty() { + words.push(' '); + } + words.push_str(w); + } + let words = words.trim_matches(|c: char| c == '.' || c.is_whitespace()).to_string(); + (!words.is_empty()).then_some(words) +} diff --git a/crates/ui-egui/src/file_dialogs/tests.rs b/crates/ui-egui/src/file_dialogs/tests.rs index 8acb162d6e..837cb96a87 100644 --- a/crates/ui-egui/src/file_dialogs/tests.rs +++ b/crates/ui-egui/src/file_dialogs/tests.rs @@ -315,7 +315,8 @@ fn save_as_answer_arriving_later_is_saved() { typed(&mut a); a.save_as_dialog(); assert!(a.file_dialog_open()); - assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "Document1.docx".into(), dir: None }); + // A new document is offered its first words as its name, as in Word (#5793). + assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: format!("{UNSAVED}.docx"), dir: None }); let ctx = egui::Context::default(); let frame = |a: &mut WordApp| ctx.run_ui(egui::RawInput::default(), |ui| a.logic(ui.ctx())).drop_without_applying_deltas(); frame(&mut a); diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index 8692b662a3..c8103b6e6b 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -96,6 +96,55 @@ fn title_stem_omits_nul_from_each_title_source() { } } +/// #5793: a name from a paragraph's first words, as Word suggests one: up to the first sentence or +/// clause end, without what a file name can't hold, at most 50 characters at a word's end. +#[test] +fn a_suggested_name_is_the_first_words_of_the_text() { + use crate::app::suggested_stem as stem; + assert_eq!(stem("Quarterly report for the board. More follows here.").as_deref(), Some("Quarterly report for the board")); + assert_eq!(stem("Minutes of the meeting, 6 November").as_deref(), Some("Minutes of the meeting, 6 November")); + assert_eq!(stem("What happened? Nobody knows").as_deref(), Some("What happened")); + assert_eq!(stem("Plan: phase one").as_deref(), Some("Plan")); + assert_eq!(stem("Q3 / Q4 \"draft\" *v2* | a\\b").as_deref(), Some("Q3 Q4 draft final v2 a b")); + assert_eq!(stem(" Leading and inner spaces ").as_deref(), Some("Leading and inner spaces")); + assert_eq!(stem("日本語の文書。続きの文").as_deref(), Some("日本語の文書")); + let long = "Annual report of the regional committee on transport and infrastructure planning"; + let got = stem(long).unwrap(); + assert!(got.chars().count() <= 50 && long.starts_with(&got) && long[got.len()..].starts_with(' '), "{got:?}"); + assert_eq!(stem(&"x".repeat(80)).map(|s| s.chars().count()), Some(50), "one long word is cut at 50"); + for nothing in ["", " ", "...", "?", "/ * |", "\u{1}"] { + assert_eq!(stem(nothing), None, "{nothing:?}"); + } +} + +/// #5793: Save As suggests the first words for a document that was never saved and has no title +/// of its own (the first paragraph with text, past empty ones); a title, a file name or a path +/// still name it. +#[test] +fn save_as_suggests_the_first_words_of_a_new_document() { + let mut app = app(); + assert_eq!(app.default_save_name(), format!("{}.docx", app.title_stem()), "an empty document"); + for c in [ + json!(["text.newParagraph", {}]), + json!(["text.insert", {"text": "Minutes of the board meeting. Present: all"}]), + json!(["text.newParagraph", {}]), + json!(["text.insert", {"text": "Second paragraph"}]), + ] { + app.session.run(c[0].as_str().unwrap(), &c[1]).unwrap(); + } + assert_eq!(app.default_save_name(), "Minutes of the board meeting.docx"); + assert_eq!( + app.title_stem(), + crate::documents::untitled_title(&app.session.resolved_filenames().0).unwrap_or_else(|| "Document1".into()), + "the window title stays Document1" + ); + app.session.doc.core.title = "Board minutes".into(); + assert_eq!(app.default_save_name(), "Board minutes.docx", "a title wins"); + app.session.doc.core.title.clear(); + app.session.path = Some(std::path::PathBuf::from("/tmp/kept.docm")); + assert_eq!(app.default_save_name(), "kept.docm", "a saved file keeps its name and format"); +} + /// #1678: Focus and Full Screen View put the window in full screen, asked of the system once /// per change (switching between the two keeps it), and the usual window comes back after. #[test]