Add workspace search, synopsis view, snapshot diffs, typewriter mode, and a writing streak

Five productivity features: search/replace across the whole workspace
(with a safety commit before Replace all), a Synopsis window compiling
every chapter's slug, a Diff button per Auto-Snapshot to compare against
an older snapshot instead of only HEAD, typewriter-style cursor centering
and paragraph dimming gated on Distraction-free mode, and a per-day
word-count streak persisted to word_log.json alongside order.json/titles.json.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 15:55:15 -05:00
parent 229e2413c4
commit 6ca8681d08
12 changed files with 781 additions and 17 deletions
+80 -3
View File
@@ -39,6 +39,13 @@ impl App {
None
};
// Typewriter mode: only while Distraction-free is active (both panels
// hidden — the same condition `toggle_distraction_free` uses), and
// only if the writer hasn't opted out.
let distraction_free = !self.config.show_toolbar && !self.config.show_file_panel;
let typewriter = self.config.typewriter_mode && distraction_free;
let dim_range = typewriter.then(|| current_paragraph_range(&self.buffer, self.cursor_pos));
egui::ScrollArea::vertical()
.id_salt("editor")
.auto_shrink([false, false])
@@ -59,6 +66,7 @@ impl App {
&find_ranges,
find_active,
&bible_ranges,
dim_range,
wrap_width,
);
ui.fonts(|f| f.layout_job(job))
@@ -140,10 +148,16 @@ impl App {
}
}
// Whether some explicit jump (an issue pick, a find-match step)
// already scrolled the view this frame — typewriter centering
// stands down when one did, rather than fighting it.
let mut jumped = false;
// Take the editor to an issue picked in the results panel:
// select the words, centre them, and hand focus back so the
// user can type the correction straight away.
if let Some((start, end)) = self.issue_jump.take() {
jumped = true;
let (cs, ce) = (
byte_to_char(&self.buffer, start),
byte_to_char(&self.buffer, end),
@@ -170,6 +184,7 @@ impl App {
// open, Next/Prev, Enter, or after a replace).
if self.find_scroll {
if let Some(&(s, _)) = self.find_matches.get(self.find_active) {
jumped = true;
let char_idx = self
.buffer
.get(..s)
@@ -183,6 +198,25 @@ impl App {
self.find_scroll = false;
}
// Typewriter mode: keep the caret's line centered, the way a
// typewriter's platen does. Only re-centers when the cursor
// actually moved since last frame, so scrolling the view by
// hand between keystrokes isn't immediately undone.
let new_cursor_pos = output
.cursor_range
.map(|cr| char_to_byte(&self.buffer, cr.primary.ccursor.index))
.unwrap_or(self.cursor_pos);
if typewriter && !jumped && new_cursor_pos != self.cursor_pos {
if let Some(cr) = output.cursor_range {
let rect = output
.galley
.pos_from_ccursor(cr.primary.ccursor)
.translate(output.galley_pos.to_vec2());
ui.scroll_to_rect(rect, Some(egui::Align::Center));
}
}
self.cursor_pos = new_cursor_pos;
// Right-click a misspelling → a menu of suggested corrections.
if output.response.secondary_clicked() {
self.spell_menu = output.response.interact_pointer_pos().and_then(|pos| {
@@ -482,6 +516,7 @@ pub(super) fn build_editor_job(
find: &[(usize, usize)],
find_active: Option<usize>,
bible: &[(usize, usize)],
dim: Option<(usize, usize)>,
wrap_width: f32,
) -> egui::text::LayoutJob {
use egui::text::{LayoutJob, TextFormat};
@@ -489,13 +524,14 @@ pub(super) fn build_editor_job(
let mut job = LayoutJob::default();
job.wrap.max_width = wrap_width;
if grammar.is_empty() && find.is_empty() && bible.is_empty() {
if grammar.is_empty() && find.is_empty() && bible.is_empty() && dim.is_none() {
job.append(text, 0.0, TextFormat::simple(font_id, color));
return job;
}
// Split the text at every match boundary (grammar, find, and bible), then format
// each run according to which ranges cover it.
// Split the text at every match boundary (grammar, find, bible, and the
// typewriter-focus paragraph), then format each run according to which
// ranges cover it.
let mut points: Vec<usize> = vec![0, text.len()];
for &(s, e, _) in grammar {
points.push(s);
@@ -509,6 +545,10 @@ pub(super) fn build_editor_job(
points.push(s);
points.push(e);
}
if let Some((s, e)) = dim {
points.push(s);
points.push(e);
}
points.retain(|&p| p <= text.len() && text.is_char_boundary(p));
points.sort_unstable();
points.dedup();
@@ -550,6 +590,12 @@ pub(super) fn build_editor_job(
if !is_find_highlight && bible.iter().any(|&(s, e)| s < e && a >= s && b <= e) {
fmt.background = bible_bg;
}
// Typewriter-focus mode: fade every run outside the current paragraph.
if let Some((ds, de)) = dim {
if !(a >= ds && b <= de) {
fmt.color = fmt.color.gamma_multiply(0.35);
}
}
job.append(&text[a..b], 0.0, fmt);
}
job
@@ -626,6 +672,17 @@ pub(super) fn byte_to_char(s: &str, byte: usize) -> usize {
s.char_indices().take_while(|(b, _)| *b < byte).count()
}
/// The byte range of the paragraph containing `pos`: from just after the
/// nearest blank line (`"\n\n"`) before it to just before the nearest one
/// after it, or the buffer's edges when there is none. Used by typewriter
/// mode to know which paragraph to keep in focus.
pub(super) fn current_paragraph_range(text: &str, pos: usize) -> (usize, usize) {
let pos = pos.min(text.len());
let start = text[..pos].rfind("\n\n").map_or(0, |i| i + 2);
let end = text[pos..].find("\n\n").map_or(text.len(), |i| pos + i);
(start, end)
}
/// Apply an inline-format action to `text` given a selection in *character*
/// indices, returning the new text and the new selection (also character indices).
///
@@ -827,6 +884,7 @@ mod tests {
&[],
None,
&[(0, 11)], // "Protagonist"
None,
f32::INFINITY,
);
let highlighted = job
@@ -836,6 +894,25 @@ mod tests {
assert!(highlighted, "a bible-only reference must still be formatted");
}
#[test]
fn current_paragraph_range_finds_the_blank_line_boundaries() {
let text = "First paragraph.\n\nSecond paragraph,\nwrapped.\n\nThird.";
// A position in the second paragraph should not reach into the first
// or third.
let pos = text.find("Second").unwrap();
let (s, e) = current_paragraph_range(text, pos);
assert_eq!(&text[s..e], "Second paragraph,\nwrapped.");
// The first paragraph has no blank line before it; the buffer start
// is the boundary.
let (s, e) = current_paragraph_range(text, 0);
assert_eq!(&text[s..e], "First paragraph.");
// The last paragraph has no blank line after it; the buffer end is
// the boundary.
let pos = text.find("Third").unwrap();
let (s, e) = current_paragraph_range(text, pos);
assert_eq!(&text[s..e], "Third.");
}
#[test]
fn byte_to_char_is_the_inverse_of_char_to_byte() {
// "café!" — é is 2 bytes, so byte and char indices diverge after it.