From 5bc2acad25b73a321ba91eac8525de84038e9685 Mon Sep 17 00:00:00 2001 From: landon Date: Fri, 14 Aug 2026 07:30:48 -0500 Subject: [PATCH] Add header field-name autocomplete above the draft marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing a header line above the draft marker (`#`/`##`/… + the start of a field name) now shows an autocomplete popup: Tab/Enter or click to insert "Field: ", Up/Down to choose, Esc to dismiss (and stay dismissed until the partial changes). It never triggers in the prose below the marker. Suggestions combine a built-in list (Title, Slug, POV, Word Count Target, Characters, Setting, Conflict, …) with field names learned from every file's header, refreshed on open and merged on save, so custom fields autocomplete too. Implementation: the editor gets a stable id so completions can drive its cursor/focus; nav keys are consumed before the TextEdit sees them, and the popup is recomputed from the caret each frame. Pure helpers (header_completion_context, field_matches, header_field_names) are unit tested; 50 tests pass. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01N9kRuP7JvXoUGdNNeg5ZSs --- README.md | 11 ++ src/app.rs | 424 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/help.rs | 6 + 3 files changed, 441 insertions(+) diff --git a/README.md b/README.md index 5bf4f09..f354b98 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,17 @@ The prose that actually gets exported begins here. exports as a chapter titled **The Gate**, captioned *in which the door will not open*, containing only the prose below the marker. +### Field-name autocomplete + +While typing a header line above the draft marker — `#`, `##`, … followed by the +start of a field name — a small popup suggests matching field names. Press +**Tab** or **Enter** (or click) to insert the field and its `": "`, **↑/↓** to +change the highlighted suggestion, and **Esc** to dismiss. The suggestions +include common fields (`Title`, `Slug`, `POV`, `Word Count Target`, +`Characters`, `Setting`, `Conflict`, …) plus any field names it **learns from +the headers of your other files**, so your own conventions autocomplete too. It +only triggers in the header region, never in the prose below the marker. + ## Files the app writes | Location | Purpose | diff --git a/src/app.rs b/src/app.rs index 358baef..5f06452 100644 --- a/src/app.rs +++ b/src/app.rs @@ -73,6 +73,52 @@ impl FileMeta { } } +/// Stable widget id for the manuscript editor's text field, so its cursor and +/// focus can be driven when inserting a header-field autocompletion. +const EDITOR_ID: &str = "manuscript_editor"; + +/// Header field names offered by autocomplete out of the box; merged with any +/// field names learned from the workspace's own files. +const DEFAULT_FIELD_NAMES: &[&str] = &[ + "Title", + "Slug", + "POV", + "Characters", + "Setting", + "Conflict", + "Feeling to convey", + "Word Count Target", + "The Setup", + "The Reveal", + "Things to Note", + "Summary", + "Notes", + "Status", + "Location", + "Time", + "Goal", + "Motivation", + "Theme", + "Mood", +]; + +/// An active header-field autocomplete popup. +struct Autocomplete { + /// Matching field names (display form), best first. + matches: Vec, + /// Highlighted match index. + selected: usize, + /// The lowercased partial that produced `matches`, so the selection stays + /// put while the user keeps typing the same prefix. + partial: String, + /// Byte offset on the line where the field-name text begins (replaced on + /// accept), and the caret byte offset (end of the replaced range). + field_start: usize, + cursor: usize, + /// Screen position just below the caret, where the popup is anchored. + anchor: egui::Pos2, +} + /// An owned snapshot of one issue for the results panel, decoupled from `self` /// so the panel can render without holding a borrow across its click handling. struct IssueItem { @@ -163,6 +209,13 @@ pub struct App { find_focus: Option, /// Request the editor to scroll the active match into view next frame. find_scroll: bool, + /// Active header-field autocomplete popup, if any. + autocomplete: Option, + /// A partial the user dismissed with Esc; the popup stays closed until the + /// partial changes, so Esc isn't undone by the next-frame recompute. + ac_dismissed: Option, + /// Known header field names (built-ins + those learned from the workspace). + field_names: Vec, /// The loaded offline spell-check dictionary (shared with the worker thread). spell_dict: Option>, /// Dictionaries available to choose from (bundled + discovered on disk). @@ -228,6 +281,9 @@ impl App { find_needs_refresh: false, find_focus: None, find_scroll: false, + autocomplete: None, + ac_dismissed: None, + field_names: Vec::new(), spell_dict: None, spell_dicts: crate::spell::available(), spell_matches: Vec::new(), @@ -267,6 +323,8 @@ impl App { self.clear_lt(); self.session_start_counts = self.snapshot_counts(); self.file_meta = self.snapshot_file_meta(); + self.rebuild_field_names(); + self.autocomplete = None; if !self.files.is_empty() { self.select(0); } @@ -399,6 +457,7 @@ impl App { } else { self.file_meta.remove(&name); } + self.merge_field_names_from_buffer(); } Err(e) => self.status = format!("Save failed: {e}"), } @@ -1828,6 +1887,77 @@ 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. + fn rebuild_field_names(&mut self) { + let mut names: Vec = + 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. + 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 `": "`. + 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(); + } + fn editor(&mut self, ui: &mut egui::Ui) { // Base monospace size scaled by the zoom percentage. `desired_width` stays // infinite and the scroll area is vertical-only, so text wraps to the pane @@ -1879,7 +2009,35 @@ impl App { ); ui.fonts(|f| f.layout_job(job)) }; + // Autocomplete: intercept navigation keys before the editor + // consumes them, acting on last frame's popup state. + let mut accept_field: Option = None; + let mut dismiss = false; + if let Some(ac) = self.autocomplete.as_mut() { + ui.input_mut(|i| { + if i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown) { + ac.selected = (ac.selected + 1) % ac.matches.len(); + } else if i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp) { + ac.selected = (ac.selected + ac.matches.len() - 1) % ac.matches.len(); + } else if i.consume_key(egui::Modifiers::NONE, egui::Key::Tab) + || i.consume_key(egui::Modifiers::NONE, egui::Key::Enter) + { + accept_field = ac.matches.get(ac.selected).cloned(); + } else if i.consume_key(egui::Modifiers::NONE, egui::Key::Escape) { + dismiss = true; + } + }); + } + if dismiss { + // Remember the dismissed partial so it doesn't immediately reopen. + self.ac_dismissed = self.autocomplete.take().map(|a| a.partial); + } + if let Some(field) = accept_field { + self.apply_field_completion(ui.ctx(), &field); + } + let output = egui::TextEdit::multiline(&mut self.buffer) + .id(egui::Id::new(EDITOR_ID)) .code_editor() .desired_width(f32::INFINITY) .desired_rows(30) @@ -1981,7 +2139,89 @@ impl App { self.apply_current_fix(i, j); self.spell_menu = None; } + + // Recompute the header-field autocomplete from the caret so the + // popup tracks what the user is typing (only for a collapsed + // caret in the focused editor). + if output.response.has_focus() { + let caret = output.cursor_range.and_then(|cr| { + let r = cr.as_sorted_char_range(); + (r.start == r.end).then_some(r.end) + }); + let mut next = None; + let mut still_dismissed = false; + if let Some(caret_char) = caret { + let caret_byte = char_to_byte(&self.buffer, caret_char); + if let Some((field_start, partial)) = header_completion_context( + &self.buffer, + caret_byte, + &self.config.draft_marker, + ) { + let partial_l = partial.to_lowercase(); + if self.ac_dismissed.as_deref() == Some(partial_l.as_str()) { + // Esc-dismissed for exactly this partial; stay closed. + still_dismissed = true; + } else { + let matches = field_matches(&self.field_names, &partial); + if !matches.is_empty() { + // Keep the highlight while typing the same prefix. + let selected = match &self.autocomplete { + Some(prev) + if prev.partial == partial_l + && prev.selected < matches.len() => + { + prev.selected + } + _ => 0, + }; + let caret_rect = output + .galley + .pos_from_ccursor(egui::text::CCursor::new(caret_char)); + let anchor = caret_rect.left_bottom() + + output.galley_pos.to_vec2() + + egui::vec2(0.0, 2.0); + next = Some(Autocomplete { + matches, + selected, + partial: partial_l, + field_start, + cursor: caret_byte, + anchor, + }); + } + } + } + } + self.autocomplete = next; + if !still_dismissed { + self.ac_dismissed = None; + } + } else { + self.autocomplete = None; + } }); + + // Header-field autocomplete popup, floating just below the caret. + let mut clicked_field: Option = None; + if let Some(ac) = &self.autocomplete { + egui::Area::new(egui::Id::new("field_autocomplete")) + .order(egui::Order::Foreground) + .fixed_pos(ac.anchor) + .constrain(true) + .show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_max_width(260.0); + for (i, m) in ac.matches.iter().enumerate() { + if ui.selectable_label(i == ac.selected, m).clicked() { + clicked_field = Some(m.clone()); + } + } + }); + }); + } + if let Some(field) = clicked_field { + self.apply_field_completion(ui.ctx(), &field); + } } /// The find/replace bar shown above the editor when `show_find` is set. @@ -2730,6 +2970,128 @@ fn row_goal_bar(ui: &mut egui::Ui, width: f32, goal: crate::preprocess::WordGoal resp.on_hover_text(format!("{text} words")); } +/// 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. +fn header_field_names(text: &str, marker: &str) -> Vec { + 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. +fn field_name_of_line(trimmed: &str) -> Option { + 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. +fn marker_line_start(buffer: &str, marker: &str) -> Option { + 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 `#+ ` 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`. +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. +fn field_matches(fields: &[String], partial: &str) -> Vec { + let p = partial.trim().to_lowercase(); + if p.is_empty() { + return Vec::new(); + } + let mut out: Vec = 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 +} + /// Format a non-negative integer with comma thousands separators (e.g. 12345 -> "12,345"). fn thousands(n: usize) -> String { let digits = n.to_string(); @@ -2978,4 +3340,66 @@ mod tests { assert_eq!(text, "café `word`"); assert_eq!(&text[char_to_byte(&text, sel.start)..char_to_byte(&text, sel.end)], "word"); } + + const DM: &str = "### Rough Draft:"; + + #[test] + fn field_matches_prefix_case_insensitive() { + let fields: Vec = ["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()); + } } diff --git a/src/help.rs b/src/help.rs index 15b76cd..f1477c8 100644 --- a/src/help.rs +++ b/src/help.rs @@ -143,6 +143,12 @@ fn cheatsheet_body(ui: &mut egui::Ui) { marker line is treated as notes and left out of the export; leave the marker \ blank to export the whole file. Comments are removed everywhere.", ); + note( + ui, + "Typing a header field name above the marker (e.g. “## PO”) pops up an \ + autocomplete — Tab/Enter to accept, ↑/↓ to choose, Esc to dismiss. It \ + suggests common fields plus ones learned from your other files.", + ); section(ui, "Spelling & grammar"); body(