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(