Finish bundled-template migration; simplify frontmatter; de-duplicate toolbar/menu

Completes the move off the external cookiecutter tool: drops
src/cookiecutter.rs and the cookiecutter_bin/Template-path setting now
that scaffolding always runs through the built-in MiniJinja engine,
and rewrites default_project_template() to walk up from the executable
without panicking when the bundled template can't be found. Rounds
out the snowflake template with the remaining 06-Revisions,
07-Final, 08-Covers and 09-Archive folders.

Simplifies the scene/chapter YAML frontmatter (drops type/word_count_min/
word_count_max in favor of status + a flat characters list) and updates
the new-file template, header parsing, outline, file list and Story
Bible editor to match.

Separately, de-duplicates the toolbar and menu bar: settings that only
existed as always-visible toolbar controls (draft marker, zero-pad,
spell-check toggle, dictionary picker) move into their Settings
windows, toolbar buttons that exactly duplicated a menu item are
dropped, and toolbar-only actions with no menu path (open project
folder, git sync/init) gain one under File. Also fixes two ambiguous
menu labels: the Story Bible editor now reads "Story Bible editor…"
next to "Story Bible…" in Tools instead of sharing a label with Edit,
and Settings' "New project…" is now "New-project settings…" so it
doesn't read like File's actual create action.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 10:35:52 -05:00
parent c61a7b605a
commit 0f144737a5
23 changed files with 1199 additions and 1091 deletions
+289 -151
View File
@@ -166,11 +166,11 @@ impl App {
let show_files = self.config.show_file_panel;
egui::SidePanel::left("files")
.resizable(true)
// `default_width` widens `width_range` to include itself, so it has
// to come before the bounds rather than after them.
.default_width(260.0)
// A hard ceiling: the panel is sized from its content, so any row
// that asks for more width than there is would otherwise push it
// wider every frame.
.width_range(160.0..=460.0)
.min_width(160.0)
.max_width(460.0)
.show_animated(ctx, show_files, |ui| {
// The default theme renders unselected list rows fairly dim; bump
// the widget text colours so file names stay legible (especially in
@@ -193,13 +193,25 @@ impl App {
}
let statuses = self.known_statuses();
if !statuses.is_empty() {
// Deliberately not `horizontal_wrapped`: in a wrapping row
// `available_width` reports the whole line, because the next
// widget may start a new one — and sizing the box from that
// is how it ended up wider than the space left beside the
// label.
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Status:").small().weak());
let current = self
.status_filter
.clone()
.unwrap_or_else(|| "all".to_string());
// Bounded and truncating: left to itself a `ComboBox` is
// as wide as its longest status name, and anything wider
// than the panel widens the panel (see `left_pane`'s note
// on why nothing here may overflow).
let combo_w = (ui.available_width() - 4.0).clamp(48.0, 140.0);
egui::ComboBox::from_id_salt("status_filter")
.width(combo_w)
.truncate()
.selected_text(egui::RichText::new(current).small())
.show_ui(ui, |ui| {
if ui
@@ -222,6 +234,96 @@ impl App {
}
ui.separator();
// The actions live in their own panel, claimed *before* the
// list: that hands the list whatever height is left over
// instead of guessing at it, and keeps the buttons on screen in
// a short window.
egui::TopBottomPanel::bottom("file_actions")
.frame(egui::Frame::none().inner_margin(egui::Margin::symmetric(0.0, 4.0)))
.show_inside(ui, |ui| {
// Full-width edits and wrapping button rows, so this
// block fits whatever width the panel is dragged to.
// The old fixed 150pt edit beside two buttons needed
// ~330pt, which — a panel being sized from its content —
// *was* the panel's real minimum width.
ui.add(
egui::TextEdit::singleline(&mut self.new_name)
.hint_text("new file name")
.desired_width(f32::INFINITY),
)
.on_hover_text(
"A name, or a path to nest it: part-1/ch-01 creates the \
folder along with the file.",
);
ui.horizontal_wrapped(|ui| {
if ui.button("+ New").clicked() {
self.create_file();
}
if ui
.button("+ New Scene")
.on_hover_text(
"Create a new scene file with YAML frontmatter in 05-First Draft"
)
.clicked()
{
self.create_scene_pair();
}
});
if ui
.add(egui::Button::new("+ New from template").truncate())
.on_hover_text(
"Create untitled-N.md seeded from the template \
(Settings ▸ New-file template…), in the current file's folder",
)
.clicked()
{
self.create_file_from_template();
}
if self.selected.is_some() {
ui.add(
egui::TextEdit::singleline(&mut self.rename_input)
.hint_text("rename")
.desired_width(f32::INFINITY),
)
.on_hover_text(
"The file's path within the workspace — edit the folder \
part to move it.",
);
ui.horizontal_wrapped(|ui| {
if ui.button("Rename").clicked() {
self.rename_selected();
}
if ui
.button("🗄 Archive")
.on_hover_text(
"Move this file into the archive folder and out of \
the manuscript, keeping it on disk",
)
.clicked()
{
self.archive_selected();
}
if !self.pending_delete {
if ui.button("🗑 Delete").clicked() {
self.pending_delete = true;
}
} else {
ui.label("Delete file?");
if ui
.button(egui::RichText::new("Yes").color(egui::Color32::RED))
.clicked()
{
self.delete_selected();
}
if ui.button("No").clicked() {
self.pending_delete = false;
}
}
});
}
});
let mut clicked: Option<usize> = None;
let mut toggled: Option<String> = None;
let mut dropped: Option<FileDrop> = None;
@@ -248,18 +350,27 @@ impl App {
.cloned()
.collect();
let rows = build_rows(&visible, &self.collapsed);
// Measured once, from the panel rather than from the scrolled
// content: reading `available_width()` inside a row makes the
// content's width depend on the content's width.
let row_width = ui.available_width();
let nested = rows.iter().any(|r| r.depth > 0);
// The width every row is laid out against, and which no row may
// exceed. A `ScrollArea` that cannot scroll sideways grows to
// fit its content, and a `SidePanel` is sized from its content
// in turn, so a single over-wide row widens the panel — and
// keeps it widened, because next frame the panel reloads that
// width as its own. That is what made the pane spring back from
// a resize. The deduction keeps the floating scroll bar from
// sitting on a row's last column.
let row_width = (ui.available_width() - SCROLLBAR_W).max(MIN_ROW_W);
let gap = ui.spacing().item_spacing.x;
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.max_height(ui.available_height() - 120.0)
.show(ui, |ui| {
for row in &rows {
let indent = row.depth as f32 * INDENT;
// Capped: past a point the indent would leave a
// deeply nested row nothing to draw its name into,
// and it would spill over the edge.
let indent = (row.depth as f32 * INDENT)
.min(row_width * MAX_INDENT_FRAC);
match row.kind {
RowKind::Folder { count, first } => {
let open = !self.collapsed.contains(&row.path);
@@ -283,14 +394,26 @@ impl App {
} else {
text.weak()
};
// Sized from the panel and truncated:
// an unconstrained label is as wide as
// its text, and the panel is sized from
// its content, so a long folder name
// would widen the panel and keep it
// widened. The full path is on hover.
// Sized from the row's budget and
// truncated: an unconstrained label is
// as wide as its text, which would push
// the row — and with it the panel — past
// the width the user chose. The full
// path is on hover. The tag goes first
// when there is no room for both.
let tag_w = gap + MANUSCRIPT_W;
let show_tag = is_manuscript_root
&& row_width
- indent
- gap
- COUNT_W
- tag_w
>= MIN_LABEL_W;
let trailing = gap
+ COUNT_W
+ if show_tag { tag_w } else { 0.0 };
let name_w =
(row_width - indent - COUNT_W).max(48.0);
(row_width - indent - trailing).max(0.0);
let button_response = ui
.add_sized(
[name_w, 20.0],
@@ -313,17 +436,28 @@ impl App {
}
});
}
ui.label(
egui::RichText::new(count.to_string())
.small()
.weak(),
);
if is_manuscript_root {
ui.label(
egui::RichText::new("· manuscript")
// Fixed widths, so what the row
// actually consumes is what `name_w`
// was budgeted against.
ui.add_sized(
[COUNT_W, 20.0],
egui::Label::new(
egui::RichText::new(count.to_string())
.small()
.weak(),
)
.truncate(),
);
if show_tag {
ui.add_sized(
[MANUSCRIPT_W, 20.0],
egui::Label::new(
egui::RichText::new("· manuscript")
.small()
.weak(),
)
.truncate(),
)
.on_hover_text(
"These files are the book: ordered, \
numbered and exported. Everything \
@@ -384,33 +518,62 @@ impl App {
egui::Id::new(("dnd", &name)),
idx,
|ui| {
ui.label(
egui::RichText::new("⠿")
.monospace()
.weak(),
// Sized, not free-standing: the
// budget below counts on the
// handle being exactly this wide.
ui.add_sized(
[HANDLE_W, 20.0],
egui::Label::new(
egui::RichText::new("⠿")
.monospace()
.weak(),
),
);
},
);
// Reserve room on the right for a per-file
// word-count-target bar when the file sets one.
let bar_w = 44.0;
let reserve = if meta.goal.is_some() {
bar_w + 6.0
} else {
0.0
};
let label_w = (row_width
- indent
- HANDLE_W
- reserve)
.max(24.0);
// Everything the row draws has to come
// out of `row_width`: the handle, the
// gap after each item, and whatever
// trailing badges the file has earned.
// A badge is dropped rather than let the
// name shrink below `MIN_LABEL_W` — a row
// that overflows would widen the panel.
let mut trailing = 0.0;
let head = row_width - indent - HANDLE_W - gap;
let show_bar = meta.goal.is_some()
&& head - (gap + BAR_W) >= MIN_LABEL_W;
if show_bar {
trailing += gap + BAR_W;
}
let show_status = meta.status.is_some()
&& head - trailing - (gap + STATUS_W)
>= MIN_LABEL_W;
if show_status {
trailing += gap + STATUS_W;
}
let label_w = (head - trailing).max(0.0);
let display_name = truncate_display_name(base_name(&name));
let text = egui::RichText::new(display_name);
let text = if in_book { text } else { text.weak() };
let mut label = ui.add_sized(
[label_w, 20.0],
egui::SelectableLabel::new(selected, text),
);
// `SelectableLabel` has no truncate
// of its own and lays its text out
// with `Extend`, so `add_sized` does
// not hold it to `label_w` — a long
// file name simply spills out of the
// panel. The wrap mode is what it
// actually reads.
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,
),
)
})
.inner;
if let Some(tooltip) = &tooltip {
label = label.on_hover_text(tooltip);
}
@@ -426,20 +589,32 @@ impl App {
);
});
if let Some(goal) = meta.goal {
row_goal_bar(
ui,
bar_w,
goal,
meta.prose_words,
);
if show_bar {
row_goal_bar(
ui,
BAR_W,
goal,
meta.prose_words,
);
}
}
if let Some(status) = &meta.status {
ui.label(
egui::RichText::new(status_tag(status))
.small()
.weak(),
)
.on_hover_text(format!("Status: {status}"));
if show_status {
ui.add_sized(
[STATUS_W, 20.0],
egui::Label::new(
egui::RichText::new(status_tag(
status,
))
.small()
.weak(),
)
.truncate(),
)
.on_hover_text(format!(
"Status: {status}"
));
}
}
})
.response;
@@ -483,14 +658,25 @@ impl App {
// to get a file back out to the top level.
if nested {
ui.add_space(2.0);
// Laid out in a row of the budgeted width, so this
// hint cannot be the thing that widens the panel.
let target = ui
.horizontal(|ui| {
ui.label(
egui::RichText::new("↥ drop here for the top level")
.small()
.weak(),
);
})
.allocate_ui_with_layout(
egui::vec2(row_width, 18.0),
egui::Layout::left_to_right(egui::Align::Center),
|ui| {
ui.add(
egui::Label::new(
egui::RichText::new(
"↥ drop here for the top level",
)
.small()
.weak(),
)
.truncate(),
);
},
)
.response;
let target = full_width_row(ui, &target, "\u{0}root-drop");
if drop_highlight(ui, &target) {
@@ -525,87 +711,6 @@ impl App {
self.open_outline_file = Some(outline_path);
}
ui.separator();
ui.horizontal(|ui| {
ui.add(
egui::TextEdit::singleline(&mut self.new_name)
.hint_text("new file name")
.desired_width(150.0),
)
.on_hover_text(
"A name, or a path to nest it: part-1/ch-01 creates the \
folder along with the file.",
);
if ui.button("+ New").clicked() {
self.create_file();
}
if ui
.button("+ New Scene")
.on_hover_text(
"Create a new scene file with YAML frontmatter in 05-First Draft"
)
.clicked()
{
self.create_scene_pair();
}
});
if ui
.button("+ New from template")
.on_hover_text(
"Create untitled-N.md seeded from the template \
(Settings ▸ New-file template…), in the current file's folder",
)
.clicked()
{
self.create_file_from_template();
}
if self.selected.is_some() {
ui.horizontal(|ui| {
ui.add(
egui::TextEdit::singleline(&mut self.rename_input)
.hint_text("rename")
.desired_width(150.0),
)
.on_hover_text(
"The file's path within the workspace — edit the folder \
part to move it.",
);
if ui.button("Rename").clicked() {
self.rename_selected();
}
});
ui.horizontal(|ui| {
if ui
.button("🗄 Archive")
.on_hover_text(
"Move this file into the archive folder and out of \
the manuscript, keeping it on disk",
)
.clicked()
{
self.archive_selected();
}
});
ui.horizontal(|ui| {
if !self.pending_delete {
if ui.button("🗑 Delete").clicked() {
self.pending_delete = true;
}
} else {
ui.label("Delete file?");
if ui
.button(egui::RichText::new("Yes").color(egui::Color32::RED))
.clicked()
{
self.delete_selected();
}
if ui.button("No").clicked() {
self.pending_delete = false;
}
}
});
}
});
}
}
@@ -694,12 +799,45 @@ fn drop_highlight(ui: &egui::Ui, response: &egui::Response) -> bool {
/// Width the drag handle occupies in a file row, so a row's label can be sized
/// from the panel width rather than from whatever is left of the content.
const HANDLE_W: f32 = 22.0;
/// The widths a row's parts are pinned to. Every one of them is *allocated*,
/// not merely assumed: a row is laid out against a budget (`row_width`), and a
/// row that spends more than its budget makes the file list wider than the
/// panel — which, because a `SidePanel` takes its width from its content, makes
/// the panel itself wider and undoes whatever width the user dragged it to.
/// Anything drawn in a row therefore gets a size, and the budget subtracts
/// exactly that size plus the item spacing that follows it.
///
/// Width the drag handle occupies in a file row.
const HANDLE_W: f32 = 16.0;
/// Room left after a folder's name for its file count and the manuscript tag.
const COUNT_W: f32 = 86.0;
/// Room after a folder's name for the count of files under it.
const COUNT_W: f32 = 22.0;
/// Room after that count for the "· manuscript" tag on the book's own folder.
const MANUSCRIPT_W: f32 = 58.0;
/// Room at the end of a file row for its status badge.
const STATUS_W: f32 = 26.0;
/// Width of a file row's word-count-target bar.
const BAR_W: f32 = 44.0;
/// Kept clear on the right of the list: egui's scroll bar floats over the
/// content rather than reserving space for itself, and would otherwise sit on
/// top of a row's last column.
const SCROLLBAR_W: f32 = 10.0;
/// How much of a row a name wants before the trailing badges are worth their
/// space. Below it a badge is dropped instead — it decides what a row shows,
/// never how wide a row is.
const MIN_LABEL_W: f32 = 56.0;
/// A row never gives up more than this share of its width to indentation, so a
/// deeply nested file still has a name to click at a narrow panel width.
const MAX_INDENT_FRAC: f32 = 0.4;
/// The narrowest budget a row is ever laid out against, whatever the panel does.
const MIN_ROW_W: f32 = 80.0;
/// A response covering the whole visible width of `ui` at the row's height.
///
+17 -3
View File
@@ -389,6 +389,9 @@ pub struct App {
story_bible_editor_content: String,
/// The workspace-relative path of the file being edited in the Story Bible editor.
story_bible_editor_path: Option<String>,
/// Whether the caret was in the Story Bible editor's text area last frame,
/// which is what decides where `Ctrl+S` sends its save.
story_bible_editor_focused: bool,
/// List of auto-snapshots for rollback.
snapshots: Vec<crate::app::auto_snapshot::SnapshotInfo>,
/// Word count at the last auto-snapshot.
@@ -502,6 +505,7 @@ impl App {
show_story_bible_editor: false,
story_bible_editor_content: String::new(),
story_bible_editor_path: None,
story_bible_editor_focused: false,
snapshots: Vec::new(),
last_snapshot_word_count: 0,
show_snapshots: false,
@@ -514,10 +518,20 @@ impl App {
impl eframe::App for App {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// Ctrl+S saves.
// Ctrl+S saves whatever is being typed in. Usually that is the open
// manuscript file, but the Story Bible editor is a second text area with
// its own file, and sending its keystroke to the manuscript both loses
// the edit and writes a file the user was not looking at.
if ctx.input(|i| i.modifiers.command && i.key_pressed(egui::Key::S)) {
self.dirty = true;
self.save_current();
match self.story_bible_editor_path.clone() {
Some(path) if self.show_story_bible_editor && self.story_bible_editor_focused => {
self.save_story_bible_item(path);
}
_ => {
self.dirty = true;
self.save_current();
}
}
}
// Ctrl+D folds the panels away for distraction-free writing, and back.
+209 -49
View File
@@ -10,73 +10,76 @@ pub(super) struct OutlineFile {
}
/// A tree node for rendering nested beats in the outline.
#[derive(Debug)]
#[derive(Debug, PartialEq, Eq)]
enum BeatTree {
/// A heading node with children (other headings or beats)
Heading { name: String, depth: usize, children: Vec<BeatTree> },
/// A heading, holding the beats written under it and any headings nested
/// inside it.
Heading { name: String, children: Vec<BeatTree> },
/// A beat leaf node
Beat { name: String, filled: bool },
}
/// Build a tree structure from a flat list of beats based on depth and path.
/// Build the tree the Outline window draws out of the flat list of beats.
///
/// Every beat carries the chain of headings it sits under ([`Beat::path`]), so
/// the tree is that chain turned inside out: a heading is opened the first time
/// a beat names it and stays open while the beats that follow keep naming it.
/// Going by `section` — the innermost heading on its own — is what used to give
/// every single beat a heading of its own, so `Act 1` with three beats under it
/// drew as three `Act 1 (0/1)` headers rather than one `Act 1 (0/3)`.
fn build_beat_tree(beats: &[crate::outline::Beat]) -> Vec<BeatTree> {
let mut tree: Vec<BeatTree> = Vec::new();
// For now, just create a simple flat list with section headings
// The full nested implementation requires more complex tree building
let mut current_section: Option<String> = None;
let mut current_children: Vec<BeatTree> = Vec::new();
for beat in beats {
if !beat.section.is_empty() {
// New section - push current section if it exists
if let Some(old_section) = current_section.take() {
tree.push(BeatTree::Heading {
name: old_section,
depth: 1,
children: std::mem::take(&mut current_children),
});
}
current_section = Some(beat.section.clone());
current_children.push(BeatTree::Beat {
name: beat.name.clone(),
filled: beat.filled,
});
} else {
current_children.push(BeatTree::Beat {
name: beat.name.clone(),
filled: beat.filled,
});
}
insert_beat(&mut tree, &beat.path, beat);
}
// Push the last section if it exists
if let Some(section) = current_section.take() {
tree.push(BeatTree::Heading {
name: section,
depth: 1,
children: current_children,
});
} else {
tree.append(&mut current_children);
}
tree
}
/// Put one beat at the end of `level`, opening whichever headings in `path` are
/// not open there already.
fn insert_beat(level: &mut Vec<BeatTree>, path: &[String], beat: &crate::outline::Beat) {
let Some((heading, rest)) = path.split_first() else {
level.push(BeatTree::Beat {
name: beat.name.clone(),
filled: beat.filled,
});
return;
};
// Only the last node is a candidate: a heading that comes back after a
// different one has intervened is a second section of the outline, and the
// window should show it where the file puts it rather than merging the two.
let reusable = matches!(
level.last(),
Some(BeatTree::Heading { name, .. }) if name == heading
);
if !reusable {
level.push(BeatTree::Heading {
name: heading.clone(),
children: Vec::new(),
});
}
if let Some(BeatTree::Heading { children, .. }) = level.last_mut() {
insert_beat(children, rest, beat);
}
}
/// Render a beat tree recursively.
fn render_beat_tree(ui: &mut egui::Ui, tree: &[BeatTree]) {
for node in tree {
///
/// `salt` is the path taken to get here. Two sections can carry the same name at
/// different points in a file — and at different depths — so a heading's id has
/// to come from its position in the tree, not from its text.
fn render_beat_tree(ui: &mut egui::Ui, tree: &[BeatTree], salt: &str) {
for (i, node) in tree.iter().enumerate() {
match node {
BeatTree::Heading { name, children, .. } => {
BeatTree::Heading { name, children } => {
let (done, total) = count_beat_tree(children);
let header_text = format!("{name} ({done}/{total})");
let salt = format!("{salt}/{i}:{name}");
egui::CollapsingHeader::new(header_text)
.id_salt(name)
.id_salt(&salt)
.default_open(true)
.show(ui, |ui| {
render_beat_tree(ui, children);
render_beat_tree(ui, children, &salt);
});
}
BeatTree::Beat { name, filled } => {
@@ -220,7 +223,7 @@ impl App {
if ui.link("open this file").clicked() {
open_file = Some(file.rel.clone());
}
render_beat_tree(ui, &tree);
render_beat_tree(ui, &tree, &file.rel);
});
}
});
@@ -240,3 +243,160 @@ impl App {
self.show_outline = open && !close;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::outline::Beat;
fn beat(path: &[&str], name: &str, filled: bool) -> Beat {
Beat {
section: path.last().map(|s| s.to_string()).unwrap_or_default(),
name: name.to_string(),
filled,
depth: path.len(),
path: path.iter().map(|s| s.to_string()).collect(),
}
}
/// Render the tree as `Heading(done/total)` / `beat` lines, indented, which
/// is compact enough to assert a whole outline's shape at once.
fn sketch(tree: &[BeatTree], depth: usize) -> Vec<String> {
let pad = " ".repeat(depth);
let mut out = Vec::new();
for node in tree {
match node {
BeatTree::Heading { name, children } => {
let (done, total) = count_beat_tree(children);
out.push(format!("{pad}{name}({done}/{total})"));
out.extend(sketch(children, depth + 1));
}
BeatTree::Beat { name, filled } => {
out.push(format!("{pad}{}{name}", if *filled { "x " } else { " " }));
}
}
}
out
}
#[test]
fn beats_under_one_heading_share_it() {
// The bug this replaces opened a fresh heading per beat, so three beats
// under `Act 1` drew as three `Act 1 (0/1)` headers.
let beats = [
beat(&["Act 1"], "Opening", true),
beat(&["Act 1"], "Inciting Incident", false),
beat(&["Act 1"], "Plot Point 1", false),
beat(&["Act 2"], "Midpoint", true),
beat(&["Act 2"], "Plot Point 2", false),
];
assert_eq!(
sketch(&build_beat_tree(&beats), 0),
vec![
"Act 1(1/3)",
" x Opening",
" Inciting Incident",
" Plot Point 1",
"Act 2(1/2)",
" x Midpoint",
" Plot Point 2",
]
);
}
#[test]
fn headings_nest_and_count_everything_below_them() {
// `#`/`##`/`###` give a beat a path, and a parent's tally is the sum of
// its children's rather than only the beats directly under it.
let beats = [
beat(&["Act 1"], "Opening", true),
beat(&["Act 1", "Chapter 1"], "She arrives", true),
beat(&["Act 1", "Chapter 1"], "She knocks", false),
beat(&["Act 1", "Chapter 2"], "Nothing", false),
];
assert_eq!(
sketch(&build_beat_tree(&beats), 0),
vec![
"Act 1(2/4)",
" x Opening",
" Chapter 1(1/2)",
" x She arrives",
" She knocks",
" Chapter 2(0/1)",
" Nothing",
]
);
}
#[test]
fn a_beat_with_no_heading_sits_at_the_top_level() {
let beats = [
beat(&[], "Loose beat", false),
beat(&["Act 1"], "Opening", true),
];
assert_eq!(
sketch(&build_beat_tree(&beats), 0),
vec![" Loose beat", "Act 1(1/1)", " x Opening"]
);
}
#[test]
fn a_heading_that_comes_back_later_opens_again() {
// Two `Notes` sections either side of `Act 1` are two places in the
// file; merging them would put beats somewhere the outline does not.
let beats = [
beat(&["Notes"], "First", false),
beat(&["Act 1"], "Opening", true),
beat(&["Notes"], "Second", false),
];
assert_eq!(
sketch(&build_beat_tree(&beats), 0),
vec![
"Notes(0/1)",
" First",
"Act 1(1/1)",
" x Opening",
"Notes(0/1)",
" Second",
]
);
}
#[test]
fn an_outline_with_no_beats_draws_nothing() {
assert!(build_beat_tree(&[]).is_empty());
}
#[test]
fn a_real_outline_file_groups_under_its_headings() {
// End to end over the two modules, in the shape the snowflake template
// actually writes: one `##` section per act file, prompts beneath it.
let markdown = "\
## Act I – Setup
<!-- Opening Image: A snapshot of the ordinary world. -->
She wakes before the alarm and lies there listening.
<!-- Inciting Incident: Something disrupts it. -->
<!-- Lock-In: The hero commits. -->
### Sequence 2
<!-- Debate: The hero hesitates. -->
";
let beats = crate::outline::beats(markdown);
assert_eq!(
sketch(&build_beat_tree(&beats), 0),
vec![
"Act I – Setup(1/4)",
" x Opening Image",
" Inciting Incident",
" Lock-In",
" Sequence 2(0/1)",
" Debate",
]
);
}
}
+23 -10
View File
@@ -433,6 +433,26 @@ impl App {
.weak(),
);
ui.separator();
ui.horizontal(|ui| {
ui.label("Draft marker:").on_hover_text(
"Text above this line in each file (its header) is dropped on \
export; # Title: / # Slug: lines become the chapter heading \
and caption. Leave blank to export files whole.",
);
let r = ui.add(
egui::TextEdit::singleline(&mut self.config.draft_marker)
.desired_width(140.0),
);
save_now |= r.lost_focus();
});
let r = ui
.checkbox(&mut self.config.zero_pad_index, "Zero-pad chapter #")
.on_hover_text(
"When a chapter title defaults to its number, pad it with \
leading zeros (e.g. 03. of 12).",
);
save_now |= r.changed();
ui.separator();
if ui.button("Close").clicked() {
close = true;
}
@@ -514,15 +534,6 @@ impl App {
);
save_now |= r.lost_focus();
ui.end_row();
ui.label("Template path:");
let r = ui.add(
egui::TextEdit::singleline(&mut self.config.cookiecutter_bin)
.hint_text("no longer used - built-in MiniJinja")
.desired_width(280.0),
);
save_now |= r.lost_focus();
ui.end_row();
});
ui.label(
egui::RichText::new("✔ Built-in MiniJinja templating - no external tool needed")
@@ -537,7 +548,9 @@ impl App {
"Publish to Gitea after creation",
)
.on_hover_text(
"When enabled and Gitea credentials are configured below, a new repository will be created and the project pushed to it.",
"When enabled and Gitea credentials are configured below, \
a new repository will be created and the project pushed \
to it.",
)
.changed()
{
+41 -1
View File
@@ -254,12 +254,52 @@ impl App {
let mut remove: Option<String> = None;
let mut harvest = false;
egui::Window::new("Word list")
egui::Window::new("Spelling")
.open(&mut open)
.resizable(true)
.collapsible(false)
.default_width(360.0)
.show(ctx, |ui| {
if ui
.checkbox(&mut self.config.spell_check, "Check as I type")
.on_hover_text(
"Underline misspellings live using the dictionary below. \
Offline (Hunspell); right-click a word for suggestions.",
)
.changed()
{
self.config.save();
if self.config.spell_check {
// Re-check the current buffer straight away.
self.spell_dirty = true;
self.spell_last_edit = None;
}
}
ui.horizontal(|ui| {
ui.label("Dictionary:");
let current = self.config.spell_language.clone();
let mut pick: Option<String> = None;
egui::ComboBox::from_id_salt("spell_dict")
.selected_text(current.clone())
.show_ui(ui, |ui| {
for d in &self.spell_dicts {
if ui
.selectable_label(current == d.id, &d.label)
.clicked()
{
pick = Some(d.id.clone());
}
}
});
if let Some(id) = pick {
self.set_spell_language(&id);
}
});
if !self.spell_status.is_empty() {
ui.label(egui::RichText::new(&self.spell_status).weak());
}
ui.separator();
ui.label(
egui::RichText::new(
"Names and invented terms the spell checker accepts. Saved in \
+11 -6
View File
@@ -221,11 +221,15 @@ impl App {
if !open {
self.story_bible_editor_content.clear();
self.story_bible_editor_path = None;
self.story_bible_editor_focused = false;
}
}
/// Contents of the Story Bible editor window.
fn story_bible_editor_contents(&mut self, ui: &mut egui::Ui, tree: &[BibleTreeNode]) {
// Set by the editor's text area below, read back out once the closures
// that borrow `self` have finished.
let mut focused = false;
ui.horizontal(|ui| {
// Left panel: Tree view (1/3 width)
let tree_width = ui.available_width() / 3.0;
@@ -275,12 +279,12 @@ impl App {
.desired_rows(20);
let response = ui.add(text_edit);
// Handle keyboard shortcuts
if response.has_focus() {
// Ctrl+S to save - we'll handle this differently
// since we can't mutate self directly from within the closure
}
// Recorded rather than acted on: the save needs `&mut
// self`, which this closure is already borrowing to
// draw with. `App::update` reads the flag next frame and
// routes Ctrl+S here instead of to the manuscript.
focused = response.has_focus();
});
// Save button
@@ -298,6 +302,7 @@ impl App {
});
});
});
self.story_bible_editor_focused = focused;
}
/// Load a Story Bible item from the workspace.
+45 -128
View File
@@ -6,8 +6,14 @@ use super::*;
impl App {
// ---- UI ----------------------------------------------------------------
/// The toolbar — the Workspace, Export, Grammar and Spelling rows — and the
/// status bar along the bottom, which is rendered here too.
/// The toolbar — the Workspace, Export and Grammar rows — and the status
/// bar along the bottom, which is rendered here too.
///
/// Anything with a menu equivalent (opening the workspace, syncing git,
/// toggling a panel, LanguageTool settings, …) lives only in the menu bar;
/// this panel keeps just the controls that need a live value at hand —
/// the workspace/export paths and the grammar-check trigger — so hiding it
/// (View ▸ Toolbar, or Ctrl+D) never costs you a feature, only convenience.
///
/// The toolbar collapses through `show_animated`, which reserves no height
/// when hidden and slides rather than snapping. Note that it guards only the
@@ -22,9 +28,6 @@ impl App {
egui::TextEdit::singleline(&mut self.workspace_input)
.desired_width(300.0),
);
if ui.button("📂").on_hover_text("Browse for workspace folder").clicked() {
self.browse_workspace();
}
if ui.button("Open").clicked()
|| (resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)))
{
@@ -33,23 +36,6 @@ impl App {
self.config.save();
self.open_workspace();
}
ui.separator();
if self.is_repo {
let hover = match &self.repo_root {
Some(root) if root.as_path() != self.workspace() => {
format!("Commit & push to the repository at {}", root.display())
}
_ => "Commit & push this workspace's repository".to_string(),
};
if ui.button("⟳ Sync (git)").on_hover_text(hover).clicked() {
self.git_sync();
}
} else if ui.button("Init git").clicked() {
self.git_init();
}
if ui.button("Log").clicked() {
self.show_log = !self.show_log;
}
});
ui.add_space(2.0);
ui.horizontal(|ui| {
@@ -78,48 +64,6 @@ impl App {
{
self.export_manuscript();
}
// Resolved first: the hover text borrows the path, and the click
// handler needs `self` mutably.
let project = self.project_root().display().to_string();
if ui
.button("📁")
.on_hover_text(format!("Open the project folder {project}"))
.clicked()
{
self.open_project_folder();
}
ui.separator();
if ui
.checkbox(&mut self.config.show_preview, "Preview")
.changed()
{
self.config.save();
}
ui.separator();
ui.label("Draft marker:")
.on_hover_text(
"Text above this line in each file (its header) is dropped on export; \
# Title: / # Slug: lines become the chapter heading and caption. \
Leave blank to export files whole.",
);
let marker_resp = ui.add(
egui::TextEdit::singleline(&mut self.config.draft_marker)
.desired_width(140.0),
);
if marker_resp.lost_focus() {
self.config.save();
}
ui.separator();
if ui
.checkbox(&mut self.config.zero_pad_index, "Zero-pad #")
.on_hover_text(
"When a chapter title defaults to its number, pad it with \
leading zeros (e.g. 03. of 12).",
)
.changed()
{
self.config.save();
}
});
ui.add_space(2.0);
ui.horizontal(|ui| {
@@ -143,58 +87,10 @@ impl App {
{
self.clear_lt();
}
if ui
.button("⚙")
.on_hover_text("LanguageTool settings")
.clicked()
{
self.show_settings = true;
}
if !self.lt_status.is_empty() {
ui.label(egui::RichText::new(&self.lt_status).weak());
}
});
ui.add_space(2.0);
ui.horizontal(|ui| {
ui.label("Spelling:").on_hover_text(
"Offline spell check (Hunspell dictionaries). Underlines misspellings \
as you type when LanguageTool results aren't current; right-click a \
word for suggestions.",
);
if ui
.checkbox(&mut self.config.spell_check, "Check as I type")
.on_hover_text("Underline misspellings live using the dictionary below")
.changed()
{
self.config.save();
if self.config.spell_check {
// Re-check the current buffer straight away.
self.spell_dirty = true;
self.spell_last_edit = None;
}
}
ui.label("Dictionary:");
let current = self.config.spell_language.clone();
let mut pick: Option<String> = None;
egui::ComboBox::from_id_salt("spell_dict")
.selected_text(current.clone())
.show_ui(ui, |ui| {
for d in &self.spell_dicts {
if ui
.selectable_label(current == d.id, &d.label)
.clicked()
{
pick = Some(d.id.clone());
}
}
});
if let Some(id) = pick {
self.set_spell_language(&id);
}
if !self.spell_status.is_empty() {
ui.label(egui::RichText::new(&self.spell_status).weak());
}
});
ui.add_space(4.0);
});
@@ -271,7 +167,8 @@ impl App {
if ui
.button("✨ New project…")
.on_hover_text(
"Scaffold a project from the cookiecutter template and open its drafting folder",
"Scaffold a project from the bundled template and open \
its drafting folder",
)
.clicked()
{
@@ -283,6 +180,26 @@ impl App {
ui.close_menu();
self.browse_workspace();
}
if ui.button("📁 Open project folder").clicked() {
ui.close_menu();
self.open_project_folder();
}
if self.is_repo {
let hover = match &self.repo_root {
Some(root) if root.as_path() != self.workspace() => {
format!("Commit & push to the repository at {}", root.display())
}
_ => "Commit & push this workspace's repository".to_string(),
};
if ui.button("⟳ Sync (git)").on_hover_text(hover).clicked() {
ui.close_menu();
self.git_sync();
}
} else if ui.button("Init git").clicked() {
ui.close_menu();
self.git_init();
}
ui.separator();
if ui.button(self.export_label()).clicked() {
ui.close_menu();
self.export_manuscript();
@@ -321,10 +238,6 @@ impl App {
ui.close_menu();
self.open_find(true);
}
if ui.button("📖 Story Bible…").clicked() {
ui.close_menu();
self.open_story_bible_editor();
}
if ui.button("📸 Snapshots…").clicked() {
ui.close_menu();
self.show_snapshots = true;
@@ -343,6 +256,10 @@ impl App {
ui.close_menu();
self.show_story_bible = true;
}
if ui.button("📖 Story Bible editor…").clicked() {
ui.close_menu();
self.open_story_bible_editor();
}
ui.separator();
let busy = self.beats_rx.is_some();
if ui
@@ -360,7 +277,7 @@ impl App {
if ui
.checkbox(&mut self.config.show_toolbar, "Toolbar")
.on_hover_text(
"The Workspace, Export, Grammar and Spelling rows \
"The Workspace, Export and Grammar rows \
under the menu bar",
)
.clicked()
@@ -432,7 +349,7 @@ impl App {
ui.close_menu();
self.show_template_settings = true;
}
if ui.button("New project…").clicked() {
if ui.button("New-project settings…").clicked() {
ui.close_menu();
self.show_project_settings = true;
}
@@ -444,7 +361,7 @@ impl App {
ui.close_menu();
self.show_manuscript_settings = true;
}
if ui.button("Word list…").clicked() {
if ui.button("Spelling…").clicked() {
ui.close_menu();
self.show_wordlist = true;
}
@@ -459,9 +376,9 @@ impl App {
// a control that hides the toolbar cannot live inside it.
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let (icon, hint) = if self.config.show_toolbar {
("⬆", "Hide the toolbar (Workspace, Export, Grammar, Spelling)")
("⬆", "Hide the toolbar (Workspace, Export, Grammar)")
} else {
("⬇", "Show the toolbar (Workspace, Export, Grammar, Spelling)")
("⬇", "Show the toolbar (Workspace, Export, Grammar)")
};
if ui.button(icon).on_hover_text(hint).clicked() {
self.config.show_toolbar = !self.config.show_toolbar;
@@ -666,11 +583,11 @@ impl App {
pub(super) fn header_toggle(&mut self, ui: &mut egui::Ui) {
let collapsed = self.header_stash.is_some();
// Nothing to say about a file with no marker to fold at.
if !collapsed
&& crate::preprocess::split_at_marker(&self.buffer, &self.config.draft_marker)
.is_none()
{
// Nothing to say about a file with no header to fold away. Both forms
// count: asking only about the draft marker meant a frontmatter file
// never offered the toggle, because it has no marker to find — the fold
// itself has handled frontmatter all along.
if !collapsed && !self.header_is_foldable() {
return;
}
ui.horizontal(|ui| {
@@ -678,7 +595,7 @@ impl App {
let n = self.hidden_field_count();
(
format!("▸ header hidden ({n} field{})", if n == 1 { "" } else { "s" }),
"Show the fields above the draft marker",
"Show this file's fields again",
)
} else {
(
+152 -141
View File
@@ -453,6 +453,11 @@ impl App {
}
}
/// Whether the open file has a header to fold away.
pub(super) fn header_is_foldable(&self) -> bool {
header_is_foldable(&self.buffer, &self.config.draft_marker)
}
/// Lift the header out of the buffer, leaving the prose. Supports both
/// YAML frontmatter (`---` delimiters) and the legacy draft marker.
pub(super) fn collapse_header(&mut self) {
@@ -650,11 +655,9 @@ impl App {
self.rebuild_field_names();
}
/// Create a new scene file with merged YAML frontmatter containing both
/// planning metadata and draft content. Used for the "New Scene" button.
/// Create a new scene file: its planning metadata as frontmatter, and the
/// prose below it. Used for the "New Scene" button.
pub(super) fn create_scene_pair(&mut self) {
use super::today_utc;
// Determine which folder we're in
let current_dir = self
.selected
@@ -678,8 +681,6 @@ impl App {
};
let act = act_num.unwrap_or(1);
let today = today_utc();
// Find next available scene number in this act
let existing_scenes: Vec<u32> = self.files.iter()
.filter(|f| {
@@ -717,141 +718,7 @@ impl App {
// Scene file
let filename = format!("{}/scene{:02}.md", draft_dir, scene_num);
// Scene ID
let scene_id = format!("scene_{:02}_{:02}_{:02}", act, chapter, scene_num);
// Create merged file with YAML frontmatter
let content = format!(r#"---
id: {}
title: "{}"
slug: {}
act: {}
chapter: {}
scene: {}
type: scene
status: draft
word_count_target: 2000
word_count_min: 1800
word_count_max: 2200
pov: ""
characters: []
setting: ""
date: ""
time: ""
conflict: ""
theme: ""
tone: ""
word_count: 0
word_count_updated: ""
session_word_count: 0
last_session: ""
total_sessions: 0
version: 1
created: "{}"
updated: "{}"
order: {}
---
# {}
**Scene File** | **ID:** {} | **Status:** Draft
---
## Scene Metadata
| Attribute | Value |
|-----------|-------|
| **Act** | {} |
| **Chapter** | {} |
| **Scene** | {} |
| **Type** | Scene |
| **POV** | Not set |
| **Version** | 1 |
| **Word Count Target** | 2000 |
---
## Word Count Tracking
| Metric | Value |
|--------|-------|
| **Target** | 2000 |
| **Minimum** | 1800 |
| **Maximum** | 2200 |
| **Current** | 0 |
| **This Session** | 0 |
| **Last Updated** | Never |
---
## Creative Elements
### POV
> Not specified
### Characters
### Setting
> Not specified
### Date & Time
> Not specified | Not specified
---
## Story Structure
### Conflict
> Not specified
### Theme
> Not specified
### Tone
> Not specified
---
[Write your scene content here...]
---
## Writing Notes
### Session Log
| Date | Words Added | Notes |
|------|--------------|-------|
### Revision Notes
-
-
---
## Status Checklist
- [ ] Planning complete
- [ ] First draft complete
- [ ] Word count target met
- [ ] POV consistent
- [ ] Character voices distinct
- [ ] Setting details accurate
- [ ] Conflict resolved/advanced
- [ ] Ready for revision
---
**Created:** {} | **Updated:** {}
"#,
scene_id, title, slug, act, chapter, scene_num,
scene_num,
today, today,
title, scene_id,
act, chapter, scene_num,
today, today
);
let content = scene_file_contents(&title, &slug, act, chapter, scene_num);
// Create the merged file
self.insert_new_file(filename.clone(), content);
@@ -1956,6 +1823,51 @@ pub(super) fn next_untitled_name(is_taken: impl Fn(&str) -> bool) -> String {
format!("untitled-{}.md", chrono_like_timestamp().trim_start_matches('@'))
}
/// Whether a document has a header to fold away — YAML frontmatter, or a legacy
/// block above the draft marker.
///
/// Split out from [`App::header_is_foldable`] so the choice can be exercised
/// without an `App`. The two forms are asked about together everywhere the fold
/// is offered, so they are asked about here once: the toggle above the editor
/// used to ask only about the draft marker, which meant a frontmatter file —
/// the one form the app now writes — never offered the fold at all.
fn header_is_foldable(buffer: &str, marker: &str) -> bool {
crate::preprocess::split_yaml_frontmatter(buffer).is_some()
|| crate::preprocess::split_at_marker(buffer, marker).is_some()
}
/// A new scene file: everything it knows about itself in the frontmatter, and
/// nothing below the closing `---` but the prose placeholder.
///
/// The body used to restate the whole header — a metadata table, a word-count
/// table, POV/characters/setting/conflict/theme/tone sections, a notes block and
/// a checklist. All of that is *body*, which is what the word count counts and
/// what the exporter writes into the .odt, so every scene opened a hundred and
/// fifty words and half a page into its own export before a line was written.
fn scene_file_contents(title: &str, slug: &str, act: u32, chapter: u32, scene: u32) -> String {
format!(
"---\n\
title: \"{title}\"\n\
slug: {slug}\n\
act: {act}\n\
chapter: {chapter}\n\
scene: {scene}\n\
status: draft\n\
pov: \"\"\n\
characters: []\n\
setting: \"\"\n\
date: \"\"\n\
time: \"\"\n\
conflict: \"\"\n\
theme: \"\"\n\
tone: \"\"\n\
word_count_target: 1800-2200\n\
---\n\
\n\
[Write your scene content here...]\n"
)
}
/// Expand the template placeholders and guarantee a trailing newline.
pub(super) fn render_template(template: &str, stem: &str, marker: &str, date: &str) -> String {
let mut out = template
@@ -1972,6 +1884,104 @@ pub(super) fn render_template(template: &str, stem: &str, marker: &str, date: &s
mod tests {
use super::*;
#[test]
fn a_frontmatter_scene_offers_the_fold() {
// An explicit marker: the default is empty now that scenes are written
// as frontmatter, so it would not exercise the legacy arm.
let marker = "### Rough Draft:";
let scene = scene_file_contents("Chapter 1: Scene 2", "chapter-1--scene-2", 1, 1, 2);
assert!(header_is_foldable(&scene, marker), "frontmatter should fold");
// A frontmatter file has no draft marker, which is exactly why asking
// only about the marker hid the toggle on every scene the app writes.
assert!(crate::preprocess::split_at_marker(&scene, marker).is_none());
// The legacy form still folds, and prose alone still does not.
let legacy = "# Title: The Gate\n\n### Rough Draft:\n\nProse.\n";
assert!(header_is_foldable(legacy, marker), "a draft marker should fold");
assert!(!header_is_foldable("Just prose.\n", marker), "nothing to fold");
// Clearing the marker disables legacy splitting but not frontmatter,
// which is delimited by `---` rather than by the marker.
assert!(!header_is_foldable(legacy, ""));
assert!(header_is_foldable(&scene, ""));
}
#[test]
fn folding_a_scene_leaves_the_prose_and_keeps_the_file_whole() {
// The fold is a view: `document()` glues the stash back on for every
// save, so what is on disk must not change for having been folded.
let scene = scene_file_contents("Chapter 1: Scene 2", "chapter-1--scene-2", 1, 1, 2);
let (header, body) =
crate::preprocess::split_yaml_frontmatter(&scene).expect("frontmatter");
assert!(
!body.contains("word_count_target"),
"no frontmatter left in the editor: {body:?}"
);
assert_eq!(body.trim(), "[Write your scene content here...]");
assert_eq!(format!("{header}{body}"), scene, "the file is unchanged");
// What the toggle's label counts while folded.
assert_eq!(crate::preprocess::fields(header, "").len(), 15);
}
/// The template the snowflake project ships and the file "+ New Scene"
/// writes have to stay the same shape, so check the bundled one too.
const BUNDLED_TEMPLATE: &str =
include_str!("../../templates/snowflake/{{ cookiecutter.project_name }}/05-First Draft/_template.md");
#[test]
fn a_new_scene_puts_nothing_but_prose_below_the_frontmatter() {
// The body is what the word count counts and what the exporter writes,
// so the header must not be restated in it. It used to be, at length.
for source in [
scene_file_contents("Chapter 1: Scene 3", "chapter-1--scene-3", 1, 1, 3),
BUNDLED_TEMPLATE.to_string(),
] {
let header = crate::preprocess::parse(&source, "### Rough Draft:");
assert_eq!(header.body, "[Write your scene content here...]");
assert!(
count_words(&header.body) < 10,
"a new scene should not open with a page of metadata"
);
}
}
#[test]
fn a_new_scene_says_each_thing_once() {
let source = scene_file_contents("Chapter 1: Scene 3", "chapter-1--scene-3", 2, 1, 3);
let fields = crate::preprocess::fields(&source, "### Rough Draft:");
let mut names: Vec<String> = fields.iter().map(|f| f.name.to_lowercase()).collect();
names.sort();
let before = names.len();
names.dedup();
assert_eq!(before, names.len(), "a key is written twice: {names:?}");
// Nothing the app cannot keep true: these were stale the moment you
// typed, and `order`/`id` restated order.json and the file's own path.
for gone in [
"id", "type", "version", "order", "created", "updated", "word_count",
"word_count_updated", "session_word_count", "last_session", "total_sessions",
] {
assert!(!names.contains(&gone.to_string()), "{gone} is back");
}
}
#[test]
fn a_new_scene_is_read_back_through_the_frontmatter() {
// The point of favouring YAML: every reader gets its answer from there.
let source = scene_file_contents("Chapter 1: Scene 3", "chapter-1--scene-3", 2, 1, 3);
let header = crate::preprocess::parse(&source, "### Rough Draft:");
assert_eq!(header.title.as_deref(), Some("Chapter 1: Scene 3"));
assert_eq!(header.slug.as_deref(), Some("chapter-1--scene-3"));
let goal = header.goal.expect("the target is readable");
assert_eq!((goal.min, goal.max), (1800, 2200));
assert_eq!(
crate::preprocess::field(&source, "", "status").as_deref(),
Some("draft")
);
assert_eq!(crate::preprocess::field(&source, "", "act").as_deref(), Some("2"));
// Unfilled reads as absent, so an empty `pov:` is not a POV of "".
assert_eq!(crate::preprocess::field(&source, "", "pov"), None);
assert_eq!(crate::preprocess::field(&source, "", "characters"), None);
}
/// The folder the "open the project folder" button points at, across the
/// shapes a workspace can take.
#[test]
@@ -2284,3 +2294,4 @@ mod tests {
assert_eq!(nearest_listed(&[], Some("05-First Draft"), 0), None);
}
}