Skip to main content

ui/components/
tab_switcher.rs

1use std::rc::Rc;
2
3use gpui::{
4    AnyElement, App, Context, FocusHandle, Focusable, IntoElement, KeyDownEvent, Pixels, Render,
5    Window, div, px, rgb,
6};
7
8use crate::{IconName, List, ListItem, overlay_backdrop, overlay_panel, prelude::*};
9
10/// Default panel width for a [`TabSwitcher`] overlay.
11pub const TAB_SWITCHER_WIDTH: Pixels = px(420.);
12
13/// A single entry rendered in a [`TabSwitcher`]'s list.
14pub struct TabSwitcherItem {
15    label: SharedString,
16    subtitle: Option<SharedString>,
17    icon: Option<IconName>,
18    on_select: Rc<dyn Fn(&mut Window, &mut App)>,
19}
20
21impl TabSwitcherItem {
22    pub fn new(
23        label: impl Into<SharedString>,
24        on_select: impl Fn(&mut Window, &mut App) + 'static,
25    ) -> Self {
26        Self {
27            label: label.into(),
28            subtitle: None,
29            icon: None,
30            on_select: Rc::new(on_select),
31        }
32    }
33
34    pub fn subtitle(mut self, subtitle: impl Into<SharedString>) -> Self {
35        self.subtitle = Some(subtitle.into());
36        self
37    }
38
39    pub fn icon(mut self, icon: IconName) -> Self {
40        self.icon = Some(icon);
41        self
42    }
43}
44
45/// A Cmd+Tab-style overlay: a plain list of open items (no search query),
46/// navigated with ↑/↓ (or Tab/Shift-Tab to cycle) and confirmed with Enter.
47/// Esc requests dismissal via [`TabSwitcher::on_dismiss`].
48///
49/// Deliberately NOT built on [`crate::CommandPalette`]/`PickerDelegate`
50/// generic abstraction — with only these two overlay use-cases in the
51/// codebase so far, a second bespoke ~150-line file is cheaper than
52/// generalizing early (YAGNI). Revisit if a third picker-shaped overlay
53/// (file-finder, go-to-line) shows up.
54///
55/// Caller-owned open/closed state, matching `CommandPalette`/`Modal`/`Drawer`:
56/// there is no internal open flag. The caller mounts a `TabSwitcher` into the
57/// tree only while it should be visible.
58pub struct TabSwitcher {
59    items: Vec<TabSwitcherItem>,
60    selected_ix: usize,
61    focus_handle: FocusHandle,
62    on_dismiss: Option<Rc<dyn Fn(&mut Window, &mut App)>>,
63}
64
65impl TabSwitcher {
66    pub fn new(cx: &mut Context<Self>, items: Vec<TabSwitcherItem>) -> Self {
67        Self {
68            items,
69            selected_ix: 0,
70            focus_handle: cx.focus_handle(),
71            on_dismiss: None,
72        }
73    }
74
75    /// Registers a callback invoked when the user presses Esc, requesting
76    /// that the caller unmount/close this switcher.
77    pub fn on_dismiss(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
78        self.on_dismiss = Some(Rc::new(handler));
79        self
80    }
81
82    /// Moves keyboard focus onto the switcher so Tab/↑/↓/Enter/Esc work
83    /// immediately. Callers should invoke this once after mounting.
84    pub fn focus(&self, window: &mut Window, cx: &mut App) {
85        window.focus(&self.focus_handle, cx);
86    }
87
88    fn select_next(&mut self, cx: &mut Context<Self>) {
89        if self.items.is_empty() {
90            return;
91        }
92        self.selected_ix = (self.selected_ix + 1) % self.items.len();
93        cx.notify();
94    }
95
96    fn select_previous(&mut self, cx: &mut Context<Self>) {
97        if self.items.is_empty() {
98            return;
99        }
100        self.selected_ix = if self.selected_ix == 0 {
101            self.items.len() - 1
102        } else {
103            self.selected_ix - 1
104        };
105        cx.notify();
106    }
107
108    fn handle_key_down(
109        &mut self,
110        event: &KeyDownEvent,
111        window: &mut Window,
112        cx: &mut Context<Self>,
113    ) {
114        let shift = event.keystroke.modifiers.shift;
115        match event.keystroke.key.as_str() {
116            "down" => self.select_next(cx),
117            "tab" if !shift => self.select_next(cx),
118            "up" => self.select_previous(cx),
119            "tab" if shift => self.select_previous(cx),
120            "enter" => {
121                if let Some(item) = self.items.get(self.selected_ix) {
122                    let on_select = item.on_select.clone();
123                    on_select(window, cx);
124                }
125            }
126            "escape" => {
127                if let Some(on_dismiss) = self.on_dismiss.clone() {
128                    on_dismiss(window, cx);
129                }
130            }
131            _ => {}
132        }
133    }
134
135    fn render_row(&self, item_ix: usize, is_selected: bool) -> AnyElement {
136        let item = &self.items[item_ix];
137        let on_select = item.on_select.clone();
138
139        ListItem::new(("tab-switcher-item", item_ix))
140            .focused(is_selected)
141            .when_some(item.icon, |this, icon| this.start_slot(Icon::new(icon)))
142            .child(
143                v_flex()
144                    .gap_0p5()
145                    .child(Label::new(item.label.clone()))
146                    .when_some(item.subtitle.clone(), |this, subtitle| {
147                        this.child(
148                            Label::new(subtitle)
149                                .size(LabelSize::Small)
150                                .color(Color::Muted),
151                        )
152                    }),
153            )
154            .on_click(move |_, window, cx| on_select(window, cx))
155            .into_any_element()
156    }
157}
158
159impl Focusable for TabSwitcher {
160    fn focus_handle(&self, _cx: &App) -> FocusHandle {
161        self.focus_handle.clone()
162    }
163}
164
165impl Render for TabSwitcher {
166    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
167        if self.selected_ix >= self.items.len() {
168            self.selected_ix = 0;
169        }
170        let selected_ix = self.selected_ix;
171
172        let rows: Vec<AnyElement> = (0..self.items.len())
173            .map(|item_ix| self.render_row(item_ix, item_ix == selected_ix))
174            .collect();
175
176        overlay_backdrop()
177            .track_focus(&self.focus_handle)
178            .on_key_down(cx.listener(Self::handle_key_down))
179            .child(
180                overlay_panel()
181                    .w(TAB_SWITCHER_WIDTH)
182                    .max_h(px(360.))
183                    .child(
184                        div()
185                            .px(px(20.))
186                            .py(px(12.))
187                            .border_b_1()
188                            .border_color(rgb(0x2A313B))
189                            .child(
190                                Label::new("Switch Tab")
191                                    .size(LabelSize::Small)
192                                    .color(Color::Muted),
193                            ),
194                    )
195                    .child(
196                        div()
197                            .id("tab-switcher-results")
198                            .flex_1()
199                            .overflow_y_scroll()
200                            .child(List::new().empty_message("No open tabs").children(rows)),
201                    ),
202            )
203    }
204}
205
206/// Standalone gallery preview for `TabSwitcher` (not registered in the
207/// `Component` catalog since it is a stateful `Entity`, matching
208/// `CodeEditor`/`SearchInput`'s existing convention in this crate).
209pub fn tab_switcher_preview(_window: &mut Window, cx: &mut App) -> AnyElement {
210    // `.relative()` + a fixed height confines the switcher's `.absolute()
211    // .inset_0()` backdrop to this preview box instead of the nearest
212    // positioned ancestor (which could be the whole gallery content pane).
213    div()
214        .relative()
215        .h(px(400.))
216        .child(cx.new(|cx| {
217            TabSwitcher::new(
218                cx,
219                vec![
220                    TabSwitcherItem::new("main.rs", |_, _| {}).icon(IconName::File),
221                    TabSwitcherItem::new("lib.rs", |_, _| {})
222                        .icon(IconName::File)
223                        .subtitle("crates/ui/src"),
224                    TabSwitcherItem::new("Cargo.toml", |_, _| {}).icon(IconName::File),
225                ],
226            )
227        }))
228        .into_any_element()
229}