Add a find/replace bar to the editor

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N9kRuP7JvXoUGdNNeg5ZSs
This commit is contained in:
landon
2026-08-14 06:02:46 -05:00
parent ae8de32f04
commit 691d1a0515
3 changed files with 472 additions and 11 deletions
+458 -9
View File
@@ -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<bool>,
/// 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<usize>,
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<usize> = 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<usize>) {
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