Add a template-backed new-file button to the file list
The file list's "+ New" needs a typed name and seeds only '# <name>',
so every new chapter started from a blank header. Add a second button,
"+ New from template", that takes no name: it picks the first free
untitled-N.md (reusing a gap left by a deleted file), seeds it from a
configurable template, appends it to the manuscript order and selects it.
The template lives in config.json and is edited under Settings > New-file
template..., alongside the existing LanguageTool and Mistral dialogs.
Three placeholders expand at creation time: {{name}} (file stem),
{{marker}} (the configured draft marker, so a template keeps working if
the marker changes) and {{date}} (today's UTC date). Blanking the box
falls back to the built-in default, which seeds the header fields the app
already understands followed by the draft marker.
The date is computed with a local civil-from-days conversion rather than
a date crate, keeping the binary self-contained - ldd still shows only
libc/libgcc/libm.
"+ New" is unchanged; both paths now share insert_new_file. Covered by
13 new tests: placeholder expansion, untitled-N gap reuse, civil date
conversion against known dates, config.json written before this field
still deserializing (Config::load falls back to Default on a parse error,
which would otherwise discard the user's workspace and API keys), and a
round trip proving the seeded header parses back into Title/Slug/POV/goal
via the app's own preprocess::parse.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBWj9TphFMCoh7VHaSRnvQ
This commit is contained in:
+239
-3
@@ -212,14 +212,39 @@ impl App {
|
||||
if stem.to_lowercase().ends_with(".md") {
|
||||
stem.truncate(stem.len() - 3);
|
||||
}
|
||||
let name = format!("{stem}.md");
|
||||
let seed = format!("# {stem}\n\n");
|
||||
self.insert_new_file(format!("{stem}.md"), seed);
|
||||
}
|
||||
|
||||
/// Create a file seeded from the configured template, naming it
|
||||
/// `untitled-N.md` so the button works without typing a name first.
|
||||
pub(super) fn create_file_from_template(&mut self) {
|
||||
let existing: std::collections::HashSet<String> =
|
||||
self.files.iter().map(|n| n.to_lowercase()).collect();
|
||||
let name = next_untitled_name(|candidate| {
|
||||
existing.contains(&candidate.to_lowercase()) || self.path_for(candidate).exists()
|
||||
});
|
||||
let stem = name.trim_end_matches(".md").to_string();
|
||||
let seed = render_template(
|
||||
&self.config.effective_new_file_template(),
|
||||
&stem,
|
||||
self.config.draft_marker.trim(),
|
||||
&today_utc(),
|
||||
);
|
||||
self.insert_new_file(name, seed);
|
||||
// The template's header fields should be offerable straight away.
|
||||
self.rebuild_field_names();
|
||||
}
|
||||
|
||||
/// Write `contents` to a new file, append it to the manuscript order and
|
||||
/// select it. Shared by the plain and template-backed create paths.
|
||||
fn insert_new_file(&mut self, name: String, contents: String) {
|
||||
let path = self.path_for(&name);
|
||||
if path.exists() {
|
||||
self.status = format!("{name} already exists");
|
||||
return;
|
||||
}
|
||||
let seed = format!("# {stem}\n\n");
|
||||
match std::fs::write(&path, seed) {
|
||||
match std::fs::write(&path, contents) {
|
||||
Ok(_) => {
|
||||
self.files.push(name.clone());
|
||||
self.persist_order();
|
||||
@@ -470,4 +495,215 @@ impl App {
|
||||
}
|
||||
self.status = "Reordered".to_string();
|
||||
}
|
||||
|
||||
/// Settings dialog for the markdown seeded into template-backed new files.
|
||||
pub(super) fn template_settings_window(&mut self, ctx: &egui::Context) {
|
||||
let mut open = self.show_template_settings;
|
||||
let mut close_clicked = false;
|
||||
egui::Window::new("New-file template")
|
||||
.open(&mut open)
|
||||
.resizable(true)
|
||||
.collapsible(false)
|
||||
.default_width(430.0)
|
||||
.show(ctx, |ui| {
|
||||
let mut save_now = false;
|
||||
ui.label(
|
||||
egui::RichText::new(
|
||||
"Seeded into files made with “+ New from template” in the file list.",
|
||||
)
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
let r = ui.add(
|
||||
egui::TextEdit::multiline(&mut self.config.new_file_template)
|
||||
.code_editor()
|
||||
.desired_width(f32::INFINITY)
|
||||
.desired_rows(12),
|
||||
);
|
||||
save_now |= r.lost_focus();
|
||||
|
||||
ui.add_space(4.0);
|
||||
ui.label(egui::RichText::new("Placeholders").strong());
|
||||
egui::Grid::new("template_placeholders")
|
||||
.num_columns(2)
|
||||
.spacing([10.0, 2.0])
|
||||
.show(ui, |ui| {
|
||||
for (token, meaning) in [
|
||||
("{{name}}", "the file stem, e.g. untitled-3"),
|
||||
("{{marker}}", "the draft marker set in the toolbar"),
|
||||
("{{date}}", "today's date (UTC), as YYYY-MM-DD"),
|
||||
] {
|
||||
ui.label(egui::RichText::new(token).monospace());
|
||||
ui.label(egui::RichText::new(meaning).weak());
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(4.0);
|
||||
ui.label(
|
||||
egui::RichText::new(
|
||||
"Leave the box empty to go back to the built-in default.",
|
||||
)
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Close").clicked() {
|
||||
close_clicked = true;
|
||||
}
|
||||
if ui
|
||||
.button("Reset to default")
|
||||
.on_hover_text("Replace the box with the built-in template")
|
||||
.clicked()
|
||||
{
|
||||
self.config.new_file_template =
|
||||
crate::config::default_new_file_template();
|
||||
save_now = true;
|
||||
}
|
||||
});
|
||||
if save_now {
|
||||
self.config.save();
|
||||
}
|
||||
});
|
||||
|
||||
let now_open = open && !close_clicked;
|
||||
if self.show_template_settings && !now_open {
|
||||
self.config.save();
|
||||
}
|
||||
self.show_template_settings = now_open;
|
||||
}
|
||||
}
|
||||
|
||||
/// First free `untitled-N.md`, so the template button needs no typed name.
|
||||
/// `is_taken` reports names already used on disk or in the manuscript order.
|
||||
pub(super) fn next_untitled_name(is_taken: impl Fn(&str) -> bool) -> String {
|
||||
for n in 1..=9_999 {
|
||||
let name = format!("untitled-{n}.md");
|
||||
if !is_taken(&name) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
// Absurdly unlikely; fall back to something guaranteed unique-ish.
|
||||
format!("untitled-{}.md", chrono_like_timestamp().trim_start_matches('@'))
|
||||
}
|
||||
|
||||
/// Expand the template placeholders and guarantee a trailing newline.
|
||||
pub(super) fn render_template(template: &str, stem: &str, marker: &str, date: &str) -> String {
|
||||
let mut out = template
|
||||
.replace("{{name}}", stem)
|
||||
.replace("{{marker}}", marker)
|
||||
.replace("{{date}}", date);
|
||||
if !out.ends_with('\n') {
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn untitled_name_fills_the_first_free_slot() {
|
||||
assert_eq!(next_untitled_name(|_| false), "untitled-1.md");
|
||||
let taken = ["untitled-1.md", "untitled-2.md"];
|
||||
assert_eq!(
|
||||
next_untitled_name(|n| taken.contains(&n)),
|
||||
"untitled-3.md"
|
||||
);
|
||||
// A gap is reused rather than skipped.
|
||||
let sparse = ["untitled-1.md", "untitled-3.md"];
|
||||
assert_eq!(
|
||||
next_untitled_name(|n| sparse.contains(&n)),
|
||||
"untitled-2.md"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_template_expands_every_placeholder() {
|
||||
let out = render_template(
|
||||
"# Title: {{name}}\n# Started: {{date}}\n\n{{marker}}\n",
|
||||
"untitled-7",
|
||||
"### Rough Draft:",
|
||||
"2026-08-22",
|
||||
);
|
||||
assert_eq!(
|
||||
out,
|
||||
"# Title: untitled-7\n# Started: 2026-08-22\n\n### Rough Draft:\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_template_repeats_and_leaves_unknown_tokens_alone() {
|
||||
let out = render_template("{{name}}/{{name}} {{nope}}", "a", "M", "D");
|
||||
assert_eq!(out, "a/a {{nope}}\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_template_always_ends_with_a_newline() {
|
||||
assert!(render_template("no trailing newline", "n", "m", "d").ends_with('\n'));
|
||||
// An already-terminated template doesn't collect a second one.
|
||||
assert_eq!(render_template("done\n", "n", "m", "d"), "done\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_template_tolerates_an_empty_marker() {
|
||||
// An empty draft_marker disables header splitting; the line goes blank.
|
||||
assert_eq!(render_template("a\n{{marker}}\nb\n", "n", "", "d"), "a\n\nb\n");
|
||||
}
|
||||
|
||||
/// The point of the default template is that the app's own header parser
|
||||
/// understands what it produces — otherwise the seeded fields are just text.
|
||||
#[test]
|
||||
fn default_template_round_trips_through_the_header_parser() {
|
||||
let marker = crate::config::default_marker();
|
||||
let seeded = render_template(
|
||||
&crate::config::default_new_file_template(),
|
||||
"chapter-01",
|
||||
&marker,
|
||||
"2026-08-22",
|
||||
);
|
||||
let h = crate::preprocess::parse(&seeded, &marker);
|
||||
assert_eq!(h.title.as_deref(), Some("chapter-01"));
|
||||
// Slug/POV are seeded blank, so they parse as absent rather than empty.
|
||||
assert_eq!(h.slug, None);
|
||||
assert_eq!(h.pov, None);
|
||||
assert_eq!(h.goal, None);
|
||||
// Nothing above the marker leaks into the exported prose.
|
||||
assert_eq!(h.body.trim(), "");
|
||||
}
|
||||
|
||||
/// A filled-in template parses into the fields the file list displays.
|
||||
#[test]
|
||||
fn filled_template_parses_into_header_fields() {
|
||||
let marker = crate::config::default_marker();
|
||||
let seeded = render_template(
|
||||
"# Title: {{name}}\n# Slug: the door\n# POV: Ada\n\
|
||||
# Word Count Target: 1500 - 2000\n\n{{marker}}\n\nReal prose here.\n",
|
||||
"chapter-02",
|
||||
&marker,
|
||||
"2026-08-22",
|
||||
);
|
||||
let h = crate::preprocess::parse(&seeded, &marker);
|
||||
assert_eq!(h.title.as_deref(), Some("chapter-02"));
|
||||
assert_eq!(h.slug.as_deref(), Some("the door"));
|
||||
assert_eq!(h.pov.as_deref(), Some("Ada"));
|
||||
assert!(h.goal.is_some(), "word-count target should parse");
|
||||
assert_eq!(h.body.trim(), "Real prose here.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_template_renders_to_a_usable_header() {
|
||||
let out = render_template(
|
||||
&crate::config::default_new_file_template(),
|
||||
"chapter-01",
|
||||
"### Rough Draft:",
|
||||
"2026-08-22",
|
||||
);
|
||||
assert!(out.starts_with("# Title: chapter-01\n"), "got {out:?}");
|
||||
assert!(out.contains("\n### Rough Draft:\n"), "got {out:?}");
|
||||
assert!(!out.contains("{{"), "placeholder left unexpanded in {out:?}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user