Add project-mode tooling: characters, outline, diff and revision status

Rounds out project mode, where the workspace is the project root and one
subfolder holds the manuscript proper:

- Characters and Outline windows, backed by new `characters` and `outline`
  modules that read the cast from character sheets and measure how much of
  the snowflake outline is actually written.
- Edit ▸ Changes… diffs the open file against its last committed version.
- Revision status, per-file and project word counts, an archive action and
  hidden folders in the file panel.
- Chapter-file export alongside the ODT master, richer header parsing, and
  a project word list for names and invented terms.
- The export path now follows the workspace: opening a project points it at
  that project root, keeping a file name you chose yourself and re-deriving
  one that merely echoed the folder it sat in.
- Clicking an issue in the grammar/spelling panel takes the editor to it,
  selecting the words and centring them; applying a suggestion jumps to the
  rewritten text as well.

README covers the new windows and workflows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bSn3Xijp8GofZVUnRX4oq
This commit is contained in:
2026-08-24 19:56:36 -05:00
parent e951d57d45
commit 043cc692ac
23 changed files with 3879 additions and 144 deletions
+202 -38
View File
@@ -21,8 +21,9 @@ pub(super) enum RowKind {
/// A folder header: how many markdown files live under it at any depth, and
/// the flat index of the first of them (where a drop into the folder lands).
Folder { count: usize, first: usize },
/// A file, by its index into [`App::files`].
File { idx: usize },
/// A file. The panel resolves its own index against `App::files`, because a
/// status filter means the row list and the file list no longer line up.
File,
}
/// Where a dragged file was let go: which file moved, the flat position it
@@ -96,7 +97,7 @@ pub(super) fn build_rows(files: &[String], collapsed: &HashSet<String>) -> Vec<R
rows.push(Row {
path: path.clone(),
depth: parts.len(),
kind: RowKind::File { idx },
kind: RowKind::File,
});
}
}
@@ -108,6 +109,10 @@ impl App {
egui::SidePanel::left("files")
.resizable(true)
.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)
.show(ctx, |ui| {
// The default theme renders unselected list rows fairly dim; bump
// the widget text colours so file names stay legible (especially in
@@ -120,13 +125,71 @@ impl App {
.small()
.weak(),
);
if self.manuscript_dir.is_some() {
let note = if self.config.show_reference_files {
"dimmed = reference, not part of the book"
} else {
"manuscript only · View ▸ Show reference files"
};
ui.label(egui::RichText::new(note).small().weak());
}
let statuses = self.known_statuses();
if !statuses.is_empty() {
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Status:").small().weak());
let current = self
.status_filter
.clone()
.unwrap_or_else(|| "all".to_string());
egui::ComboBox::from_id_salt("status_filter")
.selected_text(egui::RichText::new(current).small())
.show_ui(ui, |ui| {
if ui
.selectable_label(self.status_filter.is_none(), "all")
.clicked()
{
self.status_filter = None;
}
for status in &statuses {
let picked = self
.status_filter
.as_deref()
.is_some_and(|s| s == status);
if ui.selectable_label(picked, status).clicked() {
self.status_filter = Some(status.clone());
}
}
});
});
}
ui.separator();
let mut clicked: Option<usize> = None;
let mut toggled: Option<String> = None;
let mut dropped: Option<FileDrop> = None;
let pointer = ui.input(|i| i.pointer.interact_pos());
let rows = build_rows(&self.files, &self.collapsed);
// Filtering happens on the flat list, so folders left with no
// files simply stop appearing.
let show_reference = self.config.show_reference_files;
let visible: Vec<String> = self
.files
.iter()
.filter(|name| show_reference || self.is_manuscript(name))
.filter(|name| match &self.status_filter {
None => true,
Some(want) => self
.file_meta
.get(*name)
.and_then(|m| m.status.as_deref())
.is_some_and(|s| s.eq_ignore_ascii_case(want)),
})
.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);
egui::ScrollArea::vertical()
@@ -138,6 +201,12 @@ impl App {
match row.kind {
RowKind::Folder { count, first } => {
let open = !self.collapsed.contains(&row.path);
// In a project, one folder holds the book.
let is_manuscript_root = self
.manuscript_dir
.as_deref()
.is_some_and(|d| d == row.path);
let in_book = self.is_manuscript(&row.path);
let header = ui
.horizontal(|ui| {
ui.add_space(indent);
@@ -146,12 +215,26 @@ impl App {
"{arrow} 🗀 {}",
base_name(&row.path)
);
let text = egui::RichText::new(label);
let text = if in_book {
text.strong()
} 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.
let name_w =
(row_width - indent - COUNT_W).max(48.0);
if ui
.add(
egui::Button::new(
egui::RichText::new(label).strong(),
)
.frame(false),
.add_sized(
[name_w, 20.0],
egui::Button::new(text)
.frame(false)
.truncate(),
)
.on_hover_text(&row.path)
.clicked()
@@ -163,35 +246,59 @@ impl App {
.small()
.weak(),
);
// Claim the rest of the line so the
// whole row is a drop target.
ui.allocate_space(egui::vec2(
ui.available_width(),
0.0,
));
if is_manuscript_root {
ui.label(
egui::RichText::new("· manuscript")
.small()
.weak(),
)
.on_hover_text(
"These files are the book: ordered, \
numbered and exported. Everything \
else in the project is reference.",
);
}
})
.response;
// Widened only for hit-testing and painting,
// which cannot affect the layout's width.
let header = full_width_row(ui, &header, &row.path);
if drop_highlight(ui, &header) {
if let Some(payload) =
header.dnd_release_payload::<usize>()
{
// `first` indexes the filtered list.
let to = visible
.get(first)
.and_then(|n| {
self.files.iter().position(|f| f == n)
})
.unwrap_or(self.files.len());
dropped = Some(FileDrop {
from: *payload,
to: first,
to,
dir: row.path.clone(),
});
}
}
}
RowKind::File { idx } => {
RowKind::File => {
let name = row.path.clone();
// `build_rows` indexed the filtered list; the
// rest of the app speaks in `files` indices.
let Some(idx) =
self.files.iter().position(|f| *f == name)
else {
continue;
};
let selected = self.selected == Some(idx);
let in_book = self.is_manuscript(&name);
// The selected file's fields are read live from
// the buffer (so unsaved edits show); others come
// from the cache filled on open/save.
let meta = if selected {
FileMeta::from_markdown(
&self.buffer,
&self.document(),
&self.config.draft_marker,
)
} else {
@@ -220,14 +327,16 @@ impl App {
} else {
0.0
};
let label_w =
(ui.available_width() - reserve).max(24.0);
let label_w = (row_width
- indent
- HANDLE_W
- reserve)
.max(24.0);
let text = egui::RichText::new(base_name(&name));
let text = if in_book { text } else { text.weak() };
let mut label = ui.add_sized(
[label_w, 20.0],
egui::SelectableLabel::new(
selected,
base_name(&name),
),
egui::SelectableLabel::new(selected, text),
);
if let Some(tooltip) = &tooltip {
label = label.on_hover_text(tooltip);
@@ -243,6 +352,14 @@ impl App {
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}"));
}
})
.response;
@@ -292,9 +409,9 @@ impl App {
.small()
.weak(),
);
ui.allocate_space(egui::vec2(ui.available_width(), 0.0));
})
.response;
let target = full_width_row(ui, &target, "\u{0}root-drop");
if drop_highlight(ui, &target) {
if let Some(payload) = target.dnd_release_payload::<usize>() {
dropped = Some(FileDrop {
@@ -360,6 +477,18 @@ impl App {
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() {
@@ -397,6 +526,41 @@ fn drop_highlight(ui: &egui::Ui, response: &egui::Response) -> bool {
true
}
/// 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;
/// Room left after a folder's name for its file count and the manuscript tag.
const COUNT_W: f32 = 86.0;
/// A response covering the whole visible width of `ui` at the row's height.
///
/// Rows want to be drop targets across their full width, but *claiming* that
/// width makes the content as wide as the panel, and the panel is sized from
/// its content — which grows it, frame after frame. Interacting with a rect
/// taken from the clip rectangle sidesteps that: it is the visible area, not
/// the content, so it cannot feed back into the layout.
fn full_width_row(ui: &egui::Ui, response: &egui::Response, key: &str) -> egui::Response {
let rect = egui::Rect::from_x_y_ranges(ui.clip_rect().x_range(), response.rect.y_range());
ui.interact(rect, egui::Id::new(("row", key)), egui::Sense::hover())
}
/// A compact badge for a `Status:` value: the first letters of its words, so a
/// long stage name still fits beside a file name.
pub(super) fn status_tag(status: &str) -> String {
let initials: String = status
.split_whitespace()
.filter_map(|w| w.chars().find(|c| c.is_alphanumeric()))
.collect();
if initials.chars().count() >= 2 {
initials.to_uppercase()
} else {
status.chars().take(4).collect::<String>().to_uppercase()
}
}
/// Resolve a chapter's title: a non-empty manual `override_title` wins, then the
/// `# Title:` header value, otherwise the chapter's 1-based position followed by
/// a period (e.g. "3."), zero-padded to `pad_width` digits (`1` = no padding).
@@ -512,7 +676,7 @@ mod tests {
RowKind::Folder { count, first } => {
format!("{}:dir({count},{first}):{}", r.depth, r.path)
}
RowKind::File { idx } => format!("{}:file({idx}):{}", r.depth, r.path),
RowKind::File => format!("{}:file:{}", r.depth, r.path),
})
.collect()
}
@@ -521,7 +685,7 @@ mod tests {
fn flat_files_get_no_folder_rows() {
assert_eq!(
sketch(&["a.md", "b.md"], &[]),
vec!["0:file(0):a.md", "0:file(1):b.md"]
vec!["0:file:a.md", "0:file:b.md"]
);
}
@@ -531,9 +695,9 @@ mod tests {
sketch(&["p/a.md", "p/b.md", "top.md"], &[]),
vec![
"0:dir(2,0):p",
"1:file(0):p/a.md",
"1:file(1):p/b.md",
"0:file(2):top.md",
"1:file:p/a.md",
"1:file:p/b.md",
"0:file:top.md",
]
);
}
@@ -546,8 +710,8 @@ mod tests {
// `p` counts both files; `q` only its own.
"0:dir(2,0):p",
"1:dir(1,0):p/q",
"2:file(0):p/q/a.md",
"1:file(1):p/b.md",
"2:file:p/q/a.md",
"1:file:p/b.md",
]
);
}
@@ -556,7 +720,7 @@ mod tests {
fn collapsing_a_folder_hides_its_files_but_keeps_its_header() {
assert_eq!(
sketch(&["p/a.md", "p/b.md", "top.md"], &["p"]),
vec!["0:dir(2,0):p", "0:file(2):top.md"]
vec!["0:dir(2,0):p", "0:file:top.md"]
);
}
@@ -564,12 +728,12 @@ mod tests {
fn collapsing_hides_nested_headers_too() {
assert_eq!(
sketch(&["p/q/a.md", "p/b.md", "top.md"], &["p"]),
vec!["0:dir(2,0):p", "0:file(2):top.md"]
vec!["0:dir(2,0):p", "0:file:top.md"]
);
// Collapsing only the inner folder leaves the outer one drawn.
assert_eq!(
sketch(&["p/q/a.md", "p/b.md"], &["p/q"]),
vec!["0:dir(2,0):p", "1:dir(1,0):p/q", "1:file(1):p/b.md"]
vec!["0:dir(2,0):p", "1:dir(1,0):p/q", "1:file:p/b.md"]
);
}
@@ -581,7 +745,7 @@ mod tests {
vec![
"0:dir(1,0):part-1",
"0:dir(1,1):part-10",
"1:file(1):part-10/b.md",
"1:file:part-10/b.md",
]
);
}
@@ -592,9 +756,9 @@ mod tests {
sketch(&["p/a.md", "q/b.md"], &[]),
vec![
"0:dir(1,0):p",
"1:file(0):p/a.md",
"1:file:p/a.md",
"0:dir(1,1):q",
"1:file(1):q/b.md",
"1:file:q/b.md",
]
);
}