043cc692ac
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
271 lines
10 KiB
Rust
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());
|
|
}
|
|
}
|