Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/base/src/input/base/element.rs
Original file line number Diff line number Diff line change
Expand Up @@ -839,7 +839,7 @@ impl<M: InputModeKind> TextElement<M> {
cx: &mut App,
) -> Vec<(Path<Pixels>, bool)> {
let state = self.state.read(cx);
if !state.search_session.open {
if !state.search_session.is_active() {
return vec![];
}
let ranges = state.search_session.matcher.matched_ranges();
Expand Down
110 changes: 108 additions & 2 deletions crates/base/src/input/base/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4933,6 +4933,106 @@ mod tests {
});
}

/// A host that wants the search shortcut for its own search UI.
struct SearchHost {
editor: Entity<InputBaseState<EditorMode>>,
search_requests: Rc<Cell<usize>>,
}

impl Render for SearchHost {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let search_requests = self.search_requests.clone();
div()
.size_full()
.on_action(cx.listener(move |_, _: &Search, _, _| {
search_requests.set(search_requests.get() + 1);
}))
.child(self.editor.clone())
}
}

/// Opens an editor inside [`SearchHost`], focused, and presses the search
/// shortcut once. Returns the editor and the host's request count.
fn press_search_shortcut(
cx: &mut TestAppContext,
searchable: bool,
) -> (Entity<InputBaseState<EditorMode>>, Rc<Cell<usize>>) {
let search_requests = Rc::new(Cell::new(0));
let mut editor = None;
let window = cx.update(|cx| {
cx.open_window(Default::default(), |window, cx| {
cx.set_global(Theme::default());
super::super::init(cx);
let state =
cx.new(|cx| crate::input::EditorState::new(window, cx).searchable(searchable));
editor = Some(state.clone());
cx.new(|_| SearchHost {
editor: state,
search_requests: search_requests.clone(),
})
})
.unwrap()
});
let editor = editor.unwrap();
let mut cx = VisualTestContext::from_window(window.into(), cx);
cx.update(|window, cx| {
editor.update(cx, |state, cx| state.focus(window, cx));
});
cx.run_until_parked();
#[cfg(target_os = "macos")]
cx.simulate_keystrokes("cmd-f");
#[cfg(not(target_os = "macos"))]
cx.simulate_keystrokes("ctrl-f");
cx.run_until_parked();
(editor, search_requests)
}

#[gpui::test]
fn test_search_shortcut_reaches_the_host_when_not_searchable(cx: &mut TestAppContext) {
let (editor, search_requests) = press_search_shortcut(cx, false);
assert_eq!(search_requests.get(), 1);
editor.read_with(cx, |state, _| {
assert!(!state.search_session().open);
assert!(!state.search_session().is_active());
});
}

#[gpui::test]
fn test_search_shortcut_opens_the_panel_when_searchable(cx: &mut TestAppContext) {
let (editor, search_requests) = press_search_shortcut(cx, true);
assert_eq!(search_requests.get(), 0);
editor.read_with(cx, |state, _| {
assert!(state.search_session().open);
assert!(state.search_session().is_active());
});
}

#[gpui::test]
fn test_set_search_query_highlights_without_the_panel(cx: &mut TestAppContext) {
let input_view = InputView::build_editor(cx, |state| state.searchable(false));
let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx);
let input = input_view.input;
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.set_value("foo bar foo", window, cx);
state.set_search_query("foo", true, cx);
});
});
cx.run_until_parked();
input.read_with(&cx, |state, _| {
let session = state.search_session();
assert!(session.is_active());
assert!(!session.open);
assert_eq!(session.matcher.len(), 2);
});
cx.update(|_, cx| {
input.update(cx, |state, cx| state.close_search(cx));
});
input.read_with(&cx, |state, _| {
assert!(!state.search_session().is_active());
});
}

