Save As suggests a new document's first words as its name (#5795)

Saving a document for the first time suggested Document1.docx whatever
it held. As in Word, a document that was never saved and has no title
of its own is now offered the first words of its first paragraph with
text: up to the first sentence or clause end, without the characters a
file name can't hold, white space collapsed, at most 50 characters cut
at a word's end (Minutes of the board meeting.docx). A path, a file
name or a Title property still name it, and the window title stays
Document1.

Fixes #5793
This commit is contained in:
Hamza Naveed authored and GitHub committed 2026-11-08 01:50:43 +01:00
1 parent e7668ae53e
commit b29f3a8a2e
4 files changed
+90 -4

No files matched your search

+2
View File
@@ -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;
+37 -3
View File
@@ -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<String> {
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)
}
+2 -1
View File
@@ -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);
+49
View File
@@ -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\" <final> *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]