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:
@@ -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
|
||||
|
||||
+458
-9
@@ -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
|
||||
|
||||
+6
-2
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user