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:
@@ -26,7 +26,7 @@ impl App {
|
||||
/// Fold any new header field names from the current buffer into the list, so
|
||||
/// a field you just invented autocompletes without reopening the workspace.
|
||||
pub(super) fn merge_field_names_from_buffer(&mut self) {
|
||||
let found = header_field_names(&self.buffer, &self.config.draft_marker);
|
||||
let found = header_field_names(&self.document(), &self.config.draft_marker);
|
||||
let mut added = false;
|
||||
for f in found {
|
||||
if !self.field_names.iter().any(|n| n.eq_ignore_ascii_case(&f)) {
|
||||
|
||||
@@ -144,6 +144,9 @@ impl App {
|
||||
let mut open = true;
|
||||
let mut close = false;
|
||||
let mut save = false;
|
||||
let mut append_to_outline = false;
|
||||
// Where these beats belong in the project's outline, if it has one.
|
||||
let outline_target = self.outline_target_for_beats();
|
||||
let running = self.beats_rx.is_some();
|
||||
// Edit a local copy so the button row can borrow `self` mutably; persist
|
||||
// any edits back into `beats_output` afterwards.
|
||||
@@ -182,6 +185,18 @@ impl App {
|
||||
{
|
||||
save = true;
|
||||
}
|
||||
if let Some(target) = &outline_target {
|
||||
let label = format!("📝 Append to {}", base_name(target));
|
||||
if ui
|
||||
.add_enabled(have, egui::Button::new(label))
|
||||
.on_hover_text(format!(
|
||||
"Add these beats to {target}, where the outline lives"
|
||||
))
|
||||
.clicked()
|
||||
{
|
||||
append_to_outline = true;
|
||||
}
|
||||
}
|
||||
if ui.add_enabled(have, egui::Button::new("⧉ Copy")).clicked() {
|
||||
ui.output_mut(|o| o.copied_text = text.clone());
|
||||
self.beats_status = "Copied to clipboard.".to_string();
|
||||
@@ -198,6 +213,11 @@ impl App {
|
||||
}
|
||||
if save {
|
||||
self.save_beats_as_file(&text);
|
||||
}
|
||||
if append_to_outline {
|
||||
if let Some(target) = outline_target {
|
||||
self.append_beats_to_outline(&target, &text);
|
||||
}
|
||||
} else if close || !open {
|
||||
self.beats_output = None;
|
||||
self.beats_status.clear();
|
||||
@@ -279,6 +299,64 @@ impl App {
|
||||
}
|
||||
self.show_mistral_settings = now_open;
|
||||
}
|
||||
|
||||
/// The outline file these beats belong in, as a workspace-relative path.
|
||||
///
|
||||
/// A fill-the-gaps run wrote exactly one act, and the scene-breakdown
|
||||
/// scaffold has a file per act, so the two line up: `Act Two` goes to the
|
||||
/// file whose name carries a 2. Anything less clear-cut gets no target,
|
||||
/// since appending to the wrong act would be worse than not offering it.
|
||||
pub(super) fn outline_target_for_beats(&self) -> Option<String> {
|
||||
let [act] = self.beats_filled.as_slice() else {
|
||||
return None;
|
||||
};
|
||||
let digit = match act {
|
||||
crate::mistral::Act::One => '1',
|
||||
crate::mistral::Act::Two => '2',
|
||||
crate::mistral::Act::Three => '3',
|
||||
};
|
||||
let dir = self.outline_dir()?;
|
||||
markdown_files_under(&dir)
|
||||
.into_iter()
|
||||
.filter(|path| {
|
||||
let stem = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
stem.contains("act") && stem.contains(digit)
|
||||
})
|
||||
.find_map(|path| self.relative_to_workspace(&path))
|
||||
}
|
||||
|
||||
/// Append generated beats to an outline file, under a heading that says
|
||||
/// where they came from. Appending rather than replacing matters: the
|
||||
/// scaffold's prompts are the reason the file is worth keeping.
|
||||
pub(super) fn append_beats_to_outline(&mut self, rel: &str, beats: &str) {
|
||||
let path = self.path_for(rel);
|
||||
let existing = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
let mut out = existing;
|
||||
if !out.is_empty() && !out.ends_with('\n') {
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str(&format!(
|
||||
"\n<!-- Generated beats, {} -->\n\n{}\n",
|
||||
today_utc(),
|
||||
beats.trim()
|
||||
));
|
||||
match std::fs::write(&path, out) {
|
||||
Ok(_) => {
|
||||
self.beats_status = format!("Appended to {rel}");
|
||||
self.beats_output = None;
|
||||
// Open it so the result is in front of the user.
|
||||
if let Some(idx) = self.files.iter().position(|f| f == rel) {
|
||||
self.selected = None;
|
||||
self.select(idx);
|
||||
}
|
||||
}
|
||||
Err(e) => self.beats_status = format!("Could not write {rel}: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Join act names for a status line or title: "Act Two", "Act Two and Act
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
//! The Characters window: the project's cast, where each of them appears, and
|
||||
//! keeping the summary grid in step with the sheets.
|
||||
|
||||
use super::*;
|
||||
|
||||
impl App {
|
||||
/// (Re)read the project's character sheets.
|
||||
pub(super) fn load_characters(&mut self) {
|
||||
self.characters = match self.characters_dir() {
|
||||
Some(dir) => crate::characters::read_sheets(&dir, &self.config.draft_marker),
|
||||
None => Vec::new(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Manuscript files whose `Characters:` line names `character`, as
|
||||
/// workspace-relative paths.
|
||||
///
|
||||
/// This is the continuity question the app can answer that a folder of
|
||||
/// markdown cannot: once a draft is long, "which scenes is she actually in?"
|
||||
/// stops being answerable from memory.
|
||||
pub(super) fn scenes_with(&self, character: &crate::characters::Character) -> Vec<String> {
|
||||
self.files
|
||||
.iter()
|
||||
.filter(|name| self.is_manuscript(name))
|
||||
.filter(|name| {
|
||||
let Some(text) = std::fs::read_to_string(self.path_for(name)).ok() else {
|
||||
return false;
|
||||
};
|
||||
let Some(line) =
|
||||
crate::preprocess::field(&text, &self.config.draft_marker, "Characters")
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
crate::preprocess::value_items(&line)
|
||||
.iter()
|
||||
.any(|mention| character.is_named_by(mention))
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Write the generated character grid over the project's grid file.
|
||||
fn regenerate_character_grid(&mut self) {
|
||||
let Some(dir) = self.characters_dir() else {
|
||||
self.status = "No character folder found near this workspace".to_string();
|
||||
return;
|
||||
};
|
||||
let markdown = crate::characters::grid_markdown(&self.characters);
|
||||
// Reuse the grid file the project already has, wherever it sits.
|
||||
let existing = crate::characters::grid_file(&dir);
|
||||
let path = existing.unwrap_or_else(|| dir.join("character_grid").join("character_grid.md"));
|
||||
if let Some(parent) = path.parent() {
|
||||
if let Err(e) = std::fs::create_dir_all(parent) {
|
||||
self.status = format!("Could not write the grid: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
match std::fs::write(&path, markdown) {
|
||||
Ok(_) => {
|
||||
self.status = format!(
|
||||
"Wrote {} character(s) to {}",
|
||||
self.characters.len(),
|
||||
path.display()
|
||||
);
|
||||
// The grid may be one of the workspace's own files.
|
||||
self.file_meta = self.snapshot_file_meta();
|
||||
}
|
||||
Err(e) => self.status = format!("Could not write the grid: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The Characters window.
|
||||
pub(super) fn characters_window(&mut self, ctx: &egui::Context) {
|
||||
let mut open = self.show_characters;
|
||||
let mut close = false;
|
||||
let mut regenerate = false;
|
||||
let mut reload = false;
|
||||
// A scene or sheet the user asked to open, as a workspace-relative path.
|
||||
let mut open_file: Option<String> = None;
|
||||
|
||||
egui::Window::new("Characters")
|
||||
.open(&mut open)
|
||||
.resizable(true)
|
||||
.collapsible(false)
|
||||
.default_width(420.0)
|
||||
.show(ctx, |ui| {
|
||||
match self.characters_dir() {
|
||||
Some(dir) => {
|
||||
ui.label(
|
||||
egui::RichText::new(format!("Sheets in {}", dir.display()))
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
}
|
||||
None => {
|
||||
ui.label(
|
||||
egui::RichText::new(
|
||||
"No character folder found near this workspace.",
|
||||
)
|
||||
.weak(),
|
||||
);
|
||||
}
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("⟳ Reload sheets").clicked() {
|
||||
reload = true;
|
||||
}
|
||||
if ui
|
||||
.add_enabled(
|
||||
!self.characters.is_empty(),
|
||||
egui::Button::new("▦ Rebuild grid"),
|
||||
)
|
||||
.on_hover_text(
|
||||
"Regenerate the character grid from the sheets, replacing \
|
||||
whatever is in it",
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
regenerate = true;
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
|
||||
if self.characters.is_empty() {
|
||||
ui.label(egui::RichText::new("No character sheets read yet.").weak());
|
||||
}
|
||||
|
||||
egui::ScrollArea::vertical()
|
||||
.auto_shrink([false, true])
|
||||
.max_height(420.0)
|
||||
.show(ui, |ui| {
|
||||
for character in &self.characters {
|
||||
let scenes = self.scenes_with(character);
|
||||
let header = format!(
|
||||
"{} ({} scene{})",
|
||||
character.name,
|
||||
scenes.len(),
|
||||
if scenes.len() == 1 { "" } else { "s" }
|
||||
);
|
||||
egui::CollapsingHeader::new(header)
|
||||
.id_salt(&character.name)
|
||||
.show(ui, |ui| {
|
||||
if let Some(tagline) = character.tagline() {
|
||||
ui.label(egui::RichText::new(tagline).italics().weak());
|
||||
}
|
||||
if ui.link("open sheet").clicked() {
|
||||
if let Some(rel) = self.relative_to_workspace(
|
||||
&character.file,
|
||||
) {
|
||||
open_file = Some(rel);
|
||||
}
|
||||
}
|
||||
if scenes.is_empty() {
|
||||
ui.label(
|
||||
egui::RichText::new(
|
||||
"Not named in any scene's Characters: line.",
|
||||
)
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
}
|
||||
for scene in &scenes {
|
||||
if ui.link(scene).clicked() {
|
||||
open_file = Some(scene.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
if ui.button("Close").clicked() {
|
||||
close = true;
|
||||
}
|
||||
});
|
||||
|
||||
if reload {
|
||||
self.load_characters();
|
||||
}
|
||||
if regenerate {
|
||||
self.regenerate_character_grid();
|
||||
}
|
||||
if let Some(path) = open_file {
|
||||
if let Some(idx) = self.files.iter().position(|f| *f == path) {
|
||||
self.select(idx);
|
||||
} else {
|
||||
self.status = format!("{path} is not in this workspace");
|
||||
}
|
||||
}
|
||||
self.show_characters = open && !close;
|
||||
}
|
||||
|
||||
/// Express an absolute path as workspace-relative, if it is inside.
|
||||
pub(super) fn relative_to_workspace(&self, path: &Path) -> Option<String> {
|
||||
path.strip_prefix(self.workspace())
|
||||
.ok()?
|
||||
.to_str()
|
||||
.map(|s| s.replace('\\', "/"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//! Comparing the open file with its last committed version.
|
||||
|
||||
use super::*;
|
||||
|
||||
impl App {
|
||||
/// Load the diff for the current file, ready for the diff window.
|
||||
pub(super) fn open_diff(&mut self) {
|
||||
self.save_current();
|
||||
let Some(name) = self.selected.and_then(|i| self.files.get(i)).cloned() else {
|
||||
self.status = "Open a file first".to_string();
|
||||
return;
|
||||
};
|
||||
// Git is run from the work tree, and the workspace may be below it.
|
||||
let root = self.repo_root.clone();
|
||||
let Some(root) = root else {
|
||||
self.diff_text = "This workspace is not in a git repository.".to_string();
|
||||
self.show_diff = true;
|
||||
return;
|
||||
};
|
||||
if !gitsync::has_commits(&root) {
|
||||
self.diff_text = "The repository has no commits yet, so there is \
|
||||
nothing to compare against."
|
||||
.to_string();
|
||||
self.show_diff = true;
|
||||
return;
|
||||
}
|
||||
let abs = self.path_for(&name);
|
||||
let rel = abs
|
||||
.strip_prefix(&root)
|
||||
.map(|p| p.to_string_lossy().replace('\\', "/"))
|
||||
.unwrap_or_else(|_| name.clone());
|
||||
self.diff_text = match gitsync::diff_file(&root, &rel) {
|
||||
Ok(text) if text.trim().is_empty() => {
|
||||
format!("{name} matches the last commit — no changes.")
|
||||
}
|
||||
Ok(text) => text,
|
||||
Err(e) => format!("Could not diff {name}:\n{e}"),
|
||||
};
|
||||
self.diff_title = name;
|
||||
self.show_diff = true;
|
||||
}
|
||||
|
||||
/// The diff window: added and removed lines since the last commit.
|
||||
pub(super) fn diff_window(&mut self, ctx: &egui::Context) {
|
||||
let mut open = self.show_diff;
|
||||
let mut refresh = false;
|
||||
egui::Window::new(format!("Changes — {}", self.diff_title))
|
||||
.open(&mut open)
|
||||
.resizable(true)
|
||||
.collapsible(false)
|
||||
.default_width(680.0)
|
||||
.default_height(460.0)
|
||||
.show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("⟳ Refresh").clicked() {
|
||||
refresh = true;
|
||||
}
|
||||
ui.label(
|
||||
egui::RichText::new("compared with the last commit")
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
});
|
||||
ui.separator();
|
||||
egui::ScrollArea::vertical()
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| {
|
||||
for line in self.diff_text.lines() {
|
||||
// Colour the diff the way git does, so additions and
|
||||
// removals are separable at a glance.
|
||||
let (color, strong) = match line.as_bytes().first() {
|
||||
Some(b'+') if !line.starts_with("+++") => {
|
||||
(Some(egui::Color32::from_rgb(0x3F, 0x9E, 0x4F)), false)
|
||||
}
|
||||
Some(b'-') if !line.starts_with("---") => {
|
||||
(Some(egui::Color32::from_rgb(0xC0, 0x50, 0x50)), false)
|
||||
}
|
||||
Some(b'@') => {
|
||||
(Some(egui::Color32::from_rgb(0x3B, 0x82, 0xF6)), true)
|
||||
}
|
||||
_ => (None, false),
|
||||
};
|
||||
let mut text = egui::RichText::new(line).monospace();
|
||||
if let Some(color) = color {
|
||||
text = text.color(color);
|
||||
}
|
||||
if strong {
|
||||
text = text.strong();
|
||||
}
|
||||
ui.label(text);
|
||||
}
|
||||
});
|
||||
});
|
||||
if refresh {
|
||||
self.open_diff();
|
||||
}
|
||||
self.show_diff = open;
|
||||
}
|
||||
}
|
||||
+84
-1
@@ -110,7 +110,7 @@ impl App {
|
||||
self.buffer = new_text;
|
||||
self.dirty = true;
|
||||
// Restore the caret/selection around the change.
|
||||
let mut state = output.state;
|
||||
let mut state = output.state.clone();
|
||||
state.cursor.set_char_range(Some(egui::text::CCursorRange::two(
|
||||
egui::text::CCursor::new(new_sel.start),
|
||||
egui::text::CCursor::new(new_sel.end),
|
||||
@@ -120,6 +120,32 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
// Take the editor to an issue picked in the results panel:
|
||||
// select the words, centre them, and hand focus back so the
|
||||
// user can type the correction straight away.
|
||||
if let Some((start, end)) = self.issue_jump.take() {
|
||||
let (cs, ce) = (
|
||||
byte_to_char(&self.buffer, start),
|
||||
byte_to_char(&self.buffer, end),
|
||||
);
|
||||
let rect = output
|
||||
.galley
|
||||
.pos_from_ccursor(egui::text::CCursor::new(cs))
|
||||
.union(output.galley.pos_from_ccursor(egui::text::CCursor::new(ce)))
|
||||
.translate(output.galley_pos.to_vec2());
|
||||
ui.scroll_to_rect(rect, Some(egui::Align::Center));
|
||||
let mut state = output.state.clone();
|
||||
state
|
||||
.cursor
|
||||
.set_char_range(Some(egui::text::CCursorRange::two(
|
||||
egui::text::CCursor::new(cs),
|
||||
egui::text::CCursor::new(ce),
|
||||
)));
|
||||
state.store(ui.ctx(), output.response.id);
|
||||
ui.ctx().memory_mut(|m| m.request_focus(output.response.id));
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
|
||||
// Scroll the active search match into view when requested (on
|
||||
// open, Next/Prev, Enter, or after a replace).
|
||||
if self.find_scroll {
|
||||
@@ -162,6 +188,15 @@ impl App {
|
||||
Some((i, reps))
|
||||
});
|
||||
let mut chosen: Option<(usize, usize)> = None;
|
||||
// The word under the menu, so it can be added to the word list.
|
||||
let target_word: Option<String> = self.spell_menu.and_then(|i| {
|
||||
let m = self.current_matches().get(i)?;
|
||||
m.spelling
|
||||
.then(|| self.buffer.get(m.start..m.end))
|
||||
.flatten()
|
||||
.map(str::to_string)
|
||||
});
|
||||
let mut accept: Option<String> = None;
|
||||
output.response.context_menu(|ui| {
|
||||
match &menu {
|
||||
Some((i, reps)) if !reps.is_empty() => {
|
||||
@@ -180,7 +215,25 @@ impl App {
|
||||
ui.label(egui::RichText::new("No spelling issue here").weak());
|
||||
}
|
||||
}
|
||||
// A character or place name is not a misspelling; let it be
|
||||
// accepted for good rather than corrected every time.
|
||||
if let Some(word) = &target_word {
|
||||
ui.separator();
|
||||
if ui
|
||||
.button(format!("📗 Add “{word}” to the word list"))
|
||||
.on_hover_text(
|
||||
"Accept this word from now on, for this workspace",
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
accept = Some(word.clone());
|
||||
ui.close_menu();
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(word) = accept {
|
||||
self.add_to_dictionary(&word);
|
||||
}
|
||||
if let Some((i, j)) = chosen {
|
||||
self.apply_current_fix(i, j);
|
||||
self.spell_menu = None;
|
||||
@@ -401,6 +454,14 @@ pub(super) fn char_to_byte(s: &str, char_idx: usize) -> usize {
|
||||
.unwrap_or(s.len())
|
||||
}
|
||||
|
||||
/// Character index of byte offset `byte` in `s`, clamped to the end. An offset
|
||||
/// landing inside a character — a stale match against an edited buffer — is
|
||||
/// clamped to the next character boundary, matching how [`crate::langtool`]
|
||||
/// resolves its UTF-16 offsets, so a jump never lands mid-character.
|
||||
pub(super) fn byte_to_char(s: &str, byte: usize) -> usize {
|
||||
s.char_indices().take_while(|(b, _)| *b < byte).count()
|
||||
}
|
||||
|
||||
/// Apply an inline-format action to `text` given a selection in *character*
|
||||
/// indices, returning the new text and the new selection (also character indices).
|
||||
///
|
||||
@@ -470,6 +531,28 @@ pub(super) fn apply_format(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn byte_to_char_is_the_inverse_of_char_to_byte() {
|
||||
// "café!" — é is 2 bytes, so byte and char indices diverge after it.
|
||||
let text = "café!";
|
||||
for (i, _) in text.char_indices() {
|
||||
assert_eq!(char_to_byte(text, byte_to_char(text, i)), i);
|
||||
}
|
||||
assert_eq!(byte_to_char(text, 0), 0);
|
||||
assert_eq!(byte_to_char(text, 3), 3); // é
|
||||
assert_eq!(byte_to_char(text, 5), 4); // '!' sits after the 2-byte é
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn byte_to_char_clamps_past_the_end_and_inside_a_character() {
|
||||
let text = "a😀b";
|
||||
assert_eq!(byte_to_char(text, text.len()), 3);
|
||||
assert_eq!(byte_to_char(text, 99), 3);
|
||||
// Byte 3 is inside the 4-byte emoji, so it clamps forward to the
|
||||
// boundary after it rather than splitting the character.
|
||||
assert_eq!(byte_to_char(text, 3), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wraps_a_selection_in_bold_and_keeps_it_selected() {
|
||||
let (text, sel) = apply_format("a word here", 2..6, Fmt::Bold);
|
||||
|
||||
+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",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
+58
-17
@@ -113,31 +113,52 @@ impl App {
|
||||
}
|
||||
|
||||
/// Apply a LanguageTool suggestion to the buffer, then shift the remaining
|
||||
/// matches so their highlights stay valid.
|
||||
pub(super) fn apply_replacement(&mut self, match_idx: usize, rep_idx: usize) {
|
||||
/// matches so their highlights stay valid. Returns whether the fix landed.
|
||||
pub(super) fn apply_replacement(&mut self, match_idx: usize, rep_idx: usize) -> bool {
|
||||
// Only safe while the buffer still matches what was checked.
|
||||
if self.buffer != self.lt_checked_text {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if splice_fix(&mut self.buffer, &mut self.lt_matches, match_idx, rep_idx) {
|
||||
self.dirty = true;
|
||||
self.find_needs_refresh = true;
|
||||
self.lt_checked_text = self.buffer.clone();
|
||||
self.lt_status = match self.lt_matches.len() {
|
||||
0 => "No issues remaining".to_string(),
|
||||
1 => "1 issue".to_string(),
|
||||
n => format!("{n} issues"),
|
||||
};
|
||||
if !splice_fix(&mut self.buffer, &mut self.lt_matches, match_idx, rep_idx) {
|
||||
return false;
|
||||
}
|
||||
self.dirty = true;
|
||||
self.find_needs_refresh = true;
|
||||
self.lt_checked_text = self.buffer.clone();
|
||||
self.lt_status = match self.lt_matches.len() {
|
||||
0 => "No issues remaining".to_string(),
|
||||
1 => "1 issue".to_string(),
|
||||
n => format!("{n} issues"),
|
||||
};
|
||||
true
|
||||
}
|
||||
|
||||
/// Apply suggestion `rep_idx` of the match at `match_idx` from whichever
|
||||
/// source currently owns the issues (LanguageTool or the spell checker).
|
||||
/// source currently owns the issues (LanguageTool or the spell checker),
|
||||
/// then send the editor to the text that was rewritten.
|
||||
pub(super) fn apply_current_fix(&mut self, match_idx: usize, rep_idx: usize) {
|
||||
match self.issue_source() {
|
||||
// Where the replacement will sit once spliced in, worked out before the
|
||||
// splice shifts every match after it.
|
||||
let target = self.current_matches().get(match_idx).and_then(|m| {
|
||||
m.replacements
|
||||
.get(rep_idx)
|
||||
.map(|rep| (m.start, m.start + rep.len()))
|
||||
});
|
||||
let applied = match self.issue_source() {
|
||||
IssueSource::LanguageTool => self.apply_replacement(match_idx, rep_idx),
|
||||
IssueSource::Spell => self.apply_spell_fix(match_idx, rep_idx),
|
||||
IssueSource::None => {}
|
||||
IssueSource::None => false,
|
||||
};
|
||||
if applied {
|
||||
self.issue_jump = target;
|
||||
}
|
||||
}
|
||||
|
||||
/// Send the editor to the issue at `idx`, selecting it so it is obvious
|
||||
/// which words the panel entry was talking about.
|
||||
pub(super) fn jump_to_issue(&mut self, idx: usize) {
|
||||
if let Some(m) = self.current_matches().get(idx) {
|
||||
self.issue_jump = Some((m.start, m.end));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,6 +369,7 @@ impl App {
|
||||
}
|
||||
|
||||
let mut apply: Option<(usize, usize)> = None;
|
||||
let mut jump: Option<usize> = None;
|
||||
egui::ScrollArea::vertical()
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| {
|
||||
@@ -359,12 +381,19 @@ impl App {
|
||||
egui::Color32::from_rgb(0x3B, 0x82, 0xF6)
|
||||
};
|
||||
ui.label(egui::RichText::new("●").color(col));
|
||||
// The snippet and the explanation are both live:
|
||||
// clicking either takes the editor to the issue.
|
||||
let mut clicked = false;
|
||||
if !item.snippet.is_empty() {
|
||||
ui.label(
|
||||
clicked |= go_to_label(
|
||||
ui,
|
||||
egui::RichText::new(format!("“{}”", item.snippet)).strong(),
|
||||
);
|
||||
}
|
||||
ui.label(&item.message);
|
||||
clicked |= go_to_label(ui, egui::RichText::new(&item.message));
|
||||
if clicked {
|
||||
jump = Some(item.idx);
|
||||
}
|
||||
});
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.add_space(16.0);
|
||||
@@ -384,13 +413,25 @@ impl App {
|
||||
}
|
||||
});
|
||||
|
||||
// A fix wins over a bare jump: it moves the editor there too.
|
||||
if let Some((i, j)) = apply {
|
||||
self.apply_current_fix(i, j);
|
||||
} else if let Some(i) = jump {
|
||||
self.jump_to_issue(i);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// A panel label that behaves like a link to a place in the text: hand cursor,
|
||||
/// hover hint, and `true` on the frame it is clicked.
|
||||
fn go_to_label(ui: &mut egui::Ui, text: egui::RichText) -> bool {
|
||||
ui.add(egui::Label::new(text).sense(egui::Sense::click()))
|
||||
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
.on_hover_text("Go to this spot in the text")
|
||||
.clicked()
|
||||
}
|
||||
|
||||
/// Apply suggestion `rep_idx` of `matches[match_idx]` to `buffer` in place and
|
||||
/// remap the remaining matches. Returns whether a replacement was made (false if
|
||||
/// the indices are out of range or the match's bounds aren't valid boundaries).
|
||||
|
||||
+73
-6
@@ -16,9 +16,12 @@ use std::time::{Duration, Instant};
|
||||
|
||||
mod autocomplete;
|
||||
mod beats;
|
||||
mod characters;
|
||||
mod diff;
|
||||
mod editor;
|
||||
mod file_list;
|
||||
mod find;
|
||||
mod outline;
|
||||
mod project;
|
||||
mod grammar;
|
||||
mod spelling;
|
||||
@@ -33,6 +36,7 @@ use self::autocomplete::*;
|
||||
use self::file_list::*;
|
||||
use self::grammar::*;
|
||||
use self::project::*;
|
||||
use self::spelling::*;
|
||||
use self::style::*;
|
||||
use self::util::*;
|
||||
|
||||
@@ -70,6 +74,8 @@ struct FileMeta {
|
||||
goal: Option<crate::preprocess::WordGoal>,
|
||||
/// Prose (body) word count captured with the rest of this metadata.
|
||||
prose_words: usize,
|
||||
/// The `Status:` line, if any — the revision stage this file has reached.
|
||||
status: Option<String>,
|
||||
}
|
||||
|
||||
impl FileMeta {
|
||||
@@ -81,14 +87,10 @@ impl FileMeta {
|
||||
pov: h.pov,
|
||||
goal: h.goal,
|
||||
prose_words: count_words(&h.body),
|
||||
status: crate::preprocess::field(text, marker, "Status"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this entry carries anything worth caching/showing.
|
||||
fn has_display(&self) -> bool {
|
||||
self.slug.is_some() || self.pov.is_some() || self.goal.is_some()
|
||||
}
|
||||
|
||||
/// The tooltip text — a `POV:` line then the slug synopsis — or `None` when
|
||||
/// the file carries neither field.
|
||||
fn tooltip(&self) -> Option<String> {
|
||||
@@ -163,6 +165,10 @@ struct IssueItem {
|
||||
|
||||
pub struct App {
|
||||
config: Config,
|
||||
/// The manuscript folder within the workspace when the workspace is a
|
||||
/// project root — files under it are the book, everything else is
|
||||
/// reference. `None` when the workspace is itself the manuscript.
|
||||
manuscript_dir: Option<String>,
|
||||
/// Ordered markdown files, as workspace-relative paths with `/` separators
|
||||
/// (`part-1/ch-03.md`). Always held in folder-tree order — see
|
||||
/// [`crate::order::tree_order`] — so this list reads exactly as the file
|
||||
@@ -177,8 +183,11 @@ pub struct App {
|
||||
/// Editing buffer for the selected file's chapter title override.
|
||||
title_input: String,
|
||||
selected: Option<usize>,
|
||||
/// Editor contents for the selected file.
|
||||
/// Editor contents for the selected file. While the header is collapsed
|
||||
/// this is the prose only; [`App::document`] puts the file back together.
|
||||
buffer: String,
|
||||
/// The editorial header lifted out of `buffer` while it is collapsed.
|
||||
header_stash: Option<String>,
|
||||
dirty: bool,
|
||||
/// Editable copy of the workspace path shown in the top bar.
|
||||
workspace_input: String,
|
||||
@@ -268,9 +277,33 @@ pub struct App {
|
||||
spell_rx: Option<std::sync::mpsc::Receiver<SpellCheckMsg>>,
|
||||
/// One-line status for the spell checker (dictionary name, count, or error).
|
||||
spell_status: String,
|
||||
/// Names and invented terms the checker accepts on top of the dictionary,
|
||||
/// loaded from the workspace's `wordlist.json`.
|
||||
wordlist: crate::spell::WordList,
|
||||
/// Whether the word-list window is open.
|
||||
show_wordlist: bool,
|
||||
/// When set, the file panel shows only files whose `Status:` matches.
|
||||
status_filter: Option<String>,
|
||||
/// The project's cast, read from its character sheets.
|
||||
characters: Vec<crate::characters::Character>,
|
||||
/// Whether the Characters window is open.
|
||||
show_characters: bool,
|
||||
/// Whether the manuscript-details window is open.
|
||||
show_manuscript_settings: bool,
|
||||
/// Whether the Outline window is open.
|
||||
show_outline: bool,
|
||||
/// Whether the diff window is open, and what it is showing.
|
||||
show_diff: bool,
|
||||
diff_text: String,
|
||||
diff_title: String,
|
||||
/// Text field for adding a word by hand.
|
||||
wordlist_input: String,
|
||||
/// Index (into the currently displayed matches) of the word a right-click
|
||||
/// suggestion menu is open for, if any.
|
||||
spell_menu: Option<usize>,
|
||||
/// Byte range in `buffer` the editor should select and scroll into view next
|
||||
/// frame, set when an issue is clicked in the results panel.
|
||||
issue_jump: Option<(usize, usize)>,
|
||||
/// Whether the Mistral settings window is open.
|
||||
show_mistral_settings: bool,
|
||||
/// Whether the new-file template settings window is open.
|
||||
@@ -312,12 +345,14 @@ impl App {
|
||||
workspace_input: config.workspace.display().to_string(),
|
||||
export_input: config.export_path.display().to_string(),
|
||||
config,
|
||||
manuscript_dir: None,
|
||||
files: Vec::new(),
|
||||
collapsed: HashSet::new(),
|
||||
titles: HashMap::new(),
|
||||
title_input: String::new(),
|
||||
selected: None,
|
||||
buffer: String::new(),
|
||||
header_stash: None,
|
||||
dirty: false,
|
||||
new_name: String::new(),
|
||||
rename_input: String::new(),
|
||||
@@ -360,7 +395,19 @@ impl App {
|
||||
spell_last_edit: None,
|
||||
spell_rx: None,
|
||||
spell_status: String::new(),
|
||||
wordlist: crate::spell::WordList::default(),
|
||||
show_wordlist: false,
|
||||
status_filter: None,
|
||||
characters: Vec::new(),
|
||||
show_characters: false,
|
||||
show_manuscript_settings: false,
|
||||
show_outline: false,
|
||||
show_diff: false,
|
||||
diff_text: String::new(),
|
||||
diff_title: String::new(),
|
||||
wordlist_input: String::new(),
|
||||
spell_menu: None,
|
||||
issue_jump: None,
|
||||
show_mistral_settings: false,
|
||||
show_template_settings: false,
|
||||
beats_rx: None,
|
||||
@@ -471,6 +518,26 @@ impl eframe::App for App {
|
||||
self.beats_window(ctx);
|
||||
}
|
||||
|
||||
if self.show_wordlist {
|
||||
self.wordlist_window(ctx);
|
||||
}
|
||||
|
||||
if self.show_characters {
|
||||
self.characters_window(ctx);
|
||||
}
|
||||
|
||||
if self.show_manuscript_settings {
|
||||
self.manuscript_settings_window(ctx);
|
||||
}
|
||||
|
||||
if self.show_outline {
|
||||
self.outline_window(ctx);
|
||||
}
|
||||
|
||||
if self.show_diff {
|
||||
self.diff_window(ctx);
|
||||
}
|
||||
|
||||
if self.show_cheatsheet {
|
||||
crate::help::cheatsheet_window(ctx, &mut self.show_cheatsheet);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
//! The Outline window: how much of the project's outline is actually written.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// One outline file and the state of the beats in it.
|
||||
pub(super) struct OutlineFile {
|
||||
/// Workspace-relative path, so it can be opened in the editor.
|
||||
pub rel: String,
|
||||
pub beats: Vec<crate::outline::Beat>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// The project's outline folder, looked for beside the workspace and up
|
||||
/// through its ancestors, the same way the character folder is found.
|
||||
pub(super) fn outline_dir(&self) -> Option<PathBuf> {
|
||||
const MAX_UP: usize = 3;
|
||||
let mut dir = Some(self.workspace());
|
||||
for _ in 0..=MAX_UP {
|
||||
let current = dir?;
|
||||
if let Some(name) = order::child_dir_matching(current, "Outline") {
|
||||
return Some(current.join(name));
|
||||
}
|
||||
dir = current.parent();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Read every outline file and the beats in it.
|
||||
pub(super) fn read_outline(&self) -> Vec<OutlineFile> {
|
||||
let Some(dir) = self.outline_dir() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for path in markdown_files_under(&dir) {
|
||||
let Ok(text) = std::fs::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
let beats = crate::outline::beats(&text);
|
||||
if beats.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let rel = self
|
||||
.relative_to_workspace(&path)
|
||||
.unwrap_or_else(|| path.display().to_string());
|
||||
out.push(OutlineFile { rel, beats });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The Outline window: a checklist of the beats the scaffold names.
|
||||
pub(super) fn outline_window(&mut self, ctx: &egui::Context) {
|
||||
let mut open = self.show_outline;
|
||||
let mut close = false;
|
||||
let mut open_file: Option<String> = None;
|
||||
let files = self.read_outline();
|
||||
|
||||
egui::Window::new("Outline")
|
||||
.open(&mut open)
|
||||
.resizable(true)
|
||||
.collapsible(false)
|
||||
.default_width(440.0)
|
||||
.show(ctx, |ui| {
|
||||
match self.outline_dir() {
|
||||
Some(dir) => {
|
||||
ui.label(
|
||||
egui::RichText::new(format!("Scaffold in {}", dir.display()))
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
}
|
||||
None => {
|
||||
ui.label(
|
||||
egui::RichText::new(
|
||||
"No outline folder found near this workspace.",
|
||||
)
|
||||
.weak(),
|
||||
);
|
||||
}
|
||||
}
|
||||
if files.is_empty() {
|
||||
ui.label(
|
||||
egui::RichText::new(
|
||||
"No beat prompts found. The template writes them as HTML \
|
||||
comments like <!-- Midpoint: … -->.",
|
||||
)
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
}
|
||||
|
||||
let total: usize = files.iter().map(|f| f.beats.len()).sum();
|
||||
let done: usize = files
|
||||
.iter()
|
||||
.map(|f| crate::outline::progress(&f.beats).0)
|
||||
.sum();
|
||||
if total > 0 {
|
||||
ui.add_space(4.0);
|
||||
ui.add(
|
||||
egui::ProgressBar::new(done as f32 / total as f32)
|
||||
.text(format!("{done} of {total} beats written")),
|
||||
);
|
||||
}
|
||||
ui.separator();
|
||||
|
||||
egui::ScrollArea::vertical()
|
||||
.auto_shrink([false, true])
|
||||
.max_height(420.0)
|
||||
.show(ui, |ui| {
|
||||
for file in &files {
|
||||
let (done, total) = crate::outline::progress(&file.beats);
|
||||
egui::CollapsingHeader::new(format!(
|
||||
"{} ({done}/{total})",
|
||||
file.rel
|
||||
))
|
||||
.id_salt(&file.rel)
|
||||
.default_open(files.len() == 1)
|
||||
.show(ui, |ui| {
|
||||
if ui.link("open this file").clicked() {
|
||||
open_file = Some(file.rel.clone());
|
||||
}
|
||||
let mut section = "";
|
||||
for beat in &file.beats {
|
||||
if beat.section != section {
|
||||
section = &beat.section;
|
||||
if !section.is_empty() {
|
||||
ui.label(
|
||||
egui::RichText::new(section)
|
||||
.small()
|
||||
.strong(),
|
||||
);
|
||||
}
|
||||
}
|
||||
let mark = if beat.filled { "✔" } else { "☐" };
|
||||
let text =
|
||||
egui::RichText::new(format!(" {mark} {}", beat.name));
|
||||
ui.label(if beat.filled {
|
||||
text
|
||||
} else {
|
||||
text.weak()
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
if ui.button("Close").clicked() {
|
||||
close = true;
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(path) = open_file {
|
||||
match self.files.iter().position(|f| *f == path) {
|
||||
Some(idx) => self.select(idx),
|
||||
None => self.status = format!("{path} is not in this workspace"),
|
||||
}
|
||||
}
|
||||
self.show_outline = open && !close;
|
||||
}
|
||||
}
|
||||
+86
-16
@@ -114,21 +114,20 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Point the workspace at a generated project: its configured drafting
|
||||
/// subfolder when the template produced one, otherwise the project root.
|
||||
/// Point the workspace at a generated project. The project *root* is opened
|
||||
/// rather than the drafting folder: the app recognises the layout and treats
|
||||
/// the drafting folder as the manuscript, keeping the characters, outline and
|
||||
/// the rest reachable in the same tree.
|
||||
fn open_project(&mut self, project: &Path) {
|
||||
let subdir = self.config.project_open_subdir.trim();
|
||||
let (workspace, note) = match subdir {
|
||||
"" => (project.to_path_buf(), String::new()),
|
||||
sub if project.join(sub).is_dir() => (project.join(sub), String::new()),
|
||||
sub => (
|
||||
project.to_path_buf(),
|
||||
format!(" (no {sub} folder in it, opened the project root)"),
|
||||
),
|
||||
let note = if subdir.is_empty() || project.join(subdir).is_dir() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" (no {subdir} folder in it, so nothing is marked as the manuscript)")
|
||||
};
|
||||
self.save_current();
|
||||
self.workspace_input = workspace.display().to_string();
|
||||
self.config.workspace = workspace;
|
||||
self.workspace_input = project.display().to_string();
|
||||
self.config.workspace = project.to_path_buf();
|
||||
// Export alongside the new project rather than into the previous one.
|
||||
self.config.export_path = project.join(format!(
|
||||
"{}.odt",
|
||||
@@ -217,7 +216,7 @@ impl App {
|
||||
if !subdir.is_empty() {
|
||||
ui.label(
|
||||
egui::RichText::new(format!(
|
||||
"Then opens its {subdir} folder as the workspace."
|
||||
"Then opens the project, with {subdir} as the manuscript."
|
||||
))
|
||||
.small()
|
||||
.weak(),
|
||||
@@ -281,6 +280,71 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Title and author written into exported documents.
|
||||
pub(super) fn manuscript_settings_window(&mut self, ctx: &egui::Context) {
|
||||
let mut open = self.show_manuscript_settings;
|
||||
let mut close = false;
|
||||
egui::Window::new("Manuscript details")
|
||||
.open(&mut open)
|
||||
.resizable(false)
|
||||
.collapsible(false)
|
||||
.default_width(380.0)
|
||||
.show(ctx, |ui| {
|
||||
let mut save_now = false;
|
||||
egui::Grid::new("manuscript_details_grid")
|
||||
.num_columns(2)
|
||||
.spacing([10.0, 8.0])
|
||||
.show(ui, |ui| {
|
||||
ui.label("Title:");
|
||||
// Resolved first: the field borrows `self.config` mutably.
|
||||
let fallback = self
|
||||
.workspace()
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("Manuscript")
|
||||
.to_string();
|
||||
let r = ui
|
||||
.add(
|
||||
egui::TextEdit::singleline(&mut self.config.manuscript_title)
|
||||
.hint_text(fallback)
|
||||
.desired_width(240.0),
|
||||
)
|
||||
.on_hover_text("Blank uses the project folder's name");
|
||||
save_now |= r.lost_focus();
|
||||
ui.end_row();
|
||||
|
||||
ui.label("Author:");
|
||||
let r = ui.add(
|
||||
egui::TextEdit::singleline(&mut self.config.manuscript_author)
|
||||
.desired_width(240.0),
|
||||
);
|
||||
save_now |= r.lost_focus();
|
||||
ui.end_row();
|
||||
});
|
||||
ui.label(
|
||||
egui::RichText::new(
|
||||
"Written into exported .odt files as their document \
|
||||
properties, which is what a word processor shows under \
|
||||
File ▸ Properties.",
|
||||
)
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
ui.separator();
|
||||
if ui.button("Close").clicked() {
|
||||
close = true;
|
||||
}
|
||||
if save_now {
|
||||
self.config.save();
|
||||
}
|
||||
});
|
||||
let now_open = open && !close;
|
||||
if self.show_manuscript_settings && !now_open {
|
||||
self.config.save();
|
||||
}
|
||||
self.show_manuscript_settings = now_open;
|
||||
}
|
||||
|
||||
/// Settings for **File ▸ New project…**: which template to render, how to
|
||||
/// run it, and the credentials its hooks read.
|
||||
pub(super) fn project_settings_window(&mut self, ctx: &egui::Context) {
|
||||
@@ -331,12 +395,18 @@ impl App {
|
||||
.num_columns(2)
|
||||
.spacing([10.0, 8.0])
|
||||
.show(ui, |ui| {
|
||||
ui.label("Open subfolder:");
|
||||
let r = ui.add(
|
||||
egui::TextEdit::singleline(&mut self.config.project_open_subdir)
|
||||
ui.label("Manuscript folder:");
|
||||
let r = ui
|
||||
.add(
|
||||
egui::TextEdit::singleline(
|
||||
&mut self.config.project_open_subdir,
|
||||
)
|
||||
.hint_text("06-First Draft")
|
||||
.desired_width(280.0),
|
||||
);
|
||||
)
|
||||
.on_hover_text(
|
||||
"Which folder of a project holds the book. Its files are ordered, numbered and exported; the rest of the project is reference. A leading number is optional, so “First Draft” also matches “06-First Draft”.",
|
||||
);
|
||||
save_now |= r.lost_focus();
|
||||
ui.end_row();
|
||||
|
||||
|
||||
+255
-13
@@ -6,21 +6,23 @@ use super::*;
|
||||
impl App {
|
||||
/// Apply a spell-check suggestion to the buffer, keeping the remaining
|
||||
/// misspelling underlines aligned.
|
||||
pub(super) fn apply_spell_fix(&mut self, match_idx: usize, rep_idx: usize) {
|
||||
pub(super) fn apply_spell_fix(&mut self, match_idx: usize, rep_idx: usize) -> bool {
|
||||
if self.buffer != self.spell_checked_text {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if splice_fix(&mut self.buffer, &mut self.spell_matches, match_idx, rep_idx) {
|
||||
self.dirty = true;
|
||||
self.find_needs_refresh = true;
|
||||
// Keep these matches valid without forcing a full re-check.
|
||||
self.spell_checked_text = self.buffer.clone();
|
||||
self.spell_status = match self.spell_matches.len() {
|
||||
0 => "No spelling issues".to_string(),
|
||||
1 => "1 spelling issue".to_string(),
|
||||
n => format!("{n} spelling issues"),
|
||||
};
|
||||
if !splice_fix(&mut self.buffer, &mut self.spell_matches, match_idx, rep_idx) {
|
||||
return false;
|
||||
}
|
||||
self.dirty = true;
|
||||
self.find_needs_refresh = true;
|
||||
// Keep these matches valid without forcing a full re-check.
|
||||
self.spell_checked_text = self.buffer.clone();
|
||||
self.spell_status = match self.spell_matches.len() {
|
||||
0 => "No spelling issues".to_string(),
|
||||
1 => "1 spelling issue".to_string(),
|
||||
n => format!("{n} spelling issues"),
|
||||
};
|
||||
true
|
||||
}
|
||||
|
||||
// ---- Spell check (offline) ---------------------------------------------
|
||||
@@ -125,13 +127,253 @@ impl App {
|
||||
}
|
||||
self.spell_dirty = false;
|
||||
let text = self.buffer.clone();
|
||||
let extra = self.wordlist.clone();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
self.spell_rx = Some(rx);
|
||||
let ctx = ctx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let matches = crate::spell::check(&dict, &text);
|
||||
let matches = crate::spell::check(&dict, &extra, &text);
|
||||
let _ = tx.send((text, matches));
|
||||
ctx.request_repaint();
|
||||
});
|
||||
}
|
||||
|
||||
// ---- The project word list ---------------------------------------------
|
||||
|
||||
/// Accept `word` from now on: add it to the workspace's word list, persist
|
||||
/// it, and drop the underlines it was causing without waiting for a
|
||||
/// re-check.
|
||||
pub(super) fn add_to_dictionary(&mut self, word: &str) {
|
||||
let word = word.trim().to_string();
|
||||
if word.is_empty() {
|
||||
return;
|
||||
}
|
||||
if !self.wordlist.insert(&word) {
|
||||
self.spell_status = format!("“{word}” is already in the word list");
|
||||
return;
|
||||
}
|
||||
self.persist_wordlist();
|
||||
self.drop_accepted_matches();
|
||||
self.spell_status = format!("Added “{word}” — {}", Self::spell_count_status(
|
||||
self.spell_matches.len()
|
||||
));
|
||||
}
|
||||
|
||||
/// Stop accepting `word`, so it is flagged again.
|
||||
pub(super) fn remove_from_dictionary(&mut self, word: &str) {
|
||||
if self.wordlist.remove(word) {
|
||||
self.persist_wordlist();
|
||||
// The word has to be found again, which needs a full pass.
|
||||
self.spell_dirty = true;
|
||||
self.spell_last_edit = None;
|
||||
self.spell_status = format!("Removed “{word}” from the word list");
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn persist_wordlist(&mut self) {
|
||||
if let Err(e) = crate::spell::write_wordlist(self.workspace(), &self.wordlist) {
|
||||
self.status = format!("Could not save the word list: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the matches the word list now accepts, so an added word stops being
|
||||
/// underlined immediately rather than after the next debounce.
|
||||
fn drop_accepted_matches(&mut self) {
|
||||
let text = self.spell_checked_text.clone();
|
||||
let list = self.wordlist.clone();
|
||||
self.spell_matches.retain(|m| {
|
||||
text.get(m.start..m.end)
|
||||
.is_none_or(|w| !list.accepts(w))
|
||||
});
|
||||
self.spell_menu = None;
|
||||
}
|
||||
|
||||
/// The project's character folder, looked for beside the workspace and then
|
||||
/// up through its ancestors — in a snowflake project the workspace is
|
||||
/// `06-First Draft` and the sheets are its sibling `03-Characters`.
|
||||
pub(super) fn characters_dir(&self) -> Option<PathBuf> {
|
||||
/// How far up to look before giving up; deep enough for the layout,
|
||||
/// shallow enough not to wander into the home directory.
|
||||
const MAX_UP: usize = 3;
|
||||
let mut dir = Some(self.workspace());
|
||||
for _ in 0..=MAX_UP {
|
||||
let current = dir?;
|
||||
if let Ok(read) = std::fs::read_dir(current) {
|
||||
for entry in read.flatten() {
|
||||
let name = entry.file_name();
|
||||
let Some(name) = name.to_str() else { continue };
|
||||
if crate::spell::is_characters_dir(name) && entry.path().is_dir() {
|
||||
return Some(entry.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
dir = current.parent();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Add every name found in the project's character sheets to the word list.
|
||||
/// Returns how many were new, or an error message.
|
||||
pub(super) fn harvest_character_names(&mut self) -> Result<usize, String> {
|
||||
let dir = self
|
||||
.characters_dir()
|
||||
.ok_or_else(|| "No character folder found near this workspace".to_string())?;
|
||||
let mut added = 0usize;
|
||||
let mut sheets = 0usize;
|
||||
for path in markdown_files_under(&dir) {
|
||||
let Ok(text) = std::fs::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
sheets += 1;
|
||||
for name in crate::spell::names_from_sheet(&text) {
|
||||
if self.wordlist.insert(&name) {
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if sheets == 0 {
|
||||
return Err(format!("No character sheets in {}", dir.display()));
|
||||
}
|
||||
if added > 0 {
|
||||
self.persist_wordlist();
|
||||
self.drop_accepted_matches();
|
||||
}
|
||||
Ok(added)
|
||||
}
|
||||
/// The workspace's word list: what the checker accepts on top of the
|
||||
/// dictionary, where it came from, and how to take a word back out.
|
||||
pub(super) fn wordlist_window(&mut self, ctx: &egui::Context) {
|
||||
let mut open = self.show_wordlist;
|
||||
let mut close = false;
|
||||
let mut remove: Option<String> = None;
|
||||
let mut harvest = false;
|
||||
|
||||
egui::Window::new("Word list")
|
||||
.open(&mut open)
|
||||
.resizable(true)
|
||||
.collapsible(false)
|
||||
.default_width(360.0)
|
||||
.show(ctx, |ui| {
|
||||
ui.label(
|
||||
egui::RichText::new(
|
||||
"Names and invented terms the spell checker accepts. Saved in \
|
||||
the workspace as wordlist.json, so it is committed with the \
|
||||
manuscript.",
|
||||
)
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut self.wordlist_input)
|
||||
.hint_text("add a word")
|
||||
.desired_width(200.0),
|
||||
);
|
||||
if ui.button("+ Add").clicked() {
|
||||
let word = self.wordlist_input.trim().to_string();
|
||||
if !word.is_empty() {
|
||||
self.add_to_dictionary(&word);
|
||||
self.wordlist_input.clear();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let found = self.characters_dir();
|
||||
ui.horizontal(|ui| {
|
||||
if ui
|
||||
.add_enabled(found.is_some(), egui::Button::new("👤 Add character names"))
|
||||
.on_hover_text(match &found {
|
||||
Some(dir) => format!("Read the sheets in {}", dir.display()),
|
||||
None => "No character folder found near this workspace".to_string(),
|
||||
})
|
||||
.clicked()
|
||||
{
|
||||
harvest = true;
|
||||
}
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
ui.label(
|
||||
egui::RichText::new(format!("{} word(s)", self.wordlist.len())).strong(),
|
||||
);
|
||||
egui::ScrollArea::vertical()
|
||||
.auto_shrink([false, true])
|
||||
.max_height(260.0)
|
||||
.show(ui, |ui| {
|
||||
if self.wordlist.is_empty() {
|
||||
ui.label(
|
||||
egui::RichText::new(
|
||||
"Nothing yet — right-click a underlined word in the \
|
||||
editor to add it.",
|
||||
)
|
||||
.weak(),
|
||||
);
|
||||
}
|
||||
for word in self.wordlist.words() {
|
||||
ui.horizontal(|ui| {
|
||||
if ui
|
||||
.small_button("✖")
|
||||
.on_hover_text("Flag this word again")
|
||||
.clicked()
|
||||
{
|
||||
remove = Some(word.clone());
|
||||
}
|
||||
ui.label(word);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
if ui.button("Close").clicked() {
|
||||
close = true;
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(word) = remove {
|
||||
self.remove_from_dictionary(&word);
|
||||
}
|
||||
if harvest {
|
||||
self.spell_status = match self.harvest_character_names() {
|
||||
Ok(0) => "No new names in the character sheets".to_string(),
|
||||
Ok(n) => format!("Added {n} name(s) from the character sheets"),
|
||||
Err(e) => format!("✖ {e}"),
|
||||
};
|
||||
}
|
||||
self.show_wordlist = open && !close;
|
||||
}
|
||||
}
|
||||
|
||||
/// Every `*.md` file at or below `dir`, one level of nesting at a time. Project
|
||||
/// folders group their files into subfolders — character sheets into
|
||||
/// `main_characters`, the outline into `scene_breakdown` — so a flat read of the
|
||||
/// top level would miss all of them.
|
||||
pub(super) fn markdown_files_under(dir: &Path) -> Vec<PathBuf> {
|
||||
let mut out = Vec::new();
|
||||
let mut stack = vec![dir.to_path_buf()];
|
||||
// A guard against a symlink pointing back up the tree.
|
||||
let mut budget = 512usize;
|
||||
while let Some(current) = stack.pop() {
|
||||
let Ok(read) = std::fs::read_dir(¤t) else {
|
||||
continue;
|
||||
};
|
||||
for entry in read.flatten() {
|
||||
if budget == 0 {
|
||||
return out;
|
||||
}
|
||||
budget -= 1;
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
} else if path
|
||||
.extension()
|
||||
.is_some_and(|e| e.eq_ignore_ascii_case("md"))
|
||||
{
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
+121
-2
@@ -176,7 +176,7 @@ impl App {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if let Some(idx) = self.selected {
|
||||
let name = &self.files[idx];
|
||||
let total = count_words(&self.buffer);
|
||||
let total = count_words(&self.document());
|
||||
let baseline =
|
||||
self.session_start_counts.get(name).copied().unwrap_or(0);
|
||||
let delta = total as i64 - baseline as i64;
|
||||
@@ -193,6 +193,21 @@ impl App {
|
||||
"Words in the current file · net change since this session opened",
|
||||
);
|
||||
|
||||
// The whole book's length, which is the number that
|
||||
// actually tells you where the manuscript stands.
|
||||
ui.separator();
|
||||
ui.label(
|
||||
egui::RichText::new(format!(
|
||||
"{} total",
|
||||
thousands(self.manuscript_words())
|
||||
))
|
||||
.weak(),
|
||||
)
|
||||
.on_hover_text(
|
||||
"Prose words across the whole manuscript (reference \
|
||||
files are not counted)",
|
||||
);
|
||||
|
||||
// Progress toward the file's Word Count Target, if set.
|
||||
if let Some((goal, prose)) = goal {
|
||||
let (frac, color, text) = goal_progress(goal, prose);
|
||||
@@ -240,6 +255,17 @@ impl App {
|
||||
ui.close_menu();
|
||||
self.export_odt();
|
||||
}
|
||||
if ui
|
||||
.button("Export chapters + master (.odm)")
|
||||
.on_hover_text(
|
||||
"One .odt per chapter in a chapters/ folder, plus an \
|
||||
.odm master that links them",
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
ui.close_menu();
|
||||
self.export_master();
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("Quit").clicked() {
|
||||
ui.close_menu();
|
||||
@@ -247,12 +273,30 @@ impl App {
|
||||
}
|
||||
});
|
||||
ui.menu_button("Edit", |ui| {
|
||||
if ui
|
||||
.button("⇄ Changes since last commit…")
|
||||
.on_hover_text("Diff the open file against its committed version")
|
||||
.clicked()
|
||||
{
|
||||
ui.close_menu();
|
||||
self.open_diff();
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("🔍 Find / Replace…").clicked() {
|
||||
ui.close_menu();
|
||||
self.open_find(true);
|
||||
}
|
||||
});
|
||||
ui.menu_button("Tools", |ui| {
|
||||
if ui.button("☑ Outline…").clicked() {
|
||||
ui.close_menu();
|
||||
self.show_outline = true;
|
||||
}
|
||||
if ui.button("👥 Characters…").clicked() {
|
||||
ui.close_menu();
|
||||
self.show_characters = true;
|
||||
}
|
||||
ui.separator();
|
||||
let busy = self.beats_rx.is_some();
|
||||
if ui
|
||||
.add_enabled(
|
||||
@@ -272,6 +316,30 @@ impl App {
|
||||
{
|
||||
self.config.save();
|
||||
}
|
||||
if ui
|
||||
.checkbox(
|
||||
&mut self.config.show_reference_files,
|
||||
"Show reference files",
|
||||
)
|
||||
.on_hover_text(
|
||||
"List a project's characters, outline and scratch pad in \
|
||||
the file panel alongside the manuscript",
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
self.config.save();
|
||||
}
|
||||
if ui
|
||||
.checkbox(&mut self.config.collapse_header, "Hide header in editor")
|
||||
.on_hover_text(
|
||||
"Edit the prose alone, with the fields above the draft \
|
||||
marker folded away",
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
self.apply_header_collapse();
|
||||
self.config.save();
|
||||
}
|
||||
ui.checkbox(&mut self.show_lt_panel, "Grammar panel");
|
||||
ui.checkbox(&mut self.show_log, "Git log");
|
||||
});
|
||||
@@ -292,6 +360,14 @@ impl App {
|
||||
ui.close_menu();
|
||||
self.show_project_settings = true;
|
||||
}
|
||||
if ui.button("Manuscript details…").clicked() {
|
||||
ui.close_menu();
|
||||
self.show_manuscript_settings = true;
|
||||
}
|
||||
if ui.button("Word list…").clicked() {
|
||||
ui.close_menu();
|
||||
self.show_wordlist = true;
|
||||
}
|
||||
});
|
||||
ui.menu_button("Help", |ui| {
|
||||
if ui.button("📝 Markdown cheatsheet").clicked() {
|
||||
@@ -357,7 +433,7 @@ impl App {
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Chapter title:");
|
||||
let auto = self.auto_title(idx, &self.buffer);
|
||||
let auto = self.auto_title(idx, &self.document());
|
||||
let resp = ui.add(
|
||||
egui::TextEdit::singleline(&mut self.title_input)
|
||||
.hint_text(format!("auto: {auto}"))
|
||||
@@ -372,6 +448,7 @@ impl App {
|
||||
.weak(),
|
||||
);
|
||||
});
|
||||
self.header_toggle(ui);
|
||||
ui.separator();
|
||||
|
||||
if self.show_find {
|
||||
@@ -450,6 +527,48 @@ impl App {
|
||||
self.decline_pending_repo();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// A single line saying whether the editorial header is folded away, and
|
||||
/// letting you flip it.
|
||||
///
|
||||
/// It exists so a hidden header can never be mistaken for a file that has
|
||||
/// none: the editor is showing less than the file holds, and that has to be
|
||||
/// visible without opening a menu.
|
||||
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()
|
||||
{
|
||||
return;
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
let (label, hover) = if collapsed {
|
||||
let n = self.hidden_field_count();
|
||||
(
|
||||
format!("▸ header hidden ({n} field{})", if n == 1 { "" } else { "s" }),
|
||||
"Show the fields above the draft marker",
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"▾ header shown".to_string(),
|
||||
"Fold the fields away and edit the prose alone",
|
||||
)
|
||||
};
|
||||
if ui
|
||||
.add(egui::Button::new(egui::RichText::new(label).small().weak()).frame(false))
|
||||
.on_hover_text(hover)
|
||||
.clicked()
|
||||
{
|
||||
self.config.collapse_header = !collapsed;
|
||||
self.apply_header_collapse();
|
||||
self.config.save();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Very small markdown-ish preview (headings emphasised, everything else plain).
|
||||
|
||||
+498
-23
@@ -17,26 +17,59 @@ impl App {
|
||||
self.status = format!("Cannot create workspace: {e}");
|
||||
return;
|
||||
}
|
||||
self.files = order::resolve_order(&ws);
|
||||
// The project root has moved, so the export follows it.
|
||||
self.retarget_export();
|
||||
// Which folder is the manuscript has to be settled before the file
|
||||
// list is read, since it decides what counts as a chapter.
|
||||
self.manuscript_dir = self.detect_manuscript_dir(&ws);
|
||||
self.files = order::resolve_order(&ws, &self.config.hidden_folders);
|
||||
self.titles = order::read_titles(&ws);
|
||||
self.wordlist = crate::spell::read_wordlist(&ws);
|
||||
// Drop overrides for files that no longer exist.
|
||||
self.titles.retain(|name, _| self.files.contains(name));
|
||||
self.detect_repo(&ws);
|
||||
self.selected = None;
|
||||
self.buffer.clear();
|
||||
self.header_stash = None;
|
||||
self.dirty = false;
|
||||
self.pending_delete = false;
|
||||
self.clear_lt();
|
||||
self.session_start_counts = self.snapshot_counts();
|
||||
self.file_meta = self.snapshot_file_meta();
|
||||
self.rebuild_field_names();
|
||||
self.load_characters();
|
||||
self.autocomplete = None;
|
||||
self.collapsed.clear();
|
||||
if !self.files.is_empty() {
|
||||
self.select(0);
|
||||
if let Some(idx) = first_listed(&self.files, self.manuscript_dir.as_deref()) {
|
||||
self.select(idx);
|
||||
}
|
||||
self.persist_order();
|
||||
self.status = format!("{} file(s) in {}", self.files.len(), ws.display());
|
||||
self.status = match &self.manuscript_dir {
|
||||
Some(dir) => {
|
||||
let chapters = self.manuscript_files().len();
|
||||
format!(
|
||||
"{chapters} chapter(s) in {dir}, {} reference file(s) — {}",
|
||||
self.files.len() - chapters,
|
||||
ws.display()
|
||||
)
|
||||
}
|
||||
None => format!("{} file(s) in {}", self.files.len(), ws.display()),
|
||||
};
|
||||
}
|
||||
|
||||
/// Point the export at the opened workspace, which is the project root.
|
||||
///
|
||||
/// A file name the user picked deliberately is carried across; one that
|
||||
/// merely echoed the previous project's folder is re-derived, so exports
|
||||
/// land beside the book being worked on instead of piling up in the
|
||||
/// project left behind.
|
||||
pub(super) fn retarget_export(&mut self) {
|
||||
let path = export_path_for(&self.config.workspace, &self.config.export_path);
|
||||
if path != self.config.export_path {
|
||||
self.config.export_path = path;
|
||||
self.config.save();
|
||||
}
|
||||
self.export_input = self.config.export_path.display().to_string();
|
||||
}
|
||||
|
||||
/// Work out the git situation for a freshly opened workspace.
|
||||
@@ -94,6 +127,39 @@ impl App {
|
||||
self.status = "Not using the enclosing git repository".to_string();
|
||||
}
|
||||
|
||||
/// Work out whether the opened folder is a **project root** — a folder
|
||||
/// holding the manuscript alongside characters, outline and the rest — and
|
||||
/// if so, which of its subfolders is the manuscript.
|
||||
///
|
||||
/// The test is simply whether it has a child folder named like the
|
||||
/// configured manuscript folder (`06-First Draft`), matched leniently by
|
||||
/// [`order::dir_matches`]. `None` means the workspace is itself the
|
||||
/// manuscript, which is how the app behaved before project mode and remains
|
||||
/// the right answer for a plain folder of chapters.
|
||||
pub(super) fn detect_manuscript_dir(&self, ws: &Path) -> Option<String> {
|
||||
order::child_dir_matching(ws, self.config.project_open_subdir.trim())
|
||||
}
|
||||
|
||||
/// Whether `path` (workspace-relative) is part of the manuscript proper:
|
||||
/// ordered, numbered and exported. Everything else in a project — character
|
||||
/// sheets, outline, scratch pad — is reference material that the app will
|
||||
/// happily open and edit but leaves out of the book.
|
||||
pub(super) fn is_manuscript(&self, path: &str) -> bool {
|
||||
match &self.manuscript_dir {
|
||||
Some(dir) => is_within(path, dir),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// The manuscript's files, in order, paired with their chapter index.
|
||||
pub(super) fn manuscript_files(&self) -> Vec<(usize, &String)> {
|
||||
self.files
|
||||
.iter()
|
||||
.filter(|path| self.is_manuscript(path))
|
||||
.enumerate()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Read every file and return its current on-disk word count.
|
||||
pub(super) fn snapshot_counts(&self) -> HashMap<String, usize> {
|
||||
self.files
|
||||
@@ -105,19 +171,56 @@ impl App {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Read every file and return its cached header info (slug/POV/goal/prose),
|
||||
/// for files that carry anything worth showing in the list.
|
||||
/// Read every file and return its cached header info (slug/POV/goal/status
|
||||
/// and prose length). Every file is cached, not only the ones with a tooltip
|
||||
/// to show, because the status column and the project word total need a
|
||||
/// figure for each.
|
||||
pub(super) fn snapshot_file_meta(&self) -> HashMap<String, FileMeta> {
|
||||
self.files
|
||||
.iter()
|
||||
.filter_map(|name| {
|
||||
.map(|name| {
|
||||
let text = std::fs::read_to_string(self.path_for(name)).unwrap_or_default();
|
||||
let meta = FileMeta::from_markdown(&text, &self.config.draft_marker);
|
||||
meta.has_display().then(|| (name.clone(), meta))
|
||||
(
|
||||
name.clone(),
|
||||
FileMeta::from_markdown(&text, &self.config.draft_marker),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Prose words across the whole manuscript, counting the open file from the
|
||||
/// buffer so the total moves as you type.
|
||||
pub(super) fn manuscript_words(&self) -> usize {
|
||||
let open = self.selected.and_then(|i| self.files.get(i));
|
||||
self.manuscript_files()
|
||||
.into_iter()
|
||||
.map(|(_, name)| {
|
||||
if Some(name) == open {
|
||||
let h =
|
||||
crate::preprocess::parse(&self.document(), &self.config.draft_marker);
|
||||
count_words(&h.body)
|
||||
} else {
|
||||
self.file_meta.get(name).map_or(0, |m| m.prose_words)
|
||||
}
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Every distinct `Status:` value in the manuscript, for the filter menu.
|
||||
pub(super) fn known_statuses(&self) -> Vec<String> {
|
||||
let mut seen: Vec<String> = Vec::new();
|
||||
for name in self.files.iter() {
|
||||
let Some(status) = self.file_meta.get(name).and_then(|m| m.status.clone()) else {
|
||||
continue;
|
||||
};
|
||||
if !seen.iter().any(|s| s.eq_ignore_ascii_case(&status)) {
|
||||
seen.push(status);
|
||||
}
|
||||
}
|
||||
seen.sort_by_key(|s| s.to_lowercase());
|
||||
seen
|
||||
}
|
||||
|
||||
pub(super) fn persist_order(&self) {
|
||||
let _ = order::write_order(self.workspace(), &self.files);
|
||||
}
|
||||
@@ -146,24 +249,97 @@ impl App {
|
||||
self.workspace().join(name)
|
||||
}
|
||||
|
||||
// ---- Collapsing the editorial header ------------------------------------
|
||||
|
||||
/// The whole document: the editor's text with the collapsed header, if any,
|
||||
/// put back in front of it.
|
||||
///
|
||||
/// While the header is collapsed it is not in `buffer` at all, which is what
|
||||
/// keeps every byte offset the editor works in — search matches, spelling
|
||||
/// underlines, the caret — pointing at what is actually on screen. Anything
|
||||
/// that wants the *file* rather than the view asks for this instead.
|
||||
pub(super) fn document(&self) -> std::borrow::Cow<'_, str> {
|
||||
match &self.header_stash {
|
||||
Some(header) => std::borrow::Cow::Owned(format!("{header}{}", self.buffer)),
|
||||
None => std::borrow::Cow::Borrowed(&self.buffer),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lift the header out of the buffer, leaving the prose. Does nothing for a
|
||||
/// document with no draft marker — there is nothing to collapse.
|
||||
pub(super) fn collapse_header(&mut self) {
|
||||
if self.header_stash.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some((header, body)) =
|
||||
crate::preprocess::split_at_marker(&self.buffer, &self.config.draft_marker)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let (header, body) = (header.to_string(), body.to_string());
|
||||
self.header_stash = Some(header);
|
||||
self.buffer = body;
|
||||
self.after_view_change();
|
||||
}
|
||||
|
||||
/// Put the header back into the buffer.
|
||||
pub(super) fn expand_header(&mut self) {
|
||||
let Some(header) = self.header_stash.take() else {
|
||||
return;
|
||||
};
|
||||
self.buffer.insert_str(0, &header);
|
||||
self.after_view_change();
|
||||
}
|
||||
|
||||
/// Re-collapse or expand to match the setting, after the buffer is replaced.
|
||||
pub(super) fn apply_header_collapse(&mut self) {
|
||||
if self.config.collapse_header {
|
||||
self.collapse_header();
|
||||
} else {
|
||||
self.expand_header();
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything keyed to the buffer's contents has to be recomputed when the
|
||||
/// header moves in or out of it — but the file itself has not changed, so
|
||||
/// this must not mark the document dirty.
|
||||
fn after_view_change(&mut self) {
|
||||
self.find_matches.clear();
|
||||
self.find_active = 0;
|
||||
self.find_needs_refresh = true;
|
||||
self.spell_matches.clear();
|
||||
self.spell_checked_text.clear();
|
||||
self.spell_dirty = true;
|
||||
self.spell_last_edit = None;
|
||||
self.spell_menu = None;
|
||||
self.clear_lt();
|
||||
self.autocomplete = None;
|
||||
}
|
||||
|
||||
/// How many header fields are hidden right now, for the toggle's label.
|
||||
pub(super) fn hidden_field_count(&self) -> usize {
|
||||
match &self.header_stash {
|
||||
Some(header) => crate::preprocess::fields(header, "").len(),
|
||||
None => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Save the in-memory buffer to disk if it has unsaved changes.
|
||||
pub(super) fn save_current(&mut self) {
|
||||
if let Some(idx) = self.selected {
|
||||
if self.dirty {
|
||||
if let Some(name) = self.files.get(idx).cloned() {
|
||||
let path = self.path_for(&name);
|
||||
match std::fs::write(&path, &self.buffer) {
|
||||
match std::fs::write(&path, self.document().as_bytes()) {
|
||||
Ok(_) => {
|
||||
self.dirty = false;
|
||||
self.status = format!("Saved {name}");
|
||||
// Keep the file-list cache (slug/POV/goal/prose) in step.
|
||||
let meta =
|
||||
FileMeta::from_markdown(&self.buffer, &self.config.draft_marker);
|
||||
if meta.has_display() {
|
||||
self.file_meta.insert(name, meta);
|
||||
} else {
|
||||
self.file_meta.remove(&name);
|
||||
}
|
||||
let meta = FileMeta::from_markdown(
|
||||
&self.document(),
|
||||
&self.config.draft_marker,
|
||||
);
|
||||
self.file_meta.insert(name, meta);
|
||||
self.merge_field_names_from_buffer();
|
||||
}
|
||||
Err(e) => self.status = format!("Save failed: {e}"),
|
||||
@@ -193,7 +369,9 @@ impl App {
|
||||
return;
|
||||
};
|
||||
let path = self.path_for(&name);
|
||||
self.header_stash = None;
|
||||
self.buffer = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
self.apply_header_collapse();
|
||||
self.selected = Some(idx);
|
||||
self.dirty = false;
|
||||
self.pending_delete = false;
|
||||
@@ -316,9 +494,12 @@ impl App {
|
||||
prune_empty_dirs(self.workspace(), parent_dir(&name));
|
||||
self.selected = None;
|
||||
self.buffer.clear();
|
||||
self.header_stash = None;
|
||||
self.dirty = false;
|
||||
if !self.files.is_empty() {
|
||||
self.select(idx.min(self.files.len() - 1));
|
||||
if let Some(next) =
|
||||
nearest_listed(&self.files, self.manuscript_dir.as_deref(), idx)
|
||||
{
|
||||
self.select(next);
|
||||
}
|
||||
self.status = format!("Deleted {name}");
|
||||
}
|
||||
@@ -329,6 +510,54 @@ impl App {
|
||||
self.pending_delete = false;
|
||||
}
|
||||
|
||||
/// Move the selected file into the project's archive folder: off the
|
||||
/// manuscript, out of the panel, but still on disk.
|
||||
///
|
||||
/// Deleting is destructive and a superseded scene is often worth keeping;
|
||||
/// the archive folder is hidden from the scan, so archiving a file makes it
|
||||
/// disappear from the tree without losing it.
|
||||
pub(super) fn archive_selected(&mut self) {
|
||||
let Some(idx) = self.selected else { return };
|
||||
let Some(name) = self.files.get(idx).cloned() else {
|
||||
return;
|
||||
};
|
||||
let archive = self.archive_dir();
|
||||
// Keep the file's shape inside the archive, so a scene from
|
||||
// `Act 1/` lands in `10-Archive/Act 1/` rather than losing its place.
|
||||
let dest = join_rel(&archive, &name);
|
||||
if self.path_for(&dest).exists() {
|
||||
self.status = format!("{dest} already exists");
|
||||
return;
|
||||
}
|
||||
self.save_current();
|
||||
if let Err(e) = self.relocate(&name, &dest) {
|
||||
self.status = format!("Archive failed: {e}");
|
||||
return;
|
||||
}
|
||||
self.files.remove(idx);
|
||||
self.persist_order();
|
||||
self.selected = None;
|
||||
self.buffer.clear();
|
||||
self.header_stash = None;
|
||||
self.dirty = false;
|
||||
if let Some(next) = nearest_listed(&self.files, self.manuscript_dir.as_deref(), idx) {
|
||||
self.select(next);
|
||||
}
|
||||
self.status = format!("Archived {name} to {archive}");
|
||||
}
|
||||
|
||||
/// The archive folder's name within the workspace: whatever the project
|
||||
/// already calls it, otherwise the configured default.
|
||||
pub(super) fn archive_dir(&self) -> String {
|
||||
let configured = self.config.archive_folder.trim();
|
||||
let role = if configured.is_empty() {
|
||||
"Archive"
|
||||
} else {
|
||||
configured
|
||||
};
|
||||
order::child_dir_matching(self.workspace(), role).unwrap_or_else(|| role.to_string())
|
||||
}
|
||||
|
||||
pub(super) fn rename_selected(&mut self) {
|
||||
let Some(idx) = self.selected else { return };
|
||||
// A path in the box (`part-2/ch-07`) both renames and moves the file.
|
||||
@@ -400,7 +629,7 @@ impl App {
|
||||
/// chapter number when padding is enabled, otherwise 1 (no padding).
|
||||
pub(super) fn index_pad_width(&self) -> usize {
|
||||
if self.config.zero_pad_index {
|
||||
self.files.len().to_string().len().max(1)
|
||||
self.manuscript_files().len().to_string().len().max(1)
|
||||
} else {
|
||||
1
|
||||
}
|
||||
@@ -419,17 +648,93 @@ impl App {
|
||||
/// Prose = the body below the draft marker, so header metadata isn't counted.
|
||||
pub(super) fn current_goal(&self) -> Option<(crate::preprocess::WordGoal, usize)> {
|
||||
self.selected?;
|
||||
let header = crate::preprocess::parse(&self.buffer, &self.config.draft_marker);
|
||||
let header = crate::preprocess::parse(&self.document(), &self.config.draft_marker);
|
||||
let goal = header.goal?;
|
||||
Some((goal, count_words(&header.body)))
|
||||
}
|
||||
|
||||
/// Document properties for an export: the configured title and author, with
|
||||
/// the title falling back to the project folder's own name.
|
||||
pub(super) fn doc_meta(&self, chapters: &[Chapter]) -> odt::DocMeta {
|
||||
let title = match self.config.manuscript_title.trim() {
|
||||
"" => self
|
||||
.workspace()
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("Manuscript")
|
||||
.to_string(),
|
||||
set => set.to_string(),
|
||||
};
|
||||
odt::DocMeta {
|
||||
title,
|
||||
author: self.config.manuscript_author.trim().to_string(),
|
||||
subject: String::new(),
|
||||
keywords: String::new(),
|
||||
word_count: chapters.iter().map(|c| count_words(&c.markdown)).sum(),
|
||||
chapter_count: chapters.len(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Export the manuscript as one `.odt` per chapter plus an `.odm` master
|
||||
/// that links them, which is the shape the snowflake template expects.
|
||||
///
|
||||
/// The per-chapter files are ordinary documents and are the useful part.
|
||||
/// The master is a shell: LibreOffice does not follow its section links on
|
||||
/// load without being asked to update them, so treat it as a starting point
|
||||
/// rather than as the assembled book.
|
||||
pub(super) fn export_master(&mut self) {
|
||||
self.save_current();
|
||||
let chapters = self.collect_chapters();
|
||||
if chapters.is_empty() {
|
||||
self.status = "Nothing to export — the manuscript is empty".to_string();
|
||||
return;
|
||||
}
|
||||
let meta = self.doc_meta(&chapters);
|
||||
let out = PathBuf::from(self.export_input.trim()).with_extension("odm");
|
||||
if let Some(parent) = out.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
match odt::export_master(&chapters, &meta, &out) {
|
||||
Ok(written) => {
|
||||
self.status = format!(
|
||||
"Wrote {} chapter file(s) beside {}",
|
||||
written.len(),
|
||||
out.display()
|
||||
);
|
||||
}
|
||||
Err(e) => self.status = format!("Export failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The manuscript as chapters, ready for either export path.
|
||||
fn collect_chapters(&self) -> Vec<Chapter> {
|
||||
let marker = self.config.draft_marker.clone();
|
||||
let pad_width = self.index_pad_width();
|
||||
self.manuscript_files()
|
||||
.into_iter()
|
||||
.map(|(i, name)| {
|
||||
let raw = std::fs::read_to_string(self.path_for(name)).unwrap_or_default();
|
||||
let header = crate::preprocess::parse(&raw, &marker);
|
||||
Chapter {
|
||||
title: resolve_chapter_title(
|
||||
self.titles.get(name).map(String::as_str),
|
||||
header.title.as_deref(),
|
||||
i,
|
||||
pad_width,
|
||||
),
|
||||
slug: header.slug.clone(),
|
||||
markdown: header.body,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn export_odt(&mut self) {
|
||||
self.save_current();
|
||||
let marker = self.config.draft_marker.clone();
|
||||
let pad_width = self.index_pad_width();
|
||||
let mut chapters = Vec::new();
|
||||
for (i, name) in self.files.iter().enumerate() {
|
||||
for (i, name) in self.manuscript_files() {
|
||||
let raw = std::fs::read_to_string(self.path_for(name)).unwrap_or_default();
|
||||
// Strip comments + the editorial header, and lift out any
|
||||
// `# Title:` / `# Slug:` metadata.
|
||||
@@ -453,7 +758,8 @@ impl App {
|
||||
if let Some(parent) = out.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
match odt::export(&chapters, &out) {
|
||||
let meta = self.doc_meta(&chapters);
|
||||
match odt::export(&chapters, &meta, &out) {
|
||||
Ok(_) => {
|
||||
self.config.export_path = out.clone();
|
||||
self.config.save();
|
||||
@@ -640,6 +946,73 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Index of the first file the panel lists, which is the one to open a
|
||||
/// workspace on.
|
||||
///
|
||||
/// In a project the panel shows the manuscript, so landing on `files[0]` would
|
||||
/// open whatever sorts first across the whole project — a scratch-pad note,
|
||||
/// typically — and in a file the user cannot see in the tree. Reference files
|
||||
/// are still the fallback, for a project whose manuscript folder is empty.
|
||||
pub(super) fn first_listed(files: &[String], manuscript_dir: Option<&str>) -> Option<usize> {
|
||||
let in_book = |name: &String| match manuscript_dir {
|
||||
Some(dir) => is_within(name, dir),
|
||||
None => true,
|
||||
};
|
||||
files
|
||||
.iter()
|
||||
.position(in_book)
|
||||
.or_else(|| (!files.is_empty()).then_some(0))
|
||||
}
|
||||
|
||||
/// The listed file nearest `idx` after a removal: the next one down, else the
|
||||
/// last one before it, else whatever is left.
|
||||
pub(super) fn nearest_listed(
|
||||
files: &[String],
|
||||
manuscript_dir: Option<&str>,
|
||||
idx: usize,
|
||||
) -> Option<usize> {
|
||||
let in_book = |i: &usize| match manuscript_dir {
|
||||
Some(dir) => is_within(&files[*i], dir),
|
||||
None => true,
|
||||
};
|
||||
(idx..files.len())
|
||||
.find(in_book)
|
||||
.or_else(|| (0..idx.min(files.len())).rev().find(in_book))
|
||||
.or_else(|| first_listed(files, manuscript_dir))
|
||||
}
|
||||
|
||||
/// Where the manuscript exports to for `workspace`, given the `current` export
|
||||
/// path.
|
||||
///
|
||||
/// The folder is always the workspace (the project root). The file name is kept
|
||||
/// when it looks chosen — anything but the fallback `manuscript` or an echo of
|
||||
/// the folder the file sits in, both of which we generate ourselves.
|
||||
fn export_path_for(workspace: &Path, current: &Path) -> PathBuf {
|
||||
let stem = current.file_stem().and_then(|s| s.to_str()).unwrap_or("");
|
||||
let old_folder = current
|
||||
.parent()
|
||||
.and_then(|p| p.file_name())
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("");
|
||||
let generated = stem.is_empty()
|
||||
|| stem.eq_ignore_ascii_case("manuscript")
|
||||
|| stem.eq_ignore_ascii_case(old_folder);
|
||||
let name = if generated {
|
||||
let folder = workspace
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("manuscript");
|
||||
format!("{folder}.odt")
|
||||
} else {
|
||||
current
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("manuscript.odt")
|
||||
.to_string()
|
||||
};
|
||||
workspace.join(name)
|
||||
}
|
||||
|
||||
/// Delete `dir` (workspace-relative) and every parent it leaves childless, so
|
||||
/// the tree stops drawing branches nothing lives in any more. `remove_dir`
|
||||
/// refuses to touch a non-empty directory, which is exactly the guard wanted
|
||||
@@ -801,6 +1174,49 @@ mod tests {
|
||||
assert!(!out.contains("{{"), "placeholder left unexpanded in {out:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_a_project_moves_a_generated_export_name_with_it() {
|
||||
let out = export_path_for(
|
||||
Path::new("/books/The Winter Gate"),
|
||||
Path::new("/books/Salt Road/Salt Road.odt"),
|
||||
);
|
||||
assert_eq!(
|
||||
out,
|
||||
PathBuf::from("/books/The Winter Gate/The Winter Gate.odt")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chosen_export_name_survives_the_move_to_the_new_root() {
|
||||
let out = export_path_for(
|
||||
Path::new("/books/The Winter Gate"),
|
||||
Path::new("/books/Salt Road/submission draft.odt"),
|
||||
);
|
||||
assert_eq!(
|
||||
out,
|
||||
PathBuf::from("/books/The Winter Gate/submission draft.odt")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_export_name_is_re_derived_rather_than_kept() {
|
||||
let out = export_path_for(
|
||||
Path::new("/books/The Winter Gate"),
|
||||
Path::new("/home/writer/Manuscript/manuscript.odt"),
|
||||
);
|
||||
assert_eq!(
|
||||
out,
|
||||
PathBuf::from("/books/The Winter Gate/The Winter Gate.odt")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reopening_the_same_workspace_leaves_the_export_untouched() {
|
||||
let ws = Path::new("/books/The Winter Gate");
|
||||
let current = PathBuf::from("/books/The Winter Gate/submission draft.odt");
|
||||
assert_eq!(export_path_for(ws, ¤t), current);
|
||||
}
|
||||
|
||||
/// Build a throwaway workspace containing `files` and hand back its path.
|
||||
fn scratch_ws(tag: &str, files: &[&str]) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("md_manuscript_ws_{tag}"));
|
||||
@@ -876,4 +1292,63 @@ mod tests {
|
||||
assert!(ws.is_dir());
|
||||
let _ = std::fs::remove_dir_all(&ws);
|
||||
}
|
||||
|
||||
fn v(items: &[&str]) -> Vec<String> {
|
||||
items.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
/// The real shape of the bug: the scratch pad sorts first, so opening a
|
||||
/// project landed on a note that the panel does not even list.
|
||||
#[test]
|
||||
fn opening_a_project_lands_in_the_manuscript() {
|
||||
let files = v(&[
|
||||
"00-Scratch Pad/Generated plot points.md",
|
||||
"03-Characters/ada.md",
|
||||
"06-First Draft/Act 1/scene01.md",
|
||||
"06-First Draft/draft_v1.md",
|
||||
]);
|
||||
assert_eq!(first_listed(&files, Some("06-First Draft")), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plain_folder_still_opens_on_its_first_file() {
|
||||
let files = v(&["a.md", "b.md"]);
|
||||
assert_eq!(first_listed(&files, None), Some(0));
|
||||
assert_eq!(first_listed(&[], None), None);
|
||||
}
|
||||
|
||||
/// A project whose manuscript folder holds nothing yet still has to open
|
||||
/// something rather than nothing.
|
||||
#[test]
|
||||
fn an_empty_manuscript_falls_back_to_the_first_file() {
|
||||
let files = v(&["00-Scratch Pad/note.md", "03-Characters/ada.md"]);
|
||||
assert_eq!(first_listed(&files, Some("06-First Draft")), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn after_a_removal_the_next_listed_file_is_chosen() {
|
||||
let files = v(&[
|
||||
"00-Scratch Pad/note.md",
|
||||
"06-First Draft/a.md",
|
||||
"06-First Draft/b.md",
|
||||
]);
|
||||
// Removing index 1 leaves the following manuscript file at index 1.
|
||||
assert_eq!(nearest_listed(&files, Some("06-First Draft"), 1), Some(1));
|
||||
// Removing the last one falls back to the previous manuscript file.
|
||||
assert_eq!(nearest_listed(&files, Some("06-First Draft"), 3), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_removal_never_lands_on_a_reference_file() {
|
||||
let files = v(&["00-Scratch Pad/note.md", "06-First Draft/a.md"]);
|
||||
// Index 2 is past the end; the search back must skip the scratch pad.
|
||||
assert_eq!(nearest_listed(&files, Some("06-First Draft"), 2), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_the_only_manuscript_file_still_selects_something() {
|
||||
let files = v(&["00-Scratch Pad/note.md"]);
|
||||
assert_eq!(nearest_listed(&files, Some("06-First Draft"), 0), Some(0));
|
||||
assert_eq!(nearest_listed(&[], Some("06-First Draft"), 0), None);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user