//! 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) -> 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) -> Vec { 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 = None; let mut toggled: Option = None; let mut dropped: Option = 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 = 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::() { // `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::().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`, 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::() { 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::() { 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::().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::().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 { let files: Vec = files.iter().map(|s| s.to_string()).collect(); let collapsed: HashSet = 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()); } }