Split app.rs into an app/ module tree

src/app.rs had grown to 3,700 lines, ~2,400 of them a single impl App
block. Move it to src/app/mod.rs and spread the behaviour across eleven
child modules grouped by feature: workspace, grammar, spelling, beats,
find, editor, autocomplete, file_list, ui, style and util.

The new modules are children of app rather than siblings, so they still
reach App's private fields without widening its interface; methods and
free helpers that are now used across module boundaries are marked
pub(super). mod.rs keeps the state types, App::new and the eframe::App
update loop.

This is pure code motion - every non-blank line of the original file
reappears exactly once, and the only edits are the pub(super) markers,
the module scaffolding, and rewrapping five signatures that the added
prefix pushed past 100 columns. Largest file is now editor.rs at 520
lines. Tests still 56/56, and cargo clippy --release reports the same
five warnings as before the split.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBWj9TphFMCoh7VHaSRnvQ
This commit is contained in:
landon
2026-08-22 09:02:19 -05:00
parent 0972148e1a
commit 423e93c894
13 changed files with 3839 additions and 3700 deletions
+256
View File
@@ -0,0 +1,256 @@
//! The left-hand file list: drag-to-reorder rows, chapter titles and the
//! per-file word-count progress bar.
use super::*;
impl App {
pub(super) fn left_pane(&mut self, ctx: &egui::Context) {
egui::SidePanel::left("files")
.resizable(true)
.default_width(260.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")
.small()
.weak(),
);
ui.separator();
let mut clicked: Option<usize> = None;
let mut from_to: Option<(usize, usize)> = None;
let pointer = ui.input(|i| i.pointer.interact_pos());
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.max_height(ui.available_height() - 120.0)
.show(ui, |ui| {
for idx in 0..self.files.len() {
let name = self.files[idx].clone();
let selected = self.selected == Some(idx);
// 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.config.draft_marker)
} else {
self.file_meta.get(&name).cloned().unwrap_or_default()
};
let tooltip = meta.tooltip();
let row = ui
.horizontal(|ui| {
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 = (ui.available_width() - reserve).max(24.0);
let mut label = ui.add_sized(
[label_w, 20.0],
egui::SelectableLabel::new(selected, &name),
);
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);
}
})
.response;
// Drop handling: is a dragged item hovering this row?
if let Some(_payload) = row.dnd_hover_payload::<usize>() {
let rect = 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,
egui::Stroke::new(
2.0,
ui.visuals().selection.stroke.color,
),
);
if let Some(payload) = row.dnd_release_payload::<usize>() {
let target = if before { idx } else { idx + 1 };
from_to = Some((*payload, target));
}
}
}
});
if let Some(idx) = clicked {
self.select(idx);
}
if let Some((from, to)) = from_to {
self.reorder(from, to);
}
ui.separator();
ui.horizontal(|ui| {
ui.add(
egui::TextEdit::singleline(&mut self.new_name)
.hint_text("new file name")
.desired_width(150.0),
);
if ui.button(" New").clicked() {
self.create_file();
}
});
if self.selected.is_some() {
ui.horizontal(|ui| {
ui.add(
egui::TextEdit::singleline(&mut self.rename_input)
.hint_text("rename")
.desired_width(150.0),
);
if ui.button("Rename").clicked() {
self.rename_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;
}
}
});
}
});
}
}
/// 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,5002,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"
);
}
}