Search bar UI enhancements (#7675)

This PR introduces several enhancements (along with fixing a couple of
bugs) in the search bar UI (copied from [this
comment](https://github.com/zed-industries/zed/issues/7663#issuecomment-1937659091)):

- Moving the Replace field under the Search field makes it easier to
compare texts and avoid typos. Also, less eyes movements.
- Use red (error) color to indicate that nothing matched. VSCode, IDEA
do this. Again, it helps to get a quicker feedback on typos without
moving your eyes.
- Much less moving parts and no place for flickering.
- Better fits to narrow panes.
- The Close button that allows to close the search bar with the mouse.
- Better keyboard handling (tab, shift+tab in the replacement mode),
autofocus on the Replace field.

How it looks:


https://github.com/zed-industries/zed/assets/2101250/93b0edb4-4311-4a7f-9f43-b30c4d1aede5

Implementation details:

- Using `Self::on_query_editor_event` looked suspicious
[here](2880135037/crates/search/src/buffer_search.rs (L491))
because it triggered searching on the replacement text changes. I've
created a separate method for the replacement editor.
- These changes don't affect the project search bar. If the PR is
accepted, the same changes may be implemented there.

Fixed issues:

- #7661
- #7663

Release Notes:

- Buffer search bar UI enhancements.
This commit is contained in:
Andrew Lygin 2024-02-12 21:01:57 +03:00 committed by GitHub
parent a23313928b
commit 1c2081c10c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -9,13 +9,16 @@ use crate::{
ToggleCaseSensitive, ToggleReplace, ToggleWholeWord, ToggleCaseSensitive, ToggleReplace, ToggleWholeWord,
}; };
use collections::HashMap; use collections::HashMap;
use editor::{actions::Tab, Editor, EditorElement, EditorStyle}; use editor::{
actions::{Tab, TabPrev},
Editor, EditorElement, EditorStyle,
};
use futures::channel::oneshot; use futures::channel::oneshot;
use gpui::{ use gpui::{
actions, div, impl_actions, Action, AppContext, ClickEvent, EventEmitter, FocusableView, actions, div, impl_actions, Action, AppContext, ClickEvent, EventEmitter, FocusableView,
FontStyle, FontWeight, InteractiveElement as _, IntoElement, KeyContext, ParentElement as _, FontStyle, FontWeight, Hsla, InteractiveElement as _, IntoElement, KeyContext,
Render, Styled, Subscription, Task, TextStyle, View, ViewContext, VisualContext as _, ParentElement as _, Render, Styled, Subscription, Task, TextStyle, View, ViewContext,
WhiteSpace, WindowContext, VisualContext as _, WhiteSpace, WindowContext,
}; };
use project::search::SearchQuery; use project::search::SearchQuery;
use serde::Deserialize; use serde::Deserialize;
@ -23,7 +26,7 @@ use settings::Settings;
use std::{any::Any, sync::Arc}; use std::{any::Any, sync::Arc};
use theme::ThemeSettings; use theme::ThemeSettings;
use ui::{h_flex, prelude::*, Icon, IconButton, IconName, ToggleButton, Tooltip}; use ui::{h_flex, prelude::*, IconButton, IconName, ToggleButton, Tooltip};
use util::ResultExt; use util::ResultExt;
use workspace::{ use workspace::{
item::ItemHandle, item::ItemHandle,
@ -34,6 +37,9 @@ use workspace::{
pub use registrar::DivRegistrar; pub use registrar::DivRegistrar;
use registrar::{ForDeployed, ForDismissed, SearchActionsRegistrar, WithResults}; use registrar::{ForDeployed, ForDismissed, SearchActionsRegistrar, WithResults};
const MIN_INPUT_WIDTH_REMS: f32 = 15.;
const MAX_INPUT_WIDTH_REMS: f32 = 25.;
#[derive(PartialEq, Clone, Deserialize)] #[derive(PartialEq, Clone, Deserialize)]
pub struct Deploy { pub struct Deploy {
pub focus: bool, pub focus: bool,
@ -54,7 +60,9 @@ pub fn init(cx: &mut AppContext) {
pub struct BufferSearchBar { pub struct BufferSearchBar {
query_editor: View<Editor>, query_editor: View<Editor>,
query_editor_focused: bool,
replacement_editor: View<Editor>, replacement_editor: View<Editor>,
replacement_editor_focused: bool,
active_searchable_item: Option<Box<dyn SearchableItemHandle>>, active_searchable_item: Option<Box<dyn SearchableItemHandle>>,
active_match_index: Option<usize>, active_match_index: Option<usize>,
active_searchable_item_subscription: Option<Subscription>, active_searchable_item_subscription: Option<Subscription>,
@ -72,13 +80,18 @@ pub struct BufferSearchBar {
} }
impl BufferSearchBar { impl BufferSearchBar {
fn render_text_input(&self, editor: &View<Editor>, cx: &ViewContext<Self>) -> impl IntoElement { fn render_text_input(
&self,
editor: &View<Editor>,
color: Hsla,
cx: &ViewContext<Self>,
) -> impl IntoElement {
let settings = ThemeSettings::get_global(cx); let settings = ThemeSettings::get_global(cx);
let text_style = TextStyle { let text_style = TextStyle {
color: if editor.read(cx).read_only(cx) { color: if editor.read(cx).read_only(cx) {
cx.theme().colors().text_disabled cx.theme().colors().text_disabled
} else { } else {
cx.theme().colors().text color
}, },
font_family: settings.ui_font.family.clone(), font_family: settings.ui_font.family.clone(),
font_features: settings.ui_font.features, font_features: settings.ui_font.features,
@ -161,7 +174,8 @@ impl Render for BufferSearchBar {
editor.set_placeholder_text("Replace with...", cx); editor.set_placeholder_text("Replace with...", cx);
}); });
let match_count = self let mut match_color = Color::Default;
let match_text = self
.active_searchable_item .active_searchable_item
.as_ref() .as_ref()
.and_then(|searchable_item| { .and_then(|searchable_item| {
@ -171,14 +185,15 @@ impl Render for BufferSearchBar {
let matches = self let matches = self
.searchable_items_with_matches .searchable_items_with_matches
.get(&searchable_item.downgrade())?; .get(&searchable_item.downgrade())?;
let message = if let Some(match_ix) = self.active_match_index { if let Some(match_ix) = self.active_match_index {
format!("{}/{}", match_ix + 1, matches.len()) Some(format!("{}/{}", match_ix + 1, matches.len()))
} else { } else {
"No matches".to_string() match_color = Color::Error; // No matches found
}; None
}
Some(ui::Label::new(message)) })
}); .unwrap_or_else(|| "No matches".to_string());
let match_count = Label::new(match_text).color(match_color);
let should_show_replace_input = self.replace_enabled && supported_options.replacement; let should_show_replace_input = self.replace_enabled && supported_options.replacement;
let in_replace = self.replacement_editor.focus_handle(cx).is_focused(cx); let in_replace = self.replacement_editor.focus_handle(cx).is_focused(cx);
@ -192,35 +207,9 @@ impl Render for BufferSearchBar {
} else { } else {
cx.theme().colors().border cx.theme().colors().border
}; };
h_flex()
.w_full() let search_line = h_flex()
.gap_2() .gap_2()
.key_context(key_context)
.capture_action(cx.listener(Self::tab))
.on_action(cx.listener(Self::previous_history_query))
.on_action(cx.listener(Self::next_history_query))
.on_action(cx.listener(Self::dismiss))
.on_action(cx.listener(Self::select_next_match))
.on_action(cx.listener(Self::select_prev_match))
.on_action(cx.listener(|this, _: &ActivateRegexMode, cx| {
this.activate_search_mode(SearchMode::Regex, cx);
}))
.on_action(cx.listener(|this, _: &ActivateTextMode, cx| {
this.activate_search_mode(SearchMode::Text, cx);
}))
.when(self.supported_options().replacement, |this| {
this.on_action(cx.listener(Self::toggle_replace))
.when(in_replace, |this| {
this.on_action(cx.listener(Self::replace_next))
.on_action(cx.listener(Self::replace_all))
})
})
.when(self.supported_options().case, |this| {
this.on_action(cx.listener(Self::toggle_case_sensitive))
})
.when(self.supported_options().word, |this| {
this.on_action(cx.listener(Self::toggle_whole_word))
})
.child( .child(
h_flex() h_flex()
.flex_1() .flex_1()
@ -229,10 +218,10 @@ impl Render for BufferSearchBar {
.gap_2() .gap_2()
.border_1() .border_1()
.border_color(editor_border) .border_color(editor_border)
.min_w(rems(384. / 16.)) .min_w(rems(MIN_INPUT_WIDTH_REMS))
.max_w(rems(MAX_INPUT_WIDTH_REMS))
.rounded_lg() .rounded_lg()
.child(Icon::new(IconName::MagnifyingGlass)) .child(self.render_text_input(&self.query_editor, match_color.color(cx), cx))
.child(self.render_text_input(&self.query_editor, cx))
.children(supported_options.case.then(|| { .children(supported_options.case.then(|| {
self.render_search_option_button( self.render_search_option_button(
SearchOptions::CASE_SENSITIVE, SearchOptions::CASE_SENSITIVE,
@ -308,49 +297,6 @@ impl Render for BufferSearchBar {
) )
}), }),
) )
.child(
h_flex()
.gap_0p5()
.flex_1()
.when(self.replace_enabled, |this| {
this.child(
h_flex()
.flex_1()
// We're giving this a fixed height to match the height of the search input,
// which has an icon inside that is increasing its height.
.h_8()
.px_2()
.py_1()
.gap_2()
.border_1()
.border_color(cx.theme().colors().border)
.rounded_lg()
.child(self.render_text_input(&self.replacement_editor, cx)),
)
.when(should_show_replace_input, |this| {
this.child(
IconButton::new("search-replace-next", ui::IconName::ReplaceNext)
.tooltip(move |cx| {
Tooltip::for_action("Replace next", &ReplaceNext, cx)
})
.on_click(cx.listener(|this, _, cx| {
this.replace_next(&ReplaceNext, cx)
})),
)
.child(
IconButton::new("search-replace-all", ui::IconName::ReplaceAll)
.tooltip(move |cx| {
Tooltip::for_action("Replace all", &ReplaceAll, cx)
})
.on_click(
cx.listener(|this, _, cx| {
this.replace_all(&ReplaceAll, cx)
}),
),
)
})
}),
)
.child( .child(
h_flex() h_flex()
.gap_0p5() .gap_0p5()
@ -362,7 +308,7 @@ impl Render for BufferSearchBar {
Tooltip::for_action("Select all matches", &SelectAllMatches, cx) Tooltip::for_action("Select all matches", &SelectAllMatches, cx)
}), }),
) )
.children(match_count) .child(div().min_w(rems(6.)).child(match_count))
.child(render_nav_button( .child(render_nav_button(
ui::IconName::ChevronLeft, ui::IconName::ChevronLeft,
self.active_match_index.is_some(), self.active_match_index.is_some(),
@ -375,7 +321,89 @@ impl Render for BufferSearchBar {
"Select next match", "Select next match",
&SelectNextMatch, &SelectNextMatch,
)), )),
);
let replace_line = self.replace_enabled.then(|| {
h_flex()
.gap_0p5()
.flex_1()
.child(
h_flex()
.flex_1()
// We're giving this a fixed height to match the height of the search input,
// which has an icon inside that is increasing its height.
// .h_8()
.px_2()
.py_1()
.gap_2()
.border_1()
.border_color(cx.theme().colors().border)
.rounded_lg()
.min_w(rems(MIN_INPUT_WIDTH_REMS))
.max_w(rems(MAX_INPUT_WIDTH_REMS))
.child(self.render_text_input(
&self.replacement_editor,
cx.theme().colors().text,
cx,
)),
)
.when(should_show_replace_input, |this| {
this.child(
IconButton::new("search-replace-next", ui::IconName::ReplaceNext)
.tooltip(move |cx| {
Tooltip::for_action("Replace next", &ReplaceNext, cx)
})
.on_click(
cx.listener(|this, _, cx| this.replace_next(&ReplaceNext, cx)),
),
)
.child(
IconButton::new("search-replace-all", ui::IconName::ReplaceAll)
.tooltip(move |cx| Tooltip::for_action("Replace all", &ReplaceAll, cx))
.on_click(cx.listener(|this, _, cx| this.replace_all(&ReplaceAll, cx))),
)
})
});
v_flex()
.key_context(key_context)
.capture_action(cx.listener(Self::tab))
.capture_action(cx.listener(Self::tab_prev))
.on_action(cx.listener(Self::previous_history_query))
.on_action(cx.listener(Self::next_history_query))
.on_action(cx.listener(Self::dismiss))
.on_action(cx.listener(Self::select_next_match))
.on_action(cx.listener(Self::select_prev_match))
.on_action(cx.listener(|this, _: &ActivateRegexMode, cx| {
this.activate_search_mode(SearchMode::Regex, cx);
}))
.on_action(cx.listener(|this, _: &ActivateTextMode, cx| {
this.activate_search_mode(SearchMode::Text, cx);
}))
.when(self.supported_options().replacement, |this| {
this.on_action(cx.listener(Self::toggle_replace))
.when(in_replace, |this| {
this.on_action(cx.listener(Self::replace_next))
.on_action(cx.listener(Self::replace_all))
})
})
.when(self.supported_options().case, |this| {
this.on_action(cx.listener(Self::toggle_case_sensitive))
})
.when(self.supported_options().word, |this| {
this.on_action(cx.listener(Self::toggle_whole_word))
})
.gap_1()
.child(
h_flex().child(search_line.w_full()).child(
IconButton::new(SharedString::from("Close"), IconName::Close)
.tooltip(move |cx| Tooltip::for_action("Close search bar", &Dismiss, cx))
.on_click(
cx.listener(|this, _: &ClickEvent, cx| this.dismiss(&Dismiss, cx)),
),
),
) )
.children(replace_line)
} }
} }
@ -488,11 +516,13 @@ impl BufferSearchBar {
cx.subscribe(&query_editor, Self::on_query_editor_event) cx.subscribe(&query_editor, Self::on_query_editor_event)
.detach(); .detach();
let replacement_editor = cx.new_view(|cx| Editor::single_line(cx)); let replacement_editor = cx.new_view(|cx| Editor::single_line(cx));
cx.subscribe(&replacement_editor, Self::on_query_editor_event) cx.subscribe(&replacement_editor, Self::on_replacement_editor_event)
.detach(); .detach();
Self { Self {
query_editor, query_editor,
query_editor_focused: false,
replacement_editor, replacement_editor,
replacement_editor_focused: false,
active_searchable_item: None, active_searchable_item: None,
active_searchable_item_subscription: None, active_searchable_item_subscription: None,
active_match_index: None, active_match_index: None,
@ -765,15 +795,33 @@ impl BufferSearchBar {
event: &editor::EditorEvent, event: &editor::EditorEvent,
cx: &mut ViewContext<Self>, cx: &mut ViewContext<Self>,
) { ) {
if let editor::EditorEvent::Edited = event { match event {
self.query_contains_error = false; editor::EditorEvent::Focused => self.query_editor_focused = true,
self.clear_matches(cx); editor::EditorEvent::Blurred => self.query_editor_focused = false,
let search = self.update_matches(cx); editor::EditorEvent::Edited => {
cx.spawn(|this, mut cx| async move { self.query_contains_error = false;
search.await?; self.clear_matches(cx);
this.update(&mut cx, |this, cx| this.activate_current_match(cx)) let search = self.update_matches(cx);
}) cx.spawn(|this, mut cx| async move {
.detach_and_log_err(cx); search.await?;
this.update(&mut cx, |this, cx| this.activate_current_match(cx))
})
.detach_and_log_err(cx);
}
_ => {}
}
}
fn on_replacement_editor_event(
&mut self,
_: View<Editor>,
event: &editor::EditorEvent,
_: &mut ViewContext<Self>,
) {
match event {
editor::EditorEvent::Focused => self.replacement_editor_focused = true,
editor::EditorEvent::Blurred => self.replacement_editor_focused = false,
_ => {}
} }
} }
@ -911,11 +959,29 @@ impl BufferSearchBar {
} }
fn tab(&mut self, _: &Tab, cx: &mut ViewContext<Self>) { fn tab(&mut self, _: &Tab, cx: &mut ViewContext<Self>) {
if let Some(item) = self.active_searchable_item.as_ref() { // Search -> Replace -> Editor
let focus_handle = item.focus_handle(cx); let focus_handle = if self.replace_enabled && self.query_editor_focused {
cx.focus(&focus_handle); self.replacement_editor.focus_handle(cx)
cx.stop_propagation(); } else if let Some(item) = self.active_searchable_item.as_ref() {
} item.focus_handle(cx)
} else {
return;
};
cx.focus(&focus_handle);
cx.stop_propagation();
}
fn tab_prev(&mut self, _: &TabPrev, cx: &mut ViewContext<Self>) {
// Search -> Replace -> Search
let focus_handle = if self.replace_enabled && self.query_editor_focused {
self.replacement_editor.focus_handle(cx)
} else if self.replacement_editor_focused {
self.query_editor.focus_handle(cx)
} else {
return;
};
cx.focus(&focus_handle);
cx.stop_propagation();
} }
fn next_history_query(&mut self, _: &NextHistoryQuery, cx: &mut ViewContext<Self>) { fn next_history_query(&mut self, _: &NextHistoryQuery, cx: &mut ViewContext<Self>) {
@ -945,10 +1011,12 @@ impl BufferSearchBar {
fn toggle_replace(&mut self, _: &ToggleReplace, cx: &mut ViewContext<Self>) { fn toggle_replace(&mut self, _: &ToggleReplace, cx: &mut ViewContext<Self>) {
if let Some(_) = &self.active_searchable_item { if let Some(_) = &self.active_searchable_item {
self.replace_enabled = !self.replace_enabled; self.replace_enabled = !self.replace_enabled;
if !self.replace_enabled { let handle = if self.replace_enabled {
let handle = self.query_editor.focus_handle(cx); self.replacement_editor.focus_handle(cx)
cx.focus(&handle); } else {
} self.query_editor.focus_handle(cx)
};
cx.focus(&handle);
cx.notify(); cx.notify();
} }
} }