From 691d1a0515a5c9923b5150d998ea44a79b137d23 Mon Sep 17 00:00:00 2001 From: landon Date: Fri, 14 Aug 2026 06:02:46 -0500 Subject: [PATCH] Add a find/replace bar to the editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ctrl+F opens a find bar above the editor (Ctrl+H focuses the replace field; Edit ▸ Find / Replace… opens it too). Matches are shaded in the editor with the active one highlighted more strongly; Enter/Shift+Enter and ▼/▲ step through them with wraparound and scroll-to-view. An "Aa" toggle controls case sensitivity. Replace swaps the current match and advances; Replace all swaps every match in one pass. Esc closes the bar. Search/replace logic lives in pure, unit-tested functions (find_matches, replace_all) with non-overlapping matching and correct multibyte offset mapping for case-insensitive search. Highlighting reuses the editor's layout-job builder alongside the grammar underlines. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01N9kRuP7JvXoUGdNNeg5ZSs --- README.md | 8 + src/app.rs | 467 +++++++++++++++++++++++++++++++++++++++++++++++++++- src/help.rs | 8 +- 3 files changed, 472 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 15b9b2e..88321f9 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,14 @@ Terminal=false | `Ctrl/Cmd + E` | `inline code` | | `Ctrl/Cmd + Shift + X` | ~~strikethrough~~ (`~~…~~`) | | `Ctrl/Cmd + K` | link — `[selection](url)`, with `url` selected to replace | + + Press **`Ctrl/Cmd + F`** (or **Edit ▸ Find / Replace…**) to open the + find/replace bar above the editor; **`Ctrl/Cmd + H`** opens it with the + replace field focused. Type a search term to shade every match in the editor + (the current one brighter); **Enter** / **Shift+Enter** (or **▼** / **▲**) + step through them, wrapping around, and scroll the match into view. **Aa** + toggles case sensitivity. **Replace** swaps the current match and advances; + **Replace all** swaps every match in the file at once. **Esc** closes the bar. 4. Set the **Chapter title** for the open file if you want to override the auto-derived one (the field shows the automatic title as a hint). Leaving it blank falls back to a `# Title:` line in the header, and if there is none, to diff --git a/src/app.rs b/src/app.rs index 8cd0bf4..8c6a917 100644 --- a/src/app.rs +++ b/src/app.rs @@ -65,6 +65,27 @@ pub struct App { settings_test_status: String, /// Whether the markdown cheatsheet window is open. show_cheatsheet: bool, + /// Whether the find/replace bar above the editor is visible. + show_find: bool, + /// The search text. + find_query: String, + /// The replacement text. + replace_query: String, + /// Whether the search is case-sensitive. + find_case_sensitive: bool, + /// Byte ranges of the current search matches within `buffer` (in order). + find_matches: Vec<(usize, usize)>, + /// Index into `find_matches` of the "current" match (for Next/Prev/Replace). + find_active: usize, + /// One-line status for the find bar (e.g. "3 of 12" or "No matches"). + find_status: String, + /// Set when the matches need recomputing (query/case/buffer changed). + find_needs_refresh: bool, + /// Request keyboard focus for a find-bar field next frame: `Some(true)` for + /// the search field, `Some(false)` for the replace field. + find_focus: Option, + /// Request the editor to scroll the active match into view next frame. + find_scroll: bool, } impl App { @@ -100,6 +121,16 @@ impl App { settings_test_rx: None, settings_test_status: String::new(), show_cheatsheet: false, + show_find: false, + find_query: String::new(), + replace_query: String::new(), + find_case_sensitive: false, + find_matches: Vec::new(), + find_active: 0, + find_status: String::new(), + find_needs_refresh: false, + find_focus: None, + find_scroll: false, }; app.open_workspace(); app @@ -260,6 +291,10 @@ impl App { } self.save_current(); self.clear_lt(); + // Matches from the previous file's buffer are meaningless now. + self.find_matches.clear(); + self.find_active = 0; + self.find_needs_refresh = true; if let Some(name) = self.files.get(idx) { let path = self.path_for(name); self.buffer = std::fs::read_to_string(&path).unwrap_or_default(); @@ -637,6 +672,98 @@ impl App { }; } + // ---- Find / replace ---------------------------------------------------- + + /// Open the find/replace bar, focusing the search field (`focus_search`) or + /// the replace field, and queue a match refresh and a scroll to the first hit. + fn open_find(&mut self, focus_search: bool) { + self.show_find = true; + self.find_focus = Some(focus_search); + self.find_needs_refresh = true; + self.find_scroll = true; + } + + /// Recompute the search matches for the current buffer and query, clamping + /// the active index and refreshing the status line. + fn refresh_find(&mut self) { + self.find_matches = find_matches(&self.buffer, &self.find_query, self.find_case_sensitive); + if self.find_active >= self.find_matches.len() { + self.find_active = 0; + } + self.update_find_status(); + self.find_needs_refresh = false; + } + + fn update_find_status(&mut self) { + self.find_status = if self.find_query.is_empty() { + String::new() + } else if self.find_matches.is_empty() { + "No matches".to_string() + } else { + format!("{} of {}", self.find_active + 1, self.find_matches.len()) + }; + } + + /// Move the active match forward (or backward), wrapping around, and ask the + /// editor to scroll it into view. + fn find_step(&mut self, forward: bool) { + let n = self.find_matches.len(); + if n == 0 { + return; + } + self.find_active = if forward { + (self.find_active + 1) % n + } else { + (self.find_active + n - 1) % n + }; + self.find_scroll = true; + self.update_find_status(); + } + + /// Replace the active match with the replacement text, then re-search so the + /// remaining highlights stay accurate. + fn replace_current(&mut self) { + let Some(&(s, e)) = self.find_matches.get(self.find_active) else { + return; + }; + if e > self.buffer.len() + || !self.buffer.is_char_boundary(s) + || !self.buffer.is_char_boundary(e) + { + return; + } + self.buffer.replace_range(s..e, &self.replace_query); + self.dirty = true; + // Editing invalidates any grammar-check offsets. + self.clear_lt(); + // Keep the same index so the "current" match becomes the next occurrence + // in document order; refresh recomputes and clamps it. + self.refresh_find(); + self.find_scroll = true; + self.status = "Replaced 1 occurrence".to_string(); + } + + /// Replace every match in the buffer in one pass. + fn replace_all_matches(&mut self) { + if self.find_query.is_empty() { + return; + } + let (new, count) = replace_all( + &self.buffer, + &self.find_query, + &self.replace_query, + self.find_case_sensitive, + ); + if count > 0 { + self.buffer = new; + self.dirty = true; + self.clear_lt(); + self.find_active = 0; + self.refresh_find(); + } + self.status = format!("Replaced {count} occurrence(s)"); + } + fn reorder(&mut self, from: usize, mut to: usize) { if from >= self.files.len() || from == to { return; @@ -957,6 +1084,12 @@ impl App { ctx.send_viewport_cmd(egui::ViewportCommand::Close); } }); + ui.menu_button("Edit", |ui| { + if ui.button("🔍 Find / Replace…").clicked() { + ui.close_menu(); + self.open_find(true); + } + }); ui.menu_button("View", |ui| { if ui .checkbox(&mut self.config.show_preview, "Preview pane") @@ -1252,6 +1385,10 @@ impl App { }); ui.separator(); + if self.show_find { + self.find_bar(ui); + } + if self.config.show_preview { // Editor + read-only source-ish preview side by side. let full = ui.available_size(); @@ -1309,12 +1446,32 @@ impl App { Vec::new() }; + // Search-match highlights (all matches shaded, the active one stronger). + let find_ranges: Vec<(usize, usize)> = if self.show_find { + self.find_matches.clone() + } else { + Vec::new() + }; + let find_active = if self.show_find { + Some(self.find_active) + } else { + None + }; + egui::ScrollArea::vertical() .id_salt("editor") .auto_shrink([false, false]) .show(ui, |ui| { let mut layouter = move |ui: &egui::Ui, text: &str, wrap_width: f32| { - let job = build_editor_job(text, size, text_color, &ranges, wrap_width); + let job = build_editor_job( + text, + size, + text_color, + &ranges, + &find_ranges, + find_active, + wrap_width, + ); ui.fonts(|f| f.layout_job(job)) }; let output = egui::TextEdit::multiline(&mut self.buffer) @@ -1325,6 +1482,8 @@ impl App { .show(ui); if output.response.changed() { self.dirty = true; + // The buffer changed, so any search matches are now stale. + self.find_needs_refresh = true; } // Markdown formatting hotkeys, applied to the current selection // while the editor is focused (Ctrl/Cmd + B / I / E / K, and @@ -1348,9 +1507,120 @@ impl App { ui.ctx().request_repaint(); } } + + // Scroll the active search match into view when requested (on + // open, Next/Prev, Enter, or after a replace). + if self.find_scroll { + if let Some(&(s, _)) = self.find_matches.get(self.find_active) { + let char_idx = self + .buffer + .get(..s) + .map(|p| p.chars().count()) + .unwrap_or(0); + let local = + output.galley.pos_from_ccursor(egui::text::CCursor::new(char_idx)); + let onscreen = local.translate(output.galley_pos.to_vec2()); + ui.scroll_to_rect(onscreen, Some(egui::Align::Center)); + } + self.find_scroll = false; + } }); } + /// The find/replace bar shown above the editor when `show_find` is set. + fn find_bar(&mut self, ui: &mut egui::Ui) { + egui::Frame::group(ui.style()).show(ui, |ui| { + // Find row. + ui.horizontal(|ui| { + ui.label("Find: "); + let resp = ui.add( + egui::TextEdit::singleline(&mut self.find_query) + .desired_width(220.0) + .hint_text("search text"), + ); + if self.find_focus == Some(true) { + resp.request_focus(); + self.find_focus = None; + } + if resp.changed() { + self.find_active = 0; + self.refresh_find(); + self.find_scroll = true; + } + // Enter jumps to the next match, Shift+Enter to the previous one; + // keep the field focused so the key can be repeated. + if resp.lost_focus() { + let (enter, shift) = + ui.input(|i| (i.key_pressed(egui::Key::Enter), i.modifiers.shift)); + if enter { + self.find_step(!shift); + self.find_focus = Some(true); + } + } + let have = !self.find_matches.is_empty(); + if ui + .add_enabled(have, egui::Button::new("▲")) + .on_hover_text("Previous match (Shift+Enter)") + .clicked() + { + self.find_step(false); + } + if ui + .add_enabled(have, egui::Button::new("▼")) + .on_hover_text("Next match (Enter)") + .clicked() + { + self.find_step(true); + } + if ui + .checkbox(&mut self.find_case_sensitive, "Aa") + .on_hover_text("Match case") + .changed() + { + self.find_active = 0; + self.refresh_find(); + } + if !self.find_status.is_empty() { + ui.label(egui::RichText::new(&self.find_status).weak()); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("✕").on_hover_text("Close (Esc)").clicked() { + self.show_find = false; + } + }); + }); + // Replace row. + ui.horizontal(|ui| { + ui.label("Replace:"); + let resp = ui.add( + egui::TextEdit::singleline(&mut self.replace_query) + .desired_width(220.0) + .hint_text("replacement text"), + ); + if self.find_focus == Some(false) { + resp.request_focus(); + self.find_focus = None; + } + let have = !self.find_matches.is_empty(); + if ui + .add_enabled(have, egui::Button::new("Replace")) + .on_hover_text("Replace the current match, then move to the next") + .clicked() + { + self.replace_current(); + } + if ui + .add_enabled(have, egui::Button::new("Replace all")) + .on_hover_text("Replace every match in this file") + .clicked() + { + self.replace_all_matches(); + } + }); + }); + ui.add_space(4.0); + } + /// Ask the user whether to adopt a git repository found in a parent /// directory of the freshly opened workspace. fn repo_prompt(&mut self, ctx: &egui::Context) { @@ -1402,6 +1672,22 @@ impl eframe::App for App { self.save_current(); } + // 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)) { + self.open_find(true); + } + if ctx.input(|i| i.modifiers.command && i.key_pressed(egui::Key::H)) { + self.open_find(false); + } + // Esc closes the find bar. + if self.show_find && ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + self.show_find = false; + } + // Recompute search matches if the query, case option, or buffer changed. + if self.show_find && self.find_needs_refresh { + self.refresh_find(); + } + self.poll_lt(); self.poll_settings_test(); @@ -1459,15 +1745,20 @@ impl eframe::App for App { } } -/// Build the editor's laid-out text, underlining any grammar/spelling matches. +/// Build the editor's laid-out text, underlining grammar/spelling matches and +/// shading search matches. /// -/// `ranges` are `(start_byte, end_byte, is_spelling)` triples into `text`. -/// Spelling issues get a red underline, grammar/style issues a blue one. +/// `grammar` are `(start_byte, end_byte, is_spelling)` triples: spelling issues +/// get a red underline, grammar/style issues a blue one. `find` are +/// `(start_byte, end_byte)` search-match ranges, shaded with a translucent +/// highlight; the one at index `find_active` (if any) is shaded more strongly. fn build_editor_job( text: &str, size: f32, color: egui::Color32, - ranges: &[(usize, usize, bool)], + grammar: &[(usize, usize, bool)], + find: &[(usize, usize)], + find_active: Option, wrap_width: f32, ) -> egui::text::LayoutJob { use egui::text::{LayoutJob, TextFormat}; @@ -1475,14 +1766,19 @@ fn build_editor_job( let mut job = LayoutJob::default(); job.wrap.max_width = wrap_width; - if ranges.is_empty() { + if grammar.is_empty() && find.is_empty() { job.append(text, 0.0, TextFormat::simple(font_id, color)); return job; } - // Split the text at every match boundary, then format each run. + // Split the text at every match boundary (grammar and find), then format + // each run according to which ranges cover it. let mut points: Vec = vec![0, text.len()]; - for &(s, e, _) in ranges { + for &(s, e, _) in grammar { + points.push(s); + points.push(e); + } + for &(s, e) in find { points.push(s); points.push(e); } @@ -1492,6 +1788,9 @@ fn build_editor_job( let spell_color = egui::Color32::from_rgb(0xE0, 0x40, 0x40); let grammar_color = egui::Color32::from_rgb(0x3B, 0x82, 0xF6); + // Translucent so the highlight reads in both light and dark themes. + let find_bg = egui::Color32::from_rgba_unmultiplied(255, 213, 0, 70); + let find_active_bg = egui::Color32::from_rgba_unmultiplied(255, 145, 0, 150); let width = (size * 0.08).max(1.5); for w in points.windows(2) { @@ -1501,11 +1800,21 @@ fn build_editor_job( } let mut fmt = TextFormat::simple(font_id.clone(), color); if let Some(&(_, _, spelling)) = - ranges.iter().find(|&&(s, e, _)| s < e && a >= s && b <= e) + grammar.iter().find(|&&(s, e, _)| s < e && a >= s && b <= e) { let c = if spelling { spell_color } else { grammar_color }; fmt.underline = egui::Stroke::new(width, c); } + if let Some(i) = find + .iter() + .position(|&(s, e)| s < e && a >= s && b <= e) + { + fmt.background = if Some(i) == find_active { + find_active_bg + } else { + find_bg + }; + } job.append(&text[a..b], 0.0, fmt); } job @@ -1715,6 +2024,86 @@ fn apply_format( } } +/// Find every non-overlapping occurrence of `needle` in `haystack`, returning +/// their `(start_byte, end_byte)` ranges in document order. An empty needle +/// matches nothing. When `case_sensitive` is false, matching is done on the +/// Unicode-lowercased text while the returned offsets index the original. +fn find_matches(haystack: &str, needle: &str, case_sensitive: bool) -> Vec<(usize, usize)> { + if needle.is_empty() { + return Vec::new(); + } + if case_sensitive { + let mut out = Vec::new(); + let mut from = 0; + while let Some(pos) = haystack[from..].find(needle) { + let s = from + pos; + let e = s + needle.len(); + out.push((s, e)); + // Advance past this match (at least one byte to guarantee progress). + from = e.max(s + 1); + } + return out; + } + + // Case-insensitive: search the lowercased haystack, mapping match offsets + // back to the original string's byte positions. + let (lower, map) = lowercased_with_map(haystack); + let low_needle = needle.to_lowercase(); + let mut out = Vec::new(); + let mut from = 0; + while let Some(pos) = lower[from..].find(&low_needle) { + let ls = from + pos; + let le = ls + low_needle.len(); + out.push((map[ls], map[le])); + from = le.max(ls + 1); + } + out +} + +/// Lowercase `s`, returning the lowercased string plus a map from each byte +/// index in it (including the terminal `len`) back to the byte index in `s` +/// where the originating character began. Used to translate case-insensitive +/// match offsets back onto the original text. +fn lowercased_with_map(s: &str) -> (String, Vec) { + let mut lower = String::with_capacity(s.len()); + let mut map = Vec::with_capacity(s.len() + 1); + for (byte, ch) in s.char_indices() { + for lc in ch.to_lowercase() { + let mut buf = [0u8; 4]; + let encoded = lc.encode_utf8(&mut buf); + for _ in 0..encoded.len() { + map.push(byte); + } + lower.push_str(encoded); + } + } + map.push(s.len()); + (lower, map) +} + +/// Replace every non-overlapping match of `needle` in `text` with `replacement`, +/// returning the new text and the number of replacements made. +fn replace_all( + text: &str, + needle: &str, + replacement: &str, + case_sensitive: bool, +) -> (String, usize) { + let matches = find_matches(text, needle, case_sensitive); + if matches.is_empty() { + return (text.to_string(), 0); + } + let mut out = String::with_capacity(text.len()); + let mut last = 0; + for &(s, e) in &matches { + out.push_str(&text[last..s]); + out.push_str(replacement); + last = e; + } + out.push_str(&text[last..]); + (out, matches.len()) +} + /// Very small markdown-ish preview (headings emphasised, everything else plain). fn render_preview(ui: &mut egui::Ui, markdown: &str) { use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd}; @@ -1991,6 +2380,66 @@ mod tests { assert_eq!(&text[char_to_byte(&text, sel.start)..char_to_byte(&text, sel.end)], "url"); } + #[test] + fn find_matches_are_non_overlapping_and_ordered() { + // "aaaa" with needle "aa" yields two non-overlapping matches, not three. + assert_eq!(find_matches("aaaa", "aa", true), vec![(0, 2), (2, 4)]); + assert_eq!( + find_matches("the cat sat on the mat", "at", true), + vec![(5, 7), (9, 11), (20, 22)] + ); + // An empty needle matches nothing. + assert!(find_matches("anything", "", true).is_empty()); + // No occurrence. + assert!(find_matches("hello", "zz", true).is_empty()); + } + + #[test] + fn find_matches_respects_case_sensitivity() { + // Case-sensitive skips the capitalised occurrence. + assert_eq!(find_matches("Cat cat CAT", "cat", true), vec![(4, 7)]); + // Case-insensitive finds all three, with offsets into the original text. + assert_eq!( + find_matches("Cat cat CAT", "cat", false), + vec![(0, 3), (4, 7), (8, 11)] + ); + } + + #[test] + fn find_matches_maps_multibyte_offsets_case_insensitively() { + // "café Café" — the accented text keeps its byte offsets when matching + // case-insensitively (é is two bytes). + let hits = find_matches("café Café", "café", false); + assert_eq!(hits.len(), 2); + let (s0, e0) = hits[0]; + let (s1, e1) = hits[1]; + assert_eq!(&"café Café"[s0..e0], "café"); + assert_eq!(&"café Café"[s1..e1], "Café"); + } + + #[test] + fn replace_all_replaces_every_match() { + assert_eq!( + replace_all("the cat sat", "at", "AT", true), + ("the cAT sAT".to_string(), 2) + ); + // Case-insensitive replacement across mixed case. + assert_eq!( + replace_all("Cat cat", "cat", "dog", false), + ("dog dog".to_string(), 2) + ); + // No matches leaves the text untouched. + assert_eq!( + replace_all("hello", "zz", "!", true), + ("hello".to_string(), 0) + ); + // Replacing with a longer/shorter string doesn't corrupt boundaries. + assert_eq!( + replace_all("a.b.c", ".", " - ", true), + ("a - b - c".to_string(), 2) + ); + } + #[test] fn format_respects_multibyte_char_offsets() { // "café " is 5 chars but 6 bytes; selecting "word" (chars 5..9) must diff --git a/src/help.rs b/src/help.rs index 01a6204..6182c63 100644 --- a/src/help.rs +++ b/src/help.rs @@ -64,12 +64,16 @@ fn cheatsheet_body(ui: &mut egui::Ui) { ("Ctrl/Cmd + E", "Inline code (`…`)."), ("Ctrl/Cmd + Shift + X", "Strikethrough (~~…~~)."), ("Ctrl/Cmd + K", "Wrap as a link and select the url to replace."), + ("Ctrl/Cmd + F", "Open the find bar."), + ("Ctrl/Cmd + H", "Open find/replace (replace field focused)."), ], ); note( ui, - "Applied to the selected text (or the caret, for an empty selection). \ - Pressing the same key on already-wrapped text removes the markers.", + "Wrapping keys apply to the selected text (or the caret, for an empty \ + selection); pressing the same key on already-wrapped text removes the \ + markers. In the find bar, Enter / Shift+Enter step through matches and \ + Esc closes it.", ); section(ui, "Paragraphs & breaks");