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:
+289
-151
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 \
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user