Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d29aa664da | |||
| 3d6e134a33 |
@@ -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;
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -615,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
|
||||||
@@ -1914,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::*;
|
||||||
@@ -2097,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]
|
||||||
|
|||||||
@@ -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
@@ -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: ""
|
||||||
|
|||||||
Reference in New Issue
Block a user