From 8fdb9a4cbd04b95caa498d35c86ed788bddd3e81 Mon Sep 17 00:00:00 2001 From: landon Date: Sat, 22 Aug 2026 09:30:32 -0500 Subject: [PATCH] Add a template-backed new-file button to the file list The file list's "+ New" needs a typed name and seeds only '# ', 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 Claude-Session: https://claude.ai/code/session_01GBWj9TphFMCoh7VHaSRnvQ --- README.md | 49 ++++++++- src/app/file_list.rs | 10 ++ src/app/mod.rs | 7 ++ src/app/ui.rs | 4 + src/app/util.rs | 54 ++++++++++ src/app/workspace.rs | 242 ++++++++++++++++++++++++++++++++++++++++++- src/config.rs | 90 ++++++++++++++++ 7 files changed, 451 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 73f1b14..1c5fffc 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,9 @@ Terminal=false enclosing repository**. Accept it to have **⟳ Sync** commit and push to that repo; decline to leave the workspace on its own, where **Init git** creates a separate repository in the folder. -3. Create files with **+ New**, edit on the right, `Ctrl+S` (or the Save button) +3. Create files with **+ New** (type a name first) or **+ New from template** + (no name needed — see [New files from a template](#new-files-from-a-template)), + edit on the right, `Ctrl+S` (or the Save button) to write to disk. Drag the `⠿` handles to reorder. Use the **Zoom** slider above the editor to scale the editor text (0% = default; **Reset** returns to it), and the **Contrast** slider to brighten dim editor text (0% = theme @@ -137,6 +139,49 @@ A single number (`## Word Count Target: 1800`) sets a point goal; ranges accept `-`, `–`, `to`, and grouped digits (`1,500`). Like the other header lines, the target is stripped from the exported document. +## New files from a template + +The file list has two create buttons: + +| Button | Name | Contents | +|---|---|---| +| **+ New** | the name you type beside it | `# ` and a blank line | +| **+ New from template** | auto-assigned `untitled-N.md` | the configured template | + +**+ New from template** takes no typed name — it picks the first free +`untitled-N.md` (reusing a gap if you have deleted one), seeds it from the +template, appends it to the manuscript order and selects it. Rename it later with +the **Rename** box, or set a `# Title:` header and let the chapter title come +from that. + +Edit the template under **Settings ▸ New-file template…**. The built-in default is: + +``` +# Title: {{name}} +# Slug: +# POV: +# Word Count Target: + +### Rough Draft: + +``` + +Three placeholders are expanded when the file is created: + +| Placeholder | Expands to | +|---|---| +| `{{name}}` | the file stem, e.g. `untitled-3` | +| `{{marker}}` | the draft marker set in the top bar (default `### Rough Draft:`) | +| `{{date}}` | today's date in UTC, as `YYYY-MM-DD` | + +Anything else in double braces is left alone. Using `{{marker}}` rather than +typing the marker literally keeps the template working if you change the marker +later. Emptying the box restores the built-in default, and **Reset to default** +does the same in one click. + +The template is stored in `~/.config/md-manuscript/config.json`, so it is shared +by every workspace on the machine rather than committed with a manuscript. + ## Spelling (offline) Spelling is checked **live as you type**, entirely offline — no server, no @@ -286,7 +331,7 @@ only triggers in the header region, never in the prose below the marker. | `/*.md` | your manuscript files | | `/order.json` | the manual ordering (committed to git) | | `/titles.json` | chapter-title overrides (committed to git) | -| `~/.config/md-manuscript/config.json` | last workspace, export path, prefs | +| `~/.config/md-manuscript/config.json` | last workspace, export path, prefs, new-file template | ## Markdown supported in ODT export diff --git a/src/app/file_list.rs b/src/app/file_list.rs index 9ab8d5f..2ebc852 100644 --- a/src/app/file_list.rs +++ b/src/app/file_list.rs @@ -115,6 +115,16 @@ impl App { self.create_file(); } }); + if ui + .button("+ New from template") + .on_hover_text( + "Create untitled-N.md seeded from the template \ + (Settings ▸ New-file template…)", + ) + .clicked() + { + self.create_file_from_template(); + } if self.selected.is_some() { ui.horizontal(|ui| { diff --git a/src/app/mod.rs b/src/app/mod.rs index a1776e7..83e6fd1 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -265,6 +265,8 @@ pub struct App { spell_menu: Option, /// Whether the Mistral settings window is open. show_mistral_settings: bool, + /// Whether the new-file template settings window is open. + show_template_settings: bool, /// In-flight background plot-beat generation, if any. beats_rx: Option>>, /// One-line status for the plot-beat generator. @@ -333,6 +335,7 @@ impl App { spell_status: String::new(), spell_menu: None, show_mistral_settings: false, + show_template_settings: false, beats_rx: None, beats_status: String::new(), beats_output: None, @@ -415,6 +418,10 @@ impl eframe::App for App { self.mistral_settings_window(ctx); } + if self.show_template_settings { + self.template_settings_window(ctx); + } + if self.beats_output.is_some() { self.beats_window(ctx); } diff --git a/src/app/ui.rs b/src/app/ui.rs index 13281ad..f6ff548 100644 --- a/src/app/ui.rs +++ b/src/app/ui.rs @@ -273,6 +273,10 @@ impl App { ui.close_menu(); self.show_mistral_settings = true; } + if ui.button("New-file template…").clicked() { + ui.close_menu(); + self.show_template_settings = true; + } }); ui.menu_button("Help", |ui| { if ui.button("📝 Markdown cheatsheet").clicked() { diff --git a/src/app/util.rs b/src/app/util.rs index c564fc1..c30f5b9 100644 --- a/src/app/util.rs +++ b/src/app/util.rs @@ -29,10 +29,64 @@ pub(super) fn chrono_like_timestamp() -> String { format!("@{secs}") } +/// Today's UTC date as `YYYY-MM-DD`, for the `{{date}}` template placeholder. +/// UTC rather than local time so no timezone database is needed, keeping the +/// binary self-contained. +pub(super) fn today_utc() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let (y, m, d) = civil_from_days((secs / 86_400) as i64); + format!("{y:04}-{m:02}-{d:02}") +} + +/// Days since the Unix epoch -> civil `(year, month, day)`, using Howard +/// Hinnant's `civil_from_days`. Avoids a date-crate dependency. +pub(super) fn civil_from_days(days: i64) -> (i64, u32, u32) { + // Shift the epoch to 0000-03-01 so leap days land at the end of the cycle. + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); // day of era, 0..=146096 + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // day of year, March-based + let mp = (5 * doy + 2) / 153; // March-based month, 0..=11 + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + (if m <= 2 { y + 1 } else { y }, m, d) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn civil_from_days_matches_known_dates() { + assert_eq!(civil_from_days(0), (1970, 1, 1)); + // Straddles the 2000 leap day, the case the algorithm exists to get right. + assert_eq!(civil_from_days(11_017), (2000, 3, 1)); + assert_eq!(civil_from_days(19_358), (2023, 1, 1)); + assert_eq!(civil_from_days(19_723), (2024, 1, 1)); + // Day before the epoch, to confirm the negative branch works. + assert_eq!(civil_from_days(-1), (1969, 12, 31)); + } + + #[test] + fn today_utc_is_a_well_formed_date() { + let s = today_utc(); + assert_eq!(s.len(), 10, "expected YYYY-MM-DD, got {s}"); + let parts: Vec<&str> = s.split('-').collect(); + assert_eq!(parts.len(), 3); + let year: i64 = parts[0].parse().expect("year"); + let month: u32 = parts[1].parse().expect("month"); + let day: u32 = parts[2].parse().expect("day"); + assert!(year >= 2024, "clock looks wrong: {s}"); + assert!((1..=12).contains(&month)); + assert!((1..=31).contains(&day)); + } + #[test] fn counts_words_across_whitespace() { assert_eq!(count_words(""), 0); diff --git a/src/app/workspace.rs b/src/app/workspace.rs index 3f5e160..fb74a21 100644 --- a/src/app/workspace.rs +++ b/src/app/workspace.rs @@ -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 = + 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:?}"); + } } diff --git a/src/config.rs b/src/config.rs index ab004bc..0586d6f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -63,6 +63,27 @@ pub struct Config { /// Mistral API base URL (blank = the built-in default). Override for a proxy. #[serde(default = "default_mistral_base_url")] pub mistral_base_url: String, + /// Markdown seeded into files made with the file list's template button. + /// `{{name}}` expands to the file stem, `{{marker}}` to [`Config::draft_marker`] + /// and `{{date}}` to today's UTC date. Blank = the built-in default. + #[serde(default = "default_new_file_template")] + pub new_file_template: String, +} + +/// Default new-file template: the editorial header fields this app already +/// understands, followed by the draft marker so the body starts below it. +pub fn default_new_file_template() -> String { + [ + "# Title: {{name}}", + "# Slug:", + "# POV:", + "# Word Count Target:", + "", + "{{marker}}", + "", + "", + ] + .join("\n") } /// Default Mistral model. @@ -132,6 +153,7 @@ impl Default for Config { mistral_api_key: String::new(), mistral_model: default_mistral_model(), mistral_base_url: default_mistral_base_url(), + new_file_template: default_new_file_template(), } } } @@ -175,6 +197,16 @@ impl Config { } } + /// The new-file template to use, falling back to the built-in default when + /// blank, so an accidentally emptied box still produces a usable file. + pub fn effective_new_file_template(&self) -> String { + if self.new_file_template.trim().is_empty() { + default_new_file_template() + } else { + self.new_file_template.clone() + } + } + /// The Mistral base URL to use, falling back to the built-in default when blank. pub fn mistral_effective_base_url(&self) -> String { let b = self.mistral_base_url.trim().trim_end_matches('/'); @@ -203,3 +235,61 @@ impl Config { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A config.json written before `new_file_template` existed. `Config::load` + /// falls back to `Config::default()` when parsing fails, which would quietly + /// discard the user's workspace and API keys — so this must keep parsing. + #[test] + fn config_without_the_template_field_still_loads() { + let old = r####"{ + "workspace": "/home/writer/Manuscript", + "export_path": "/home/writer/Manuscript/book.odt", + "show_preview": true, + "draft_marker": "### Rough Draft:", + "editor_zoom": 10.0, + "languagetool_host": "lt.example.com", + "mistral_api_key": "secret" + }"####; + let cfg: Config = serde_json::from_str(old).expect("old config must still parse"); + // Pre-existing values survive. + assert_eq!(cfg.workspace, PathBuf::from("/home/writer/Manuscript")); + assert_eq!(cfg.languagetool_host, "lt.example.com"); + assert_eq!(cfg.mistral_api_key, "secret"); + assert!(cfg.show_preview); + // The new field is filled in from its default rather than left blank. + assert_eq!(cfg.new_file_template, default_new_file_template()); + assert!(cfg.new_file_template.contains("{{marker}}")); + } + + #[test] + fn blank_template_falls_back_to_the_default() { + let blank = Config { + new_file_template: " \n ".to_string(), + ..Config::default() + }; + assert_eq!( + blank.effective_new_file_template(), + default_new_file_template() + ); + let custom = Config { + new_file_template: "# Mine\n".to_string(), + ..Config::default() + }; + assert_eq!(custom.effective_new_file_template(), "# Mine\n"); + } + + #[test] + fn config_survives_a_save_load_round_trip() { + let cfg = Config { + new_file_template: "# Title: {{name}}\n{{date}}\n".to_string(), + ..Config::default() + }; + let text = serde_json::to_string_pretty(&cfg).expect("serialize"); + let back: Config = serde_json::from_str(&text).expect("deserialize"); + assert_eq!(back.new_file_template, cfg.new_file_template); + } +}