Bundle the project template into the binary; add outline-to-draft
links, a sprint timer, placeholder scanning, corkboard, and a quick-capture inbox - Compile templates/snowflake into the binary via include_dir, so New project… never depends on files installed beside the executable; Settings ▸ Template becomes an optional external override, and a broken saved override now falls back to the bundled template instead of failing project generation. - Outline beats now link to a matching chapter for a one-click jump. - Sprint window: a daily whole-manuscript word goal plus a Pomodoro-style writing sprint timer, with a compact countdown in the status bar while in Distraction-free mode. - Placeholders window: find [TODO]/[TK]/[FIXME]/[XXX]/[NOTE]/[???] markers left in the draft and jump straight to any of them. - Snowflake progress strip in the file panel: a nudge-only readout of which snowflake stage folders actually have content. - Corkboard window: drag-reorderable synopsis cards, one per chapter. - Quick-capture popup (Ctrl+Shift+N): jot a stray idea straight into the project's Scratch Pad without losing your place in the draft. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
//! Finds bracketed placeholder markers (`[TODO]`, `[TK]`, `[???]`, …) dropped
|
||||
//! into prose while drafting fast, so nothing gets forgotten before a draft
|
||||
//! is called done. No `regex` dependency: a hand-rolled bracket scan is
|
||||
//! plenty for a short, fixed set of markers.
|
||||
|
||||
/// A bracketed placeholder found in a file's text.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Placeholder {
|
||||
/// Byte offset of the `[` in the text.
|
||||
pub start: usize,
|
||||
/// Byte offset just past the `]` in the text.
|
||||
pub end: usize,
|
||||
/// 1-indexed line number the marker appears on.
|
||||
pub line: usize,
|
||||
/// The marker text as written, e.g. `TODO` or `???`.
|
||||
pub marker: String,
|
||||
}
|
||||
|
||||
/// Marker keywords recognised inside brackets, matched case-insensitively.
|
||||
const MARKER_WORDS: &[&str] = &["TODO", "TK", "FIXME", "XXX", "NOTE"];
|
||||
|
||||
/// Longest a bracketed span can be and still be considered a placeholder
|
||||
/// rather than, say, a Markdown link's link text.
|
||||
const MAX_SPAN_LEN: usize = 40;
|
||||
|
||||
/// Find every placeholder marker in `text`.
|
||||
pub fn find_placeholders(text: &str) -> Vec<Placeholder> {
|
||||
let mut out = Vec::new();
|
||||
let mut line = 1;
|
||||
let mut last_end = 0;
|
||||
let bytes = text.as_bytes();
|
||||
|
||||
let mut search_from = 0;
|
||||
while let Some(rel_start) = text[search_from..].find('[') {
|
||||
let start = search_from + rel_start;
|
||||
let Some(rel_close) = text[start + 1..].find(']') else {
|
||||
break;
|
||||
};
|
||||
let close = start + 1 + rel_close;
|
||||
let inner = &text[start + 1..close];
|
||||
|
||||
// Count newlines passed since the last marker (or the start) to
|
||||
// keep `line` in step without rescanning from the top each time.
|
||||
line += text[last_end..start].matches('\n').count();
|
||||
last_end = start;
|
||||
|
||||
if inner.len() <= MAX_SPAN_LEN && !inner.contains('\n') && is_marker(inner) {
|
||||
out.push(Placeholder {
|
||||
start,
|
||||
end: close + 1,
|
||||
line,
|
||||
marker: inner.trim().to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
search_from = close + 1;
|
||||
if search_from > bytes.len() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether bracketed inner text `s` reads as a placeholder: an exact (case
|
||||
/// -insensitive) marker keyword, or entirely `?` characters.
|
||||
fn is_marker(s: &str) -> bool {
|
||||
let trimmed = s.trim();
|
||||
if trimmed.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if trimmed.chars().all(|c| c == '?') {
|
||||
return true;
|
||||
}
|
||||
MARKER_WORDS
|
||||
.iter()
|
||||
.any(|m| m.eq_ignore_ascii_case(trimmed))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn finds_each_known_marker_word() {
|
||||
for word in MARKER_WORDS {
|
||||
let text = format!("Some prose [{word}] continues.");
|
||||
let found = find_placeholders(&text);
|
||||
assert_eq!(found.len(), 1, "expected a match for [{word}]");
|
||||
assert_eq!(found[0].marker, *word);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_question_mark_placeholders() {
|
||||
let found = find_placeholders("She said [???] and left.");
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].marker, "???");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_matching_is_case_insensitive() {
|
||||
let found = find_placeholders("[todo] fix this scene");
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].marker, "todo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_markdown_link_is_not_a_false_positive() {
|
||||
let found = find_placeholders("See [a markdown link](https://example.com) for more.");
|
||||
assert!(found.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ordinary_bracketed_aside_is_not_a_false_positive() {
|
||||
let found = find_placeholders("He shrugged [as he always did when cornered].");
|
||||
assert!(found.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_numbers_are_tracked_across_multiple_lines() {
|
||||
let text = "Line one.\nLine two has [TK] in it.\nLine three has [FIXME] too.";
|
||||
let found = find_placeholders(text);
|
||||
assert_eq!(found.len(), 2);
|
||||
assert_eq!(found[0].line, 2);
|
||||
assert_eq!(found[1].line, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn byte_offsets_cover_the_brackets_themselves() {
|
||||
let text = "abc [TODO] def";
|
||||
let found = find_placeholders(text);
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(&text[found[0].start..found[0].end], "[TODO]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unterminated_bracket_does_not_hang_or_panic() {
|
||||
let found = find_placeholders("This has an [unterminated bracket");
|
||||
assert!(found.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_brackets_at_all_yields_nothing() {
|
||||
assert!(find_placeholders("Plain prose, nothing to see here.").is_empty());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user