use egui::{
Align2, Context, EventFilter, Key, Modifiers, Response, TextEdit as EguiTextEdit, Ui, Window,
vec2,
};
use crate::search::{find_matches, replace_all, replace_range};
fn hold_tab(ui: &Ui, response: &Response) {
if response.has_focus() {
ui.memory_mut(|memory| {
memory.set_focus_lock_filter(
response.id,
EventFilter {
tab: true,
..EventFilter::default()
},
);
});
}
}
fn action(ui: &mut Ui, label: &str) -> bool {
let response = ui.button(label);
hold_tab(ui, &response);
response.clicked()
}
#[derive(Default)]
pub(super) struct Search {
pub(super) open: bool,
pub(super) replacing: bool,
pub(super) term: String,
pub(super) replacement: String,
pub(super) last_term: Option<String>,
pub(super) taking_focus: bool,
}
pub(super) struct SearchOutput {
pub(super) cursor: Option<(usize, usize)>,
pub(super) edited: bool,
pub(super) carets: Vec<usize>,
}
impl Search {
pub(super) fn open(&mut self, initial: String, replacing: bool) {
if !initial.is_empty() {
self.term = initial;
}
self.replacing = replacing;
self.open = true;
self.taking_focus = true;
}
pub(super) fn jump_to_match(
&self,
text: &str,
cursor: usize,
anchor: usize,
forward: bool,
) -> Option<(usize, usize)> {
let term = self.last_term.as_ref()?;
let matches = find_matches(text, term);
if matches.is_empty() {
return None;
}
let length = term.chars().count();
let next = if forward {
matches
.iter()
.find(|&&position| position > cursor)
.or_else(|| matches.first())
} else {
let start = anchor.min(cursor);
matches
.iter()
.rev()
.find(|&&position| position < start)
.or_else(|| matches.last())
}?;
Some((next + length, *next))
}
fn replace_current(&self, text: &mut String, cursor: usize, anchor: usize) -> (usize, usize) {
let start = cursor.min(anchor);
let length = self.term.chars().count();
let selected: String = text.chars().skip(start).take(length).collect();
if cursor == anchor || find_matches(&selected, &self.term).first() != Some(&0) {
return (cursor, anchor);
}
*text = replace_range(text, start, length, &self.replacement);
let end = start + self.replacement.chars().count();
(end, end)
}
fn fields(&mut self, ui: &mut Ui, text: &str) -> bool {
let response = ui
.horizontal(|ui| {
let (arrow, hint) = if self.replacing {
("⏷", "Find only (Ctrl+F)")
} else {
("⏵", "Replace as well (Ctrl+H)")
};
let toggle = ui.small_button(arrow).on_hover_text(hint);
hold_tab(ui, &toggle);
if toggle.clicked() {
self.replacing = !self.replacing;
}
let field = ui.add(EguiTextEdit::singleline(&mut self.term).desired_width(240.0));
hold_tab(ui, &field);
field
})
.inner;
if response.changed() {
self.last_term = Some(self.term.clone());
}
let replacement = self.replacing.then(|| {
let field =
ui.add(EguiTextEdit::singleline(&mut self.replacement).desired_width(240.0));
hold_tab(ui, &field);
field
});
if self.taking_focus {
response.request_focus();
self.taking_focus = false;
}
if !self.term.is_empty() {
let matches = find_matches(text, &self.term);
ui.label(format!("{} match(es)", matches.len()));
}
replacement.is_some_and(|field| field.has_focus())
}
pub(super) fn draw(
&mut self,
ctx: &Context,
text: &mut String,
cursor: usize,
anchor: usize,
id_source: impl std::hash::Hash + std::fmt::Debug,
) -> SearchOutput {
let mut output = SearchOutput {
cursor: None,
edited: false,
carets: Vec::new(),
};
if !self.open {
return output;
}
if ctx.input_mut(|input| input.consume_key(Modifiers::NONE, Key::Escape)) {
self.open = false;
return output;
}
let mut backward = ctx.input_mut(|input| input.consume_key(Modifiers::SHIFT, Key::Tab));
let mut forward = ctx.input_mut(|input| input.consume_key(Modifiers::NONE, Key::Tab));
let entered = ctx.input_mut(|input| input.consume_key(Modifiers::NONE, Key::Enter));
let every = ctx.input_mut(|input| input.consume_key(Modifiers::CTRL, Key::Enter));
let mut replace_every = every && self.replacing;
let mut all_as_carets = every && !self.replacing;
let mut replace_one = false;
let mut confirm = false;
let mut close = false;
Window::new("Find")
.id(egui::Id::new("idet-core-search").with(id_source))
.collapsible(false)
.resizable(false)
.anchor(Align2::CENTER_TOP, vec2(0.0, 40.0))
.show(ctx, |ui| {
let in_replacement = self.fields(ui, text);
replace_one |= entered && in_replacement;
confirm |= entered && !in_replacement;
ui.horizontal(|ui| {
forward |= action(ui, "Next (Tab)");
backward |= action(ui, "Previous (Shift+Tab)");
if self.replacing {
replace_one |= action(ui, "Replace (Enter)");
replace_every |= action(ui, "Replace all (Ctrl+Enter)");
} else {
all_as_carets |= action(ui, "A caret on each (Ctrl+Enter)");
}
close |= action(ui, "Close (Esc)");
});
});
if forward || backward {
self.last_term = Some(self.term.clone());
output.cursor = self.jump_to_match(text, cursor, anchor, forward);
}
if replace_one && !self.term.is_empty() {
self.last_term = Some(self.term.clone());
let (moved_cursor, moved_anchor) = self.replace_current(text, cursor, anchor);
output.edited = moved_cursor != cursor || moved_anchor != anchor;
output.cursor = self
.jump_to_match(text, moved_cursor, moved_anchor, true)
.or(Some((moved_cursor, moved_anchor)));
}
if replace_every && !self.term.is_empty() {
let (replaced, count) = replace_all(text, &self.term, &self.replacement);
if count > 0 {
*text = replaced;
output.edited = true;
let position = cursor.min(text.chars().count());
output.cursor = Some((position, position));
}
self.open = false;
}
if all_as_carets && !self.term.is_empty() {
self.last_term = Some(self.term.clone());
let length = self.term.chars().count();
output.carets = find_matches(text, &self.term)
.into_iter()
.map(|start| start + length)
.collect();
if !output.carets.is_empty() {
self.open = false;
}
}
if confirm {
self.last_term = Some(self.term.clone());
output.cursor = self.jump_to_match(text, cursor, anchor, true);
self.open = self.replacing;
}
if close {
self.open = false;
}
output
}
}
#[cfg(test)]
mod tests {
use super::Search;
fn replacing(term: &str, replacement: &str) -> Search {
Search {
open: true,
replacing: true,
term: term.to_owned(),
replacement: replacement.to_owned(),
last_term: None,
taking_focus: false,
}
}
#[test]
fn a_selected_match_is_replaced_and_the_cursor_follows_it() {
let mut text = "alpha beta".to_owned();
let search = replacing("alpha", "omega");
assert_eq!(search.replace_current(&mut text, 5, 0), (5, 5));
assert_eq!(text, "omega beta");
}
#[test]
fn a_selection_that_is_not_the_term_is_left_alone() {
let mut text = "alpha beta".to_owned();
let search = replacing("alpha", "omega");
assert_eq!(search.replace_current(&mut text, 10, 6), (10, 6));
assert_eq!(text, "alpha beta");
}
#[test]
fn nothing_selected_replaces_nothing() {
let mut text = "alpha beta".to_owned();
let search = replacing("alpha", "omega");
assert_eq!(search.replace_current(&mut text, 3, 3), (3, 3));
assert_eq!(text, "alpha beta");
}
#[test]
fn a_shorter_replacement_leaves_the_cursor_at_its_end() {
let mut text = "alpha beta".to_owned();
let search = replacing("alpha", "hi");
assert_eq!(search.replace_current(&mut text, 5, 0), (2, 2));
assert_eq!(text, "hi beta");
}
}