mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-08 11:41:35 +00:00
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:
1 parent
e7668ae53e
commit
b29f3a8a2e
4 files changed
+90
-4
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user