2 Commits

Author SHA1 Message Date
landon d29aa664da Parse a scene's summary as a real list of bullet points
A frontmatter `summary:` list written as a multi-line flow sequence
(one bullet per line, closing bracket on its own line — the natural
way to hand-write several sentences) used to vanish: no line in it
has a colon or a leading `-`, so the parser read none of the lines
after the opening `summary: [` and the field ended up holding a
literal "[". Even written on one line, `flow_list_items` split on
every comma, which cuts a prose bullet in two if it has one of its
own.

`Header` now has a `summary: Vec<String>`, read by a parser that
joins a flow sequence's continuation lines before splitting it on
commas outside of quotes, so each bullet survives whole. Also added
`summary: []` to the bundled scene template and the in-app default
new-file template, so new scenes offer the field.

No new UI surfaces this yet (corkboard/synopsis still show only the
slug) — out of scope for the parsing fix itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-05 20:00:36 -05:00
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
6 changed files with 247 additions and 7 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;
+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(),
+27
View File
@@ -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]
+1
View File
@@ -273,6 +273,7 @@ pub fn default_new_file_template() -> String {
"status: draft",
"pov: ",
"characters: []",
"summary: []",
"word_count_target: ",
"---",
"",
+185 -7
View File
@@ -33,6 +33,12 @@ pub struct Header {
pub pov: Option<String>,
/// Word-count target from a `Word Count Target:` line, if present and parsable.
pub goal: Option<WordGoal>,
/// Bullet points from a frontmatter `summary` list, in writing order. Each
/// item keeps its own punctuation intact — unlike a generic [`field`]'s
/// comma-joined value, nothing here is lost if a bullet has a comma of its
/// own. Empty when there's no frontmatter or no `summary` key (YAML-only;
/// the legacy marker-based header has no list syntax to borrow).
pub summary: Vec<String>,
/// The prose body, with comments, the header block, and metadata lines removed.
pub body: String,
}
@@ -207,7 +213,8 @@ fn yaml_fields(frontmatter: &str) -> Vec<Field> {
// Only a key written with nothing after it can be opening a block list, so
// a stray `- ` cannot append itself to a scalar above it.
let mut collecting = false;
for line in frontmatter.lines() {
let mut lines = frontmatter.lines();
while let Some(line) = lines.next() {
if line.trim() == "---" {
continue;
}
@@ -223,6 +230,7 @@ fn yaml_fields(frontmatter: &str) -> Vec<Field> {
continue;
}
if let Some((name, value)) = parse_yaml_field(line) {
let value = join_flow_continuation(value, &mut lines);
let value = flow_list_items(&value);
collecting = value.is_empty();
out.push(Field { name, value });
@@ -231,6 +239,79 @@ fn yaml_fields(frontmatter: &str) -> Vec<Field> {
out
}
/// Net count of un-quoted `[`/`]` in `s`, positive while a flow sequence begun
/// on an earlier line is still open.
fn bracket_depth(s: &str) -> i32 {
let mut depth = 0;
let mut in_quote = false;
let mut quote_char = '\0';
for c in s.chars() {
match c {
'"' | '\'' if !in_quote => {
in_quote = true;
quote_char = c;
}
c2 if in_quote && c2 == quote_char => in_quote = false,
'[' if !in_quote => depth += 1,
']' if !in_quote => depth -= 1,
_ => {}
}
}
depth
}
/// Pull in whatever further lines a flow sequence needs to balance its
/// brackets, so `key: [\n "a",\n "b",\n]` — how a multi-line list is often
/// hand-written, one bullet per line — reads the same as `key: [a, b]` on one
/// line. A value that was already balanced (or never opened a list) passes
/// through untouched, and an unterminated one is returned as far as it got.
fn join_flow_continuation<'a>(mut value: String, lines: &mut impl Iterator<Item = &'a str>) -> String {
while bracket_depth(&value) > 0 {
match lines.next() {
Some(cont) => {
value.push(' ');
value.push_str(cont.trim());
}
None => break,
}
}
value
}
/// Split a flow sequence's inner text on commas that are not inside a quoted
/// item, so `"a, b"` keeps its own comma rather than being cut in two by it —
/// unlike a bare name, prose can't be trusted to never contain one. A trailing
/// comma (the only way to end a list spread one item per line, and a habit
/// many writers bring from other languages) leaves no empty item behind.
fn split_flow_items(inner: &str) -> Vec<String> {
let mut items = Vec::new();
let mut current = String::new();
let mut in_quote = false;
let mut quote_char = '\0';
for c in inner.chars() {
match c {
'"' | '\'' if !in_quote => {
in_quote = true;
quote_char = c;
current.push(c);
}
c2 if in_quote && c2 == quote_char => {
in_quote = false;
current.push(c2);
}
',' if !in_quote => {
items.push(current.trim().to_string());
current.clear();
}
_ => current.push(c),
}
}
if !current.trim().is_empty() {
items.push(current.trim().to_string());
}
items.into_iter().map(|s| unquote(&s)).filter(|s| !s.is_empty()).collect()
}
/// Look one frontmatter key up, trying each alias in turn. Empty reads as absent,
/// the same way [`field`] treats an unfilled `## POV:`.
fn yaml_value(fields: &[Field], names: &[&str]) -> Option<String> {
@@ -260,12 +341,7 @@ fn flow_list_items(value: &str) -> String {
let Some(inner) = value.strip_prefix('[').and_then(|v| v.strip_suffix(']')) else {
return value.to_string();
};
inner
.split(',')
.map(|item| unquote(item.trim()))
.filter(|item| !item.is_empty())
.collect::<Vec<_>>()
.join(", ")
split_flow_items(inner).join(", ")
}
/// Strip one pair of matching surrounding quotes.
@@ -399,6 +475,7 @@ pub fn parse(markdown: &str, marker: &str) -> Header {
slug: yaml_value(&fields, &["slug"]),
pov: yaml_value(&fields, &["pov"]),
goal,
summary: summary_items(frontmatter),
body: body.trim().to_string(),
};
}
@@ -457,10 +534,50 @@ pub fn parse(markdown: &str, marker: &str) -> Header {
slug: slug.filter(|s| !s.is_empty()),
pov: pov.filter(|s| !s.is_empty()),
goal,
summary: Vec::new(),
body: body_lines.join("\n").trim().to_string(),
}
}
/// Every bullet of a frontmatter `summary` list, in writing order — see
/// [`Header::summary`]. Supports both YAML list forms, including a flow
/// sequence spread over several lines (`summary: [\n "...",\n]`).
fn summary_items(frontmatter: &str) -> Vec<String> {
let mut lines = frontmatter.lines().peekable();
while let Some(line) = lines.next() {
if line.trim() == "---" {
continue;
}
if block_list_item(line).is_some() {
continue; // belongs to some other key's block list; keep scanning
}
let Some((key, value)) = parse_yaml_field(line) else {
continue;
};
if !key.eq_ignore_ascii_case("summary") {
continue;
}
if value.is_empty() {
// A bare `summary:` opens a block list on the following lines.
let mut items = Vec::new();
while let Some(next) = lines.peek().copied() {
let Some(item) = block_list_item(next) else {
break;
};
items.push(item);
lines.next();
}
return items;
}
let value = join_flow_continuation(value, &mut lines);
let Some(inner) = value.strip_prefix('[').and_then(|v| v.strip_suffix(']')) else {
return Vec::new(); // a scalar, not a list
};
return split_flow_items(inner);
}
Vec::new()
}
/// Parse a YAML field line like `key: value` or `key: "value"`.
/// Returns (key, value) if the line looks like a YAML field.
/// Strips inline comments (everything after # that's not in quotes).
@@ -708,6 +825,67 @@ mod tests {
assert_eq!(field(empty, "", "characters"), None);
}
#[test]
fn a_flow_list_item_keeps_its_own_comma() {
// `flow_list_items`' old naive `split(',')` cut "a, b" into two items;
// prose can't promise to never contain a comma the way a name can.
let md = r#"---
summary: ["First, then second.", "Third."]
---
Prose.
"#;
assert_eq!(parse(md, "").summary, ["First, then second.", "Third."]);
}
#[test]
fn a_summary_list_spread_over_several_lines_is_read_whole() {
// The shape a hand-written multi-line flow sequence actually takes:
// the opening `[` ends its line, one bullet per line after that, the
// closing `]` alone on the last. Each line used to vanish (no colon,
// so `parse_yaml_field` ignored it, and it doesn't start with `-` so
// `block_list_item` ignored it too) and `summary` read as a literal "[".
let md = "---\n\
title: \"A Title\"\n\
summary: [\n\
\t\"Jonah is waiting for the bus.\",\n\
\t\"We see the loneliness of his existence.\",\n\
\t\"Jonah goes to the lab and does a favour for a co-worker\",\n\
]\n\
pov: \"Jonah\"\n\
---\n\n\
Prose.\n";
let h = parse(md, "");
assert_eq!(
h.summary,
[
"Jonah is waiting for the bus.",
"We see the loneliness of his existence.",
"Jonah goes to the lab and does a favour for a co-worker",
]
);
// A key after the multi-line list is still read correctly.
assert_eq!(h.pov.as_deref(), Some("Jonah"));
assert_eq!(h.title.as_deref(), Some("A Title"));
}
#[test]
fn a_block_style_summary_list_is_read_too() {
let md = "---\nsummary:\n - First point.\n - \"Second, with a comma.\"\nsetting: kitchen\n---\n\nProse.\n";
let h = parse(md, "");
assert_eq!(h.summary, ["First point.", "Second, with a comma."]);
}
#[test]
fn summary_is_empty_when_absent_or_a_bare_empty_list() {
assert!(parse("---\ntitle: x\n---\n\nProse.\n", "").summary.is_empty());
assert!(parse("---\nsummary: []\n---\n\nProse.\n", "").summary.is_empty());
// Legacy marker-based header: no list syntax exists there.
assert!(parse("# Title: x\n### Rough Draft:\n\nProse.", "### Rough Draft:")
.summary
.is_empty());
}
#[test]
fn frontmatter_keys_may_carry_underscores() {
// `field_line`'s bare form allows only letters, so snake_case keys were
@@ -11,6 +11,7 @@ status: draft
pov: ""
characters: []
setting: ""
summary: []
date: ""
time: ""
conflict: ""