Skip to main content

liora_components/
tabs.rs

1use crate::gpui_compat::element_id;
2use crate::motion::pop_in;
3use gpui::{
4    AnyElement, App, Context, IntoElement, Render, SharedString, Window, div, prelude::*, px,
5};
6use liora_core::Config;
7use liora_icons::Icon;
8use liora_icons_lucide::IconName;
9use std::sync::Arc;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
12pub enum TabPosition {
13    #[default]
14    Top,
15    Bottom,
16    Left,
17    Right,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
21pub enum TabType {
22    #[default]
23    Standard,
24    Card,
25    BorderCard,
26}
27
28pub struct TabPane {
29    pub name: SharedString,
30    pub label: SharedString,
31    pub content: Arc<dyn Fn(&mut Window, &mut Context<Tabs>) -> AnyElement + 'static>,
32    pub closable: bool,
33    pub icon: Option<IconName>,
34}
35
36pub struct Tabs {
37    id: SharedString,
38    active_name: SharedString,
39    position: TabPosition,
40    tab_type: TabType,
41    panes: Vec<TabPane>,
42    editable: bool,
43    stretch: bool,
44    on_tab_click: Option<Box<dyn Fn(SharedString, &mut Window, &mut App) + 'static>>,
45    on_tab_remove: Option<Box<dyn Fn(SharedString, &mut Window, &mut App) + 'static>>,
46    on_tab_add: Option<Box<dyn Fn(&mut Window, &mut App) + 'static>>,
47}
48
49impl Tabs {
50    pub fn new(active_name: impl Into<SharedString>) -> Self {
51        let name = active_name.into();
52        Self {
53            id: liora_core::unique_id("tabs"),
54            active_name: name,
55            position: TabPosition::Top,
56            tab_type: TabType::Standard,
57            panes: vec![],
58            editable: false,
59            stretch: false,
60            on_tab_click: None,
61            on_tab_remove: None,
62            on_tab_add: None,
63        }
64    }
65
66    pub fn id(mut self, id: impl Into<SharedString>) -> Self {
67        self.id = id.into();
68        self
69    }
70
71    pub fn position(mut self, pos: TabPosition) -> Self {
72        self.position = pos;
73        self
74    }
75
76    pub fn type_(mut self, t: TabType) -> Self {
77        self.tab_type = t;
78        self
79    }
80
81    pub fn editable(mut self, e: bool) -> Self {
82        self.editable = e;
83        self
84    }
85
86    pub fn stretch(mut self, stretch: bool) -> Self {
87        self.stretch = stretch;
88        self
89    }
90
91    pub fn on_tab_click(
92        mut self,
93        f: impl Fn(SharedString, &mut Window, &mut App) + 'static,
94    ) -> Self {
95        self.on_tab_click = Some(Box::new(f));
96        self
97    }
98
99    pub fn on_tab_remove(
100        mut self,
101        f: impl Fn(SharedString, &mut Window, &mut App) + 'static,
102    ) -> Self {
103        self.on_tab_remove = Some(Box::new(f));
104        self
105    }
106
107    pub fn on_tab_add(mut self, f: impl Fn(&mut Window, &mut App) + 'static) -> Self {
108        self.on_tab_add = Some(Box::new(f));
109        self
110    }
111
112    pub fn pane<F, E>(
113        mut self,
114        name: impl Into<SharedString>,
115        label: impl Into<SharedString>,
116        f: F,
117    ) -> Self
118    where
119        F: Fn(&mut Window, &mut Context<Self>) -> E + 'static,
120        E: IntoElement,
121    {
122        self.panes.push(TabPane {
123            name: name.into(),
124            label: label.into(),
125            content: Arc::new(move |window, cx| f(window, cx).into_any_element()),
126            closable: true,
127            icon: None,
128        });
129        self
130    }
131
132    fn select_tab(&mut self, name: SharedString, window: &mut Window, cx: &mut Context<Self>) {
133        self.active_name = name.clone();
134        if let Some(on_click) = &self.on_tab_click {
135            (on_click)(name, window, cx);
136        }
137        cx.notify();
138    }
139
140    fn remove_tab(&mut self, name: SharedString, window: &mut Window, cx: &mut Context<Self>) {
141        if let Some(pos) = self.panes.iter().position(|p| p.name == name) {
142            self.panes.remove(pos);
143            if self.active_name == name {
144                if let Some(new_active) =
145                    self.panes.get(pos.min(self.panes.len().saturating_sub(1)))
146                {
147                    self.active_name = new_active.name.clone();
148                }
149            }
150        }
151        if let Some(on_remove) = &self.on_tab_remove {
152            (on_remove)(name, window, cx);
153        }
154        cx.notify();
155    }
156
157    fn add_tab(&mut self, window: &mut Window, cx: &mut Context<Self>) {
158        let mut next = self.panes.len() + 1;
159        let name = loop {
160            let candidate = SharedString::from(format!("tab-{}", next));
161            if self.panes.iter().all(|pane| pane.name != candidate) {
162                break candidate;
163            }
164            next += 1;
165        };
166        let label = SharedString::from(format!("Tab {}", next));
167        let content_text = SharedString::from(format!("Content of Tab {}", next));
168
169        self.panes.push(TabPane {
170            name: name.clone(),
171            label,
172            content: Arc::new(move |_, _| div().child(content_text.clone()).into_any_element()),
173            closable: true,
174            icon: None,
175        });
176        self.active_name = name;
177
178        if let Some(on_add) = &self.on_tab_add {
179            (on_add)(window, cx);
180        }
181        cx.notify();
182    }
183}
184
185impl Render for Tabs {
186    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
187        let theme = cx.global::<Config>().theme.clone();
188        let tab_type = self.tab_type;
189        let position = self.position;
190        let is_vertical = position == TabPosition::Left || position == TabPosition::Right;
191
192        let render_header = |this: &Self, cx: &mut Context<Self>| {
193            let theme = cx.global::<Config>().theme.clone();
194            div()
195                .flex()
196                .when(!is_vertical, |s| s.flex_row().items_center().w_full())
197                .when(is_vertical, |s| s.flex_col().w(px(120.0)))
198                .when(tab_type == TabType::Standard, |s| match position {
199                    TabPosition::Top => s
200                        .when(!this.stretch, |s| s.gap_8())
201                        .border_b_1()
202                        .border_color(theme.neutral.border),
203                    TabPosition::Bottom => s
204                        .when(!this.stretch, |s| s.gap_8())
205                        .border_t_1()
206                        .border_color(theme.neutral.border),
207                    TabPosition::Left => s.gap_2().border_r_1().border_color(theme.neutral.border),
208                    TabPosition::Right => s.gap_2().border_l_1().border_color(theme.neutral.border),
209                })
210                .when(
211                    tab_type == TabType::Card || tab_type == TabType::BorderCard,
212                    |s| {
213                        s.bg(theme.neutral.hover)
214                            .border_b_1()
215                            .border_color(theme.neutral.border)
216                    },
217                )
218                .children(this.panes.iter().map(|pane| {
219                    let name = pane.name.clone();
220                    let is_active = this.active_name == name;
221                    let closable = pane.closable;
222
223                    div()
224                        .id(element_id(format!("{}-tab-{}", this.id, name)))
225                        .cursor_pointer()
226                        .flex()
227                        .items_center()
228                        .justify_center()
229                        .when(this.stretch && !is_vertical, |s| s.flex_1())
230                        .when(!is_vertical, |s| s.h(px(40.0)))
231                        .when(is_vertical, |s| s.w_full().py_3())
232                        .when(tab_type == TabType::Standard, |s| {
233                            s.px_2()
234                                .text_color(if is_active {
235                                    theme.primary.base
236                                } else {
237                                    theme.neutral.text_1
238                                })
239                                .hover(|s| s.text_color(theme.primary.base))
240                                .when(is_active, |s| match position {
241                                    TabPosition::Top => s.child(pop_in(
242                                        element_id(format!(
243                                            "{}-indicator-motion-{}",
244                                            this.id, name
245                                        )),
246                                        div()
247                                            .absolute()
248                                            .bottom_0()
249                                            .w_full()
250                                            .h(px(2.0))
251                                            .bg(theme.primary.base),
252                                    )),
253                                    TabPosition::Bottom => s.child(pop_in(
254                                        element_id(format!(
255                                            "{}-indicator-motion-{}",
256                                            this.id, name
257                                        )),
258                                        div()
259                                            .absolute()
260                                            .top_0()
261                                            .w_full()
262                                            .h(px(2.0))
263                                            .bg(theme.primary.base),
264                                    )),
265                                    TabPosition::Left => s.child(pop_in(
266                                        element_id(format!(
267                                            "{}-indicator-motion-{}",
268                                            this.id, name
269                                        )),
270                                        div()
271                                            .absolute()
272                                            .right_0()
273                                            .h_full()
274                                            .w(px(2.0))
275                                            .bg(theme.primary.base),
276                                    )),
277                                    TabPosition::Right => s.child(pop_in(
278                                        element_id(format!(
279                                            "{}-indicator-motion-{}",
280                                            this.id, name
281                                        )),
282                                        div()
283                                            .absolute()
284                                            .left_0()
285                                            .h_full()
286                                            .w(px(2.0))
287                                            .bg(theme.primary.base),
288                                    )),
289                                })
290                        })
291                        .when(
292                            tab_type == TabType::Card || tab_type == TabType::BorderCard,
293                            |s| {
294                                s.px_5()
295                                    .border_r_1()
296                                    .border_color(theme.neutral.border)
297                                    .bg(if is_active {
298                                        theme.neutral.card
299                                    } else {
300                                        gpui::transparent_black()
301                                    })
302                                    .text_color(if is_active {
303                                        theme.primary.base
304                                    } else {
305                                        theme.neutral.text_1
306                                    })
307                                    .hover(|s| s.text_color(theme.primary.base))
308                                    .when(is_active, |s| {
309                                        s.border_b_1().border_color(theme.neutral.card).mb(px(-1.0))
310                                    })
311                            },
312                        )
313                        .on_click(cx.listener({
314                            let name = name.clone();
315                            move |this, _, window, cx| {
316                                this.select_tab(name.clone(), window, cx);
317                            }
318                        }))
319                        .child(
320                            div()
321                                .flex()
322                                .flex_row()
323                                .items_center()
324                                .gap_2()
325                                .child(div().text_sm().child(pane.label.clone()))
326                                .when(closable && this.editable, |s| {
327                                    s.child(
328                                        div()
329                                            .id(element_id(format!("{}-close-{}", this.id, name)))
330                                            .flex()
331                                            .items_center()
332                                            .justify_center()
333                                            .w_4()
334                                            .h_4()
335                                            .rounded_full()
336                                            .hover(|s| s.bg(theme.neutral.hover))
337                                            .on_click(cx.listener({
338                                                let name = name.clone();
339                                                move |this, _, window, cx| {
340                                                    this.remove_tab(name.clone(), window, cx);
341                                                }
342                                            }))
343                                            .child(
344                                                Icon::new(IconName::X)
345                                                    .size(px(12.0))
346                                                    .color(theme.neutral.icon),
347                                            ),
348                                    )
349                                }),
350                        )
351                }))
352                .when(this.editable, |s| {
353                    s.child(
354                        div()
355                            .id(element_id(format!("{}-add-tab", this.id)))
356                            .cursor_pointer()
357                            .flex()
358                            .items_center()
359                            .justify_center()
360                            .w_10()
361                            .h_10()
362                            .hover(|s| s.text_color(theme.primary.base))
363                            .on_click(cx.listener(move |this, _, window, cx| {
364                                this.add_tab(window, cx);
365                            }))
366                            .child(
367                                Icon::new(IconName::Plus)
368                                    .size(px(16.0))
369                                    .color(theme.neutral.icon),
370                            ),
371                    )
372                })
373        };
374
375        let content = self
376            .panes
377            .iter()
378            .find(|p| p.name == self.active_name)
379            .map(|p| (p.content)(_window, cx))
380            .unwrap_or_else(|| div().into_any_element());
381
382        div()
383            .flex()
384            .w_full()
385            .when(!is_vertical, |s| s.flex_col())
386            .when(is_vertical, |s| s.flex_row())
387            .when(tab_type == TabType::BorderCard, |s| {
388                s.border_1()
389                    .border_color(theme.neutral.border)
390                    .rounded(px(theme.radius.md))
391                    .overflow_hidden()
392            })
393            .bg(theme.neutral.card)
394            .child(match position {
395                TabPosition::Top | TabPosition::Left => render_header(self, cx).into_any_element(),
396                _ => div().into_any_element(),
397            })
398            .child(div().flex_1().p_4().child(content))
399            .child(match position {
400                TabPosition::Bottom | TabPosition::Right => {
401                    render_header(self, cx).into_any_element()
402                }
403                _ => div().into_any_element(),
404            })
405    }
406}