1use 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#[derive(Debug, Clone, PartialEq, Eq, Default)]
35pub enum HitCount {
36 #[default]
39 Unsearched,
40 Counting,
42 None,
44 Known {
47 total: usize,
48 current: Option<usize>,
49 },
50 TooMany { counted: usize },
55 Unavailable(SharedString),
57}
58
59impl HitCount {
60 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 pub fn exact(&self) -> Option<usize> {
74 match self {
75 Self::Known { total, .. } => Some(*total),
76 _ => None,
77 }
78 }
79
80 fn steppable(&self) -> bool {
82 match self {
83 Self::Known { total, .. } => *total > 0,
84 Self::TooMany { .. } => true,
85 _ => false,
86 }
87 }
88
89 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 Self::Unavailable(reason) => reason.clone(),
116 }
117 }
118}
119
120#[derive(Debug, Clone, PartialEq, Eq)]
122pub enum SearchFieldEvent {
123 QueryChanged(SharedString),
124 Next,
126 Previous,
128 Cancelled,
130 MatchCaseToggled(bool),
131 WholeWordToggled(bool),
132}
133
134impl EventEmitter<SearchFieldEvent> for SearchField {}
135
136pub 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 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 pub fn match_case(mut self, on: bool) -> Self {
199 self.match_case = Some(on);
200 self
201 }
202
203 pub fn whole_word(mut self, on: bool) -> Self {
205 self.whole_word = Some(on);
206 self
207 }
208
209 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
401pub enum FindReplaceEvent {
402 Search(SearchFieldEvent),
404 ReplacementChanged(SharedString),
405 ReplaceOne,
407 ReplaceAll {
410 count: usize,
411 },
412}
413
414impl EventEmitter<FindReplaceEvent> for FindReplace {}
415
416pub 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 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 .when_value(counted),
624 )
625 }
626}
627
628trait 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}