043cc692ac
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
779 lines
35 KiB
Rust
779 lines
35 KiB
Rust
//! The left-hand file list: a collapsible folder tree of the workspace's
|
||
//! markdown files, drag-to-reorder (and drag-to-move) rows, chapter titles and
|
||
//! the per-file word-count progress bar.
|
||
|
||
use super::*;
|
||
|
||
/// Horizontal indent per folder level, in points.
|
||
const INDENT: f32 = 12.0;
|
||
|
||
/// One drawn line of the file tree.
|
||
pub(super) struct Row {
|
||
/// Workspace-relative path: the folder for a folder row, the file for a
|
||
/// file row.
|
||
pub path: String,
|
||
/// Nesting level; 0 sits directly in the workspace root.
|
||
pub depth: usize,
|
||
pub kind: RowKind,
|
||
}
|
||
|
||
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. 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
|
||
/// should take, and the folder it should end up in (`""` = the workspace root).
|
||
pub(super) struct FileDrop {
|
||
pub from: usize,
|
||
pub to: usize,
|
||
pub dir: String,
|
||
}
|
||
|
||
/// Whether a collapsed folder somewhere above `path` is hiding it. The last
|
||
/// component of `path` is the item itself, so it is never its own concealer.
|
||
fn hidden_by_collapse(path: &str, collapsed: &HashSet<String>) -> bool {
|
||
let mut ancestor = String::new();
|
||
let mut parts = path.split('/').peekable();
|
||
while let Some(part) = parts.next() {
|
||
if parts.peek().is_none() {
|
||
return false;
|
||
}
|
||
if !ancestor.is_empty() {
|
||
ancestor.push('/');
|
||
}
|
||
ancestor.push_str(part);
|
||
if collapsed.contains(&ancestor) {
|
||
return true;
|
||
}
|
||
}
|
||
false
|
||
}
|
||
|
||
/// Turn the flat, folder-tree-ordered file list into the rows to draw, opening
|
||
/// a folder header wherever the path prefix changes and skipping everything
|
||
/// inside a collapsed folder.
|
||
///
|
||
/// This relies on `files` being in [`crate::order::tree_order`]: because each
|
||
/// folder's files are contiguous there, a header is needed exactly once, and
|
||
/// the file that opens it is by construction the folder's first.
|
||
pub(super) fn build_rows(files: &[String], collapsed: &HashSet<String>) -> Vec<Row> {
|
||
let mut rows = Vec::new();
|
||
// The folder components currently open, outermost first.
|
||
let mut open: Vec<&str> = Vec::new();
|
||
|
||
for (idx, path) in files.iter().enumerate() {
|
||
let mut parts: Vec<&str> = path.split('/').collect();
|
||
parts.pop(); // the file name itself
|
||
let shared = open
|
||
.iter()
|
||
.zip(&parts)
|
||
.take_while(|(a, b)| a == b)
|
||
.count();
|
||
open.truncate(shared);
|
||
for dir in &parts[shared..] {
|
||
open.push(dir);
|
||
let folder = open.join("/");
|
||
if hidden_by_collapse(&folder, collapsed) {
|
||
continue;
|
||
}
|
||
// Contiguity makes the run of files under this folder easy to count.
|
||
let prefix = format!("{folder}/");
|
||
let count = files[idx..]
|
||
.iter()
|
||
.take_while(|f| f.starts_with(&prefix))
|
||
.count();
|
||
rows.push(Row {
|
||
depth: open.len() - 1,
|
||
kind: RowKind::Folder { count, first: idx },
|
||
path: folder,
|
||
});
|
||
}
|
||
if !hidden_by_collapse(path, collapsed) {
|
||
rows.push(Row {
|
||
path: path.clone(),
|
||
depth: parts.len(),
|
||
kind: RowKind::File,
|
||
});
|
||
}
|
||
}
|
||
rows
|
||
}
|
||
|
||
impl App {
|
||
pub(super) fn left_pane(&mut self, ctx: &egui::Context) {
|
||
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
|
||
// dark mode) without affecting the rest of the app.
|
||
boost_list_contrast(ui.visuals_mut());
|
||
ui.add_space(4.0);
|
||
ui.heading("Files");
|
||
ui.label(
|
||
egui::RichText::new("drag ⠿ to reorder · drop on a folder to move")
|
||
.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());
|
||
// 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()
|
||
.auto_shrink([false, false])
|
||
.max_height(ui.available_height() - 120.0)
|
||
.show(ui, |ui| {
|
||
for row in &rows {
|
||
let indent = row.depth as f32 * INDENT;
|
||
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);
|
||
let arrow = if open { "⏷" } else { "⏵" };
|
||
let label = format!(
|
||
"{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_sized(
|
||
[name_w, 20.0],
|
||
egui::Button::new(text)
|
||
.frame(false)
|
||
.truncate(),
|
||
)
|
||
.on_hover_text(&row.path)
|
||
.clicked()
|
||
{
|
||
toggled = Some(row.path.clone());
|
||
}
|
||
ui.label(
|
||
egui::RichText::new(count.to_string())
|
||
.small()
|
||
.weak(),
|
||
);
|
||
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,
|
||
dir: row.path.clone(),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
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.document(),
|
||
&self.config.draft_marker,
|
||
)
|
||
} else {
|
||
self.file_meta.get(&name).cloned().unwrap_or_default()
|
||
};
|
||
let tooltip = meta.tooltip();
|
||
let file_row = ui
|
||
.horizontal(|ui| {
|
||
ui.add_space(indent);
|
||
ui.dnd_drag_source(
|
||
egui::Id::new(("dnd", &name)),
|
||
idx,
|
||
|ui| {
|
||
ui.label(
|
||
egui::RichText::new("⠿")
|
||
.monospace()
|
||
.weak(),
|
||
);
|
||
},
|
||
);
|
||
// Reserve room on the right for a per-file
|
||
// word-count-target bar when the file sets one.
|
||
let bar_w = 44.0;
|
||
let reserve = if meta.goal.is_some() {
|
||
bar_w + 6.0
|
||
} else {
|
||
0.0
|
||
};
|
||
let label_w = (row_width
|
||
- indent
|
||
- HANDLE_W
|
||
- reserve)
|
||
.max(24.0);
|
||
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, text),
|
||
);
|
||
if let Some(tooltip) = &tooltip {
|
||
label = label.on_hover_text(tooltip);
|
||
}
|
||
if label.clicked() {
|
||
clicked = Some(idx);
|
||
}
|
||
if let Some(goal) = meta.goal {
|
||
row_goal_bar(
|
||
ui,
|
||
bar_w,
|
||
goal,
|
||
meta.prose_words,
|
||
);
|
||
}
|
||
if let Some(status) = &meta.status {
|
||
ui.label(
|
||
egui::RichText::new(status_tag(status))
|
||
.small()
|
||
.weak(),
|
||
)
|
||
.on_hover_text(format!("Status: {status}"));
|
||
}
|
||
})
|
||
.response;
|
||
|
||
// Drop handling: is a dragged item hovering this row?
|
||
if file_row.dnd_hover_payload::<usize>().is_some() {
|
||
let rect = file_row.rect;
|
||
let before = pointer
|
||
.map(|p| p.y < rect.center().y)
|
||
.unwrap_or(true);
|
||
let y = if before { rect.top() } else { rect.bottom() };
|
||
ui.painter().hline(
|
||
rect.x_range(),
|
||
y,
|
||
// `Stroke::new` takes `impl Into<f32>`, which
|
||
// gives an unsuffixed literal no concrete type
|
||
// to infer; suffix it rather than lean on the
|
||
// f32 fallback that rustc is removing.
|
||
egui::Stroke::new(
|
||
2.0_f32,
|
||
ui.visuals().selection.stroke.color,
|
||
),
|
||
);
|
||
if let Some(payload) =
|
||
file_row.dnd_release_payload::<usize>()
|
||
{
|
||
dropped = Some(FileDrop {
|
||
from: *payload,
|
||
to: if before { idx } else { idx + 1 },
|
||
// Dropping among a folder's files
|
||
// means joining that folder.
|
||
dir: parent_dir(&name).to_string(),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// With folders in play there is otherwise no row to aim at
|
||
// to get a file back out to the top level.
|
||
if nested {
|
||
ui.add_space(2.0);
|
||
let target = ui
|
||
.horizontal(|ui| {
|
||
ui.label(
|
||
egui::RichText::new("↥ drop here for the top level")
|
||
.small()
|
||
.weak(),
|
||
);
|
||
})
|
||
.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 {
|
||
from: *payload,
|
||
to: self.files.len(),
|
||
dir: String::new(),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
if let Some(idx) = clicked {
|
||
self.select(idx);
|
||
}
|
||
if let Some(path) = toggled {
|
||
if !self.collapsed.remove(&path) {
|
||
self.collapsed.insert(path);
|
||
}
|
||
}
|
||
if let Some(drop) = dropped {
|
||
self.apply_drop(drop);
|
||
}
|
||
|
||
ui.separator();
|
||
ui.horizontal(|ui| {
|
||
ui.add(
|
||
egui::TextEdit::singleline(&mut self.new_name)
|
||
.hint_text("new file name")
|
||
.desired_width(150.0),
|
||
)
|
||
.on_hover_text(
|
||
"A name, or a path to nest it: part-1/ch-01 creates the \
|
||
folder along with the file.",
|
||
);
|
||
if ui.button("+ New").clicked() {
|
||
self.create_file();
|
||
}
|
||
});
|
||
if ui
|
||
.button("+ New from template")
|
||
.on_hover_text(
|
||
"Create untitled-N.md seeded from the template \
|
||
(Settings ▸ New-file template…), in the current file's folder",
|
||
)
|
||
.clicked()
|
||
{
|
||
self.create_file_from_template();
|
||
}
|
||
|
||
if self.selected.is_some() {
|
||
ui.horizontal(|ui| {
|
||
ui.add(
|
||
egui::TextEdit::singleline(&mut self.rename_input)
|
||
.hint_text("rename")
|
||
.desired_width(150.0),
|
||
)
|
||
.on_hover_text(
|
||
"The file's path within the workspace — edit the folder \
|
||
part to move it.",
|
||
);
|
||
if ui.button("Rename").clicked() {
|
||
self.rename_selected();
|
||
}
|
||
});
|
||
ui.horizontal(|ui| {
|
||
if ui
|
||
.button("🗄 Archive")
|
||
.on_hover_text(
|
||
"Move this file into the archive folder and out of \
|
||
the manuscript, keeping it on disk",
|
||
)
|
||
.clicked()
|
||
{
|
||
self.archive_selected();
|
||
}
|
||
});
|
||
ui.horizontal(|ui| {
|
||
if !self.pending_delete {
|
||
if ui.button("🗑 Delete").clicked() {
|
||
self.pending_delete = true;
|
||
}
|
||
} else {
|
||
ui.label("Delete file?");
|
||
if ui
|
||
.button(egui::RichText::new("Yes").color(egui::Color32::RED))
|
||
.clicked()
|
||
{
|
||
self.delete_selected();
|
||
}
|
||
if ui.button("No").clicked() {
|
||
self.pending_delete = false;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
/// Outline a whole-row drop target while a file is dragged over it, and report
|
||
/// whether it is being hovered (so the caller can look for the release).
|
||
fn drop_highlight(ui: &egui::Ui, response: &egui::Response) -> bool {
|
||
if response.dnd_hover_payload::<usize>().is_none() {
|
||
return false;
|
||
}
|
||
ui.painter().rect_stroke(
|
||
response.rect,
|
||
egui::Rounding::same(2.0),
|
||
egui::Stroke::new(2.0_f32, ui.visuals().selection.stroke.color),
|
||
);
|
||
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).
|
||
pub(super) fn resolve_chapter_title(
|
||
override_title: Option<&str>,
|
||
header_title: Option<&str>,
|
||
index: usize,
|
||
pad_width: usize,
|
||
) -> String {
|
||
override_title
|
||
.map(str::trim)
|
||
.filter(|t| !t.is_empty())
|
||
.or_else(|| header_title.map(str::trim).filter(|t| !t.is_empty()))
|
||
.map(|t| t.to_string())
|
||
.unwrap_or_else(|| format!("{:0width$}.", index + 1, width = pad_width))
|
||
}
|
||
|
||
/// Progress toward a word-count target: the bar fraction (0..=1), a fill colour
|
||
/// (amber under the range, green within it, blue over it), and a label like
|
||
/// "1,234 / 1,500–2,000".
|
||
pub(super) fn goal_progress(
|
||
goal: crate::preprocess::WordGoal,
|
||
prose: usize,
|
||
) -> (f32, egui::Color32, String) {
|
||
let frac = if goal.max == 0 {
|
||
0.0
|
||
} else {
|
||
(prose as f32 / goal.max as f32).clamp(0.0, 1.0)
|
||
};
|
||
let color = if prose < goal.min {
|
||
egui::Color32::from_rgb(0xC8, 0x8A, 0x2A) // amber: below target
|
||
} else if prose <= goal.max {
|
||
egui::Color32::from_rgb(0x3F, 0x9E, 0x4F) // green: in range
|
||
} else {
|
||
egui::Color32::from_rgb(0x3B, 0x82, 0xF6) // blue: over target
|
||
};
|
||
let target = if goal.min == goal.max {
|
||
thousands(goal.max)
|
||
} else {
|
||
format!("{}–{}", thousands(goal.min), thousands(goal.max))
|
||
};
|
||
(frac, color, format!("{} / {}", thousands(prose), target))
|
||
}
|
||
|
||
/// Paint a compact word-count-target progress bar for one file-list row, with
|
||
/// the numeric progress (and target) as a hover tooltip. The colour matches the
|
||
/// status-bar bar: amber under target, green in range, blue over.
|
||
pub(super) fn row_goal_bar(
|
||
ui: &mut egui::Ui,
|
||
width: f32,
|
||
goal: crate::preprocess::WordGoal,
|
||
prose: usize,
|
||
) {
|
||
let (frac, color, text) = goal_progress(goal, prose);
|
||
let (rect, resp) = ui.allocate_exact_size(egui::vec2(width, 8.0), egui::Sense::hover());
|
||
let rounding = egui::Rounding::same(2.0);
|
||
let track = ui.visuals().extreme_bg_color;
|
||
let painter = ui.painter();
|
||
painter.rect_filled(rect, rounding, track);
|
||
if frac > 0.0 {
|
||
let fill = egui::Rect::from_min_size(
|
||
rect.min,
|
||
egui::vec2((rect.width() * frac).max(1.0), rect.height()),
|
||
);
|
||
painter.rect_filled(fill, rounding, color);
|
||
}
|
||
resp.on_hover_text(format!("{text} words"));
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn chapter_title_falls_back_to_position() {
|
||
// No override, no header title -> 1-based index with a period (width 1).
|
||
assert_eq!(resolve_chapter_title(None, None, 0, 1), "1.");
|
||
assert_eq!(resolve_chapter_title(None, None, 2, 1), "3.");
|
||
// Header title is used when present.
|
||
assert_eq!(resolve_chapter_title(None, Some("The Gate"), 4, 1), "The Gate");
|
||
// Override wins over everything, even a header title.
|
||
assert_eq!(
|
||
resolve_chapter_title(Some("My Override"), Some("The Gate"), 4, 1),
|
||
"My Override"
|
||
);
|
||
// Blank/whitespace override or header title are ignored.
|
||
assert_eq!(resolve_chapter_title(Some(" "), None, 1, 1), "2.");
|
||
assert_eq!(resolve_chapter_title(Some(""), Some(" "), 6, 1), "7.");
|
||
}
|
||
|
||
#[test]
|
||
fn chapter_number_zero_pads_to_width() {
|
||
// Width 2 pads single digits; wider numbers are unaffected.
|
||
assert_eq!(resolve_chapter_title(None, None, 0, 2), "01.");
|
||
assert_eq!(resolve_chapter_title(None, None, 8, 2), "09.");
|
||
assert_eq!(resolve_chapter_title(None, None, 11, 2), "12.");
|
||
assert_eq!(resolve_chapter_title(None, None, 4, 3), "005.");
|
||
// Padding never applies to a real title.
|
||
assert_eq!(
|
||
resolve_chapter_title(None, Some("The Gate"), 0, 3),
|
||
"The Gate"
|
||
);
|
||
}
|
||
|
||
/// Render the rows as `depth:kind:path` lines, which is compact enough to
|
||
/// assert the whole tree shape in one go.
|
||
fn sketch(files: &[&str], collapsed: &[&str]) -> Vec<String> {
|
||
let files: Vec<String> = files.iter().map(|s| s.to_string()).collect();
|
||
let collapsed: HashSet<String> = collapsed.iter().map(|s| s.to_string()).collect();
|
||
build_rows(&files, &collapsed)
|
||
.iter()
|
||
.map(|r| match r.kind {
|
||
RowKind::Folder { count, first } => {
|
||
format!("{}:dir({count},{first}):{}", r.depth, r.path)
|
||
}
|
||
RowKind::File => format!("{}:file:{}", r.depth, r.path),
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
#[test]
|
||
fn flat_files_get_no_folder_rows() {
|
||
assert_eq!(
|
||
sketch(&["a.md", "b.md"], &[]),
|
||
vec!["0:file:a.md", "0:file:b.md"]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_folder_header_is_opened_once_for_its_run_of_files() {
|
||
assert_eq!(
|
||
sketch(&["p/a.md", "p/b.md", "top.md"], &[]),
|
||
vec![
|
||
"0:dir(2,0):p",
|
||
"1:file:p/a.md",
|
||
"1:file:p/b.md",
|
||
"0:file:top.md",
|
||
]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn nested_folders_indent_and_count_everything_below_them() {
|
||
assert_eq!(
|
||
sketch(&["p/q/a.md", "p/b.md"], &[]),
|
||
vec![
|
||
// `p` counts both files; `q` only its own.
|
||
"0:dir(2,0):p",
|
||
"1:dir(1,0):p/q",
|
||
"2:file:p/q/a.md",
|
||
"1:file:p/b.md",
|
||
]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
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:top.md"]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
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: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:p/b.md"]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_collapsed_name_only_hides_its_own_children() {
|
||
// `part-1` must not swallow `part-10`, which merely shares a prefix.
|
||
assert_eq!(
|
||
sketch(&["part-1/a.md", "part-10/b.md"], &["part-1"]),
|
||
vec![
|
||
"0:dir(1,0):part-1",
|
||
"0:dir(1,1):part-10",
|
||
"1:file:part-10/b.md",
|
||
]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn sibling_folders_each_get_their_own_header() {
|
||
assert_eq!(
|
||
sketch(&["p/a.md", "q/b.md"], &[]),
|
||
vec![
|
||
"0:dir(1,0):p",
|
||
"1:file:p/a.md",
|
||
"0:dir(1,1):q",
|
||
"1:file:q/b.md",
|
||
]
|
||
);
|
||
}
|
||
|
||
/// The first index a folder header reports is where a drop into that folder
|
||
/// lands, so it has to point at the folder's own first file.
|
||
#[test]
|
||
fn a_folder_header_points_at_its_first_file() {
|
||
let rows = sketch(&["top.md", "p/a.md", "p/b.md"], &[]);
|
||
assert_eq!(rows[1], "0:dir(2,1):p");
|
||
}
|
||
|
||
#[test]
|
||
fn an_empty_list_draws_nothing() {
|
||
assert!(sketch(&[], &[]).is_empty());
|
||
}
|
||
}
|