//! 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 = 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 { 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 { 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 { 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`. 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 { 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 } #[cfg(test)] mod tests { use super::*; 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()); } }