1use std::rc::Rc;
25
26use gpui::{
27 App, Entity, InteractiveElement, IntoElement, MouseButton, MouseDownEvent, ParentElement,
28 RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, point,
29 prelude::FluentBuilder, px,
30};
31use gpui_kit_assets::{Icon, icon};
32use gpui_kit_semantics::{NodeSpec, Role, Semantic};
33use gpui_kit_theme::{ActiveTheme, ControlMetrics, ControlSize, Space, Theme, TypeScale};
34
35use crate::display::badge::Badge;
36use crate::foundation::direction::{ActiveDirection, DirectionalExt};
37use crate::foundation::stepping::bounded_step;
38use crate::foundation::{Disableable, FocusRing, Ident, Pressable, Sizable, StyledExt, text};
39use crate::interaction::dnd::{
40 self, DragItem, DropAxis, DropIntent, DropPosition, MakingWay, RowTarget, SurfaceDrag,
41};
42use crate::motion::{Flipping, flip};
43use crate::overlay::{Menu, MenuItem};
44use crate::strings::{ActiveStrings, StringKey};
45
46type SelectHandler = Rc<dyn Fn(SharedString, &mut Window, &mut App)>;
47type CloseHandler = Rc<dyn Fn(SharedString, &mut Window, &mut App)>;
48type ReorderHandler = Rc<dyn Fn(&DropIntent, &mut Window, &mut App)>;
49type Accepts = Rc<dyn Fn(&DragItem, &DropPosition) -> bool>;
50
51const MARK_SIZE: f32 = 7.0;
54
55#[derive(Debug, Clone, PartialEq, Eq, Default)]
64pub enum SaveState {
65 #[default]
67 Clean,
68 Dirty,
70 Saving,
72 Failed { reason: SharedString },
74}
75
76impl SaveState {
77 pub fn name(&self) -> &'static str {
80 match self {
81 Self::Clean => "clean",
82 Self::Dirty => "dirty",
83 Self::Saving => "saving",
84 Self::Failed { .. } => "save-failed",
85 }
86 }
87
88 pub fn is_clean(&self) -> bool {
89 matches!(self, Self::Clean)
90 }
91
92 fn wording(&self, cx: &App) -> Option<SharedString> {
94 match self {
95 Self::Failed { reason } => Some(reason.clone()),
98 Self::Dirty => Some(cx.strings().text(StringKey::TabDirty)),
99 Self::Saving => Some(cx.strings().text(StringKey::TabSaving)),
100 Self::Clean => None,
101 }
102 }
103
104 fn menu_icon(&self) -> Option<Icon> {
107 match self {
108 Self::Clean => None,
109 Self::Dirty => Some(Icon::Pen),
110 Self::Saving => Some(Icon::Refresh),
111 Self::Failed { .. } => Some(Icon::Danger),
112 }
113 }
114}
115
116#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct TabItem {
119 pub id: SharedString,
120 pub label: SharedString,
121 pub icon: Option<Icon>,
122 pub badge: Option<SharedString>,
123 pub disabled: bool,
124 pub save_state: SaveState,
125 pub closable: bool,
126}
127
128impl TabItem {
129 pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
130 Self {
131 id: id.into(),
132 label: label.into(),
133 icon: None,
134 badge: None,
135 disabled: false,
136 save_state: SaveState::Clean,
137 closable: false,
138 }
139 }
140
141 pub fn icon(mut self, icon: Icon) -> Self {
142 self.icon = Some(icon);
143 self
144 }
145
146 pub fn badge(mut self, badge: impl Into<SharedString>) -> Self {
148 self.badge = Some(badge.into());
149 self
150 }
151
152 pub fn disabled(mut self, disabled: bool) -> Self {
153 self.disabled = disabled;
154 self
155 }
156
157 pub fn save_state(mut self, state: SaveState) -> Self {
159 self.save_state = state;
160 self
161 }
162
163 pub fn dirty(self) -> Self {
165 self.save_state(SaveState::Dirty)
166 }
167
168 pub fn saving(self) -> Self {
170 self.save_state(SaveState::Saving)
171 }
172
173 pub fn save_failed(self, reason: impl Into<SharedString>) -> Self {
175 self.save_state(SaveState::Failed {
176 reason: reason.into(),
177 })
178 }
179
180 pub fn closable(mut self, closable: bool) -> Self {
185 self.closable = closable;
186 self
187 }
188
189 fn menu_row(&self, selected: bool) -> MenuItem {
196 if selected {
197 return MenuItem::check(self.id.clone(), self.label.clone(), true)
198 .disabled(self.disabled);
199 }
200 let mut row =
201 MenuItem::command(self.id.clone(), self.label.clone()).disabled(self.disabled);
202 if let Some(glyph) = self.save_state.menu_icon().or(self.icon) {
203 row = row.icon(glyph);
204 }
205 row
206 }
207}
208
209#[derive(IntoElement)]
211pub struct Tabs {
212 ident: Ident,
213 tabs: Vec<TabItem>,
214 selected: Option<SharedString>,
215 size: ControlSize,
216 disabled: bool,
217 on_select: Option<SelectHandler>,
218 on_close: Option<CloseHandler>,
219 reorderable: bool,
220 accepts: Option<Accepts>,
221 on_reorder: Option<ReorderHandler>,
222 overflow_after: Option<usize>,
223 overflow_menu: Option<Entity<Menu>>,
224}
225
226impl std::fmt::Debug for Tabs {
227 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
228 formatter
229 .debug_struct("Tabs")
230 .field("ident", &self.ident)
231 .field("tabs", &self.tabs.len())
232 .field("selected", &self.selected)
233 .field("disabled", &self.disabled)
234 .field("has_handler", &self.on_select.is_some())
235 .field("closable", &self.on_close.is_some())
236 .field("overflow_after", &self.overflow_after)
237 .finish()
238 }
239}
240
241impl Tabs {
242 pub fn new(ident: impl Into<Ident>) -> Self {
243 Self {
244 ident: ident.into(),
245 tabs: Vec::new(),
246 selected: None,
247 size: ControlSize::Md,
248 disabled: false,
249 on_select: None,
250 on_close: None,
251 reorderable: false,
252 accepts: None,
253 on_reorder: None,
254 overflow_after: None,
255 overflow_menu: None,
256 }
257 }
258
259 pub fn tab(mut self, tab: TabItem) -> Self {
260 self.tabs.push(tab);
261 self
262 }
263
264 pub fn tabs(mut self, tabs: impl IntoIterator<Item = TabItem>) -> Self {
265 self.tabs.extend(tabs);
266 self
267 }
268
269 pub fn selected(mut self, id: impl Into<SharedString>) -> Self {
270 self.selected = Some(id.into());
271 self
272 }
273
274 pub fn on_select(
275 mut self,
276 handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
277 ) -> Self {
278 self.on_select = Some(Rc::new(handler));
279 self
280 }
281
282 pub fn on_close(
290 mut self,
291 handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
292 ) -> Self {
293 self.on_close = Some(Rc::new(handler));
294 self
295 }
296
297 pub fn overflow_after(mut self, count: usize) -> Self {
315 self.overflow_after = Some(count);
316 self
317 }
318
319 pub fn overflow_menu(mut self, menu: Entity<Menu>) -> Self {
325 self.overflow_menu = Some(menu);
326 self
327 }
328
329 fn cut(&self) -> usize {
332 match (self.overflow_after, self.overflow_menu.is_some()) {
333 (Some(cut), true) => cut,
334 _ => usize::MAX,
335 }
336 }
337
338 pub fn reorderable(mut self, reorderable: bool) -> Self {
340 self.reorderable = reorderable;
341 self
342 }
343
344 pub fn accepts(
347 mut self,
348 predicate: impl Fn(&DragItem, &DropPosition) -> bool + 'static,
349 ) -> Self {
350 self.accepts = Some(Rc::new(predicate));
351 self
352 }
353
354 pub fn on_reorder(
356 mut self,
357 handler: impl Fn(&DropIntent, &mut Window, &mut App) + 'static,
358 ) -> Self {
359 self.on_reorder = Some(Rc::new(handler));
360 self
361 }
362
363 fn reorder(&self, window: &mut Window, cx: &mut App) -> Option<Reorder> {
364 if self.disabled || !self.reorderable {
365 return None;
366 }
367 let on_drop = self.on_reorder.clone()?;
368 let surface = self.ident.semantic_id();
369 let accepts = self.accepts.clone().unwrap_or_else(|| {
370 let own = surface.clone();
371 Rc::new(move |item: &DragItem, _: &DropPosition| item.source == own)
372 });
373 Some(Reorder {
374 drag: dnd::surface_drag(&surface, window, cx),
375 surface,
376 accepts,
377 on_drop,
378 })
379 }
380
381 fn closes(&self, tab: &TabItem) -> bool {
384 tab.closable && !self.disabled && !tab.disabled && self.on_close.is_some()
385 }
386
387 fn save_mark(
392 &self,
393 tab: &TabItem,
394 ident: &Ident,
395 theme: &Theme,
396 cx: &App,
397 ) -> Option<gpui::AnyElement> {
398 let wording = tab.save_state.wording(cx)?;
399 let (color, filled) = match tab.save_state {
400 SaveState::Dirty => (theme.colors.accent, true),
401 SaveState::Saving => (theme.colors.text_muted, false),
405 SaveState::Failed { .. } => (theme.colors.danger, true),
406 SaveState::Clean => return None,
407 };
408 Some(
409 div()
410 .flex_none()
411 .size(px(MARK_SIZE))
412 .rounded_full()
413 .when(filled, |element| element.bg(color))
414 .when(!filled, |element| {
415 element.border(px(theme.borders.thick)).border_color(color)
416 })
417 .semantic_in(
418 cx,
419 NodeSpec::new(ident.child("save").semantic_id(), Role::Status)
420 .parent(ident.semantic_id())
421 .text(wording)
422 .value(tab.save_state.name())
423 .busy(matches!(tab.save_state, SaveState::Saving))
424 .invalid(matches!(tab.save_state, SaveState::Failed { .. })),
425 )
426 .into_any_element(),
427 )
428 }
429
430 fn close_control(
436 &self,
437 tab: &TabItem,
438 ident: &Ident,
439 theme: &Theme,
440 metrics: ControlMetrics,
441 cx: &mut App,
442 ) -> Option<gpui::AnyElement> {
443 let handler = self.on_close.clone().filter(|_| self.closes(tab))?;
444 let close_ident = ident.child("close");
445 let name = cx
446 .strings()
447 .format(StringKey::TabClose, &[tab.label.as_ref()]);
448 let id = tab.id.clone();
449 let keyed_id = id.clone();
450 let keyed = Rc::clone(&handler);
451
452 Some(
453 div()
454 .id(close_ident.element_id())
455 .flex()
456 .flex_none()
457 .items_center()
458 .justify_center()
459 .size(px(metrics.icon_size + 4.0))
460 .rounded_full()
461 .cursor_pointer()
462 .tab_index(0)
463 .hover(|style| style.bg(theme.colors.hover))
464 .focus_ring(theme)
465 .child(
466 icon(Icon::Close)
467 .size(px(metrics.icon_size - 3.0))
468 .text_color(theme.colors.text_muted),
469 )
470 .on_click(move |_, window, cx| {
471 cx.stop_propagation();
472 handler(id.clone(), window, cx);
473 })
474 .on_key_down(move |event, window, cx| {
475 if matches!(event.keystroke.key.as_str(), "enter" | "space") {
476 cx.stop_propagation();
477 keyed(keyed_id.clone(), window, cx);
478 }
479 })
480 .semantic_in(
481 cx,
482 NodeSpec::new(close_ident.semantic_id(), Role::Button)
483 .parent(ident.semantic_id())
484 .text(name),
485 )
486 .into_any_element(),
487 )
488 }
489
490 #[allow(clippy::too_many_arguments)]
491 fn tab_element(
492 &self,
493 tab: &TabItem,
494 index: usize,
495 theme: &Theme,
496 metrics: ControlMetrics,
497 reorder: Option<&Reorder>,
498 window: &mut Window,
499 cx: &mut App,
500 ) -> gpui::AnyElement {
501 let selected = self.selected.as_ref() == Some(&tab.id);
502 let disabled = self.disabled || tab.disabled;
503 let actionable = !disabled && self.on_select.is_some();
504 let draggable = reorder.filter(|_| !disabled);
505 let drag = draggable.and_then(|reorder| reorder.drag.as_ref());
506 let carried = drag.is_some_and(|drag| drag.carries(&tab.id));
507 let landing = drag.and_then(|drag| drag.indicator_for(&tab.id));
508 let ident = self.ident.child(tab.id.as_ref());
509 let hover_group = ident.child("hover").semantic_id();
510 let color = if disabled {
511 theme.colors.text_faint
512 } else if selected {
513 theme.colors.text
514 } else {
515 theme.colors.text_muted
516 };
517
518 let mut element = div()
519 .id(ident.element_id())
520 .group(hover_group.clone())
521 .flex_none()
522 .column()
523 .child(
524 div()
525 .row()
526 .h(px(metrics.height))
527 .px(px(metrics.padding_x))
528 .gap(px(metrics.gap))
529 .children(
530 tab.icon
531 .map(|glyph| icon(glyph).size(px(metrics.icon_size)).text_color(color)),
532 )
533 .child(
534 text(theme, TypeScale::Label, tab.label.clone())
535 .text_size(px(metrics.font_size))
536 .text_color(color)
537 .when(actionable, |element| {
538 element.group_hover(hover_group, |style| {
539 style.text_color(theme.colors.text)
540 })
541 }),
542 )
543 .children(tab.badge.clone().map(|badge| Badge::new(badge).neutral()))
544 .children(self.save_mark(tab, &ident, theme, cx))
545 .children(self.close_control(tab, &ident, theme, metrics, cx)),
546 )
547 .child(
553 div()
554 .relative()
555 .h(px(theme.borders.thick))
556 .children(selected.then(|| {
557 let indicator = flip(self.ident.child("indicator").semantic_id(), cx);
558 div()
559 .absolute()
560 .inset_0()
561 .bg(theme.colors.accent)
562 .flip(&indicator, window, cx)
563 })),
564 )
565 .children(landing.map(|(position, accepted)| {
566 dnd::indicator(&position, accepted, DropAxis::Horizontal, cx)
567 }))
568 .when(disabled, |element| element.opacity(theme.opacity.disabled))
569 .when(carried, |element| element.opacity(theme.opacity.muted))
570 .when(actionable, |element| {
571 element
572 .cursor_pointer()
573 .tab_index(0)
574 .pressable(cx)
575 .focus_ring(theme)
576 });
577
578 if let (true, Some(handler)) = (actionable, self.on_select.clone()) {
579 let id = tab.id.clone();
580 let click = Rc::clone(&handler);
581 let clicked = id.clone();
582 element = element
583 .on_click(move |_, window, cx| click(clicked.clone(), window, cx))
584 .on_key_down(move |event, window, cx| {
585 if matches!(event.keystroke.key.as_str(), "enter" | "space") {
586 handler(id.clone(), window, cx);
587 cx.stop_propagation();
588 }
589 });
590 }
591
592 if let (true, Some(handler)) = (self.closes(tab), self.on_close.clone()) {
598 let id = tab.id.clone();
599 element = element.on_mouse_down(
600 MouseButton::Middle,
601 move |_: &MouseDownEvent, window, cx| {
602 handler(id.clone(), window, cx);
603 cx.stop_propagation();
604 },
605 );
606 }
607
608 if let Some(reorder) = draggable {
609 let mut item =
610 DragItem::new(reorder.surface.clone(), tab.id.clone(), tab.label.clone());
611 if let Some(glyph) = tab.icon {
612 item = item.icon(glyph);
613 }
614 element = dnd::draggable(element, item);
615 element = dnd::drop_target(
616 element,
617 RowTarget {
618 surface: reorder.surface.clone(),
619 id: tab.id.clone(),
620 index,
621 allow_into: false,
622 axis: DropAxis::Horizontal,
623 accepts: Rc::clone(&reorder.accepts),
624 on_drop: Rc::clone(&reorder.on_drop),
625 },
626 );
627 }
628
629 let element = element.semantic_in(
630 cx,
631 NodeSpec::new(ident.semantic_id(), Role::Tab)
632 .parent(self.ident.semantic_id())
633 .checked(selected)
634 .disabled(disabled)
635 .text(tab.label.clone())
636 .value(tab.save_state.name())
639 .busy(matches!(tab.save_state, SaveState::Saving))
640 .invalid(matches!(tab.save_state, SaveState::Failed { .. })),
641 );
642
643 match draggable {
644 Some(reorder) => {
645 let shift = reorder
646 .drag
647 .as_ref()
648 .filter(|drag| drag.makes_way(index))
649 .map_or(px(0.0), |_| dnd::make_way_gap(cx, DropAxis::Horizontal));
650 element
651 .make_way(ident.semantic_id(), point(shift, px(0.0)), window, cx)
652 .into_any_element()
653 }
654 None => element.into_any_element(),
655 }
656 }
657}
658
659#[derive(Clone)]
661struct Reorder {
662 surface: SharedString,
663 drag: Option<SurfaceDrag>,
664 accepts: Accepts,
665 on_drop: ReorderHandler,
666}
667
668impl Disableable for Tabs {
669 fn disabled(mut self, disabled: bool) -> Self {
672 self.disabled = disabled;
673 self
674 }
675}
676
677impl Sizable for Tabs {
678 fn control_size(mut self, size: ControlSize) -> Self {
679 self.size = size;
680 self
681 }
682}
683
684impl RenderOnce for Tabs {
685 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
686 let theme = cx.theme().clone();
687 let metrics = theme.control.get(self.size);
688 let reorder = self.reorder(window, cx);
689
690 let direction = cx.layout_direction();
691 let mut strip = div()
692 .id(self.ident.element_id())
693 .row_reading(direction)
694 .items_end()
695 .flex_wrap()
696 .gap(px(theme.space(Space::Xs)));
697
698 if let (false, Some(handler)) = (self.disabled, self.on_select.clone()) {
699 let tabs = self.tabs.clone();
700 let selected = self.selected.clone();
701 strip = strip.on_key_down(move |event, window, cx| {
705 let key = event.keystroke.key.as_str();
706 let next = match direction.arrow_step(key) {
707 Some(step_by) => step(&tabs, selected.as_ref(), step_by as isize),
708 None => match key {
709 "home" => edge(&tabs, -1),
710 "end" => edge(&tabs, 1),
711 _ => return,
712 },
713 };
714 let Some(next) = next.filter(|next| Some(next) != selected.as_ref()) else {
717 return;
718 };
719 handler(next, window, cx);
720 cx.stop_propagation();
721 });
722 }
723
724 let cut = self.cut();
725 let mut hidden: Vec<MenuItem> = Vec::new();
726 for (index, tab) in self.tabs.iter().enumerate() {
727 if index >= cut {
728 hidden.push(tab.menu_row(self.selected.as_ref() == Some(&tab.id)));
729 continue;
730 }
731 strip = strip.child(self.tab_element(
732 tab,
733 index,
734 &theme,
735 metrics,
736 reorder.as_ref(),
737 window,
738 cx,
739 ));
740 }
741
742 let hidden_count = hidden.len();
743 let overflow = self
744 .overflow_menu
745 .clone()
746 .filter(|_| hidden_count > 0)
747 .map(|menu| {
748 if menu.read(cx).offered() != hidden.as_slice() {
749 menu.update(cx, |menu, cx| menu.set_items(hidden, cx));
750 }
751 let overflow_ident = self.ident.child("overflow");
752 div()
753 .flex()
754 .flex_none()
755 .child(menu)
756 .semantic_in(
759 cx,
760 NodeSpec::new(overflow_ident.semantic_id(), Role::Group)
761 .parent(self.ident.semantic_id())
762 .text(cx.strings().text(StringKey::TabMoreTabs))
763 .value(hidden_count.to_string()),
764 )
765 });
766
767 strip.children(overflow).semantic_in(
768 cx,
769 NodeSpec::new(self.ident.semantic_id(), Role::List).value(self.tabs.len().to_string()),
772 )
773 }
774}
775
776fn step(tabs: &[TabItem], selected: Option<&SharedString>, delta: isize) -> Option<SharedString> {
781 let from = selected.and_then(|id| tabs.iter().position(|tab| &tab.id == id));
782 bounded_step(tabs.len(), from, delta, |index| tabs[index].disabled)
783 .map(|index| tabs[index].id.clone())
784}
785
786fn edge(tabs: &[TabItem], delta: isize) -> Option<SharedString> {
789 step(tabs, None, -delta)
790}