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}