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:
+80
-3
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user