#[gpui::test]
fn test_search_reveals_offscreen_wrapped_match(cx: &mut TestAppContext) {
let input_view = InputView::new(cx);
Expand Down Expand Up @@ -8987,13 +9087,19 @@ impl InputBaseState<crate::input::InputMode> {
/// Methods shared by the two multi-line modes, and reachable on neither a
/// single-line input nor anything else.
impl<M: crate::input::MultiLineMode> InputBaseState<M> {
/// Set this input is searchable, default is false (Default true for Code Editor).
#[doc(hidden)]
/// Whether the built-in search panel and its shortcut are enabled. Off by
/// default, on for the code editor.
///
/// This only concerns the panel. An input that is not searchable still
/// answers [`InputBaseState::set_search_query`] and the other search
/// methods, and lets `Ctrl-F` / `Cmd-F` bubble up to its ancestors, so an
/// application can put its own search UI on top of the same engine.
pub fn searchable(mut self, searchable: bool) -> Self {
self.searchable = searchable;
self
}

/// See [`InputBaseState::searchable`].
pub fn set_searchable(&mut self, searchable: bool, cx: &mut Context<Self>) {
self.searchable = searchable;
cx.notify();
Expand Down
77 changes: 73 additions & 4 deletions crates/base/src/input/editor/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,31 @@ pub struct SearchMatcher {
replacing: bool,
}

/// One search over an input: the query, how the built-in panel shows it, and
/// its matches. Read it through [`InputBaseState::search_session`]; it is
/// written only through the input state's search methods, and it grows, so
/// build it with `Default` and do not destructure it exhaustively.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct SearchSession {
/// The built-in search panel is showing.
pub open: bool,
pub replace_mode: bool,
pub case_insensitive: bool,
pub query: String,
pub replacement: String,
pub anchor_offset: Option<usize>,
pub matcher: SearchMatcher,
/// A search is in progress and its matches are highlighted: the panel is
/// open, or a query was set without it and not closed since.
active: bool,
}

impl Default for SearchSession {
fn default() -> Self {
Self {
open: false,
active: false,
replace_mode: false,
case_insensitive: true,
query: String::new(),
Expand All @@ -46,11 +56,27 @@ impl Default for SearchSession {
impl SearchSession {
pub(crate) fn open(&mut self, replace_mode: bool, replaceable: bool) {
self.open = true;
self.active = true;
self.replace_mode = replace_mode && replaceable;
}

/// Start a search without the built-in panel. A custom search UI drives
/// the session through [`InputBaseState::set_search_query`], and the
/// editor highlights the matches the same way it does for the panel.
pub(crate) fn activate(&mut self) {
self.active = true;
}

pub(crate) fn close(&mut self) {
self.open = false;
self.active = false;
}

/// Whether a search is in progress: the built-in panel is open, or a
/// query was set without it and [`InputBaseState::close_search`] has not
/// run since. Matches are highlighted while this holds.
pub fn is_active(&self) -> bool {
self.active
}

pub(crate) fn update_query(&mut self, query: impl Into<String>, case_insensitive: bool) {
Expand Down Expand Up @@ -135,17 +161,28 @@ impl<M: InputModeKind> InputBaseState<M> {
self.replaceable && self.is_editable()
}

/// Set the search query and highlight its matches.
///
/// This is the entry point for a custom search UI: it needs neither
/// `searchable` nor the built-in panel. Navigate the matches with
/// [`InputBaseState::next_search_match`] and
/// [`InputBaseState::previous_search_match`], read the count and the
/// current index from [`InputBaseState::search_session`], and end the
/// search with [`InputBaseState::close_search`].
pub fn set_search_query(
&mut self,
query: impl Into<String>,
case_insensitive: bool,
cx: &mut Context<Self>,
) {
self.search_session.activate();
self.search_session.update_query(query, case_insensitive);
self.search_session.matcher.update(&self.text);
cx.notify();
}

/// End the search: hide the built-in panel and the match highlights. The
/// query is kept so the next [`InputBaseState::open_search`] resumes it.
pub fn close_search(&mut self, cx: &mut Context<Self>) {
self.search_session.close();
cx.notify();
Expand All @@ -167,6 +204,8 @@ impl<M: InputModeKind> InputBaseState<M> {
Some(range)
}

/// Replace the current match and move on to the next one. Returns whether
/// there was a match to replace.
pub fn replace_current_search_match(
&mut self,
replacement: &str,
Expand Down Expand Up @@ -198,6 +237,7 @@ impl<M: InputModeKind> InputBaseState<M> {
true
}

/// Replace every match. Returns how many were replaced.
pub fn replace_all_search_matches(
&mut self,
replacement: &str,
Expand Down Expand Up @@ -226,8 +266,11 @@ impl<M: InputModeKind> InputBaseState<M> {
self.search_session.matcher.update(&self.text);
}

/// An input that is not `searchable` leaves the shortcut to its
/// ancestors, so a custom search UI can take it.
pub(super) fn on_action_search(&mut self, _: &Search, _: &mut Window, cx: &mut Context<Self>) {
if !self.searchable {
cx.propagate();
return;
}
self.open_search(false, cx);
Expand All @@ -240,6 +283,7 @@ impl<M: InputModeKind> InputBaseState<M> {
cx: &mut Context<Self>,
) {
if !self.searchable {
cx.propagate();
return;
}
self.open_search(true, cx);
Expand Down Expand Up @@ -291,6 +335,12 @@ impl SearchMatcher {
self.current_match_ix
}

/// The index of the current match into [`SearchMatcher::matched_ranges`],
/// `None` while there is no match.
pub fn current(&self) -> Option<usize> {
(!self.is_empty()).then_some(self.current_match_ix)
}

pub fn len(&self) -> usize {
self.matched_ranges.len()
}
Expand All @@ -299,11 +349,11 @@ impl SearchMatcher {
self.matched_ranges.is_empty()
}

/// `2/5`: the current match and the total, `0/0` without matches.
pub fn label(&self) -> String {
if self.is_empty() {
"0/0".into()
} else {
format!("{}/{}", self.current_match_ix + 1, self.len())
match self.current() {
Some(ix) => format!("{}/{}", ix + 1, self.len()),
None => "0/0".into(),
}
}

Expand Down Expand Up @@ -415,6 +465,25 @@ mod tests {
assert_eq!(matcher.next(), Some(5..10));
}

#[test]
fn a_query_set_without_the_panel_keeps_the_session_active_until_closed() {
let mut session = SearchSession::default();
assert!(!session.is_active());

session.open(false, true);
assert!(session.is_active());
session.close();
assert!(!session.is_active());

// A custom search UI never opens the panel; setting a query is what
// turns the match highlights on, and closing turns them off again.
session.activate();
assert!(session.is_active());
assert!(!session.open);
session.close();
assert!(!session.is_active());
}

#[test]
fn identical_query_keeps_the_current_match() {
let mut session = SearchSession::default();
Expand Down
53 changes: 53 additions & 0 deletions website/component/editor.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,59 @@ editor.update(cx, |state, cx| {
A read-only editor can still be searched — the replace UI is hidden
automatically.

### Custom search UI

The search engine is usable without the panel, so an application can draw
its own search bar on top of the editor's matching, highlighting, scrolling
and replacing. `set_search_query` starts a search; the editor highlights the
matches until `close_search`. An editor that is not `searchable` never opens
the built-in panel and leaves `Ctrl-F` / `Cmd-F` to its ancestors, so the
application can bind the shortcut to its own search field.

```rust
let editor = cx.new(|cx| EditorState::new(window, cx).searchable(false));

// Search from the application's own field
editor.update(cx, |state, cx| {
state.set_search_query("needle", true, cx);
});

// Navigate; each call scrolls the match into view
editor.update(cx, |state, cx| {
state.next_search_match(cx);
state.previous_search_match(cx);
});

// Describe the matches: "2/5"
let matcher = &editor.read(cx).search_session().matcher;
let label = matcher.label();
let count = matcher.len();
let current = matcher.current(); // None without matches

// Replace, when the editor is editable
editor.update(cx, |state, cx| {
state.replace_current_search_match("replacement", window, cx);
state.replace_all_search_matches("replacement", window, cx);
});

// End the search and its highlights
editor.update(cx, |state, cx| {
state.close_search(cx);
});
```

Take the shortcut on the view that owns the search field:

```rust
use gpui_kit::component::input::Search;

div()
.on_action(cx.listener(|this: &mut Self, _: &Search, window, cx| {
this.search.update(cx, |search, cx| search.focus(window, cx));
}))
.child(Editor::new(&this.editor))
```

## Decorations

```rust
Expand Down
Loading
Loading