Skip to main content

gpui_kit/controls/
search.rs

1//! Find, and find and replace, over text this crate does not hold.
2//!
3//! [`SearchField`] is a query field, a hit count, and a way to step between
4//! hits. [`FindReplace`] puts a replacement field and two replace actions on
5//! top of the same field.
6//!
7//! Neither searches anything. The text belongs to the caller, the matching
8//! rules belong to the caller, and so does the answer; the components report
9//! what the typist asked for and render the count the host established. See
10//! [`crate::display::highlight`] for the marking side of the same boundary and
11//! for exactly what a caller owes.
12
13use gpui::{
14    App, AppContext as _, Context, Entity, EventEmitter, FocusHandle, Focusable,
15    InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
16    Window, div,
17};
18use gpui_kit_assets::Icon;
19use gpui_kit_semantics::{NodeSpec, Role, Semantic};
20use gpui_kit_theme::{ActiveTheme, ControlSize, Elevation, Radius, Space, Surface, TypeScale};
21
22use crate::controls::button::{Button, IconButton};
23use crate::controls::input::{TextInput, TextInputEvent};
24use crate::foundation::{
25    Disableable, Ident, Selectable, Sizable, StyledExt, text as foundation_text,
26};
27use crate::strings::{ActiveStrings, StringKey};
28
29/// How many hits the host says the query has.
30///
31/// Counting is not zero, and a count that stopped early is not a count. A
32/// field that renders an unfinished search as "No results" tells the typist
33/// their query matched nothing, which nobody has established yet.
34#[derive(Debug, Clone, PartialEq, Eq, Default)]
35pub enum HitCount {
36    /// Nothing has been searched, so nothing is claimed. A field with an
37    /// empty query sits here.
38    #[default]
39    Unsearched,
40    /// The host is looking right now.
41    Counting,
42    /// The host looked and found nothing.
43    None,
44    /// The host counted. `current` is the hit the typist is on, zero-based,
45    /// or `None` when the hits are known and no particular one is current.
46    Known {
47        total: usize,
48        current: Option<usize>,
49    },
50    /// The host stopped counting at `counted` and there are more.
51    ///
52    /// Distinct from [`HitCount::Known`] because "at least 500" is not 500,
53    /// and a replace-all that trusted it would change a number nobody knows.
54    TooMany { counted: usize },
55    /// The host could not search, in its own words.
56    Unavailable(SharedString),
57}
58
59impl HitCount {
60    /// The name the semantic tree publishes, so a test tells the five apart.
61    pub fn name(&self) -> &'static str {
62        match self {
63            Self::Unsearched => "unsearched",
64            Self::Counting => "counting",
65            Self::None => "none",
66            Self::Known { .. } => "known",
67            Self::TooMany { .. } => "too-many",
68            Self::Unavailable(_) => "unavailable",
69        }
70    }
71
72    /// How many hits this claims exactly, which is only ever a counted one.
73    pub fn exact(&self) -> Option<usize> {
74        match self {
75            Self::Known { total, .. } => Some(*total),
76            _ => None,
77        }
78    }
79
80    /// Whether there is anywhere for next and previous to go.
81    fn steppable(&self) -> bool {
82        match self {
83            Self::Known { total, .. } => *total > 0,
84            Self::TooMany { .. } => true,
85            _ => false,
86        }
87    }
88
89    /// The words beside the field.
90    fn sentence(&self, cx: &App) -> SharedString {
91        let strings = cx.strings();
92        match self {
93            Self::Unsearched => strings.text(StringKey::SearchNotSearched),
94            Self::Counting => strings.text(StringKey::SearchCounting),
95            Self::None => strings.text(StringKey::SearchNoHits),
96            Self::Known {
97                total,
98                current: Some(current),
99            } => strings.format(
100                StringKey::CountOfTotal,
101                &[&(current + 1).to_string(), &total.to_string()],
102            ),
103            Self::Known {
104                total: 1,
105                current: None,
106            } => strings.text(StringKey::SearchHitOne),
107            Self::Known {
108                total,
109                current: None,
110            } => strings.format(StringKey::SearchHitMany, &[&total.to_string()]),
111            Self::TooMany { counted } => {
112                strings.format(StringKey::SearchTooMany, &[&counted.to_string()])
113            }
114            // The host's own words outrank the catalogue's.
115            Self::Unavailable(reason) => reason.clone(),
116        }
117    }
118}
119
120/// What a search field reports. It applies none of it.
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub enum SearchFieldEvent {
123    QueryChanged(SharedString),
124    /// Enter, or the next control.
125    Next,
126    /// Shift-enter, or the previous control.
127    Previous,
128    /// Escape. The host decides whether that closes the field.
129    Cancelled,
130    MatchCaseToggled(bool),
131    WholeWordToggled(bool),
132}
133
134impl EventEmitter<SearchFieldEvent> for SearchField {}
135
136/// A query field, a hit count, and next and previous.
137pub struct SearchField {
138    ident: Ident,
139    focus_handle: FocusHandle,
140    query: Entity<TextInput>,
141    count: HitCount,
142    placeholder: Option<SharedString>,
143    size: ControlSize,
144    disabled: bool,
145    match_case: Option<bool>,
146    whole_word: Option<bool>,
147    _subscriptions: Vec<Subscription>,
148}
149
150impl std::fmt::Debug for SearchField {
151    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152        formatter
153            .debug_struct("SearchField")
154            .field("ident", &self.ident)
155            .field("count", &self.count)
156            .field("disabled", &self.disabled)
157            .finish()
158    }
159}
160
161impl SearchField {
162    pub fn new(ident: impl Into<Ident>, window: &mut Window, cx: &mut Context<Self>) -> Self {
163        let ident = ident.into();
164        let query = cx.new(|cx| TextInput::new(ident.child("query"), window, cx).bare(true));
165        let subscription = cx.subscribe(&query, |_field, _query, event, cx| match event {
166            TextInputEvent::Change(text) => {
167                cx.emit(SearchFieldEvent::QueryChanged(text.clone()));
168            }
169            // Enter steps forward, which is what every find field does. The
170            // field steps nothing itself; the host owns where the caret is.
171            TextInputEvent::Submit => cx.emit(SearchFieldEvent::Next),
172            TextInputEvent::Cancel => cx.emit(SearchFieldEvent::Cancelled),
173            _ => {}
174        });
175
176        Self {
177            ident,
178            focus_handle: cx.focus_handle(),
179            query,
180            count: HitCount::default(),
181            placeholder: None,
182            size: ControlSize::Sm,
183            disabled: false,
184            match_case: None,
185            whole_word: None,
186            _subscriptions: vec![subscription],
187        }
188    }
189
190    pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
191        self.placeholder = Some(placeholder.into());
192        self
193    }
194
195    /// Offers a match-case toggle in the state the host holds. Without a call
196    /// there is no such control, because a control over a rule the host does
197    /// not implement would report a change nothing acts on.
198    pub fn match_case(mut self, on: bool) -> Self {
199        self.match_case = Some(on);
200        self
201    }
202
203    /// Offers a whole-word toggle in the state the host holds.
204    pub fn whole_word(mut self, on: bool) -> Self {
205        self.whole_word = Some(on);
206        self
207    }
208
209    /// The count the host established. The field counts nothing itself.
210    pub fn set_count(&mut self, count: HitCount, cx: &mut Context<Self>) {
211        self.count = count;
212        cx.notify();
213    }
214
215    pub fn count(&self) -> &HitCount {
216        &self.count
217    }
218
219    pub fn query_text(&self, cx: &App) -> SharedString {
220        self.query.read(cx).value().clone()
221    }
222
223    pub fn query_input(&self) -> &Entity<TextInput> {
224        &self.query
225    }
226
227    pub fn set_query(&mut self, text: impl Into<SharedString>, cx: &mut Context<Self>) {
228        self.query.update(cx, |query, cx| query.set_value(text, cx));
229    }
230
231    pub fn set_disabled(&mut self, disabled: bool, cx: &mut Context<Self>) {
232        self.disabled = disabled;
233        self.query
234            .update(cx, |query, cx| query.set_disabled(disabled, cx));
235        cx.notify();
236    }
237
238    /// Puts the keyboard in the query field, which is where a find surface
239    /// that has just opened expects it.
240    pub fn focus(&self, window: &mut Window, cx: &mut Context<Self>) {
241        let handle = self.query.read(cx).focus_handle(cx);
242        window.focus(&handle, cx);
243    }
244
245    fn step_control(
246        &self,
247        suffix: &str,
248        glyph: Icon,
249        key: StringKey,
250        event: SearchFieldEvent,
251        cx: &mut Context<Self>,
252    ) -> IconButton {
253        let ident = self.ident.child(suffix);
254        let name = cx.strings().text(key);
255        // A step with nowhere to go installs no handler at all, which is the
256        // same refusal `Pagination` makes at the ends of a run.
257        let live = !self.disabled && self.count.steppable();
258        let mut control = IconButton::new(ident, glyph, name)
259            .semantic_parent(self.ident.semantic_id())
260            .control_size(self.size)
261            .disabled(!live);
262        if live {
263            let field = cx.entity().downgrade();
264            control = control.on_click(move |_, cx| {
265                let event = event.clone();
266                field.update(cx, |_, cx| cx.emit(event)).ok();
267            });
268        }
269        control
270    }
271}
272
273impl Focusable for SearchField {
274    fn focus_handle(&self, _cx: &App) -> FocusHandle {
275        self.focus_handle.clone()
276    }
277}
278
279impl Render for SearchField {
280    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
281        let theme = cx.theme().clone();
282        let count_ident = self.ident.child("count");
283        let sentence = self.count.sentence(cx);
284
285        let toggles = [
286            (
287                "match-case",
288                self.match_case,
289                StringKey::SearchCaseSensitive,
290                Icon::Pen,
291                true,
292            ),
293            (
294                "whole-word",
295                self.whole_word,
296                StringKey::SearchWholeWord,
297                Icon::List,
298                false,
299            ),
300        ]
301        .into_iter()
302        .filter_map(|(suffix, state, key, glyph, is_case)| {
303            let on = state?;
304            let ident = self.ident.child(suffix);
305            let name = cx.strings().text(key);
306            let mut control = IconButton::new(ident, glyph, name)
307                .semantic_parent(self.ident.semantic_id())
308                .control_size(self.size)
309                .selected(on)
310                .disabled(self.disabled);
311            if !self.disabled {
312                let field = cx.entity().downgrade();
313                control = control.on_click(move |_, cx| {
314                    field
315                        .update(cx, |_, cx| {
316                            cx.emit(if is_case {
317                                SearchFieldEvent::MatchCaseToggled(!on)
318                            } else {
319                                SearchFieldEvent::WholeWordToggled(!on)
320                            })
321                        })
322                        .ok();
323                });
324            }
325            Some(control)
326        })
327        .collect::<Vec<_>>();
328
329        div()
330            .id(self.ident.element_id())
331            .row()
332            .w_full()
333            .gap_token(&theme, Space::Sm)
334            .px_token(&theme, Space::Sm)
335            .py_token(&theme, Space::Xs)
336            .radius(&theme, Radius::Card)
337            .frame(&theme, Surface::Panel, Elevation::Raised)
338            .child(
339                div().flex_1().min_w_0().child(self.query.clone()).child(
340                    div().absolute().size_0().semantic_in(
341                        cx,
342                        NodeSpec::new(self.ident.child("query.label").semantic_id(), Role::Text)
343                            .parent(self.ident.semantic_id())
344                            .labels(self.ident.child("query").semantic_id())
345                            .text(self.placeholder.clone().unwrap_or_else(|| {
346                                cx.strings().text(StringKey::SearchPlaceholder)
347                            })),
348                    ),
349                ),
350            )
351            .child(
352                foundation_text(&theme, TypeScale::Caption, sentence.clone())
353                    .flex_none()
354                    .text_color(match self.count {
355                        HitCount::Unavailable(_) => theme.colors.warning,
356                        HitCount::Counting | HitCount::Unsearched => theme.colors.text_faint,
357                        _ => theme.colors.text_muted,
358                    })
359                    .semantic_in(
360                        cx,
361                        NodeSpec::new(count_ident.semantic_id(), Role::Status)
362                            .parent(self.ident.semantic_id())
363                            .text(sentence)
364                            .value(self.count.name())
365                            .busy(self.count == HitCount::Counting),
366                    ),
367            )
368            .children(toggles)
369            .child(self.step_control(
370                "previous",
371                Icon::AltArrowLeft,
372                StringKey::SearchPrevious,
373                SearchFieldEvent::Previous,
374                cx,
375            ))
376            .child(self.step_control(
377                "next",
378                Icon::AltArrowRight,
379                StringKey::SearchNext,
380                SearchFieldEvent::Next,
381                cx,
382            ))
383            .semantic_in(
384                cx,
385                NodeSpec::new(self.ident.semantic_id(), Role::Group)
386                    .disabled(self.disabled)
387                    .value(self.count.name()),
388            )
389    }
390}
391
392impl Sizable for SearchField {
393    fn control_size(mut self, size: ControlSize) -> Self {
394        self.size = size;
395        self
396    }
397}
398
399/// What a find-and-replace surface reports. It replaces nothing.
400#[derive(Debug, Clone, PartialEq, Eq)]
401pub enum FindReplaceEvent {
402    /// Whatever the search field itself reported.
403    Search(SearchFieldEvent),
404    ReplacementChanged(SharedString),
405    /// Replace the hit the typist is on.
406    ReplaceOne,
407    /// Replace every hit. `count` is the number the control stated before it
408    /// was taken, so a host that has since found more can refuse.
409    ReplaceAll {
410        count: usize,
411    },
412}
413
414impl EventEmitter<FindReplaceEvent> for FindReplace {}
415
416/// A [`SearchField`] with a replacement field and the two replace actions.
417///
418/// Replace-all says how many hits it will change **before** it is taken, and
419/// is only offered when that number is a counted one: an unfinished count, a
420/// count that stopped early, and a search the host could not run are all
421/// numbers nobody has, so the action is refused with the reason on it rather
422/// than fired against a guess.
423pub struct FindReplace {
424    ident: Ident,
425    focus_handle: FocusHandle,
426    search: Entity<SearchField>,
427    replacement: Entity<TextInput>,
428    size: ControlSize,
429    disabled: bool,
430    _subscriptions: Vec<Subscription>,
431}
432
433impl std::fmt::Debug for FindReplace {
434    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
435        formatter
436            .debug_struct("FindReplace")
437            .field("ident", &self.ident)
438            .field("disabled", &self.disabled)
439            .finish()
440    }
441}
442
443impl FindReplace {
444    pub fn new(ident: impl Into<Ident>, window: &mut Window, cx: &mut Context<Self>) -> Self {
445        let ident = ident.into();
446        let search = cx.new(|cx| SearchField::new(ident.child("find"), window, cx));
447        let replacement = cx.new(|cx| {
448            TextInput::new(ident.child("replacement"), window, cx)
449                .bare(true)
450                .placeholder(cx.strings().text(StringKey::ReplacePlaceholder))
451        });
452        let subscriptions = vec![
453            cx.subscribe(&search, |_, _, event: &SearchFieldEvent, cx| {
454                cx.emit(FindReplaceEvent::Search(event.clone()));
455            }),
456            cx.subscribe(&replacement, |_, _, event, cx| {
457                if let TextInputEvent::Change(text) = event {
458                    cx.emit(FindReplaceEvent::ReplacementChanged(text.clone()));
459                }
460            }),
461        ];
462
463        Self {
464            ident,
465            focus_handle: cx.focus_handle(),
466            search,
467            replacement,
468            size: ControlSize::Sm,
469            disabled: false,
470            _subscriptions: subscriptions,
471        }
472    }
473
474    pub fn search_field(&self) -> &Entity<SearchField> {
475        &self.search
476    }
477
478    pub fn replacement_input(&self) -> &Entity<TextInput> {
479        &self.replacement
480    }
481
482    pub fn set_count(&mut self, count: HitCount, cx: &mut Context<Self>) {
483        self.search
484            .update(cx, |search, cx| search.set_count(count, cx));
485        cx.notify();
486    }
487
488    pub fn count(&self, cx: &App) -> HitCount {
489        self.search.read(cx).count().clone()
490    }
491
492    pub fn replacement_text(&self, cx: &App) -> SharedString {
493        self.replacement.read(cx).value().clone()
494    }
495
496    pub fn set_disabled(&mut self, disabled: bool, cx: &mut Context<Self>) {
497        self.disabled = disabled;
498        self.search
499            .update(cx, |search, cx| search.set_disabled(disabled, cx));
500        self.replacement
501            .update(cx, |input, cx| input.set_disabled(disabled, cx));
502        cx.notify();
503    }
504}
505
506impl Focusable for FindReplace {
507    fn focus_handle(&self, _cx: &App) -> FocusHandle {
508        self.focus_handle.clone()
509    }
510}
511
512impl Sizable for FindReplace {
513    fn control_size(mut self, size: ControlSize) -> Self {
514        self.size = size;
515        self
516    }
517}
518
519impl Render for FindReplace {
520    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
521        let theme = cx.theme().clone();
522        let count = self.search.read(cx).count().clone();
523        let counted = count.exact().filter(|total| *total > 0);
524        let one_live =
525            !self.disabled && matches!(count, HitCount::Known { total, .. } if total > 0);
526
527        let replace_one = {
528            let ident = self.ident.child("replace-one");
529            let mut control = Button::new(ident)
530                .label(cx.strings().text(StringKey::ReplaceOne))
531                .secondary()
532                .control_size(self.size)
533                .semantic_parent(self.ident.semantic_id())
534                .disabled(!one_live);
535            if one_live {
536                let surface = cx.entity().downgrade();
537                control = control.on_click(move |_, cx| {
538                    surface
539                        .update(cx, |_, cx| cx.emit(FindReplaceEvent::ReplaceOne))
540                        .ok();
541                });
542            }
543            control
544        };
545
546        let replace_all = {
547            let ident = self.ident.child("replace-all");
548            // The label carries the number before the action is taken, so
549            // nobody has to find out afterwards how much it changed.
550            let label = match counted {
551                Some(total) => cx
552                    .strings()
553                    .format(StringKey::ReplaceAllCounted, &[&total.to_string()]),
554                None => cx.strings().text(StringKey::ReplaceAllUncounted),
555            };
556            let live = !self.disabled && counted.is_some();
557            let mut control = Button::new(ident)
558                .label(label)
559                .secondary()
560                .control_size(self.size)
561                .semantic_parent(self.ident.semantic_id())
562                .disabled(!live);
563            if let Some(total) = counted.filter(|_| live) {
564                let surface = cx.entity().downgrade();
565                control = control.on_click(move |_, cx| {
566                    surface
567                        .update(cx, |_, cx| {
568                            cx.emit(FindReplaceEvent::ReplaceAll { count: total })
569                        })
570                        .ok();
571                });
572            }
573            control
574        };
575
576        let uncountable = counted.is_none().then(|| {
577            let ident = self.ident.child("replace-all.reason");
578            foundation_text(
579                &theme,
580                TypeScale::Caption,
581                cx.strings().text(StringKey::ReplaceAllUncountable),
582            )
583            .text_tone(&theme, gpui_kit_theme::TextTone::Faint)
584            .semantic_in(
585                cx,
586                NodeSpec::new(ident.semantic_id(), Role::Text)
587                    .parent(self.ident.semantic_id())
588                    .text(cx.strings().text(StringKey::ReplaceAllUncountable)),
589            )
590        });
591
592        div()
593            .id(self.ident.element_id())
594            .column()
595            .w_full()
596            .gap_token(&theme, Space::Sm)
597            .child(self.search.clone())
598            .child(
599                div()
600                    .row()
601                    .w_full()
602                    .gap_token(&theme, Space::Sm)
603                    .child(
604                        div()
605                            .flex_1()
606                            .min_w_0()
607                            .px_token(&theme, Space::Sm)
608                            .py_token(&theme, Space::Xs)
609                            .radius(&theme, Radius::Card)
610                            .frame(&theme, Surface::Panel, Elevation::Raised)
611                            .child(self.replacement.clone()),
612                    )
613                    .child(replace_one)
614                    .child(replace_all),
615            )
616            .children(uncountable)
617            .semantic_in(
618                cx,
619                NodeSpec::new(self.ident.semantic_id(), Role::Group)
620                    .disabled(self.disabled)
621                    // The container publishes the number replace-all claims,
622                    // so a snapshot shows the claim and not just the wording.
623                    .when_value(counted),
624            )
625    }
626}
627
628/// Adds the counted total to a spec only when there is one.
629trait CountedSpec {
630    fn when_value(self, count: Option<usize>) -> Self;
631}
632
633impl CountedSpec for NodeSpec {
634    fn when_value(self, count: Option<usize>) -> Self {
635        match count {
636            Some(count) => self.value(count.to_string()),
637            None => self,
638        }
639    }
640}
641
642#[cfg(test)]
643mod tests {
644    use super::*;
645
646    #[test]
647    fn only_a_counted_total_is_an_exact_one() {
648        assert_eq!(
649            HitCount::Known {
650                total: 4,
651                current: Some(0)
652            }
653            .exact(),
654            Some(4)
655        );
656        assert_eq!(HitCount::TooMany { counted: 500 }.exact(), None);
657        assert_eq!(HitCount::Counting.exact(), None);
658        assert_eq!(HitCount::None.exact(), None);
659        assert_eq!(HitCount::Unsearched.exact(), None);
660    }
661
662    #[test]
663    fn nothing_steps_until_there_is_somewhere_to_go() {
664        assert!(!HitCount::Unsearched.steppable());
665        assert!(!HitCount::Counting.steppable());
666        assert!(!HitCount::None.steppable());
667        assert!(
668            !HitCount::Known {
669                total: 0,
670                current: None
671            }
672            .steppable()
673        );
674        assert!(
675            HitCount::Known {
676                total: 1,
677                current: Some(0)
678            }
679            .steppable()
680        );
681        assert!(HitCount::TooMany { counted: 9 }.steppable());
682    }
683
684    #[test]
685    fn every_state_publishes_a_name_of_its_own() {
686        let names = [
687            HitCount::Unsearched.name(),
688            HitCount::Counting.name(),
689            HitCount::None.name(),
690            HitCount::Known {
691                total: 1,
692                current: None,
693            }
694            .name(),
695            HitCount::TooMany { counted: 1 }.name(),
696            HitCount::Unavailable("offline".into()).name(),
697        ];
698        let mut sorted = names.to_vec();
699        sorted.sort_unstable();
700        sorted.dedup();
701        assert_eq!(sorted.len(), names.len());
702    }
703}