use std::{ops::Range, rc::Rc};
use aho_corasick::AhoCorasick;
use gpui::{
App, AppContext as _, Context, Empty, Entity, FocusHandle, Focusable, Half,
InteractiveElement as _, IntoElement, KeyBinding, KeyDownEvent, ParentElement as _, Pixels,
Render, Styled, Subscription, Window, div, prelude::FluentBuilder as _,
};
use ropey::Rope;
use super::{
movement::MoveDirection,
rope_ext::RopeExt as _,
state::{InputState, Search},
};
use crate::{
ActiveTheme, Disableable, ElementExt, Icon, IconName, Selectable, Sizable, Size, StyleSized as _,
actions::SelectUp,
h_flex, translate_woocraft, v_flex,
widgets::{
button::{Button, ButtonVariants as _},
input::{
Escape as TextInputEscape, Input as TextInput, InputEvent as TextInputEvent,
InputState as TextInputState, SelectAll as TextInputSelectAll,
},
label::Label,
},
};
const CONTEXT: &str = "SearchPanel";
pub(super) fn init(cx: &mut App) {
cx.bind_keys(vec![KeyBinding::new(
"shift-enter",
SelectUp,
Some(CONTEXT),
)]);
}
#[derive(Debug, Clone)]
pub struct SearchMatcher {
text: Rope,
pub query: Option<AhoCorasick>,
pub(super) matched_ranges: Rc<Vec<Range<usize>>>,
pub(super) current_match_ix: usize,
replacing: bool,
}
impl SearchMatcher {
pub fn new() -> Self {
Self {
text: "".into(),
query: None,
matched_ranges: Rc::new(Vec::new()),
current_match_ix: 0,
replacing: false,
}
}
pub(crate) fn update(&mut self, text: &Rope) {
if self.text.eq(text) {
return;
}
self.text = text.clone();
self.update_matches();
}
fn update_matches(&mut self) {
let mut new_ranges = Vec::new();
if let Some(query) = &self.query {
let text = self.text.to_string();
let matches = query.stream_find_iter(text.as_bytes());
for query_match in matches.into_iter() {
let query_match = query_match.expect("query match for select all action");
new_ranges.push(query_match.range());
}
}
self.matched_ranges = Rc::new(new_ranges);
if !self.replacing {
self.current_match_ix = 0;
self.replacing = false;
}
}
pub fn update_query(&mut self, query: &str, case_insensitive: bool) {
if !query.is_empty() {
self.query = Some(
AhoCorasick::builder()
.ascii_case_insensitive(case_insensitive)
.build(&[query.to_string()])
.expect("failed to build AhoCorasick query in SearchMatcher"),
);
} else {
self.query = None;
}
self.update_matches();
}
#[allow(unused)]
#[inline]
fn len(&self) -> usize {
self.matched_ranges.len()
}
fn peek(&self) -> Option<Range<usize>> {
self.matched_ranges.get(self.current_match_ix + 1).cloned()
}
fn label(&self) -> String {
if self.len() == 0 {
return "0/0".to_string();
}
format!("{}/{}", self.current_match_ix + 1, self.len())
}
fn update_cursor_by_offset(&mut self, offset: usize) {
for (ix, range) in self.matched_ranges.iter().enumerate() {
self.current_match_ix = ix;
if range.contains(&offset) || range.end >= offset {
return;
}
}
}
}
impl Iterator for SearchMatcher {
type Item = Range<usize>;
fn next(&mut self) -> Option<Self::Item> {
if self.matched_ranges.is_empty() {
return None;
}
if self.current_match_ix < self.matched_ranges.len().saturating_sub(1) {
self.current_match_ix += 1;
} else {
self.current_match_ix = 0;
}
self.matched_ranges.get(self.current_match_ix).cloned()
}
}
impl DoubleEndedIterator for SearchMatcher {
fn next_back(&mut self) -> Option<Self::Item> {
if self.matched_ranges.is_empty() {
return None;
}
if self.current_match_ix == 0 {
self.current_match_ix = self.matched_ranges.len();
}
self.current_match_ix -= 1;
let item = self.matched_ranges[self.current_match_ix].clone();
Some(item)
}
}
pub(super) struct SearchPanel {
editor: Entity<InputState>,
search_input: Entity<TextInputState>,
replace_input: Entity<TextInputState>,
case_insensitive: bool,
replace_mode: bool,
matcher: SearchMatcher,
input_width: Pixels,
open: bool,
_subscriptions: Vec<Subscription>,
}
impl InputState {
pub(super) fn update_search(&mut self, cx: &mut App) {
let Some(search_panel) = self.search_panel.as_ref() else {
return;
};
let text = self.text.clone();
search_panel.update(cx, |this, _| {
this.matcher.update(&text);
});
}
pub(super) fn on_action_search(
&mut self, _: &Search, window: &mut Window, cx: &mut Context<Self>,
) {
if !self.searchable {
return;
}
let search_panel = match self.search_panel.as_ref() {
Some(panel) => panel.clone(),
None => SearchPanel::new(cx.entity(), cx),
};
let text = self.text.clone();
let editor = cx.entity();
let selected_text = Rope::from(self.selected_text());
search_panel.update(cx, |this, cx| {
this.editor = editor;
this.matcher.update(&text);
this.show(&selected_text, window, cx);
});
self.search_panel = Some(search_panel);
cx.notify();
}
}
impl SearchPanel {
pub fn new(editor: Entity<InputState>, cx: &mut App) -> Entity<Self> {
let search_input = cx.new(TextInputState::new);
let replace_input = cx.new(TextInputState::new);
cx.new(|cx| {
let _subscriptions = vec![
cx.subscribe(
&search_input,
|this: &mut Self, _, ev: &TextInputEvent, cx| match ev {
TextInputEvent::Change => {
this.update_search_query(cx);
}
TextInputEvent::PressEnter { secondary } => {
if *secondary {
this.prev(cx);
} else {
this.next(cx);
}
}
_ => {}
},
),
cx.subscribe(
&replace_input,
|this: &mut Self, _, ev: &TextInputEvent, cx| {
if let TextInputEvent::PressEnter { secondary } = ev {
if *secondary {
this.prev(cx);
} else {
this.next(cx);
}
}
},
),
];
Self {
editor,
search_input,
replace_input,
case_insensitive: true,
replace_mode: false,
matcher: SearchMatcher::new(),
open: true,
input_width: Pixels::ZERO,
_subscriptions,
}
})
}
pub(super) fn show(&mut self, selected_text: &Rope, window: &mut Window, cx: &mut Context<Self>) {
self.open = true;
self.search_input.update(cx, |this, cx| {
this.focus(window, cx);
if selected_text.len() > 0 {
this.set_value(selected_text.to_string(), window, cx);
}
this.select_all(&TextInputSelectAll, window, cx);
});
}
fn update_search_query(&mut self, cx: &mut Context<Self>) {
let query = self.search_input.read(cx).value();
let visible_range_offset = self
.editor
.read(cx)
.last_layout
.as_ref()
.map(|l| l.visible_range_offset.clone());
self
.matcher
.update_query(query.as_ref(), self.case_insensitive);
if let Some(visible_range_offset) = visible_range_offset {
self
.matcher
.update_cursor_by_offset(visible_range_offset.start);
}
cx.notify();
}
pub(super) fn hide(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.open = false;
self.editor.focus_handle(cx).focus(window);
cx.notify();
}
fn on_action_prev(&mut self, _: &SelectUp, window: &mut Window, cx: &mut Context<Self>) {
let _ = window;
self.prev(cx);
}
fn on_action_escape(&mut self, _: &TextInputEscape, window: &mut Window, cx: &mut Context<Self>) {
self.hide(window, cx);
}
fn on_key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
if event.keystroke.key.as_str() != "tab" {
return;
}
self.editor.focus_handle(cx).focus(window);
window.prevent_default();
cx.stop_propagation();
}
fn prev(&mut self, cx: &mut Context<Self>) {
if let Some(range) = self.matcher.next_back() {
self.editor.update(cx, |state, cx| {
state.scroll_to(range.start, Some(MoveDirection::Up), cx);
});
}
}
fn next(&mut self, cx: &mut Context<Self>) {
if let Some(range) = self.matcher.next() {
self.editor.update(cx, |state, cx| {
state.scroll_to(range.end, Some(MoveDirection::Down), cx);
});
}
}
pub(super) fn matcher(&self) -> Option<&SearchMatcher> {
if !self.open {
return None;
}
Some(&self.matcher)
}
fn replace_next(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let new_text = self.replace_input.read(cx).value();
self.matcher.replacing = true;
if let Some(range) = self
.matcher
.matched_ranges
.get(self.matcher.current_match_ix)
.cloned()
{
let text_state = self.editor.clone();
let next_range = self.matcher.peek().unwrap_or(range.clone());
cx.spawn_in(window, async move |_, cx| {
cx.update(|window, cx| {
text_state.update(cx, |state, cx| {
let range_utf16 = state.range_to_utf16(&range);
state.scroll_to(next_range.end, Some(MoveDirection::Down), cx);
state.replace_text_in_range_silent(Some(range_utf16), new_text.as_str(), window, cx);
});
})
})
.detach();
}
}
fn replace_all(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let new_text = self.replace_input.read(cx).value();
self.matcher.replacing = true;
let ranges = self.matcher.matched_ranges.clone();
if ranges.is_empty() {
return;
}
let editor = self.editor.clone();
cx.spawn_in(window, async move |_, cx| {
cx.update(|window, cx| {
editor.update(cx, |state, cx| {
let mut rope = state.text.clone();
for range in ranges.iter().rev() {
rope.replace(range.clone(), new_text.as_str());
}
state.replace_text_in_range_silent(
Some(0..state.text.len()),
&rope.to_string(),
window,
cx,
);
state.scroll_to(0, Some(MoveDirection::Down), cx);
});
})
})
.detach();
}
}
impl Focusable for SearchPanel {
fn focus_handle(&self, cx: &App) -> FocusHandle {
self.search_input.focus_handle(cx)
}
}
impl Render for SearchPanel {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
if !self.open {
return Empty.into_any_element();
}
let has_matches = self.matcher.len() > 0;
v_flex()
.id("search-panel")
.occlude()
.track_focus(&self.focus_handle(cx))
.key_context(CONTEXT)
.on_action(cx.listener(Self::on_action_prev))
.on_action(cx.listener(Self::on_action_escape))
.on_key_down(cx.listener(Self::on_key_down))
.items_center()
.container_padding(Size::Medium)
.w_full()
.container_gap_y(Size::Medium)
.bg(cx.theme().popover)
.border_b_1()
.rounded(cx.theme().radius.half())
.border_color(cx.theme().border)
.child(
h_flex()
.w_full()
.container_gap_x(Size::Medium)
.child(
h_flex()
.flex_1()
.container_gap_x(Size::Medium)
.child(
div()
.flex_1()
.child(
TextInput::new(&self.search_input)
.focus_bordered(false)
.medium()
.w_full()
.shadow_none(),
)
.on_prepaint({
let view = cx.entity();
move |bounds, _, cx| view.update(cx, |r, _| r.input_width = bounds.size.width)
}),
)
.child(
Button::new("case-insensitive")
.selected(!self.case_insensitive)
.flat()
.icon(Icon::new(IconName::TextChangeCase))
.on_click(cx.listener(|this, _, _, cx| {
this.case_insensitive = !this.case_insensitive;
this.update_search_query(cx);
cx.notify();
})),
),
)
.child(
Button::new("replace-mode")
.flat()
.icon(Icon::new(IconName::ArrowRepeatAll))
.selected(self.replace_mode)
.on_click(cx.listener(|this, _, window, cx| {
this.replace_mode = !this.replace_mode;
if this.replace_mode {
this
.replace_input
.update(cx, |state, cx| state.focus(window, cx));
} else {
this
.search_input
.update(cx, |state, cx| state.focus(window, cx));
}
cx.notify();
})),
)
.child(
Button::new("prev")
.flat()
.icon(Icon::new(IconName::ChevronLeft))
.disabled(!has_matches)
.on_click(cx.listener(|this, _, window, cx| {
let _ = window;
this.prev(cx);
})),
)
.child(
Button::new("next")
.flat()
.icon(Icon::new(IconName::ChevronRight))
.disabled(!has_matches)
.on_click(cx.listener(|this, _, window, cx| {
let _ = window;
this.next(cx);
})),
)
.child(
Label::new(self.matcher.label())
.component_px(Size::Medium)
.when(!has_matches, |this| {
this.text_color(cx.theme().muted_foreground)
})
.text_left()
.min_w_16(),
)
.child(div().w_7())
.child(
Button::new("close")
.flat()
.icon(Icon::new(IconName::Dismiss))
.on_click(cx.listener(|this, _, window, cx| {
this.hide(window, cx);
})),
),
)
.when(self.replace_mode, |this| {
this.child(
h_flex()
.w_full()
.container_gap_x(Size::Medium)
.child(
TextInput::new(&self.replace_input)
.focus_bordered(false)
.medium()
.w(self.input_width)
.shadow_none(),
)
.child(
Button::new("replace-one")
.label(translate_woocraft("editor.search.replace"))
.disabled(!has_matches)
.on_click(cx.listener(|this, _, window, cx| {
this.replace_next(window, cx);
})),
)
.child(
Button::new("replace-all")
.label(translate_woocraft("editor.search.replace_all"))
.disabled(!has_matches)
.on_click(cx.listener(|this, _, window, cx| {
this.replace_all(window, cx);
})),
),
)
})
.into_any_element()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_search() {
let mut matcher = SearchMatcher::new();
matcher.update(&Rope::from("Hello 世界 this is a Is test string."));
matcher.update_query("Is", true);
assert_eq!(matcher.len(), 3);
let mut matches = matcher.clone();
assert_eq!(matches.current_match_ix, 0);
assert_eq!(matches.next(), Some(18..20));
assert_eq!(matches.next(), Some(23..25));
assert_eq!(matches.current_match_ix, 2);
assert_eq!(matches.next(), Some(15..17));
assert_eq!(matches.current_match_ix, 0);
assert_eq!(matches.next_back(), Some(23..25));
assert_eq!(matches.current_match_ix, 2);
assert_eq!(matches.next_back(), Some(18..20));
assert_eq!(matches.current_match_ix, 1);
assert_eq!(matches.next_back(), Some(15..17));
assert_eq!(matches.current_match_ix, 0);
assert_eq!(matches.next_back(), Some(23..25));
matcher.update_query("IS", false);
assert_eq!(matcher.len(), 0);
assert_eq!(matcher.next(), None);
assert_eq!(matcher.next_back(), None);
}
#[test]
fn test_search_label() {
let mut matcher = SearchMatcher::new();
matcher.update(&Rope::from("Hello 世界 this is a Is test string."));
matcher.update_query("Is", true);
assert_eq!(matcher.label(), "1/3");
matcher.next();
assert_eq!(matcher.label(), "2/3");
matcher.next();
assert_eq!(matcher.label(), "3/3");
matcher.next();
assert_eq!(matcher.label(), "1/3");
matcher.update_query("IS", false);
assert_eq!(matcher.label(), "0/0");
}
#[test]
fn test_select_range_start() {
let mut matcher = SearchMatcher::new();
matcher.matched_ranges = Rc::new(vec![5..10, 15..20, 25..30]);
matcher.update_cursor_by_offset(0);
assert_eq!(matcher.current_match_ix, 0);
matcher.update_cursor_by_offset(5);
assert_eq!(matcher.current_match_ix, 0);
matcher.update_cursor_by_offset(12);
assert_eq!(matcher.current_match_ix, 1);
matcher.update_cursor_by_offset(16);
assert_eq!(matcher.current_match_ix, 1);
matcher.update_cursor_by_offset(30);
assert_eq!(matcher.current_match_ix, 2);
matcher.update_cursor_by_offset(31);
assert_eq!(matcher.current_match_ix, 2);
}
}