4 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
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
7 changed files with 294 additions and 17 deletions
+27
View File
@@ -5,6 +5,32 @@ use super::*;
impl App { impl App {
pub(super) fn editor(&mut self, ui: &mut egui::Ui) { 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 // 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 // infinite and the scroll area is vertical-only, so text wraps to the pane
// width at any zoom (no horizontal scrolling). // width at any zoom (no horizontal scrolling).
@@ -113,6 +139,7 @@ impl App {
.desired_width(f32::INFINITY) .desired_width(f32::INFINITY)
.desired_rows(rows) .desired_rows(rows)
.layouter(&mut layouter) .layouter(&mut layouter)
.interactive(!locked)
.show(ui); .show(ui);
if output.response.changed() { if output.response.changed() {
self.dirty = true; self.dirty = true;
+42 -10
View File
@@ -415,13 +415,32 @@ impl App {
+ if show_tag { tag_w } else { 0.0 }; + if show_tag { tag_w } else { 0.0 };
let name_w = let name_w =
(row_width - indent - trailing).max(0.0); (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 let button_response = ui
.add_sized( .allocate_ui_with_layout(
[name_w, 20.0], egui::vec2(name_w, 20.0),
egui::Button::new(text) egui::Layout::left_to_right(egui::Align::Center)
.frame(false) .with_main_align(egui::Align::Min),
.truncate(), |ui| {
ui.add(
egui::Button::new(text)
.frame(false)
.truncate(),
)
},
) )
.inner
.on_hover_text(&row.path); .on_hover_text(&row.path);
if button_response.clicked() { if button_response.clicked() {
@@ -563,16 +582,29 @@ impl App {
// file name simply spills out of the // file name simply spills out of the
// panel. The wrap mode is what it // panel. The wrap mode is what it
// actually reads. // 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 let mut label = ui
.scope(|ui| { .scope(|ui| {
ui.style_mut().wrap_mode = ui.style_mut().wrap_mode =
Some(egui::TextWrapMode::Truncate); Some(egui::TextWrapMode::Truncate);
ui.add_sized( ui.allocate_ui_with_layout(
[label_w, 20.0], egui::vec2(label_w, 20.0),
egui::SelectableLabel::new( egui::Layout::left_to_right(egui::Align::Center)
selected, text, .with_main_align(egui::Align::Min),
), |ui| {
ui.add(egui::SelectableLabel::new(
selected, text,
))
},
) )
.inner
}) })
.inner; .inner;
if let Some(tooltip) = &tooltip { 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. /// The editorial header lifted out of `buffer` while it is collapsed.
header_stash: Option<String>, header_stash: Option<String>,
dirty: bool, 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. /// 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) /// 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 /// versus the writer scrolling the view manually, and to find the current
@@ -509,6 +514,7 @@ impl App {
buffer: String::new(), buffer: String::new(),
header_stash: None, header_stash: None,
dirty: false, dirty: false,
template_unlocked: false,
cursor_pos: 0, cursor_pos: 0,
new_name: String::new(), new_name: String::new(),
rename_input: String::new(), rename_input: String::new(),
+32
View File
@@ -541,6 +541,11 @@ impl App {
self.spell_menu = None; self.spell_menu = None;
self.clear_lt(); self.clear_lt();
self.autocomplete = None; 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. /// How many header fields are hidden right now, for the toggle's label.
@@ -610,6 +615,8 @@ impl App {
self.selected = Some(idx); self.selected = Some(idx);
self.dirty = false; self.dirty = false;
self.pending_delete = 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 // Update Story Bible references for the newly selected file
self.update_current_references(); self.update_current_references();
// The rename box holds the whole workspace-relative path, so it doubles // 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 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -2092,6 +2109,21 @@ mod tests {
assert_eq!(render_template("a\n{{marker}}\nb\n", "n", "", "d"), "a\n\nb\n"); 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 /// 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. /// understands what it produces — otherwise the seeded fields are just text.
#[test] #[test]
+1
View File
@@ -273,6 +273,7 @@ pub fn default_new_file_template() -> String {
"status: draft", "status: draft",
"pov: ", "pov: ",
"characters: []", "characters: []",
"summary: []",
"word_count_target: ", "word_count_target: ",
"---", "---",
"", "",
+185 -7
View File
@@ -33,6 +33,12 @@ pub struct Header {
pub pov: Option<String>, pub pov: Option<String>,
/// Word-count target from a `Word Count Target:` line, if present and parsable. /// Word-count target from a `Word Count Target:` line, if present and parsable.
pub goal: Option<WordGoal>, 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. /// The prose body, with comments, the header block, and metadata lines removed.
pub body: String, 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 // 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. // a stray `- ` cannot append itself to a scalar above it.
let mut collecting = false; let mut collecting = false;
for line in frontmatter.lines() { let mut lines = frontmatter.lines();
while let Some(line) = lines.next() {
if line.trim() == "---" { if line.trim() == "---" {
continue; continue;
} }
@@ -223,6 +230,7 @@ fn yaml_fields(frontmatter: &str) -> Vec<Field> {
continue; continue;
} }
if let Some((name, value)) = parse_yaml_field(line) { if let Some((name, value)) = parse_yaml_field(line) {
let value = join_flow_continuation(value, &mut lines);
let value = flow_list_items(&value); let value = flow_list_items(&value);
collecting = value.is_empty(); collecting = value.is_empty();
out.push(Field { name, value }); out.push(Field { name, value });
@@ -231,6 +239,79 @@ fn yaml_fields(frontmatter: &str) -> Vec<Field> {
out 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, /// Look one frontmatter key up, trying each alias in turn. Empty reads as absent,
/// the same way [`field`] treats an unfilled `## POV:`. /// the same way [`field`] treats an unfilled `## POV:`.
fn yaml_value(fields: &[Field], names: &[&str]) -> Option<String> { 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 { let Some(inner) = value.strip_prefix('[').and_then(|v| v.strip_suffix(']')) else {
return value.to_string(); return value.to_string();
}; };
inner split_flow_items(inner).join(", ")
.split(',')
.map(|item| unquote(item.trim()))
.filter(|item| !item.is_empty())
.collect::<Vec<_>>()
.join(", ")
} }
/// Strip one pair of matching surrounding quotes. /// Strip one pair of matching surrounding quotes.
@@ -399,6 +475,7 @@ pub fn parse(markdown: &str, marker: &str) -> Header {
slug: yaml_value(&fields, &["slug"]), slug: yaml_value(&fields, &["slug"]),
pov: yaml_value(&fields, &["pov"]), pov: yaml_value(&fields, &["pov"]),
goal, goal,
summary: summary_items(frontmatter),
body: body.trim().to_string(), body: body.trim().to_string(),
}; };
} }
@@ -457,10 +534,50 @@ pub fn parse(markdown: &str, marker: &str) -> Header {
slug: slug.filter(|s| !s.is_empty()), slug: slug.filter(|s| !s.is_empty()),
pov: pov.filter(|s| !s.is_empty()), pov: pov.filter(|s| !s.is_empty()),
goal, goal,
summary: Vec::new(),
body: body_lines.join("\n").trim().to_string(), 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"`. /// Parse a YAML field line like `key: value` or `key: "value"`.
/// Returns (key, value) if the line looks like a YAML field. /// Returns (key, value) if the line looks like a YAML field.
/// Strips inline comments (everything after # that's not in quotes). /// Strips inline comments (everything after # that's not in quotes).
@@ -708,6 +825,67 @@ mod tests {
assert_eq!(field(empty, "", "characters"), None); 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] #[test]
fn frontmatter_keys_may_carry_underscores() { fn frontmatter_keys_may_carry_underscores() {
// `field_line`'s bare form allows only letters, so snake_case keys were // `field_line`'s bare form allows only letters, so snake_case keys were
@@ -11,6 +11,7 @@ status: draft
pov: "" pov: ""
characters: [] characters: []
setting: "" setting: ""
summary: []
date: "" date: ""
time: "" time: ""
conflict: "" conflict: ""