diff --git a/src/app/editor.rs b/src/app/editor.rs index 94e083a..7d1d090 100644 --- a/src/app/editor.rs +++ b/src/app/editor.rs @@ -5,6 +5,32 @@ use super::*; impl App { pub(super) fn editor(&mut self, ui: &mut egui::Ui) { + // The bundled scene template is a reference writers copy from by + // hand, not something the app reads — so an edit to it just sits + // there, invisible, until someone notices the scene they copied from + // it looks wrong. Lock it against typing until explicitly unlocked. + let is_template = self + .selected + .and_then(|i| self.files.get(i)) + .is_some_and(|f| workspace::is_template_file(f)); + let locked = is_template && !self.template_unlocked; + if is_template { + ui.horizontal(|ui| { + if locked { + ui.label("🔒 This is the scene template — locked to prevent accidental edits."); + if ui.button("Unlock").clicked() { + self.template_unlocked = true; + } + } else { + ui.label("🔓 Unlocked for editing."); + if ui.button("Lock").clicked() { + self.template_unlocked = false; + } + } + }); + ui.add_space(4.0); + } + // Base monospace size scaled by the zoom percentage. `desired_width` stays // infinite and the scroll area is vertical-only, so text wraps to the pane // width at any zoom (no horizontal scrolling). @@ -113,6 +139,7 @@ impl App { .desired_width(f32::INFINITY) .desired_rows(rows) .layouter(&mut layouter) + .interactive(!locked) .show(ui); if output.response.changed() { self.dirty = true; diff --git a/src/app/mod.rs b/src/app/mod.rs index 35c69fc..bc180ab 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -209,6 +209,11 @@ pub struct App { /// The editorial header lifted out of `buffer` while it is collapsed. header_stash: Option, dirty: bool, + /// Whether the currently open `_template.md` has been unlocked for + /// editing this session. Not persisted and not file-specific — it resets + /// to locked whenever a different file is selected, so re-opening the + /// template always starts protected again. + template_unlocked: bool, /// Byte offset of the primary cursor in `buffer`, as of the last frame. /// Used by typewriter mode to notice the cursor moved (and re-center it) /// versus the writer scrolling the view manually, and to find the current @@ -509,6 +514,7 @@ impl App { buffer: String::new(), header_stash: None, dirty: false, + template_unlocked: false, cursor_pos: 0, new_name: String::new(), rename_input: String::new(), diff --git a/src/app/workspace.rs b/src/app/workspace.rs index a4e5c48..5b34d27 100644 --- a/src/app/workspace.rs +++ b/src/app/workspace.rs @@ -615,6 +615,8 @@ impl App { self.selected = Some(idx); self.dirty = false; self.pending_delete = false; + // A lock earned on one file doesn't carry over to the next. + self.template_unlocked = false; // Update Story Bible references for the newly selected file self.update_current_references(); // The rename box holds the whole workspace-relative path, so it doubles @@ -1914,6 +1916,16 @@ pub(super) fn render_template(template: &str, stem: &str, marker: &str, date: &s out } +/// Whether `path` is the bundled scene template (`_template.md`): a reference +/// file writers open to see the expected header fields and copy from by hand, +/// not something the app reads from — "New from template" instead pulls from +/// [`Config::new_file_template`](crate::config::Config::new_file_template). +/// Easy to edit by mistake while skimming it, so the editor locks it against +/// changes until explicitly unlocked — see [`App::editor`](super::App::editor). +pub(super) fn is_template_file(path: &str) -> bool { + base_name(path).eq_ignore_ascii_case("_template.md") +} + #[cfg(test)] mod tests { use super::*; @@ -2097,6 +2109,21 @@ mod tests { assert_eq!(render_template("a\n{{marker}}\nb\n", "n", "", "d"), "a\n\nb\n"); } + #[test] + fn is_template_file_matches_only_the_scene_template_by_name() { + assert!(is_template_file("_template.md")); + assert!(is_template_file("05-First Draft/_template.md")); + // Case-insensitive, like the file system it's read from. + assert!(is_template_file("05-First Draft/_TEMPLATE.MD")); + // A chapter folder could have its own copy — still matches by name. + assert!(is_template_file("Act 1/Chapter 2/_template.md")); + // Other underscore-prefixed files (chapter info, notes) aren't it. + assert!(!is_template_file("_chapter.md")); + assert!(!is_template_file("_notes.md")); + assert!(!is_template_file("scene01.md")); + assert!(!is_template_file("template.md")); + } + /// 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]