editor: Improve search to select first match in visible range. (#1258)
This commit is contained in:
parent
a942c57627
commit
51efa766ea
3 changed files with 221 additions and 86 deletions
51
Cargo.lock
generated
51
Cargo.lock
generated
|
|
@ -2979,6 +2979,19 @@ dependencies = [
|
|||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "git2"
|
||||
version = "0.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2deb07a133b1520dc1a5690e9bd08950108873d7ed5de38dcc74d3b5ebffa110"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"libc",
|
||||
"libgit2-sys",
|
||||
"log",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glib"
|
||||
version = "0.18.5"
|
||||
|
|
@ -3118,6 +3131,7 @@ dependencies = [
|
|||
"as-raw-xcb-connection",
|
||||
"ashpd 0.11.0",
|
||||
"async-task",
|
||||
"backtrace",
|
||||
"bindgen 0.71.1",
|
||||
"blade-graphics",
|
||||
"blade-macros",
|
||||
|
|
@ -4284,6 +4298,18 @@ dependencies = [
|
|||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libgit2-sys"
|
||||
version = "0.18.2+1.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c42fe03df2bd3c53a3a9c7317ad91d80c81cd1fb0caec8d7cc4cd2bfa10c222"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"libz-sys",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
version = "0.8.8"
|
||||
|
|
@ -4311,6 +4337,18 @@ dependencies = [
|
|||
"redox_syscall 0.5.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libz-sys"
|
||||
version = "1.1.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b70e7a7df205e92a1a4cd9aaae7898dac0aa555503cc0a649494d0d60e7651d"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.3.8"
|
||||
|
|
@ -9215,11 +9253,13 @@ dependencies = [
|
|||
"dunce",
|
||||
"futures",
|
||||
"futures-lite 1.13.0",
|
||||
"git2",
|
||||
"globset",
|
||||
"itertools 0.14.0",
|
||||
"libc",
|
||||
"log",
|
||||
"nix 0.29.0",
|
||||
"rand 0.9.2",
|
||||
"regex",
|
||||
"rust-embed",
|
||||
"schemars",
|
||||
|
|
@ -9232,10 +9272,21 @@ dependencies = [
|
|||
"tempfile",
|
||||
"tendril",
|
||||
"unicase",
|
||||
"util_macros",
|
||||
"walkdir",
|
||||
"workspace-hack",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "util_macros"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed.git#53b2f37452189870c93d4514604f903d5ed885d9"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.105",
|
||||
"workspace-hack",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.18.0"
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ tree-sitter-languages = [
|
|||
]
|
||||
|
||||
[dependencies]
|
||||
gpui.workspace = true
|
||||
gpui = { workspace = true, "features" = ["test-support"] }
|
||||
sum_tree.workspace = true
|
||||
gpui_macros.workspace = true
|
||||
rope.workspace = true
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ use rust_i18n::t;
|
|||
use std::{ops::Range, rc::Rc};
|
||||
|
||||
use gpui::{
|
||||
actions, div, prelude::FluentBuilder as _, App, AppContext as _, Context, Empty, Entity,
|
||||
EntityInputHandler, FocusHandle, Focusable, Half, InteractiveElement as _, IntoElement,
|
||||
KeyBinding, ParentElement as _, Render, Styled, Subscription, Window,
|
||||
actions, canvas, div, prelude::FluentBuilder as _, App, AppContext as _, Context, Empty,
|
||||
Entity, EntityInputHandler, FocusHandle, Focusable, Half, InteractiveElement as _, IntoElement,
|
||||
KeyBinding, ParentElement as _, Pixels, Render, Styled, Subscription, Window,
|
||||
};
|
||||
use rope::Rope;
|
||||
|
||||
|
|
@ -14,7 +14,8 @@ use crate::{
|
|||
button::{Button, ButtonVariants},
|
||||
h_flex,
|
||||
input::{Enter, Escape, IndentInline, InputEvent, InputState, RopeExt, Search, TextInput},
|
||||
v_flex, ActiveTheme, IconName, Selectable, Sizable,
|
||||
label::Label,
|
||||
v_flex, ActiveTheme, Disableable, IconName, Selectable, Sizable,
|
||||
};
|
||||
|
||||
const KEY_CONTEXT: &'static str = "SearchPanel";
|
||||
|
|
@ -103,6 +104,23 @@ impl SearchMatcher {
|
|||
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())
|
||||
}
|
||||
|
||||
/// Update the current match index based on the given offset.
|
||||
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 {
|
||||
|
|
@ -141,12 +159,13 @@ impl DoubleEndedIterator for SearchMatcher {
|
|||
}
|
||||
|
||||
pub(super) struct SearchPanel {
|
||||
text_state: Entity<InputState>,
|
||||
editor: Entity<InputState>,
|
||||
search_input: Entity<InputState>,
|
||||
replace_input: Entity<InputState>,
|
||||
case_insensitive: bool,
|
||||
replace_mode: bool,
|
||||
matcher: SearchMatcher,
|
||||
input_width: Pixels,
|
||||
|
||||
open: bool,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
|
|
@ -181,10 +200,10 @@ impl InputState {
|
|||
};
|
||||
|
||||
let text = self.text.clone();
|
||||
let text_state = cx.entity();
|
||||
let editor = cx.entity();
|
||||
let selected_text = self.selected_text();
|
||||
search_panel.update(cx, |this, cx| {
|
||||
this.text_state = text_state;
|
||||
this.editor = editor;
|
||||
this.matcher.update(&text);
|
||||
this.show(&selected_text, window, cx);
|
||||
});
|
||||
|
|
@ -194,34 +213,33 @@ impl InputState {
|
|||
}
|
||||
|
||||
impl SearchPanel {
|
||||
pub fn new(text_state: Entity<InputState>, window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
pub fn new(editor: Entity<InputState>, window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
let search_input = cx.new(|cx| InputState::new(window, cx));
|
||||
let replace_input = cx.new(|cx| InputState::new(window, cx));
|
||||
|
||||
cx.new(|cx| {
|
||||
let _subscriptions = vec![cx.subscribe(
|
||||
&search_input,
|
||||
|this: &mut Self, search_input, ev: &InputEvent, cx| {
|
||||
// Handle search input changes
|
||||
match ev {
|
||||
InputEvent::Change => {
|
||||
let value = search_input.read(cx).value();
|
||||
this.matcher
|
||||
.update_query(value.as_str(), this.case_insensitive);
|
||||
let _subscriptions =
|
||||
vec![
|
||||
cx.subscribe(&search_input, |this: &mut Self, _, ev: &InputEvent, cx| {
|
||||
// Handle search input changes
|
||||
match ev {
|
||||
InputEvent::Change => {
|
||||
this.update_search_query(cx);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
},
|
||||
)];
|
||||
}),
|
||||
];
|
||||
|
||||
Self {
|
||||
text_state,
|
||||
editor,
|
||||
search_input,
|
||||
replace_input,
|
||||
case_insensitive: true,
|
||||
replace_mode: false,
|
||||
matcher: SearchMatcher::new(),
|
||||
open: true,
|
||||
input_width: Pixels::ZERO,
|
||||
_subscriptions,
|
||||
}
|
||||
})
|
||||
|
|
@ -238,24 +256,35 @@ impl SearchPanel {
|
|||
|
||||
self.search_input.update(cx, |this, cx| {
|
||||
if selected_text.len() > 0 {
|
||||
// Set value will emit to update_search_query
|
||||
this.set_value(selected_text.to_string(), window, cx);
|
||||
}
|
||||
this.select_all(&super::SelectAll, window, cx);
|
||||
});
|
||||
self.update_search(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn update_search(&mut self, cx: &mut Context<Self>) {
|
||||
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_str(), self.case_insensitive);
|
||||
self.update_text_selection(cx);
|
||||
|
||||
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.text_state.read(cx).focus_handle.focus(window);
|
||||
self.editor.read(cx).focus_handle.focus(window);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
|
|
@ -272,32 +301,12 @@ impl SearchPanel {
|
|||
}
|
||||
|
||||
fn on_action_tab(&mut self, _: &IndentInline, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.text_state.focus_handle(cx).focus(window);
|
||||
}
|
||||
|
||||
fn update_text_selection(&mut self, cx: &mut Context<Self>) {
|
||||
if let Some(range) = self
|
||||
.matcher
|
||||
.matched_ranges
|
||||
.get(self.matcher.current_match_ix)
|
||||
.cloned()
|
||||
{
|
||||
let state = self.text_state.clone();
|
||||
cx.spawn(async move |_, cx| {
|
||||
_ = cx.update(|cx| {
|
||||
state.update(cx, |state, cx| {
|
||||
state.selected_range = range.into();
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
self.editor.focus_handle(cx).focus(window);
|
||||
}
|
||||
|
||||
fn prev(&mut self, _: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Some(range) = self.matcher.next_back() {
|
||||
self.text_state.update(cx, |state, cx| {
|
||||
self.editor.update(cx, |state, cx| {
|
||||
state.scroll_to(range.start, cx);
|
||||
});
|
||||
}
|
||||
|
|
@ -305,7 +314,7 @@ impl SearchPanel {
|
|||
|
||||
fn next(&mut self, _: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Some(range) = self.matcher.next() {
|
||||
self.text_state.update(cx, |state, cx| {
|
||||
self.editor.update(cx, |state, cx| {
|
||||
state.scroll_to(range.end, cx);
|
||||
});
|
||||
}
|
||||
|
|
@ -328,7 +337,7 @@ impl SearchPanel {
|
|||
.get(self.matcher.current_match_ix)
|
||||
.cloned()
|
||||
{
|
||||
let text_state = self.text_state.clone();
|
||||
let text_state = self.editor.clone();
|
||||
|
||||
let next_range = self.matcher.peek().unwrap_or(range.clone());
|
||||
cx.spawn_in(window, async move |_, cx| {
|
||||
|
|
@ -357,10 +366,10 @@ impl SearchPanel {
|
|||
return;
|
||||
}
|
||||
|
||||
let text_state = self.text_state.clone();
|
||||
let editor = self.editor.clone();
|
||||
cx.spawn_in(window, async move |_, cx| {
|
||||
cx.update(|window, cx| {
|
||||
text_state.update(cx, |state, cx| {
|
||||
editor.update(cx, |state, cx| {
|
||||
// Replace from the end to avoid messing up the ranges.
|
||||
let mut rope = state.text.clone();
|
||||
for range in ranges.iter().rev() {
|
||||
|
|
@ -392,6 +401,8 @@ impl Render for SearchPanel {
|
|||
return Empty.into_any_element();
|
||||
}
|
||||
|
||||
let has_matches = self.matcher.len() > 0;
|
||||
|
||||
v_flex()
|
||||
.id("search-panel")
|
||||
.occlude()
|
||||
|
|
@ -416,27 +427,44 @@ impl Render for SearchPanel {
|
|||
.w_full()
|
||||
.gap_2()
|
||||
.child(
|
||||
div().flex_1().gap_1().child(
|
||||
TextInput::new(&self.search_input)
|
||||
.focus_bordered(false)
|
||||
.suffix(
|
||||
Button::new("case-insensitive")
|
||||
.selected(!self.case_insensitive)
|
||||
.xsmall()
|
||||
.compact()
|
||||
.ghost()
|
||||
.icon(IconName::CaseSensitive)
|
||||
.on_click(cx.listener(|this, _, _, cx| {
|
||||
this.case_insensitive = !this.case_insensitive;
|
||||
this.update_search(cx);
|
||||
cx.notify();
|
||||
})),
|
||||
div()
|
||||
.flex_1()
|
||||
.gap_1()
|
||||
.child(
|
||||
TextInput::new(&self.search_input)
|
||||
.focus_bordered(false)
|
||||
.suffix(
|
||||
Button::new("case-insensitive")
|
||||
.selected(!self.case_insensitive)
|
||||
.xsmall()
|
||||
.compact()
|
||||
.ghost()
|
||||
.icon(IconName::CaseSensitive)
|
||||
.on_click(cx.listener(|this, _, _, cx| {
|
||||
this.case_insensitive = !this.case_insensitive;
|
||||
this.update_search_query(cx);
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
.small()
|
||||
.w_full()
|
||||
.shadow_none(),
|
||||
)
|
||||
.child(
|
||||
canvas(
|
||||
{
|
||||
let view = cx.entity();
|
||||
move |bounds, _, cx| {
|
||||
view.update(cx, |r, _| {
|
||||
r.input_width = bounds.size.width
|
||||
})
|
||||
}
|
||||
},
|
||||
|_, _, _, _| {},
|
||||
)
|
||||
.small()
|
||||
.w_full()
|
||||
.cleanable()
|
||||
.shadow_none(),
|
||||
),
|
||||
.absolute()
|
||||
.size_full(),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Button::new("replace-mode")
|
||||
|
|
@ -446,7 +474,11 @@ impl Render for SearchPanel {
|
|||
.selected(self.replace_mode)
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.replace_mode = !this.replace_mode;
|
||||
this.replace_input.read(cx).focus_handle.focus(window);
|
||||
if this.replace_mode {
|
||||
this.replace_input.read(cx).focus_handle.focus(window);
|
||||
} else {
|
||||
this.search_input.read(cx).focus_handle.focus(window);
|
||||
}
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
|
|
@ -455,6 +487,7 @@ impl Render for SearchPanel {
|
|||
.xsmall()
|
||||
.ghost()
|
||||
.icon(IconName::ChevronLeft)
|
||||
.disabled(!has_matches)
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.prev(window, cx);
|
||||
})),
|
||||
|
|
@ -464,11 +497,20 @@ impl Render for SearchPanel {
|
|||
.xsmall()
|
||||
.ghost()
|
||||
.icon(IconName::ChevronRight)
|
||||
.disabled(!has_matches)
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.next(window, cx);
|
||||
})),
|
||||
)
|
||||
.child(div().w_5())
|
||||
.child(
|
||||
Label::new(self.matcher.label())
|
||||
.when(!has_matches, |this| {
|
||||
this.text_color(cx.theme().muted_foreground)
|
||||
})
|
||||
.text_left()
|
||||
.min_w_16(),
|
||||
)
|
||||
.child(div().w_7())
|
||||
.child(
|
||||
Button::new("close")
|
||||
.xsmall()
|
||||
|
|
@ -488,13 +530,14 @@ impl Render for SearchPanel {
|
|||
TextInput::new(&self.replace_input)
|
||||
.focus_bordered(false)
|
||||
.small()
|
||||
.w_full()
|
||||
.w(self.input_width)
|
||||
.shadow_none(),
|
||||
)
|
||||
.child(
|
||||
Button::new("replace-one")
|
||||
.small()
|
||||
.label(t!("Input.Replace"))
|
||||
.disabled(!has_matches)
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.replace_next(window, cx);
|
||||
})),
|
||||
|
|
@ -503,6 +546,7 @@ impl Render for SearchPanel {
|
|||
Button::new("replace-all")
|
||||
.small()
|
||||
.label(t!("Input.Replace All"))
|
||||
.disabled(!has_matches)
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.replace_all(window, cx);
|
||||
})),
|
||||
|
|
@ -519,12 +563,12 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_search() {
|
||||
let mut search = SearchMatcher::new();
|
||||
search.update(&Rope::from("Hello 世界 this is a Is test string."));
|
||||
search.update_query("Is", true);
|
||||
let mut matcher = SearchMatcher::new();
|
||||
matcher.update(&Rope::from("Hello 世界 this is a Is test string."));
|
||||
matcher.update_query("Is", true);
|
||||
|
||||
assert_eq!(search.len(), 3);
|
||||
let mut matches = search.clone().into_iter();
|
||||
assert_eq!(matcher.len(), 3);
|
||||
let mut matches = matcher.clone().into_iter();
|
||||
assert_eq!(matches.current_match_ix, 0);
|
||||
assert_eq!(matches.next(), Some(18..20));
|
||||
assert_eq!(matches.next(), Some(23..25));
|
||||
|
|
@ -539,9 +583,49 @@ mod tests {
|
|||
assert_eq!(matches.current_match_ix, 0);
|
||||
assert_eq!(matches.next_back(), Some(23..25));
|
||||
|
||||
search.update_query("IS", false);
|
||||
assert_eq!(search.len(), 0);
|
||||
assert_eq!(search.next(), None);
|
||||
assert_eq!(search.next_back(), None);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue