From 6ca8681d0810818178d998edb29ace9acd5971ef Mon Sep 17 00:00:00 2001 From: landon Date: Mon, 21 Sep 2026 15:55:15 -0500 Subject: [PATCH] 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 --- src/app/auto_snapshot.rs | 17 ++- src/app/diff.rs | 22 ++- src/app/editor.rs | 83 +++++++++++- src/app/mod.rs | 60 ++++++++- src/app/synopsis.rs | 91 +++++++++++++ src/app/ui.rs | 34 +++++ src/app/workspace.rs | 7 + src/app/workspace_search.rs | 257 ++++++++++++++++++++++++++++++++++++ src/config.rs | 43 ++++++ src/gitsync.rs | 7 +- src/main.rs | 1 + src/word_log.rs | 176 ++++++++++++++++++++++++ 12 files changed, 781 insertions(+), 17 deletions(-) create mode 100644 src/app/synopsis.rs create mode 100644 src/app/workspace_search.rs create mode 100644 src/word_log.rs diff --git a/src/app/auto_snapshot.rs b/src/app/auto_snapshot.rs index 3cacdaa..93d45d1 100644 --- a/src/app/auto_snapshot.rs +++ b/src/app/auto_snapshot.rs @@ -103,7 +103,7 @@ impl App { } /// Get the HEAD commit hash. - fn get_head_commit_hash(workspace: &std::path::Path) -> Option { + pub(super) fn get_head_commit_hash(workspace: &std::path::Path) -> Option { let (ok, out) = crate::gitsync::run_git(workspace, &["rev-parse", "HEAD"]); if ok { out.lines() @@ -115,7 +115,7 @@ impl App { } /// Commit only (no pull/push) - used for snapshots. - fn git_commit_only(workspace: &std::path::Path, message: &str) -> crate::gitsync::GitOutcome { + pub(super) fn git_commit_only(workspace: &std::path::Path, message: &str) -> crate::gitsync::GitOutcome { let mut log = String::new(); let mut ok = true; @@ -298,11 +298,20 @@ impl App { ui.label(format!("{} words", snapshot.word_count)); ui.label(format!("at {}", snapshot.formatted_time())); ui.label(format!("{}...", &snapshot.message[..snapshot.message.len().min(30)])); - + + if ui + .button("Diff") + .on_hover_text("Compare the current file against this snapshot") + .clicked() + { + let label = format!("the {} snapshot", snapshot.formatted_time()); + self.open_diff_against(Some(snapshot.commit_hash.clone()), label); + } + if ui.button("Rollback").clicked() { self.rollback_to_snapshot(orig_index); } - + // Show commit hash on hover if ui.button("ℹ️").on_hover_text(&snapshot.commit_hash).clicked() { // Could copy to clipboard in the future diff --git a/src/app/diff.rs b/src/app/diff.rs index 317cdc1..31a45ea 100644 --- a/src/app/diff.rs +++ b/src/app/diff.rs @@ -3,13 +3,24 @@ use super::*; impl App { - /// Load the diff for the current file, ready for the diff window. + /// Load the diff for the current file against the last commit, ready for + /// the diff window. pub(super) fn open_diff(&mut self) { + self.open_diff_against(None, "the last commit".to_string()); + } + + /// Load the diff for the current file against `base` (`None` = `HEAD`, the + /// last commit; `Some(hash)` = an older Auto-Snapshot), ready for the diff + /// window. `label` is the human-readable description of `base` shown in + /// the window's header. + pub(super) fn open_diff_against(&mut self, base: Option, label: String) { self.save_current(); let Some(name) = self.selected.and_then(|i| self.files.get(i)).cloned() else { self.status = "Open a file first".to_string(); return; }; + self.diff_base = base.clone(); + self.diff_base_label = label.clone(); // Git is run from the work tree, and the workspace may be below it. let root = self.repo_root.clone(); let Some(root) = root else { @@ -29,9 +40,10 @@ impl App { .strip_prefix(&root) .map(|p| p.to_string_lossy().replace('\\', "/")) .unwrap_or_else(|_| name.clone()); - self.diff_text = match gitsync::diff_file(&root, &rel) { + let base_ref = base.as_deref().unwrap_or("HEAD"); + self.diff_text = match gitsync::diff_file_at(&root, &rel, base_ref) { Ok(text) if text.trim().is_empty() => { - format!("{name} matches the last commit — no changes.") + format!("{name} matches {label} — no changes.") } Ok(text) => text, Err(e) => format!("Could not diff {name}:\n{e}"), @@ -56,7 +68,7 @@ impl App { refresh = true; } ui.label( - egui::RichText::new("compared with the last commit") + egui::RichText::new(format!("compared with {}", self.diff_base_label)) .small() .weak(), ); @@ -92,7 +104,7 @@ impl App { }); }); if refresh { - self.open_diff(); + self.open_diff_against(self.diff_base.clone(), self.diff_base_label.clone()); } self.show_diff = open; } diff --git a/src/app/editor.rs b/src/app/editor.rs index 1549bcb..94e083a 100644 --- a/src/app/editor.rs +++ b/src/app/editor.rs @@ -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, 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 = 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. diff --git a/src/app/mod.rs b/src/app/mod.rs index 1a54878..6990f60 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -31,9 +31,11 @@ mod spelling; mod story_bible_editor; mod story_bible_ui; mod style; +mod synopsis; mod ui; mod util; mod workspace; +mod workspace_search; // Pulled up so the sibling modules reach these free helpers through their own // `use super::*;`. Modules whose helpers are only used locally aren't listed. @@ -189,6 +191,10 @@ pub struct App { /// Per-file chapter title overrides (file name -> title). Missing/empty means /// the title is derived automatically at export time. titles: HashMap, + /// Per-day manuscript word-count log, for the writing streak in the + /// status bar. Lives in the workspace (`word_log.json`) and syncs with + /// git the same way `titles`/`order.json` do. + word_log: crate::word_log::WordLog, /// Editing buffer for the selected file's chapter title override. title_input: String, selected: Option, @@ -198,6 +204,11 @@ pub struct App { /// The editorial header lifted out of `buffer` while it is collapsed. header_stash: Option, dirty: bool, + /// Byte offset of the primary cursor in `buffer`, as of the last frame. + /// Used by typewriter mode to notice the cursor moved (and re-center it) + /// versus the writer scrolling the view manually, and to find the current + /// paragraph to keep in focus. + cursor_pos: usize, /// Editable copy of the workspace path shown in the top bar. workspace_input: String, /// Editable copy of the export path. @@ -283,6 +294,19 @@ pub struct App { find_focus: Option, /// Request the editor to scroll the active match into view next frame. find_scroll: bool, + /// Whether the workspace-wide search window is visible. + show_workspace_search: bool, + /// The workspace search text. + ws_query: String, + /// The workspace replacement text. + ws_replace: String, + /// Whether the workspace search is case-sensitive. + ws_case_sensitive: bool, + /// The last workspace search's results: one entry per file with at least + /// one match — its name, total match count, and a few line snippets. + ws_results: Vec<(String, usize, Vec)>, + /// One-line status for the workspace search window. + ws_status: String, /// Active header-field autocomplete popup, if any. autocomplete: Option, /// A partial the user dismissed with Esc; the popup stays closed until the @@ -321,6 +345,8 @@ pub struct App { show_manuscript_settings: bool, /// Whether the Outline window is open. show_outline: bool, + /// Whether the Synopsis window is open. + show_synopsis: bool, /// Whether to open an outline file from a folder context menu. /// Contains the workspace-relative path of the outline file to open. open_outline_file: Option, @@ -328,6 +354,12 @@ pub struct App { show_diff: bool, diff_text: String, diff_title: String, + /// Commit the diff window is comparing against. `None` means the last + /// commit (`HEAD`); `Some(hash)` means an older Auto-Snapshot picked from + /// the Snapshots window. + diff_base: Option, + /// Human-readable label for `diff_base`, shown in the diff window's header. + diff_base_label: String, /// Text field for adding a word by hand. wordlist_input: String, /// Index (into the currently displayed matches) of the word a right-click @@ -424,11 +456,13 @@ impl App { files: Vec::new(), collapsed: HashSet::new(), titles: HashMap::new(), + word_log: crate::word_log::WordLog::default(), title_input: String::new(), selected: None, buffer: String::new(), header_stash: None, dirty: false, + cursor_pos: 0, new_name: String::new(), rename_input: String::new(), pending_delete: false, @@ -467,6 +501,12 @@ impl App { find_needs_refresh: false, find_focus: None, find_scroll: false, + show_workspace_search: false, + ws_query: String::new(), + ws_replace: String::new(), + ws_case_sensitive: false, + ws_results: Vec::new(), + ws_status: String::new(), autocomplete: None, ac_dismissed: None, field_names: Vec::new(), @@ -485,10 +525,13 @@ impl App { show_characters: false, show_manuscript_settings: false, show_outline: false, + show_synopsis: false, open_outline_file: None, show_diff: false, diff_text: String::new(), diff_title: String::new(), + diff_base: None, + diff_base_label: "the last commit".to_string(), wordlist_input: String::new(), spell_menu: None, dismissed: HashSet::new(), @@ -555,10 +598,16 @@ impl eframe::App for App { self.toggle_distraction_free(); } - // Ctrl+F opens find (search focused); Ctrl+H opens it focused on replace. - if ctx.input(|i| i.modifiers.command && i.key_pressed(egui::Key::F)) { + // Ctrl+F opens find (search focused); Ctrl+Shift+F searches the whole + // workspace instead; Ctrl+H opens find focused on replace. + if ctx.input(|i| i.modifiers.command && !i.modifiers.shift && i.key_pressed(egui::Key::F)) + { self.open_find(true); } + if ctx.input(|i| i.modifiers.command && i.modifiers.shift && i.key_pressed(egui::Key::F)) + { + self.open_workspace_search(); + } if ctx.input(|i| i.modifiers.command && i.key_pressed(egui::Key::H)) { self.open_find(false); } @@ -676,6 +725,13 @@ impl eframe::App for App { if self.show_outline { self.outline_window(ctx); } + if self.show_synopsis { + self.synopsis_window(ctx); + } + + if self.show_workspace_search { + self.workspace_search_window(ctx); + } if self.show_diff { self.diff_window(ctx); diff --git a/src/app/synopsis.rs b/src/app/synopsis.rs new file mode 100644 index 0000000..ec37531 --- /dev/null +++ b/src/app/synopsis.rs @@ -0,0 +1,91 @@ +//! A single-glance list of every chapter's synopsis (its `# Slug:` line), read +//! straight from the same cached metadata the file-list tooltips use, so a +//! revision pass can scan the whole manuscript's shape without opening each +//! file in turn. + +use super::*; + +impl App { + /// The synopsis window: one row per manuscript file, in export order. + pub(super) fn synopsis_window(&mut self, ctx: &egui::Context) { + let mut open = self.show_synopsis; + let mut jump_to: Option = None; + + // Collect everything the window needs up front, so the window's + // closure only reads — no borrow conflicts with the deferred jump. + let marker = self.config.draft_marker.clone(); + let pad_width = self.index_pad_width(); + let selected_name = self.selected.and_then(|i| self.files.get(i)).cloned(); + let rows: Vec<(String, String, Option)> = self + .manuscript_files() + .into_iter() + .map(|(i, name)| { + let raw = if Some(name) == selected_name.as_ref() { + self.document().into_owned() + } else { + std::fs::read_to_string(self.path_for(name)).unwrap_or_default() + }; + let header = crate::preprocess::parse(&raw, &marker); + let title = resolve_chapter_title( + self.titles.get(name).map(String::as_str), + header.title.as_deref(), + i, + pad_width, + ); + (name.clone(), title, header.slug) + }) + .collect(); + + egui::Window::new("Synopsis") + .open(&mut open) + .resizable(true) + .collapsible(false) + .default_width(520.0) + .default_height(480.0) + .show(ctx, |ui| { + ui.label( + egui::RichText::new( + "Every chapter's # Slug: line, in manuscript order. Click a \ + title to open it.", + ) + .small() + .weak(), + ); + ui.separator(); + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + if rows.is_empty() { + ui.label("No manuscript files yet."); + } + for (name, title, slug) in &rows { + ui.horizontal_wrapped(|ui| { + if ui.link(egui::RichText::new(title).strong()).clicked() { + jump_to = Some(name.clone()); + } + match slug.as_deref().map(str::trim) { + Some(s) if !s.is_empty() => { + ui.label(s); + } + _ => { + ui.label( + egui::RichText::new("— no synopsis —") + .weak() + .italics(), + ); + } + } + }); + ui.separator(); + } + }); + }); + + if let Some(name) = jump_to { + if let Some(idx) = self.files.iter().position(|f| *f == name) { + self.select(idx); + } + } + self.show_synopsis = open; + } +} diff --git a/src/app/ui.rs b/src/app/ui.rs index 5f93c62..de7f83f 100644 --- a/src/app/ui.rs +++ b/src/app/ui.rs @@ -136,6 +136,17 @@ impl App { files are not counted)", ); + // A streak of consecutive days with words added, + // persisted in word_log.json so it survives restarts. + let streak = self.word_log.streak(crate::word_log::today()); + if streak > 1 { + ui.separator(); + ui.label(egui::RichText::new(format!("🔥 {streak}-day streak")).weak()) + .on_hover_text( + "Consecutive days the manuscript's word count has grown", + ); + } + // Progress toward the file's Word Count Target, if set. if let Some((goal, prose)) = goal { let (frac, color, text) = goal_progress(goal, prose); @@ -238,6 +249,14 @@ impl App { ui.close_menu(); self.open_find(true); } + if ui + .button("🔎 Search workspace… Ctrl+Shift+F") + .on_hover_text("Find (and optionally replace) across every file") + .clicked() + { + ui.close_menu(); + self.open_workspace_search(); + } if ui.button("📸 Snapshots…").clicked() { ui.close_menu(); self.show_snapshots = true; @@ -260,6 +279,10 @@ impl App { ui.close_menu(); self.open_story_bible_editor(); } + if ui.button("🧾 Synopsis…").clicked() { + ui.close_menu(); + self.show_synopsis = true; + } ui.separator(); let busy = self.beats_rx.is_some(); if ui @@ -302,6 +325,17 @@ impl App { ui.close_menu(); self.toggle_distraction_free(); } + if ui + .checkbox(&mut self.config.typewriter_mode, "Typewriter scrolling & focus") + .on_hover_text( + "While Distraction-free mode is on, keep the caret's line \ + centered and dim every paragraph but the current one. \ + Has no effect otherwise.", + ) + .changed() + { + self.config.save(); + } ui.separator(); if ui .checkbox(&mut self.config.show_preview, "Preview pane") diff --git a/src/app/workspace.rs b/src/app/workspace.rs index 1b9b5a9..26c0773 100644 --- a/src/app/workspace.rs +++ b/src/app/workspace.rs @@ -78,6 +78,7 @@ impl App { self.manuscript_dir = self.detect_manuscript_dir(&ws); self.files = order::resolve_order(&ws, &self.config.hidden_folders); self.titles = order::read_titles(&ws); + self.word_log = crate::word_log::WordLog::load(&ws); self.wordlist = crate::spell::read_wordlist(&ws); // Drop overrides for files that no longer exist. self.titles.retain(|name, _| self.files.contains(name)); @@ -546,6 +547,12 @@ impl App { ); self.file_meta.insert(name, meta); self.merge_field_names_from_buffer(); + // Record today's running total for the writing + // streak. Cheap: a handful of small JSON writes, + // no more often than the manuscript itself is saved. + let total = self.manuscript_words(); + self.word_log.record(crate::word_log::today(), total); + self.word_log.save(self.workspace()); } Err(e) => self.status = format!("Save failed: {e}"), } diff --git a/src/app/workspace_search.rs b/src/app/workspace_search.rs new file mode 100644 index 0000000..bdbc0a9 --- /dev/null +++ b/src/app/workspace_search.rs @@ -0,0 +1,257 @@ +//! Search and replace across every file in the workspace, not just the one +//! open in the editor. Reuses the matching/replacing helpers from `find.rs` +//! rather than reimplementing them. + +use super::*; +use super::find::{find_matches, replace_all}; + +impl App { + /// Open the workspace search window and run the current query, if any. + pub(super) fn open_workspace_search(&mut self) { + self.show_workspace_search = true; + self.refresh_workspace_search(); + } + + /// Re-run the workspace-wide search: every file in `self.files` is checked + /// (the currently open one via its in-memory buffer, in case it has + /// unsaved edits; every other one is read fresh from disk). + pub(super) fn refresh_workspace_search(&mut self) { + self.ws_results.clear(); + if self.ws_query.trim().is_empty() { + self.ws_status = String::new(); + return; + } + let selected_name = self.selected.and_then(|i| self.files.get(i)).cloned(); + let mut total = 0usize; + for name in self.files.clone() { + let text = if Some(&name) == selected_name.as_ref() { + self.document().into_owned() + } else { + match std::fs::read_to_string(self.path_for(&name)) { + Ok(t) => t, + Err(_) => continue, + } + }; + let matches = find_matches(&text, &self.ws_query, self.ws_case_sensitive); + if matches.is_empty() { + continue; + } + total += matches.len(); + let snippets = matches + .iter() + .take(3) + .map(|&(s, e)| line_snippet(&text, s, e)) + .collect(); + self.ws_results.push((name, matches.len(), snippets)); + } + self.ws_status = if self.ws_results.is_empty() { + "No matches".to_string() + } else { + format!( + "{total} match{} in {} file{}", + if total == 1 { "" } else { "es" }, + self.ws_results.len(), + if self.ws_results.len() == 1 { "" } else { "s" } + ) + }; + } + + /// Open `name` and hand off to the ordinary find bar, so stepping through + /// the rest of that file's matches works exactly like Ctrl+F already does. + /// The match position is recomputed against the freshly loaded buffer + /// rather than reusing a byte offset from the search pass, since opening + /// the file can fold its header away and shift every offset within it. + fn jump_to(&mut self, name: &str) { + let Some(idx) = self.files.iter().position(|f| f == name) else { + return; + }; + self.select(idx); + self.find_query = self.ws_query.clone(); + self.find_case_sensitive = self.ws_case_sensitive; + self.find_active = 0; + self.refresh_find(); + self.show_find = true; + self.find_scroll = true; + } + + /// Replace every match of `ws_query` with `ws_replace`, in every file the + /// last search found it in. Takes a safety commit first (if the workspace + /// is a git repository) so a bad bulk replace is one `git revert`/checkout + /// away from undone, regardless of whether Auto-Snapshot is enabled. + pub(super) fn workspace_replace_all(&mut self) { + if self.ws_query.is_empty() || self.ws_results.is_empty() { + return; + } + + if let Some(root) = self.repo_root.clone() { + self.save_current(); + self.persist_order(); + self.persist_titles(); + let message = format!( + "Snapshot before workspace replace: '{}' -> '{}'", + self.ws_query, self.ws_replace + ); + let outcome = Self::git_commit_only(&root, &message); + if !outcome.ok { + self.status = format!( + "Safety snapshot failed, replace cancelled: {}", + outcome.log + ); + return; + } + } else { + self.status = + "Not in a git repository — replacing without a safety snapshot".to_string(); + } + + let selected_name = self.selected.and_then(|i| self.files.get(i)).cloned(); + let names: Vec = self.ws_results.iter().map(|(n, _, _)| n.clone()).collect(); + let mut total = 0usize; + let mut files_changed = 0usize; + for name in names { + let is_open = Some(&name) == selected_name.as_ref(); + let text = if is_open { + self.document().into_owned() + } else { + match std::fs::read_to_string(self.path_for(&name)) { + Ok(t) => t, + Err(_) => continue, + } + }; + let (new_text, count) = + replace_all(&text, &self.ws_query, &self.ws_replace, self.ws_case_sensitive); + if count == 0 { + continue; + } + total += count; + files_changed += 1; + if is_open { + // `new_text` is the whole document (header included, if any + // was folded); fold it back the same way it was before. + self.header_stash = None; + self.buffer = new_text; + self.dirty = true; + self.apply_header_collapse(); + self.clear_lt(); + self.save_current(); + self.find_needs_refresh = true; + } else if let Err(e) = std::fs::write(self.path_for(&name), &new_text) { + self.status = format!("Failed to write {name}: {e}"); + } else { + let meta = FileMeta::from_markdown(&new_text, &self.config.draft_marker); + self.file_meta.insert(name, meta); + } + } + self.status = + format!("Replaced {total} occurrence(s) across {files_changed} file(s)"); + self.refresh_workspace_search(); + } + + /// The workspace search window. + pub(super) fn workspace_search_window(&mut self, ctx: &egui::Context) { + let mut open = self.show_workspace_search; + let mut jump: Option = None; + let mut do_search = false; + let mut do_replace = false; + + egui::Window::new("Search workspace") + .open(&mut open) + .resizable(true) + .collapsible(false) + .default_width(560.0) + .default_height(480.0) + .show(ctx, |ui| { + ui.horizontal(|ui| { + ui.label("Find:"); + let resp = ui.add( + egui::TextEdit::singleline(&mut self.ws_query) + .desired_width(240.0) + .hint_text("search every file"), + ); + if resp.changed() || resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + do_search = true; + } + if ui + .checkbox(&mut self.ws_case_sensitive, "Aa") + .on_hover_text("Match case") + .changed() + { + do_search = true; + } + if ui.button("Search").clicked() { + do_search = true; + } + }); + ui.horizontal(|ui| { + ui.label("Replace:"); + ui.add( + egui::TextEdit::singleline(&mut self.ws_replace) + .desired_width(240.0) + .hint_text("replacement text"), + ); + if ui + .add_enabled(!self.ws_results.is_empty(), egui::Button::new("Replace all in workspace")) + .on_hover_text( + "Replaces every match in every file listed below. Takes a \ + safety git commit first, if this workspace is a repository.", + ) + .clicked() + { + do_replace = true; + } + }); + if !self.ws_status.is_empty() { + ui.label(egui::RichText::new(&self.ws_status).weak()); + } + ui.separator(); + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + for (name, count, snippets) in &self.ws_results { + ui.horizontal(|ui| { + if ui.link(egui::RichText::new(name).strong()).clicked() { + jump = Some(name.clone()); + } + ui.label( + egui::RichText::new(format!( + "({count} match{})", + if *count == 1 { "" } else { "es" } + )) + .weak(), + ); + }); + for snippet in snippets { + ui.label(egui::RichText::new(snippet).monospace().small()); + } + ui.separator(); + } + }); + }); + + if do_search { + self.refresh_workspace_search(); + } + if do_replace { + self.workspace_replace_all(); + } + if let Some(name) = jump { + self.jump_to(&name); + } + self.show_workspace_search = open; + } +} + +/// A short, trimmed excerpt of the line containing the byte range `s..e`, for +/// display in the workspace search results list. +fn line_snippet(text: &str, s: usize, e: usize) -> String { + let line_start = text[..s].rfind('\n').map_or(0, |i| i + 1); + let line_end = text[e..].find('\n').map_or(text.len(), |i| e + i); + let line = text[line_start..line_end].trim(); + const MAX: usize = 120; + if line.chars().count() > MAX { + let truncated: String = line.chars().take(MAX).collect(); + format!("{truncated}…") + } else { + line.to_string() + } +} diff --git a/src/config.rs b/src/config.rs index a0a62f5..ef1ad27 100644 --- a/src/config.rs +++ b/src/config.rs @@ -169,6 +169,18 @@ pub struct Config { /// Word count threshold for auto-snapshots. #[serde(default = "default_auto_snapshot_threshold")] pub auto_snapshot_threshold: usize, + /// Whether Distraction-free mode also keeps the caret's line centered and + /// dims every paragraph but the current one. Has no effect while + /// Distraction-free mode is off; this is the opt-out for writers who + /// don't want it even then. + #[serde(default = "default_typewriter_mode")] + pub typewriter_mode: bool, +} + +/// Typewriter mode is on by default, for anyone turning on Distraction-free +/// mode for the first time. +pub fn default_typewriter_mode() -> bool { + true } /// Default auto-snapshot threshold: 250 words. @@ -371,6 +383,7 @@ impl Default for Config { manuscript_screenplay: false, auto_snapshot_enabled: false, auto_snapshot_threshold: default_auto_snapshot_threshold(), + typewriter_mode: default_typewriter_mode(), } } } @@ -597,6 +610,36 @@ mod tests { assert!(!cfg.manuscript_screenplay); } + /// A config.json written before typewriter mode existed. Same risk as the + /// tests above: a missing `#[serde(default)]` would fail the whole parse + /// and reset every setting the user had. + #[test] + fn config_without_the_typewriter_mode_field_still_loads() { + let old = r####"{ + "workspace": "/home/writer/Manuscript", + "export_path": "/home/writer/Manuscript/book.odt", + "manuscript_author": "A. Writer" + }"####; + let cfg: Config = serde_json::from_str(old).expect("old config must still parse"); + assert_eq!(cfg.manuscript_author, "A. Writer"); + // On by default, so an existing Distraction-free habit gains the + // feature rather than needing to discover and enable it. + assert!(cfg.typewriter_mode); + } + + /// An explicit opt-out must survive a reload, the same way `gitea_use_ssh` + /// being turned off has to (both default to `true`). + #[test] + fn turning_off_typewriter_mode_survives_a_reload() { + let saved = r####"{ + "workspace": "/home/writer/Manuscript", + "export_path": "/home/writer/Manuscript/book.odt", + "typewriter_mode": false + }"####; + let cfg: Config = serde_json::from_str(saved).expect("config must parse"); + assert!(!cfg.typewriter_mode); + } + /// A saved subfolder choice must win over the built-in default, so changing /// the default never silently moves an existing user's projects. #[test] diff --git a/src/gitsync.rs b/src/gitsync.rs index a2917d9..8b9aea2 100644 --- a/src/gitsync.rs +++ b/src/gitsync.rs @@ -192,7 +192,8 @@ fn current_branch(workspace: &Path) -> String { .unwrap_or_else(|| "main".to_string()) } -/// The unified diff of one file against the last commit, as raw diff text. +/// The unified diff of one file against an arbitrary commit-ish (`HEAD`, a +/// commit hash, a tag...) — the last commit, or an older Auto-Snapshot. /// /// Revising is comparing: what changed in this scene since the last time it was /// good? The repository already holds that answer, so the app asks git rather @@ -200,10 +201,10 @@ fn current_branch(workspace: &Path) -> String { /// /// `rel` is relative to the *workspace*, which may sit below the repository /// root, so the path is resolved through the work tree before being handed over. -pub fn diff_file(workspace: &Path, rel: &str) -> Result { +pub fn diff_file_at(workspace: &Path, rel: &str, base: &str) -> Result { let (ok, out) = run_git( workspace, - &["--no-pager", "diff", "--no-color", "HEAD", "--", rel], + &["--no-pager", "diff", "--no-color", base, "--", rel], ); if !ok { // A repository with no commits yet has no HEAD to compare against. diff --git a/src/main.rs b/src/main.rs index 5b83afa..3fe5be8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,6 +20,7 @@ mod project_template; mod screenplay; mod spell; mod story_bible; +mod word_log; use eframe::egui; diff --git a/src/word_log.rs b/src/word_log.rs new file mode 100644 index 0000000..4b14cba --- /dev/null +++ b/src/word_log.rs @@ -0,0 +1,176 @@ +//! A per-day log of the manuscript's total word count, kept in the workspace +//! (like `order.json`/`titles.json`) so writing progress — and a streak — +//! survives between launches and syncs with git for free. + +use serde::{Deserialize, Serialize}; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Name of the file (inside the workspace) that stores the word log. +pub const WORD_LOG_FILE: &str = "word_log.json"; + +/// One calendar day's recorded state: the manuscript's total prose word +/// count as of the last save made that day. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct DayEntry { + /// Days since the Unix epoch (UTC) — so entries compare and subtract + /// without any calendar arithmetic. + pub day: i64, + /// Total prose word count across the manuscript as of the last save on + /// this day. + pub total_words: usize, +} + +/// The word log: one entry per day that had at least one save, oldest first. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct WordLog { + pub days: Vec, +} + +/// Today, as days since the Unix epoch (UTC). +pub fn today() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| (d.as_secs() / 86_400) as i64) + .unwrap_or(0) +} + +impl WordLog { + /// Load the log from the workspace. Missing or unparsable -> empty log, + /// the same tolerant fallback `Config::load` uses — a corrupt or + /// not-yet-existing log must never block opening the workspace. + pub fn load(workspace: &Path) -> WordLog { + let path = workspace.join(WORD_LOG_FILE); + match std::fs::read_to_string(&path) { + Ok(text) => serde_json::from_str(&text).unwrap_or_default(), + Err(_) => WordLog::default(), + } + } + + /// Persist the log as JSON into the workspace. + pub fn save(&self, workspace: &Path) { + let path = workspace.join(WORD_LOG_FILE); + if let Ok(text) = serde_json::to_string_pretty(self) { + let _ = std::fs::write(path, text); + } + } + + /// Record `total_words` as `day`'s state, replacing any earlier record + /// for the same day — later saves on a day simply overwrite it, so the + /// log only ever holds each day's final total. + pub fn record(&mut self, day: i64, total_words: usize) { + if let Some(entry) = self.days.iter_mut().find(|d| d.day == day) { + entry.total_words = total_words; + } else { + self.days.push(DayEntry { day, total_words }); + self.days.sort_by_key(|d| d.day); + } + } + + /// Whether `day` grew the manuscript: its recorded total is higher than + /// the most recent *earlier* recorded total (whichever day that was — + /// not necessarily `day - 1`; a missing day before it defaults to a + /// baseline of 0, so the very first entry ever logged counts as long as + /// it has any words at all). + fn wrote(&self, day: i64) -> bool { + let Some(today_total) = self.days.iter().find(|d| d.day == day).map(|d| d.total_words) + else { + return false; + }; + let prev_total = self + .days + .iter() + .filter(|d| d.day < day) + .map(|d| d.total_words) + .max() + .unwrap_or(0); + today_total > prev_total + } + + /// The current writing streak as of `today`: consecutive calendar days, + /// ending today (or yesterday, if nothing has been written yet today), + /// each of which grew the manuscript. A missing day breaks the chain — + /// today itself still counts if it grew the manuscript, but the streak + /// doesn't reach back past the gap. + pub fn streak(&self, today: i64) -> usize { + let mut day = if self.days.iter().any(|d| d.day == today) { + today + } else { + today - 1 + }; + let mut count = 0usize; + loop { + if !self.wrote(day) { + break; + } + count += 1; + if !self.days.iter().any(|d| d.day == day - 1) { + break; + } + day -= 1; + } + count + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn streak_counts_consecutive_growing_days() { + let mut log = WordLog::default(); + log.record(10, 1000); + log.record(11, 1500); + log.record(12, 1800); + assert_eq!(log.streak(12), 3); + // Nothing written yet today (13) — still counts through yesterday. + assert_eq!(log.streak(13), 3); + } + + #[test] + fn a_gap_day_breaks_the_streak() { + let mut log = WordLog::default(); + log.record(10, 1000); + log.record(11, 1500); + // 12 is skipped entirely. + log.record(13, 1800); + assert_eq!(log.streak(13), 1); + } + + #[test] + fn a_flat_or_shrinking_day_breaks_the_streak() { + let mut log = WordLog::default(); + log.record(10, 1000); + log.record(11, 1000); // no change + log.record(12, 1400); + assert_eq!(log.streak(12), 1); + } + + #[test] + fn the_very_first_day_counts_from_a_zero_baseline() { + let mut log = WordLog::default(); + log.record(10, 500); + assert_eq!(log.streak(10), 1); + assert_eq!(log.streak(11), 1); + } + + #[test] + fn later_saves_the_same_day_overwrite_rather_than_duplicate() { + let mut log = WordLog::default(); + log.record(10, 500); + log.record(10, 900); + assert_eq!(log.days.len(), 1); + assert_eq!(log.days[0].total_words, 900); + } + + #[test] + fn load_of_a_missing_file_is_an_empty_log_not_an_error() { + let dir = std::env::temp_dir().join(format!( + "md-manuscript-wordlog-test-{}", + std::process::id() + )); + let log = WordLog::load(&dir); + assert!(log.days.is_empty()); + } +}