Add header field-name autocomplete above the draft marker

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N9kRuP7JvXoUGdNNeg5ZSs
This commit is contained in:
landon
2026-08-14 07:30:48 -05:00
parent 894a0746e3
commit 5bc2acad25
3 changed files with 441 additions and 0 deletions
+11
View File
@@ -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 exports as a chapter titled **The Gate**, captioned *in which the door will not
open*, containing only the prose below the marker. 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 ## Files the app writes
| Location | Purpose | | Location | Purpose |
+424
View File
@@ -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<String>,
/// 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` /// 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. /// so the panel can render without holding a borrow across its click handling.
struct IssueItem { struct IssueItem {
@@ -163,6 +209,13 @@ pub struct App {
find_focus: Option<bool>, find_focus: Option<bool>,
/// Request the editor to scroll the active match into view next frame. /// Request the editor to scroll the active match into view next frame.
find_scroll: bool, find_scroll: bool,
/// Active header-field autocomplete popup, if any.
autocomplete: Option<Autocomplete>,
/// 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<String>,
/// Known header field names (built-ins + those learned from the workspace).
field_names: Vec<String>,
/// The loaded offline spell-check dictionary (shared with the worker thread). /// The loaded offline spell-check dictionary (shared with the worker thread).
spell_dict: Option<Arc<spellbook::Dictionary>>, spell_dict: Option<Arc<spellbook::Dictionary>>,
/// Dictionaries available to choose from (bundled + discovered on disk). /// Dictionaries available to choose from (bundled + discovered on disk).
@@ -228,6 +281,9 @@ impl App {
find_needs_refresh: false, find_needs_refresh: false,
find_focus: None, find_focus: None,
find_scroll: false, find_scroll: false,
autocomplete: None,
ac_dismissed: None,
field_names: Vec::new(),
spell_dict: None, spell_dict: None,
spell_dicts: crate::spell::available(), spell_dicts: crate::spell::available(),
spell_matches: Vec::new(), spell_matches: Vec::new(),
@@ -267,6 +323,8 @@ impl App {
self.clear_lt(); self.clear_lt();
self.session_start_counts = self.snapshot_counts(); self.session_start_counts = self.snapshot_counts();
self.file_meta = self.snapshot_file_meta(); self.file_meta = self.snapshot_file_meta();
self.rebuild_field_names();
self.autocomplete = None;
if !self.files.is_empty() { if !self.files.is_empty() {
self.select(0); self.select(0);
} }
@@ -399,6 +457,7 @@ impl App {
} else { } else {
self.file_meta.remove(&name); self.file_meta.remove(&name);
} }
self.merge_field_names_from_buffer();
} }
Err(e) => self.status = format!("Save failed: {e}"), 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<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.
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) { fn editor(&mut self, ui: &mut egui::Ui) {
// Base monospace size scaled by the zoom percentage. `desired_width` stays // 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 // 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)) 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<String> = 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) let output = egui::TextEdit::multiline(&mut self.buffer)
.id(egui::Id::new(EDITOR_ID))
.code_editor() .code_editor()
.desired_width(f32::INFINITY) .desired_width(f32::INFINITY)
.desired_rows(30) .desired_rows(30)
@@ -1981,7 +2139,89 @@ impl App {
self.apply_current_fix(i, j); self.apply_current_fix(i, j);
self.spell_menu = None; 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<String> = 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. /// 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")); 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<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.
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.
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`.
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<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
}
/// Format a non-negative integer with comma thousands separators (e.g. 12345 -> "12,345"). /// Format a non-negative integer with comma thousands separators (e.g. 12345 -> "12,345").
fn thousands(n: usize) -> String { fn thousands(n: usize) -> String {
let digits = n.to_string(); let digits = n.to_string();
@@ -2978,4 +3340,66 @@ mod tests {
assert_eq!(text, "café `word`"); assert_eq!(text, "café `word`");
assert_eq!(&text[char_to_byte(&text, sel.start)..char_to_byte(&text, sel.end)], "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<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());
}
} }
+6
View File
@@ -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 \ 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.", 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"); section(ui, "Spelling & grammar");
body( body(