Files
md-manuscript/src/app/autocomplete.rs
T
landon 043cc692ac 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
2026-08-24 19:56:36 -05:00

271 lines
10 KiB
Rust

//! 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.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)) {
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());
}
}