Skip to main content

twrite_gpui/
prompt_bar.rs

1use gpui::*;
2use twrite_core::{KeyCode, PromptState, SearchAction, SearchSnapshot};
3
4use crate::editor::Editor;
5
6/// Interactive renderer for the shared headless [`PromptState`].
7///
8/// Hooks own the meaning (search, ex-commands, custom palettes) via
9/// `HookContext::prompt`; this only draws the box and routes clicks back
10/// through [`Editor::press_search_key`], so chips and buttons share the exact
11/// keyboard path (`Alt+C` toggles Match Case, plain `arrowdown` walks to the
12/// next match). Renders nothing when the prompt is closed, and no toggle
13/// chrome when no hook reports a search snapshot (foreign prompts).
14pub struct PromptBar;
15
16impl PromptBar {
17    /// Bottom-anchored vim-style line or dedicated search/replace toolbar.
18    ///
19    /// Takes plain data on purpose: reading the entity here would
20    /// re-enterantly borrow it mid-render and panic.
21    pub fn bottom_bar(
22        prompt: &PromptState,
23        snapshot: Option<SearchSnapshot>,
24        cx: &mut Context<Editor>,
25    ) -> Div {
26        if !prompt.is_open() {
27            return div();
28        }
29        if let Some(snapshot) = snapshot {
30            return Self::search_toolbar(prompt, &snapshot, cx);
31        }
32        div()
33            .w_full()
34            .border_t_1()
35            .border_color(rgb(0x313244))
36            .bg(rgb(0x11111b))
37            .px(px(12.0))
38            .py(px(6.0))
39            .on_mouse_down(MouseButton::Left, |_event, _window, cx| {
40                cx.stop_propagation();
41            })
42            .flex()
43            .flex_col()
44            .gap_1()
45            .child(Self::input_row(prompt))
46            .child(Self::item_list(prompt, 6))
47            .child(Self::message_row(prompt))
48    }
49
50    /// Browser-`F1` style floating palette centered near the top.
51    pub fn palette(
52        prompt: &PromptState,
53        snapshot: Option<SearchSnapshot>,
54        cx: &mut Context<Editor>,
55    ) -> Div {
56        if !prompt.is_open() {
57            return div();
58        }
59        if let Some(snapshot) = snapshot {
60            return div()
61                .absolute()
62                .top(px(48.0))
63                .w_full()
64                .flex()
65                .justify_center()
66                .child(
67                    div()
68                        .w(px(720.0))
69                        .rounded_md()
70                        .border_1()
71                        .border_color(rgb(0x45475a))
72                        .bg(rgb(0x181825))
73                        .p(px(8.0))
74                        .on_mouse_down(MouseButton::Left, |_event, _window, cx| {
75                            cx.stop_propagation();
76                        })
77                        .child(Self::search_toolbar_inner(prompt, &snapshot, cx)),
78                );
79        }
80        div()
81            .absolute()
82            .top(px(48.0))
83            .w_full()
84            .flex()
85            .justify_center()
86            .child(
87                div()
88                    .w(px(560.0))
89                    .rounded_md()
90                    .border_1()
91                    .border_color(rgb(0x45475a))
92                    .bg(rgb(0x1e1e2e))
93                    .px(px(12.0))
94                    .py(px(8.0))
95                    .on_mouse_down(MouseButton::Left, |_event, _window, cx| {
96                        cx.stop_propagation();
97                    })
98                    .flex()
99                    .flex_col()
100                    .gap_1()
101                    .child(Self::input_row(prompt))
102                    .child(Self::item_list(prompt, 8))
103                    .child(Self::message_row(prompt)),
104            )
105    }
106
107    /// Outer container for bottom-bar search and replace toolbar.
108    fn search_toolbar(
109        prompt: &PromptState,
110        snapshot: &SearchSnapshot,
111        cx: &mut Context<Editor>,
112    ) -> Div {
113        div()
114            .w_full()
115            .border_t_1()
116            .border_color(rgb(0x313244))
117            .bg(rgb(0x181825))
118            .px(px(12.0))
119            .py(px(6.0))
120            .on_mouse_down(MouseButton::Left, |_event, _window, cx| {
121                cx.stop_propagation();
122            })
123            .child(Self::search_toolbar_inner(prompt, snapshot, cx))
124    }
125
126    /// Inner content of search toolbar (Find row + optional Replace row).
127    fn search_toolbar_inner(
128        prompt: &PromptState,
129        snapshot: &SearchSnapshot,
130        cx: &mut Context<Editor>,
131    ) -> Div {
132        let mut col = div().flex().flex_col().gap_2();
133        col = col.child(Self::find_row(prompt, snapshot, cx));
134        if snapshot.replace_mode {
135            col = col.child(Self::replace_row(prompt, snapshot, cx));
136        }
137        col
138    }
139
140    /// First row: find input, steppers, match count, checkboxes, close button.
141    fn find_row(prompt: &PromptState, snapshot: &SearchSnapshot, cx: &mut Context<Editor>) -> Div {
142        let is_find_active = !snapshot.is_replace_prompt;
143        let find_text = if is_find_active {
144            prompt.input().to_string()
145        } else {
146            snapshot.query.clone()
147        };
148        let cursor_idx = if is_find_active {
149            Some(prompt.cursor())
150        } else {
151            None
152        };
153
154        let count_text = if snapshot.matches.is_empty() {
155            if snapshot.query.is_empty() && prompt.input().is_empty() {
156                String::new()
157            } else {
158                "No matches".to_string()
159            }
160        } else {
161            format!(
162                "{}/{}",
163                snapshot.current.map(|i| i + 1).unwrap_or(0),
164                snapshot.matches.len()
165            )
166        };
167
168        let mut row = div()
169            .flex()
170            .flex_row()
171            .items_center()
172            .gap_2()
173            .child(Self::replace_toggle_button(snapshot.replace_mode, cx))
174            .child(Self::text_box(
175                "search-find-box",
176                &find_text,
177                cursor_idx,
178                "Find in page",
179                is_find_active,
180                SearchAction::FocusSearch,
181                cx,
182            ))
183            .child(Self::stepper_button(
184                "search-step-prev",
185                "^",
186                KeyCode::Up,
187                cx,
188            ))
189            .child(Self::stepper_button(
190                "search-step-next",
191                "v",
192                KeyCode::Down,
193                cx,
194            ));
195
196        if !count_text.is_empty() {
197            let color = if snapshot.matches.is_empty() {
198                rgb(0xf38ba8)
199            } else {
200                rgb(0xa6adc8)
201            };
202            row = row.child(div().text_xs().text_color(color).child(count_text));
203        }
204
205        row = row
206            .child(Self::checkbox(
207                "search-check-highlight",
208                "Highlight All",
209                Some(0),
210                snapshot.highlight_all,
211                KeyCode::Char('h'),
212                cx,
213            ))
214            .child(Self::checkbox(
215                "search-check-case",
216                "Match Case",
217                Some(6),
218                snapshot.case_sensitive,
219                KeyCode::Char('c'),
220                cx,
221            ))
222            .child(Self::checkbox(
223                "search-check-word",
224                "Whole Words",
225                Some(0),
226                snapshot.whole_word,
227                KeyCode::Char('w'),
228                cx,
229            ))
230            .child(div().flex_1())
231            .child(Self::close_button(cx));
232
233        row
234    }
235
236    /// Second row: replace input, replace button, replace all button, status message.
237    fn replace_row(
238        prompt: &PromptState,
239        snapshot: &SearchSnapshot,
240        cx: &mut Context<Editor>,
241    ) -> Div {
242        let is_replace_active = snapshot.is_replace_prompt;
243        let replace_text = if is_replace_active {
244            prompt.input().to_string()
245        } else {
246            snapshot.replacement.clone()
247        };
248        let cursor_idx = if is_replace_active {
249            Some(prompt.cursor())
250        } else {
251            None
252        };
253
254        let mut row = div()
255            .flex()
256            .flex_row()
257            .items_center()
258            .gap_2()
259            .child(div().w(px(24.0)))
260            .child(Self::text_box(
261                "search-replace-box",
262                &replace_text,
263                cursor_idx,
264                "Replace with",
265                is_replace_active,
266                SearchAction::FocusReplace,
267                cx,
268            ))
269            .child(Self::action_button(
270                "search-btn-replace",
271                "Replace",
272                KeyCode::Enter,
273                true,
274                false,
275                cx,
276            ))
277            .child(Self::action_button(
278                "search-btn-replace-all",
279                "Replace All",
280                KeyCode::Char('a'),
281                false,
282                true,
283                cx,
284            ));
285
286        if let Some(msg) = prompt.message() {
287            row = row.child(
288                div()
289                    .text_xs()
290                    .text_color(rgb(0xa6e3a1))
291                    .child(msg.to_string()),
292            );
293        }
294
295        row
296    }
297
298    /// Styled text input box with focus border, text cursor, and click-to-focus.
299    fn text_box(
300        id: &'static str,
301        text: &str,
302        cursor: Option<usize>,
303        placeholder: &'static str,
304        focused: bool,
305        focus_action: SearchAction,
306        cx: &mut Context<Editor>,
307    ) -> Stateful<Div> {
308        let border_color = if focused {
309            rgb(0x89b4fa)
310        } else {
311            rgb(0x45475a)
312        };
313
314        let mut box_div = div()
315            .id(id)
316            .cursor_text()
317            .w(px(240.0))
318            .h(px(28.0))
319            .rounded_md()
320            .border_1()
321            .border_color(border_color)
322            .bg(rgb(0x11111b))
323            .px(px(8.0))
324            .flex()
325            .items_center()
326            .overflow_hidden();
327
328        if let Some(cursor_offset) = cursor {
329            if text.is_empty() {
330                box_div = box_div
331                    .child(div().w(px(1.5)).h(px(15.0)).bg(rgb(0x89b4fa)).mr(px(2.0)))
332                    .child(
333                        div()
334                            .text_sm()
335                            .text_color(rgb(0x6c7086))
336                            .child(placeholder.to_string()),
337                    );
338            } else {
339                let cursor = cursor_offset.min(text.len());
340                let before = text[..cursor].to_string();
341                let after = text[cursor..].to_string();
342                if !before.is_empty() {
343                    box_div =
344                        box_div.child(div().text_sm().text_color(rgb(0xcdd6f4)).child(before));
345                }
346                box_div = box_div.child(div().w(px(1.5)).h(px(15.0)).bg(rgb(0x89b4fa)));
347                if !after.is_empty() {
348                    box_div = box_div.child(div().text_sm().text_color(rgb(0xcdd6f4)).child(after));
349                }
350            }
351        } else if text.is_empty() {
352            box_div = box_div.child(
353                div()
354                    .text_sm()
355                    .text_color(rgb(0x6c7086))
356                    .child(placeholder.to_string()),
357            );
358        } else {
359            box_div = box_div.child(
360                div()
361                    .text_sm()
362                    .text_color(rgb(0xcdd6f4))
363                    .child(text.to_string()),
364            );
365        }
366
367        box_div = box_div.on_mouse_down(
368            MouseButton::Left,
369            cx.listener(move |this, _event: &MouseDownEvent, window, cx| {
370                if !focused {
371                    this.press_search_action(focus_action, Some(window), cx);
372                }
373                cx.stop_propagation();
374            }),
375        );
376
377        box_div
378    }
379
380    /// Checkbox with full words and keyboard mnemonic underline.
381    fn checkbox(
382        id: &'static str,
383        label: &'static str,
384        underline_index: Option<usize>,
385        checked: bool,
386        alt_key: KeyCode,
387        cx: &mut Context<Editor>,
388    ) -> Stateful<Div> {
389        let box_bg = if checked {
390            rgb(0x89b4fa)
391        } else {
392            rgb(0x181825)
393        };
394        let box_border = if checked {
395            rgb(0x89b4fa)
396        } else {
397            rgb(0x585b70)
398        };
399        let text_color = if checked {
400            rgb(0xcdd6f4)
401        } else {
402            rgb(0xa6adc8)
403        };
404
405        let mut check_icon = div()
406            .w(px(14.0))
407            .h(px(14.0))
408            .rounded(px(3.0))
409            .border_1()
410            .border_color(box_border)
411            .bg(box_bg)
412            .flex()
413            .items_center()
414            .justify_center();
415
416        if checked {
417            check_icon = check_icon.child(
418                div()
419                    .text_xs()
420                    .font_weight(FontWeight::BOLD)
421                    .text_color(rgb(0x11111b))
422                    .child("✓"),
423            );
424        }
425
426        let mut label_div = div().text_xs().text_color(text_color).flex().flex_row();
427        if let Some(idx) = underline_index {
428            let before = &label[..idx];
429            let under = &label[idx..idx + 1];
430            let after = &label[idx + 1..];
431            if !before.is_empty() {
432                label_div = label_div.child(before.to_string());
433            }
434            label_div = label_div.child(div().underline().child(under.to_string()));
435            if !after.is_empty() {
436                label_div = label_div.child(after.to_string());
437            }
438        } else {
439            label_div = label_div.child(label.to_string());
440        }
441
442        div()
443            .id(id)
444            .cursor_pointer()
445            .rounded_md()
446            .px(px(6.0))
447            .py(px(2.0))
448            .flex()
449            .flex_row()
450            .items_center()
451            .gap_1()
452            .hover(|s| s.bg(rgb(0x313244)))
453            .child(check_icon)
454            .child(label_div)
455            .on_mouse_down(
456                MouseButton::Left,
457                cx.listener(move |this, _event: &MouseDownEvent, window, cx| {
458                    this.press_search_key(alt_key, false, true, false, Some(window), cx);
459                    cx.stop_propagation();
460                }),
461            )
462    }
463
464    /// Match stepper button (`^` / `v`).
465    fn stepper_button(
466        id: &'static str,
467        label: &'static str,
468        key: KeyCode,
469        cx: &mut Context<Editor>,
470    ) -> Stateful<Div> {
471        div()
472            .id(id)
473            .cursor_pointer()
474            .rounded_md()
475            .px(px(8.0))
476            .py(px(2.0))
477            .bg(rgb(0x313244))
478            .hover(|s| s.bg(rgb(0x45475a)))
479            .text_xs()
480            .font_weight(FontWeight::BOLD)
481            .text_color(rgb(0xcdd6f4))
482            .child(label.to_string())
483            .on_mouse_down(
484                MouseButton::Left,
485                cx.listener(move |this, _event: &MouseDownEvent, window, cx| {
486                    this.press_search_key(key, false, false, false, Some(window), cx);
487                    cx.stop_propagation();
488                }),
489            )
490    }
491
492    /// Toggle replace section button (`▶` / `▼`).
493    fn replace_toggle_button(expanded: bool, cx: &mut Context<Editor>) -> Stateful<Div> {
494        let (bg, fg, label) = if expanded {
495            (rgb(0x45475a), rgb(0x89b4fa), "▼")
496        } else {
497            (rgb(0x313244), rgb(0x6c7086), "▶")
498        };
499        div()
500            .id("search-toggle-replace")
501            .cursor_pointer()
502            .rounded_md()
503            .w(px(24.0))
504            .h(px(24.0))
505            .flex()
506            .items_center()
507            .justify_center()
508            .bg(bg)
509            .hover(|s| s.bg(rgb(0x45475a)))
510            .text_xs()
511            .font_weight(FontWeight::BOLD)
512            .text_color(fg)
513            .child(label.to_string())
514            .on_mouse_down(
515                MouseButton::Left,
516                cx.listener(move |this, _event: &MouseDownEvent, window, cx| {
517                    this.press_search_action(SearchAction::ToggleReplace, Some(window), cx);
518                    cx.stop_propagation();
519                }),
520            )
521    }
522
523    /// Action button (Replace / Replace All).
524    fn action_button(
525        id: &'static str,
526        label: &'static str,
527        key: KeyCode,
528        ctrl: bool,
529        alt: bool,
530        cx: &mut Context<Editor>,
531    ) -> Stateful<Div> {
532        div()
533            .id(id)
534            .cursor_pointer()
535            .rounded_md()
536            .border_1()
537            .border_color(rgb(0x45475a))
538            .bg(rgb(0x313244))
539            .hover(|s| s.bg(rgb(0x45475a)).border_color(rgb(0x89b4fa)))
540            .px(px(10.0))
541            .py(px(2.0))
542            .text_xs()
543            .font_weight(FontWeight::BOLD)
544            .text_color(rgb(0xcdd6f4))
545            .child(label.to_string())
546            .on_mouse_down(
547                MouseButton::Left,
548                cx.listener(move |this, _event: &MouseDownEvent, window, cx| {
549                    this.press_search_key(key, ctrl, alt, false, Some(window), cx);
550                    cx.stop_propagation();
551                }),
552            )
553    }
554
555    /// Close button (`✕`) at far right of find bar.
556    fn close_button(cx: &mut Context<Editor>) -> Stateful<Div> {
557        div()
558            .id("search-close")
559            .cursor_pointer()
560            .rounded_md()
561            .px(px(6.0))
562            .py(px(2.0))
563            .text_sm()
564            .text_color(rgb(0x6c7086))
565            .hover(|s| s.text_color(rgb(0xcdd6f4)).bg(rgb(0x313244)))
566            .child("✕")
567            .on_mouse_down(
568                MouseButton::Left,
569                cx.listener(move |this, _event: &MouseDownEvent, window, cx| {
570                    this.press_search_key(KeyCode::Escape, false, false, false, Some(window), cx);
571                    cx.stop_propagation();
572                }),
573            )
574    }
575
576    /// `prefix + before-cursor + bar cursor + after-cursor` flowing as one
577    /// line (no growing spacers: the cursor stays glued to the text), or the
578    /// placeholder when the input is empty.
579    fn input_row(prompt: &PromptState) -> Div {
580        let mut row = div().w_full().flex().flex_row().items_center().gap_2();
581
582        let spec = prompt.spec();
583        let prefix = spec.map(|s| s.prefix.as_str()).unwrap_or("");
584        if !prefix.is_empty() {
585            row = row.child(
586                div()
587                    .text_sm()
588                    .font_weight(FontWeight::BOLD)
589                    .text_color(rgb(0xf9e2af))
590                    .child(prefix.to_string()),
591            );
592        }
593
594        if prompt.input().is_empty() {
595            let placeholder = spec.map(|s| s.placeholder.as_str()).unwrap_or("");
596            row = row
597                .child(div().w(px(1.5)).h(px(15.0)).bg(rgb(0x89b4fa)).mr(px(2.0)))
598                .child(
599                    div()
600                        .text_sm()
601                        .text_color(rgb(0x6c7086))
602                        .child(placeholder.to_string()),
603                );
604        } else {
605            let cursor = prompt.cursor().min(prompt.input().len());
606            let before = prompt.input()[..cursor].to_string();
607            let after = prompt.input()[cursor..].to_string();
608            let mut input_content = div().flex().flex_row().items_center();
609            if !before.is_empty() {
610                input_content =
611                    input_content.child(div().text_sm().text_color(rgb(0xcdd6f4)).child(before));
612            }
613            input_content = input_content.child(div().w(px(1.5)).h(px(15.0)).bg(rgb(0x89b4fa)));
614            if !after.is_empty() {
615                input_content =
616                    input_content.child(div().text_sm().text_color(rgb(0xcdd6f4)).child(after));
617            }
618            row = row.child(input_content);
619        }
620        row
621    }
622
623    /// Up to `limit` item rows, selected row highlighted.
624    fn item_list(prompt: &PromptState, limit: usize) -> Div {
625        if prompt.items().is_empty() {
626            return div();
627        }
628        let mut list = div().flex().flex_col();
629        for (idx, item) in prompt.items().iter().enumerate().take(limit) {
630            let selected = idx == prompt.selected_index();
631            let mut row = div()
632                .flex()
633                .flex_row()
634                .items_center()
635                .justify_between()
636                .rounded_md()
637                .px(px(8.0))
638                .py(px(2.0));
639            row = if selected { row.bg(rgb(0x45475a)) } else { row };
640            row = row.child(
641                div()
642                    .text_sm()
643                    .text_color(rgb(0xcdd6f4))
644                    .child(item.label.clone()),
645            );
646            if let Some(hint) = &item.hint {
647                row = row.child(
648                    div()
649                        .text_xs()
650                        .text_color(rgb(0x6c7086))
651                        .child(hint.clone()),
652                );
653            }
654            list = list.child(row);
655        }
656        list
657    }
658
659    /// Validation / error message line (empty when none).
660    fn message_row(prompt: &PromptState) -> Div {
661        match prompt.message() {
662            Some(message) => div()
663                .text_xs()
664                .text_color(rgb(0xf38ba8))
665                .child(message.to_string()),
666            None => div(),
667        }
668    }
669}