use crate::{ SearchOption, SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive, ToggleRegex, ToggleWholeWord, }; use collections::HashMap; use editor::Editor; use gpui::{ actions, elements::*, impl_actions, platform::CursorStyle, Action, AnyViewHandle, AppContext, Entity, MouseButton, MutableAppContext, RenderContext, Subscription, Task, View, ViewContext, ViewHandle, }; use project::search::SearchQuery; use serde::Deserialize; use settings::Settings; use std::any::Any; use workspace::{ searchable::{Direction, SearchEvent, SearchableItemHandle, WeakSearchableItemHandle}, ItemHandle, Pane, ToolbarItemLocation, ToolbarItemView, }; #[derive(Clone, Deserialize, PartialEq)] pub struct Deploy { pub focus: bool, } actions!(buffer_search, [Dismiss, FocusEditor]); impl_actions!(buffer_search, [Deploy]); pub enum Event { UpdateLocation, } pub fn init(cx: &mut MutableAppContext) { cx.add_action(BufferSearchBar::deploy); cx.add_action(BufferSearchBar::dismiss); cx.add_action(BufferSearchBar::focus_editor); cx.add_action(BufferSearchBar::select_next_match); cx.add_action(BufferSearchBar::select_prev_match); cx.add_action(BufferSearchBar::select_next_match_on_pane); cx.add_action(BufferSearchBar::select_prev_match_on_pane); cx.add_action(BufferSearchBar::handle_editor_cancel); add_toggle_option_action::(SearchOption::CaseSensitive, cx); add_toggle_option_action::(SearchOption::WholeWord, cx); add_toggle_option_action::(SearchOption::Regex, cx); } fn add_toggle_option_action(option: SearchOption, cx: &mut MutableAppContext) { cx.add_action(move |pane: &mut Pane, _: &A, cx: &mut ViewContext| { if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::() { if search_bar.update(cx, |search_bar, cx| search_bar.show(false, false, cx)) { search_bar.update(cx, |search_bar, cx| { search_bar.toggle_search_option(option, cx); }); return; } } cx.propagate_action(); }); } pub struct BufferSearchBar { pub query_editor: ViewHandle, active_searchable_item: Option>, active_match_index: Option, active_searchable_item_subscription: Option, seachable_items_with_matches: HashMap, Vec>>, pending_search: Option>, case_sensitive: bool, whole_word: bool, regex: bool, query_contains_error: bool, dismissed: bool, } impl Entity for BufferSearchBar { type Event = Event; } impl View for BufferSearchBar { fn ui_name() -> &'static str { "BufferSearchBar" } fn on_focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext) { if cx.is_self_focused() { cx.focus(&self.query_editor); } } fn render(&mut self, cx: &mut RenderContext) -> ElementBox { let theme = cx.global::().theme.clone(); let editor_container = if self.query_contains_error { theme.search.invalid_editor } else { theme.search.editor.input.container }; let supported_options = self .active_searchable_item .as_ref() .map(|active_searchable_item| active_searchable_item.supported_options()) .unwrap_or_default(); Flex::row() .with_child( Flex::row() .with_child( ChildView::new(&self.query_editor) .aligned() .left() .flex(1., true) .boxed(), ) .with_children(self.active_searchable_item.as_ref().and_then( |searchable_item| { let matches = self .seachable_items_with_matches .get(&searchable_item.downgrade())?; let message = if let Some(match_ix) = self.active_match_index { format!("{}/{}", match_ix + 1, matches.len()) } else { "No matches".to_string() }; Some( Label::new(message, theme.search.match_index.text.clone()) .contained() .with_style(theme.search.match_index.container) .aligned() .boxed(), ) }, )) .contained() .with_style(editor_container) .aligned() .constrained() .with_min_width(theme.search.editor.min_width) .with_max_width(theme.search.editor.max_width) .flex(1., false) .boxed(), ) .with_child( Flex::row() .with_child(self.render_nav_button("<", Direction::Prev, cx)) .with_child(self.render_nav_button(">", Direction::Next, cx)) .aligned() .boxed(), ) .with_child( Flex::row() .with_children(self.render_search_option( supported_options.case, "Case", SearchOption::CaseSensitive, cx, )) .with_children(self.render_search_option( supported_options.word, "Word", SearchOption::WholeWord, cx, )) .with_children(self.render_search_option( supported_options.regex, "Regex", SearchOption::Regex, cx, )) .contained() .with_style(theme.search.option_button_group) .aligned() .boxed(), ) .contained() .with_style(theme.search.container) .named("search bar") } } impl ToolbarItemView for BufferSearchBar { fn set_active_pane_item( &mut self, item: Option<&dyn ItemHandle>, cx: &mut ViewContext, ) -> ToolbarItemLocation { cx.notify(); self.active_searchable_item_subscription.take(); self.active_searchable_item.take(); self.pending_search.take(); if let Some(searchable_item_handle) = item.and_then(|item| item.as_searchable(cx)) { let handle = cx.weak_handle(); self.active_searchable_item_subscription = Some(searchable_item_handle.subscribe( cx, Box::new(move |search_event, cx| { if let Some(this) = handle.upgrade(cx) { this.update(cx, |this, cx| { this.on_active_searchable_item_event(search_event, cx) }); } }), )); self.active_searchable_item = Some(searchable_item_handle); self.update_matches(false, cx); if !self.dismissed { return ToolbarItemLocation::Secondary; } } ToolbarItemLocation::Hidden } fn location_for_event( &self, _: &Self::Event, _: ToolbarItemLocation, _: &AppContext, ) -> ToolbarItemLocation { if self.active_searchable_item.is_some() && !self.dismissed { ToolbarItemLocation::Secondary } else { ToolbarItemLocation::Hidden } } } impl BufferSearchBar { pub fn new(cx: &mut ViewContext) -> Self { let query_editor = cx.add_view(|cx| { Editor::auto_height(2, Some(|theme| theme.search.editor.input.clone()), cx) }); cx.subscribe(&query_editor, Self::on_query_editor_event) .detach(); Self { query_editor, active_searchable_item: None, active_searchable_item_subscription: None, active_match_index: None, seachable_items_with_matches: Default::default(), case_sensitive: false, whole_word: false, regex: false, pending_search: None, query_contains_error: false, dismissed: true, } } fn dismiss(&mut self, _: &Dismiss, cx: &mut ViewContext) { self.dismissed = true; for searchable_item in self.seachable_items_with_matches.keys() { if let Some(searchable_item) = WeakSearchableItemHandle::upgrade(searchable_item.as_ref(), cx) { searchable_item.clear_matches(cx); } } if let Some(active_editor) = self.active_searchable_item.as_ref() { cx.focus(active_editor); } cx.emit(Event::UpdateLocation); cx.notify(); } fn show(&mut self, focus: bool, suggest_query: bool, cx: &mut ViewContext) -> bool { let searchable_item = if let Some(searchable_item) = &self.active_searchable_item { SearchableItemHandle::boxed_clone(searchable_item.as_ref()) } else { return false; }; if suggest_query { let text = searchable_item.query_suggestion(cx); if !text.is_empty() { self.set_query(&text, cx); } } if focus { let query_editor = self.query_editor.clone(); query_editor.update(cx, |query_editor, cx| { query_editor.select_all(&editor::SelectAll, cx); }); cx.focus_self(); } self.dismissed = false; cx.notify(); cx.emit(Event::UpdateLocation); true } fn set_query(&mut self, query: &str, cx: &mut ViewContext) { self.query_editor.update(cx, |query_editor, cx| { query_editor.buffer().update(cx, |query_buffer, cx| { let len = query_buffer.len(cx); query_buffer.edit([(0..len, query)], None, cx); }); }); } fn render_search_option( &self, option_supported: bool, icon: &str, option: SearchOption, cx: &mut RenderContext, ) -> Option { if !option_supported { return None; } let tooltip_style = cx.global::().theme.tooltip.clone(); let is_active = self.is_search_option_enabled(option); Some( MouseEventHandler::new::(option as usize, cx, |state, cx| { let style = &cx .global::() .theme .search .option_button .style_for(state, is_active); Label::new(icon.to_string(), style.text.clone()) .contained() .with_style(style.container) .boxed() }) .on_click(MouseButton::Left, move |_, cx| { cx.dispatch_any_action(option.to_toggle_action()) }) .with_cursor_style(CursorStyle::PointingHand) .with_tooltip::( option as usize, format!("Toggle {}", option.label()), Some(option.to_toggle_action()), tooltip_style, cx, ) .boxed(), ) } fn render_nav_button( &self, icon: &str, direction: Direction, cx: &mut RenderContext, ) -> ElementBox { let action: Box; let tooltip; match direction { Direction::Prev => { action = Box::new(SelectPrevMatch); tooltip = "Select Previous Match"; } Direction::Next => { action = Box::new(SelectNextMatch); tooltip = "Select Next Match"; } }; let tooltip_style = cx.global::().theme.tooltip.clone(); enum NavButton {} MouseEventHandler::new::(direction as usize, cx, |state, cx| { let style = &cx .global::() .theme .search .option_button .style_for(state, false); Label::new(icon.to_string(), style.text.clone()) .contained() .with_style(style.container) .boxed() }) .on_click(MouseButton::Left, { let action = action.boxed_clone(); move |_, cx| cx.dispatch_any_action(action.boxed_clone()) }) .with_cursor_style(CursorStyle::PointingHand) .with_tooltip::( direction as usize, tooltip.to_string(), Some(action), tooltip_style, cx, ) .boxed() } fn deploy(pane: &mut Pane, action: &Deploy, cx: &mut ViewContext) { if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::() { if search_bar.update(cx, |search_bar, cx| search_bar.show(action.focus, true, cx)) { return; } } cx.propagate_action(); } fn handle_editor_cancel(pane: &mut Pane, _: &editor::Cancel, cx: &mut ViewContext) { if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::() { if !search_bar.read(cx).dismissed { search_bar.update(cx, |search_bar, cx| search_bar.dismiss(&Dismiss, cx)); return; } } cx.propagate_action(); } fn focus_editor(&mut self, _: &FocusEditor, cx: &mut ViewContext) { if let Some(active_editor) = self.active_searchable_item.as_ref() { cx.focus(active_editor); } } fn is_search_option_enabled(&self, search_option: SearchOption) -> bool { match search_option { SearchOption::WholeWord => self.whole_word, SearchOption::CaseSensitive => self.case_sensitive, SearchOption::Regex => self.regex, } } fn toggle_search_option(&mut self, search_option: SearchOption, cx: &mut ViewContext) { let value = match search_option { SearchOption::WholeWord => &mut self.whole_word, SearchOption::CaseSensitive => &mut self.case_sensitive, SearchOption::Regex => &mut self.regex, }; *value = !*value; self.update_matches(false, cx); cx.notify(); } fn select_next_match(&mut self, _: &SelectNextMatch, cx: &mut ViewContext) { self.select_match(Direction::Next, cx); } fn select_prev_match(&mut self, _: &SelectPrevMatch, cx: &mut ViewContext) { self.select_match(Direction::Prev, cx); } fn select_match(&mut self, direction: Direction, cx: &mut ViewContext) { if let Some(index) = self.active_match_index { if let Some(searchable_item) = self.active_searchable_item.as_ref() { if let Some(matches) = self .seachable_items_with_matches .get(&searchable_item.downgrade()) { let new_match_index = searchable_item.match_index_for_direction(matches, index, direction, cx); searchable_item.update_matches(matches, cx); searchable_item.activate_match(new_match_index, matches, cx); } } } } fn select_next_match_on_pane( pane: &mut Pane, action: &SelectNextMatch, cx: &mut ViewContext, ) { if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::() { search_bar.update(cx, |bar, cx| bar.select_next_match(action, cx)); } } fn select_prev_match_on_pane( pane: &mut Pane, action: &SelectPrevMatch, cx: &mut ViewContext, ) { if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::() { search_bar.update(cx, |bar, cx| bar.select_prev_match(action, cx)); } } fn on_query_editor_event( &mut self, _: ViewHandle, event: &editor::Event, cx: &mut ViewContext, ) { if let editor::Event::BufferEdited { .. } = event { self.query_contains_error = false; self.clear_matches(cx); self.update_matches(true, cx); cx.notify(); } } fn on_active_searchable_item_event(&mut self, event: SearchEvent, cx: &mut ViewContext) { match event { SearchEvent::MatchesInvalidated => self.update_matches(false, cx), SearchEvent::ActiveMatchChanged => self.update_match_index(cx), } } fn clear_matches(&mut self, cx: &mut ViewContext) { let mut active_item_matches = None; for (searchable_item, matches) in self.seachable_items_with_matches.drain() { if let Some(searchable_item) = WeakSearchableItemHandle::upgrade(searchable_item.as_ref(), cx) { if Some(&searchable_item) == self.active_searchable_item.as_ref() { active_item_matches = Some((searchable_item.downgrade(), matches)); } else { searchable_item.clear_matches(cx); } } } self.seachable_items_with_matches .extend(active_item_matches); } fn update_matches(&mut self, select_closest_match: bool, cx: &mut ViewContext) { let query = self.query_editor.read(cx).text(cx); self.pending_search.take(); if let Some(active_searchable_item) = self.active_searchable_item.as_ref() { if query.is_empty() { self.active_match_index.take(); active_searchable_item.clear_matches(cx); } else { let query = if self.regex { match SearchQuery::regex(query, self.whole_word, self.case_sensitive) { Ok(query) => query, Err(_) => { self.query_contains_error = true; cx.notify(); return; } } } else { SearchQuery::text(query, self.whole_word, self.case_sensitive) }; let matches = active_searchable_item.find_matches(query, cx); let active_searchable_item = active_searchable_item.downgrade(); self.pending_search = Some(cx.spawn_weak(|this, mut cx| async move { let matches = matches.await; if let Some(this) = this.upgrade(&cx) { this.update(&mut cx, |this, cx| { if let Some(active_searchable_item) = WeakSearchableItemHandle::upgrade( active_searchable_item.as_ref(), cx, ) { this.seachable_items_with_matches .insert(active_searchable_item.downgrade(), matches); this.update_match_index(cx); if !this.dismissed { let matches = this .seachable_items_with_matches .get(&active_searchable_item.downgrade()) .unwrap(); active_searchable_item.update_matches(matches, cx); if select_closest_match { if let Some(match_ix) = this.active_match_index { active_searchable_item .activate_match(match_ix, matches, cx); } } } cx.notify(); } }); } })); } } } fn update_match_index(&mut self, cx: &mut ViewContext) { let new_index = self .active_searchable_item .as_ref() .and_then(|searchable_item| { let matches = self .seachable_items_with_matches .get(&searchable_item.downgrade())?; searchable_item.active_match_index(matches, cx) }); if new_index != self.active_match_index { self.active_match_index = new_index; cx.notify(); } } } #[cfg(test)] mod tests { use super::*; use editor::{DisplayPoint, Editor}; use gpui::{color::Color, test::EmptyView, TestAppContext}; use language::Buffer; use std::sync::Arc; use unindent::Unindent as _; #[gpui::test] async fn test_search_simple(cx: &mut TestAppContext) { let fonts = cx.font_cache(); let mut theme = gpui::fonts::with_font_cache(fonts.clone(), theme::Theme::default); theme.search.match_background = Color::red(); cx.update(|cx| { let mut settings = Settings::test(cx); settings.theme = Arc::new(theme); cx.set_global(settings) }); let buffer = cx.add_model(|cx| { Buffer::new( 0, r#" A regular expression (shortened as regex or regexp;[1] also referred to as rational expression[2][3]) is a sequence of characters that specifies a search pattern in text. Usually such patterns are used by string-searching algorithms for "find" or "find and replace" operations on strings, or for input validation. "# .unindent(), cx, ) }); let (_, root_view) = cx.add_window(|_| EmptyView); let editor = cx.add_view(&root_view, |cx| { Editor::for_buffer(buffer.clone(), None, cx) }); let search_bar = cx.add_view(&root_view, |cx| { let mut search_bar = BufferSearchBar::new(cx); search_bar.set_active_pane_item(Some(&editor), cx); search_bar.show(false, true, cx); search_bar }); // Search for a string that appears with different casing. // By default, search is case-insensitive. search_bar.update(cx, |search_bar, cx| { search_bar.set_query("us", cx); }); editor.next_notification(cx).await; editor.update(cx, |editor, cx| { assert_eq!( editor.all_background_highlights(cx), &[ ( DisplayPoint::new(2, 17)..DisplayPoint::new(2, 19), Color::red(), ), ( DisplayPoint::new(2, 43)..DisplayPoint::new(2, 45), Color::red(), ), ] ); }); // Switch to a case sensitive search. search_bar.update(cx, |search_bar, cx| { search_bar.toggle_search_option(SearchOption::CaseSensitive, cx); }); editor.next_notification(cx).await; editor.update(cx, |editor, cx| { assert_eq!( editor.all_background_highlights(cx), &[( DisplayPoint::new(2, 43)..DisplayPoint::new(2, 45), Color::red(), )] ); }); // Search for a string that appears both as a whole word and // within other words. By default, all results are found. search_bar.update(cx, |search_bar, cx| { search_bar.set_query("or", cx); }); editor.next_notification(cx).await; editor.update(cx, |editor, cx| { assert_eq!( editor.all_background_highlights(cx), &[ ( DisplayPoint::new(0, 24)..DisplayPoint::new(0, 26), Color::red(), ), ( DisplayPoint::new(0, 41)..DisplayPoint::new(0, 43), Color::red(), ), ( DisplayPoint::new(2, 71)..DisplayPoint::new(2, 73), Color::red(), ), ( DisplayPoint::new(3, 1)..DisplayPoint::new(3, 3), Color::red(), ), ( DisplayPoint::new(3, 11)..DisplayPoint::new(3, 13), Color::red(), ), ( DisplayPoint::new(3, 56)..DisplayPoint::new(3, 58), Color::red(), ), ( DisplayPoint::new(3, 60)..DisplayPoint::new(3, 62), Color::red(), ), ] ); }); // Switch to a whole word search. search_bar.update(cx, |search_bar, cx| { search_bar.toggle_search_option(SearchOption::WholeWord, cx); }); editor.next_notification(cx).await; editor.update(cx, |editor, cx| { assert_eq!( editor.all_background_highlights(cx), &[ ( DisplayPoint::new(0, 41)..DisplayPoint::new(0, 43), Color::red(), ), ( DisplayPoint::new(3, 11)..DisplayPoint::new(3, 13), Color::red(), ), ( DisplayPoint::new(3, 56)..DisplayPoint::new(3, 58), Color::red(), ), ] ); }); editor.update(cx, |editor, cx| { editor.change_selections(None, cx, |s| { s.select_display_ranges([DisplayPoint::new(0, 0)..DisplayPoint::new(0, 0)]) }); }); search_bar.update(cx, |search_bar, cx| { assert_eq!(search_bar.active_match_index, Some(0)); search_bar.select_next_match(&SelectNextMatch, cx); assert_eq!( editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)), [DisplayPoint::new(0, 41)..DisplayPoint::new(0, 43)] ); }); search_bar.read_with(cx, |search_bar, _| { assert_eq!(search_bar.active_match_index, Some(0)); }); search_bar.update(cx, |search_bar, cx| { search_bar.select_next_match(&SelectNextMatch, cx); assert_eq!( editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)), [DisplayPoint::new(3, 11)..DisplayPoint::new(3, 13)] ); }); search_bar.read_with(cx, |search_bar, _| { assert_eq!(search_bar.active_match_index, Some(1)); }); search_bar.update(cx, |search_bar, cx| { search_bar.select_next_match(&SelectNextMatch, cx); assert_eq!( editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)), [DisplayPoint::new(3, 56)..DisplayPoint::new(3, 58)] ); }); search_bar.read_with(cx, |search_bar, _| { assert_eq!(search_bar.active_match_index, Some(2)); }); search_bar.update(cx, |search_bar, cx| { search_bar.select_next_match(&SelectNextMatch, cx); assert_eq!( editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)), [DisplayPoint::new(0, 41)..DisplayPoint::new(0, 43)] ); }); search_bar.read_with(cx, |search_bar, _| { assert_eq!(search_bar.active_match_index, Some(0)); }); search_bar.update(cx, |search_bar, cx| { search_bar.select_prev_match(&SelectPrevMatch, cx); assert_eq!( editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)), [DisplayPoint::new(3, 56)..DisplayPoint::new(3, 58)] ); }); search_bar.read_with(cx, |search_bar, _| { assert_eq!(search_bar.active_match_index, Some(2)); }); search_bar.update(cx, |search_bar, cx| { search_bar.select_prev_match(&SelectPrevMatch, cx); assert_eq!( editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)), [DisplayPoint::new(3, 11)..DisplayPoint::new(3, 13)] ); }); search_bar.read_with(cx, |search_bar, _| { assert_eq!(search_bar.active_match_index, Some(1)); }); search_bar.update(cx, |search_bar, cx| { search_bar.select_prev_match(&SelectPrevMatch, cx); assert_eq!( editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)), [DisplayPoint::new(0, 41)..DisplayPoint::new(0, 43)] ); }); search_bar.read_with(cx, |search_bar, _| { assert_eq!(search_bar.active_match_index, Some(0)); }); // Park the cursor in between matches and ensure that going to the previous match selects // the closest match to the left. editor.update(cx, |editor, cx| { editor.change_selections(None, cx, |s| { s.select_display_ranges([DisplayPoint::new(1, 0)..DisplayPoint::new(1, 0)]) }); }); search_bar.update(cx, |search_bar, cx| { assert_eq!(search_bar.active_match_index, Some(1)); search_bar.select_prev_match(&SelectPrevMatch, cx); assert_eq!( editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)), [DisplayPoint::new(0, 41)..DisplayPoint::new(0, 43)] ); }); search_bar.read_with(cx, |search_bar, _| { assert_eq!(search_bar.active_match_index, Some(0)); }); // Park the cursor in between matches and ensure that going to the next match selects the // closest match to the right. editor.update(cx, |editor, cx| { editor.change_selections(None, cx, |s| { s.select_display_ranges([DisplayPoint::new(1, 0)..DisplayPoint::new(1, 0)]) }); }); search_bar.update(cx, |search_bar, cx| { assert_eq!(search_bar.active_match_index, Some(1)); search_bar.select_next_match(&SelectNextMatch, cx); assert_eq!( editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)), [DisplayPoint::new(3, 11)..DisplayPoint::new(3, 13)] ); }); search_bar.read_with(cx, |search_bar, _| { assert_eq!(search_bar.active_match_index, Some(1)); }); // Park the cursor after the last match and ensure that going to the previous match selects // the last match. editor.update(cx, |editor, cx| { editor.change_selections(None, cx, |s| { s.select_display_ranges([DisplayPoint::new(3, 60)..DisplayPoint::new(3, 60)]) }); }); search_bar.update(cx, |search_bar, cx| { assert_eq!(search_bar.active_match_index, Some(2)); search_bar.select_prev_match(&SelectPrevMatch, cx); assert_eq!( editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)), [DisplayPoint::new(3, 56)..DisplayPoint::new(3, 58)] ); }); search_bar.read_with(cx, |search_bar, _| { assert_eq!(search_bar.active_match_index, Some(2)); }); // Park the cursor after the last match and ensure that going to the next match selects the // first match. editor.update(cx, |editor, cx| { editor.change_selections(None, cx, |s| { s.select_display_ranges([DisplayPoint::new(3, 60)..DisplayPoint::new(3, 60)]) }); }); search_bar.update(cx, |search_bar, cx| { assert_eq!(search_bar.active_match_index, Some(2)); search_bar.select_next_match(&SelectNextMatch, cx); assert_eq!( editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)), [DisplayPoint::new(0, 41)..DisplayPoint::new(0, 43)] ); }); search_bar.read_with(cx, |search_bar, _| { assert_eq!(search_bar.active_match_index, Some(0)); }); // Park the cursor before the first match and ensure that going to the previous match // selects the last match. editor.update(cx, |editor, cx| { editor.change_selections(None, cx, |s| { s.select_display_ranges([DisplayPoint::new(0, 0)..DisplayPoint::new(0, 0)]) }); }); search_bar.update(cx, |search_bar, cx| { assert_eq!(search_bar.active_match_index, Some(0)); search_bar.select_prev_match(&SelectPrevMatch, cx); assert_eq!( editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)), [DisplayPoint::new(3, 56)..DisplayPoint::new(3, 58)] ); }); search_bar.read_with(cx, |search_bar, _| { assert_eq!(search_bar.active_match_index, Some(2)); }); } }