3 Commits

Author SHA1 Message Date
landon 3d6e134a33 Lock the scene template against accidental edits
_template.md is a reference writers copy from by hand, not something
the app reads programmatically, so an accidental edit to it is easy
to make and easy to miss. The editor now shows it read-only with a
lock banner; an "Unlock" button allows editing for the rest of the
session, resetting back to locked the next time a file is selected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-04 10:46:52 -05:00
landon a700eed63c Refresh Story Bible reference ranges when the header folds or unfolds
Folding/unfolding the header moves the frontmatter in or out of
`buffer`, shifting every char offset into it, but after_view_change()
never recomputed current_references — so the cached ranges stayed
pointing at pre-toggle positions and highlights landed on unrelated
words. Recompute them alongside the other view-state invalidations it
already does.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-03 15:32:17 -05:00
landon 80f3d50fc4 Stop the file tree from centering short folder/file names
ui.add_sized() centers a widget narrower than the box it's given, and
Button/SelectableLabel size themselves to their actual text — so a
short name in a wide column (a folder with no trailing badges, most
visibly) drifted away from its own indent, landing to the right of
its own deeper children. Pin main_align to the start so every name
sits right after its indent regardless of length.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-03 15:32:13 -05:00
4 changed files with 107 additions and 10 deletions
+27
View File
@@ -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;
+42 -10
View File
@@ -415,13 +415,32 @@ impl App {
+ if show_tag { tag_w } else { 0.0 };
let name_w =
(row_width - indent - trailing).max(0.0);
// `add_sized` centers a widget
// smaller than the box it's given
// — fine for a fixed-width badge,
// but it let a short folder name
// drift away from the indent that
// was just drawn for it, and
// further than its own deeper
// children whose names fill more
// of their (narrower) box. Pinning
// `main_align` to the start keeps
// the name anchored right after
// the indent regardless of length.
let button_response = ui
.add_sized(
[name_w, 20.0],
egui::Button::new(text)
.frame(false)
.truncate(),
.allocate_ui_with_layout(
egui::vec2(name_w, 20.0),
egui::Layout::left_to_right(egui::Align::Center)
.with_main_align(egui::Align::Min),
|ui| {
ui.add(
egui::Button::new(text)
.frame(false)
.truncate(),
)
},
)
.inner
.on_hover_text(&row.path);
if button_response.clicked() {
@@ -563,16 +582,29 @@ impl App {
// file name simply spills out of the
// panel. The wrap mode is what it
// actually reads.
//
// `add_sized` also *centers* a
// widget narrower than the box
// it's given, which drifted short
// file names away from the indent
// just like folder names did —
// `with_main_align(Min)` pins it
// to the start instead.
let mut label = ui
.scope(|ui| {
ui.style_mut().wrap_mode =
Some(egui::TextWrapMode::Truncate);
ui.add_sized(
[label_w, 20.0],
egui::SelectableLabel::new(
selected, text,
),
ui.allocate_ui_with_layout(
egui::vec2(label_w, 20.0),
egui::Layout::left_to_right(egui::Align::Center)
.with_main_align(egui::Align::Min),
|ui| {
ui.add(egui::SelectableLabel::new(
selected, text,
))
},
)
.inner
})
.inner;
if let Some(tooltip) = &tooltip {
+6
View File
@@ -209,6 +209,11 @@ pub struct App {
/// The editorial header lifted out of `buffer` while it is collapsed.
header_stash: Option<String>,
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(),
+32
View File
@@ -541,6 +541,11 @@ impl App {
self.spell_menu = None;
self.clear_lt();
self.autocomplete = None;
// Story Bible reference ranges are char offsets into `buffer` too —
// stale ones (from before the header moved in or out) land on
// whatever text now happens to sit `header.len()` characters away
// from the real mention.
self.update_current_references();
}
/// How many header fields are hidden right now, for the toggle's label.
@@ -610,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
@@ -1909,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::*;
@@ -2092,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]