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:
+202
-38
@@ -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",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user