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
+270
View File
@@ -0,0 +1,270 @@
//! Header field-name autocomplete: the popup state machine plus the parsing
//! helpers that decide which field names to offer above the draft marker.
use super::*;
impl App {
// ---- Header-field autocomplete -----------------------------------------
/// Rebuild the field-name list: the built-ins plus any `#+ Name:` fields
/// found in the header of every workspace file.
pub(super) fn rebuild_field_names(&mut self) {
let mut names: Vec<String> =
DEFAULT_FIELD_NAMES.iter().map(|s| s.to_string()).collect();
for name in &self.files {
let text = std::fs::read_to_string(self.path_for(name)).unwrap_or_default();
for f in header_field_names(&text, &self.config.draft_marker) {
if !names.iter().any(|n| n.eq_ignore_ascii_case(&f)) {
names.push(f);
}
}
}
names.sort_by_key(|s| s.to_lowercase());
self.field_names = names;
}
/// 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 mut added = false;
for f in found {
if !self.field_names.iter().any(|n| n.eq_ignore_ascii_case(&f)) {
self.field_names.push(f);
added = true;
}
}
if added {
self.field_names.sort_by_key(|s| s.to_lowercase());
}
}
/// Insert the chosen field name at the caret, replacing the partial the user
/// had typed and leaving the caret after the inserted `": "`.
pub(super) fn apply_field_completion(&mut self, ctx: &egui::Context, field: &str) {
let Some(ac) = self.autocomplete.take() else {
return;
};
// The stored offsets are from the last layout; a nav-key or click didn't
// edit the buffer, but guard against anything unexpected.
if ac.cursor > self.buffer.len()
|| ac.field_start > ac.cursor
|| !self.buffer.is_char_boundary(ac.field_start)
|| !self.buffer.is_char_boundary(ac.cursor)
{
return;
}
let replacement = format!("{field}: ");
self.buffer.replace_range(ac.field_start..ac.cursor, &replacement);
self.dirty = true;
self.find_needs_refresh = true;
self.spell_dirty = true;
self.spell_last_edit = Some(Instant::now());
// Put the caret just after the inserted "Field: " and refocus the editor.
let new_cursor_byte = ac.field_start + replacement.len();
let char_idx = self.buffer[..new_cursor_byte].chars().count();
let id = egui::Id::new(EDITOR_ID);
if let Some(mut state) = egui::widgets::text_edit::TextEditState::load(ctx, id) {
state.cursor.set_char_range(Some(egui::text::CCursorRange::one(
egui::text::CCursor::new(char_idx),
)));
state.store(ctx, id);
}
ctx.memory_mut(|m| m.request_focus(id));
ctx.request_repaint();
}
}
/// Collect the `#+ Name:` field names from a file's header (the lines above the
/// draft marker). Returns empty when the marker is disabled or not present, so
/// prose headings in marker-less files aren't mistaken for fields.
pub(super) fn header_field_names(text: &str, marker: &str) -> Vec<String> {
let marker_l = marker.trim().to_ascii_lowercase();
if marker_l.is_empty() {
return Vec::new();
}
let has_marker = text
.lines()
.any(|l| l.trim_start().to_ascii_lowercase().starts_with(&marker_l));
if !has_marker {
return Vec::new();
}
let mut names = Vec::new();
for line in text.lines() {
let t = line.trim_start();
if t.to_ascii_lowercase().starts_with(&marker_l) {
break; // reached the marker; the rest is prose
}
if let Some(name) = field_name_of_line(t) {
names.push(name);
}
}
names
}
/// If `trimmed` (a left-trimmed line) is a `#+ Name:` field line, return the
/// field name. Names are limited to short, plain strings to avoid catching
/// ordinary prose headings that merely contain a colon.
pub(super) fn field_name_of_line(trimmed: &str) -> Option<String> {
let hashes = trimmed.len() - trimmed.trim_start_matches('#').len();
if hashes == 0 {
return None;
}
let after = &trimmed[hashes..];
let ws = after.len() - after.trim_start().len();
if ws == 0 {
return None; // need whitespace after the #'s
}
let rest = after.trim_start();
let colon = rest.find(':')?;
let name = rest[..colon].trim();
let plausible = !name.is_empty()
&& name.chars().count() <= 40
&& name
.chars()
.all(|c| c.is_alphanumeric() || c == ' ' || c == '-' || c == '\'');
plausible.then(|| name.to_string())
}
/// The byte offset where the draft-marker line begins in `buffer`, if present.
pub(super) fn marker_line_start(buffer: &str, marker: &str) -> Option<usize> {
let marker_l = marker.trim().to_ascii_lowercase();
if marker_l.is_empty() {
return None;
}
let mut off = 0;
for line in buffer.split_inclusive('\n') {
if line.trim().to_ascii_lowercase().starts_with(&marker_l) {
return Some(off);
}
off += line.len();
}
None
}
/// If the caret sits in a header field-name position — on a `#+ <partial>` line
/// above the draft marker, before any colon — return the byte range of the
/// partial field text and the partial itself, for autocompletion.
///
/// Returns `(field_start, partial)`; the caret byte is the caller's `cursor`.
pub(super) fn header_completion_context(
buffer: &str,
cursor: usize,
marker: &str,
) -> Option<(usize, String)> {
if marker.trim().is_empty() || cursor > buffer.len() || !buffer.is_char_boundary(cursor) {
return None;
}
let line_start = buffer[..cursor].rfind('\n').map(|i| i + 1).unwrap_or(0);
// Suppress once the caret's line is at or past a present marker line.
if let Some(ms) = marker_line_start(buffer, marker) {
if line_start >= ms {
return None;
}
}
let before = &buffer[line_start..cursor];
let trimmed = before.trim_start();
let indent = before.len() - trimmed.len();
let hashes = trimmed.len() - trimmed.trim_start_matches('#').len();
if hashes == 0 {
return None;
}
let after_hash = &trimmed[hashes..];
let ws = after_hash.len() - after_hash.trim_start().len();
if ws == 0 {
return None; // need whitespace after the #'s
}
let partial = &after_hash[ws..];
if partial.contains(':') {
return None; // past the field name, into the value
}
let field_start = line_start + indent + hashes + ws;
Some((field_start, partial.to_string()))
}
/// Field names whose start matches `partial` (case-insensitive), best first.
/// An empty (or whitespace-only) partial matches nothing.
pub(super) fn field_matches(fields: &[String], partial: &str) -> Vec<String> {
let p = partial.trim().to_lowercase();
if p.is_empty() {
return Vec::new();
}
let mut out: Vec<String> = fields
.iter()
.filter(|f| f.to_lowercase().starts_with(&p))
.cloned()
.collect();
// Shorter (closer) matches first, then alphabetical.
out.sort_by(|a, b| a.len().cmp(&b.len()).then_with(|| a.cmp(b)));
out.dedup();
out.truncate(8);
out
}
#[cfg(test)]
mod tests {
use super::*;
const DM: &str = "### Rough Draft:";
#[test]
fn field_matches_prefix_case_insensitive() {
let fields: Vec<String> = ["POV", "Point of View", "Setting", "Slug"]
.iter()
.map(|s| s.to_string())
.collect();
assert_eq!(field_matches(&fields, "po"), vec!["POV", "Point of View"]);
assert_eq!(field_matches(&fields, "S"), vec!["Slug", "Setting"]); // shorter first
assert!(field_matches(&fields, "").is_empty());
assert!(field_matches(&fields, "zz").is_empty());
}
#[test]
fn completion_context_offers_partial_above_marker() {
let buf = "# Title: X\n## PO\n### Rough Draft:\n\nprose";
// Caret at the end of "## PO" (byte 16).
let got = header_completion_context(buf, 16, DM);
assert_eq!(got, Some((14, "PO".to_string())));
assert_eq!(&buf[14..16], "PO");
}
#[test]
fn completion_context_handles_multiword_fields() {
// A field name may contain spaces; the partial spans them.
let buf = "## Word Count Ta\n### Rough Draft:\n\nx";
let got = header_completion_context(buf, 16, DM);
assert_eq!(got, Some((3, "Word Count Ta".to_string())));
assert!(field_matches(
&["Word Count Target".to_string()],
"Word Count Ta"
)
.contains(&"Word Count Target".to_string()));
}
#[test]
fn completion_context_suppressed_after_colon_and_below_marker() {
// Past the colon (into the value) → no completion.
let after_colon = "## POV: Bi\n### Rough Draft:\n\nx";
assert_eq!(header_completion_context(after_colon, 10, DM), None);
// Below the marker (in prose) → no completion, even for a heading.
let below = "### Rough Draft:\n\n## Scene\n";
let caret = below.find("## Scene").unwrap() + "## Scene".len();
assert_eq!(header_completion_context(below, caret, DM), None);
// No marker configured → disabled.
assert_eq!(header_completion_context("## PO", 5, ""), None);
}
#[test]
fn header_field_names_collects_above_marker_only() {
let buf = "# Title: A\n## POV: Bixby\n## Setting: hall\n\
### Rough Draft:\n\n## Not a field: prose\n";
let names = header_field_names(buf, DM);
assert!(names.contains(&"Title".to_string()));
assert!(names.contains(&"POV".to_string()));
assert!(names.contains(&"Setting".to_string()));
assert!(!names.iter().any(|n| n == "Not a field"));
// A file with no marker contributes no learned names.
assert!(header_field_names("## POV: X\n\nprose", DM).is_empty());
}
}