1use std::{fmt, rc::Rc, sync::Arc};
22
23use gpui_kit::{
24 AnyElement, App, AppContext as _, Context, DispatchPhase, Div, ElementId, Entity, EventEmitter,
25 HitboxBehavior, Hsla, InteractiveElement as _, IntoElement, KeyDownEvent, MouseButton,
26 ParentElement as _, Pixels, RenderOnce, ScrollEvent, ScrollHandle, SharedString,
27 StatefulInteractiveElement as _, StyleRefinement, Styled, Subscription, Window,
28 assets::IconName,
29 base::{
30 self, Disableable as _, ElementExt as _, Interpolate as _, Sequence, StyledExt as _,
31 TestSupportExt as _, h_flex, transition,
32 },
33 canvas, div, point,
34 prelude::FluentBuilder as _,
35 px,
36};
37
38use crate::{
39 ActiveTheme as _, Button, ButtonSize, Icon, Input, InputEvent, InputState, MenuAnchor,
40 MenuEntry, MenuState, Theme, ThemeTokens, TooltipHost, menu::MenuPanels,
41 tooltip::TooltipTrigger,
42};
43#[derive(Clone)]
46pub struct Tab {
47 id: SharedString,
48 label: SharedString,
49 icon: Option<Icon>,
50}
51
52impl Tab {
53 pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
55 Self {
56 id: id.into(),
57 label: label.into(),
58 icon: None,
59 }
60 }
61
62 pub fn with_icon(mut self, icon: impl Into<Icon>) -> Self {
64 self.icon = Some(icon.into());
65 self
66 }
67
68 pub fn id(&self) -> &SharedString {
70 &self.id
71 }
72
73 pub fn label(&self) -> &SharedString {
75 &self.label
76 }
77
78 pub fn icon(&self) -> Option<&Icon> {
80 self.icon.as_ref()
81 }
82}
83
84impl fmt::Debug for Tab {
85 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86 f.debug_struct("Tab")
87 .field("id", &self.id)
88 .field("label", &self.label)
89 .field("icon", &self.icon.as_ref().map(Icon::source))
90 .finish()
91 }
92}
93
94#[derive(Clone, Debug, PartialEq, Eq)]
96#[non_exhaustive]
97pub enum TabsEvent {
98 Selected(SharedString),
100 Closed(SharedString),
103 AddRequested,
106 Renamed(SharedString),
109 CloseRequested(SharedString),
114}
115
116pub struct TabsState {
122 tabs: Vec<Tab>,
123 selected: Option<SharedString>,
124 scroll: ScrollHandle,
125 glide: Option<Glide>,
126 reveal: bool,
129 entering: Vec<SharedString>,
131 leaving: Vec<Leaving>,
134 renaming: Option<Renaming>,
136}
137
138struct Renaming {
140 id: SharedString,
141 input: Entity<InputState>,
142 _subscription: Subscription,
143}
144
145#[derive(Clone, Debug)]
148struct Leaving {
149 tab: Tab,
150 index: usize,
151}
152
153#[derive(Clone, Copy, Debug, PartialEq)]
158struct Glide {
159 serial: usize,
160 from: Pixels,
161 to: Pixels,
162}
163
164impl EventEmitter<TabsEvent> for TabsState {}
165
166impl TabsState {
167 pub fn new(tabs: impl IntoIterator<Item = Tab>) -> Self {
169 let tabs: Vec<Tab> = tabs.into_iter().collect();
170 debug_assert!(unique_ids(&tabs), "tab ids must be unique");
171 let selected = tabs.first().map(|tab| tab.id.clone());
172 Self {
173 tabs,
174 selected,
175 scroll: ScrollHandle::new(),
176 glide: None,
177 reveal: false,
178 entering: Vec::new(),
179 leaving: Vec::new(),
180 renaming: None,
181 }
182 }
183
184 pub fn tabs(&self) -> &[Tab] {
186 &self.tabs
187 }
188
189 pub fn selected(&self) -> Option<&SharedString> {
191 self.selected.as_ref()
192 }
193
194 fn index_of(&self, id: &SharedString) -> Option<usize> {
195 self.tabs.iter().position(|tab| &tab.id == id)
196 }
197
198 fn selected_index(&self) -> Option<usize> {
199 self.selected.as_ref().and_then(|id| self.index_of(id))
200 }
201
202 pub fn select(&mut self, id: impl Into<SharedString>, cx: &mut Context<Self>) {
205 let id = id.into();
206 let Some(index) = self.index_of(&id) else {
207 return;
208 };
209 if self.selected.as_ref() == Some(&id) {
210 return;
211 }
212 self.select_index(index);
213 cx.emit(TabsEvent::Selected(id));
214 cx.notify();
215 }
216
217 pub fn select_neighbor(&mut self, step: isize, cx: &mut Context<Self>) {
220 let len = self.tabs.len();
221 if len == 0 {
222 return;
223 }
224 let current = self.selected_index().unwrap_or(0) as isize;
225 let next = (current + step).rem_euclid(len as isize) as usize;
226 let id = self.tabs[next].id.clone();
227 self.select(id, cx);
228 }
229
230 pub fn select_edge(&mut self, last: bool, cx: &mut Context<Self>) {
232 let Some(tab) = (if last {
233 self.tabs.last()
234 } else {
235 self.tabs.first()
236 }) else {
237 return;
238 };
239 let id = tab.id.clone();
240 self.select(id, cx);
241 }
242
243 fn select_index(&mut self, index: usize) {
244 self.selected = Some(self.tabs[index].id.clone());
245 self.reveal = true;
246 }
247
248 fn revealed(&mut self, cx: &mut Context<Self>) {
249 if self.reveal {
250 self.reveal = false;
251 cx.notify();
252 }
253 }
254
255 pub fn push(&mut self, tab: Tab, cx: &mut Context<Self>) {
257 debug_assert!(self.index_of(&tab.id).is_none(), "tab ids must be unique");
258 let id = tab.id.clone();
259 self.leaving.retain(|leaving| leaving.tab.id != id);
260 self.tabs.push(tab);
261 self.entering.push(id.clone());
262 self.select_index(self.tabs.len() - 1);
263 cx.emit(TabsEvent::Selected(id));
264 cx.notify();
265 }
266
267 pub fn insert(&mut self, index: usize, tab: Tab, cx: &mut Context<Self>) {
271 debug_assert!(self.index_of(&tab.id).is_none(), "tab ids must be unique");
272 let index = index.min(self.tabs.len());
273 let id = tab.id.clone();
274 self.leaving.retain(|leaving| leaving.tab.id != id);
275 self.tabs.insert(index, tab);
276 self.entering.push(id);
277 cx.notify();
278 }
279
280 pub fn remove(&mut self, id: impl Into<SharedString>, cx: &mut Context<Self>) {
284 let id = id.into();
285 let Some(index) = self.index_of(&id) else {
286 return;
287 };
288 let was_selected = self.selected.as_ref() == Some(&id);
289 let tab = self.tabs.remove(index);
290 self.entering.retain(|entering| entering != &id);
291 self.leaving.push(Leaving { tab, index });
292 let next = if was_selected {
293 self.selected = None;
294 next_selection(index, self.tabs.len())
295 } else {
296 None
297 };
298 if let Some(next) = next {
299 self.select_index(next);
300 }
301 cx.emit(TabsEvent::Closed(id));
302 if let Some(next) = next {
303 cx.emit(TabsEvent::Selected(self.tabs[next].id.clone()));
304 }
305 cx.notify();
306 }
307
308 pub fn set_icon(
311 &mut self,
312 id: impl Into<SharedString>,
313 icon: Option<impl Into<Icon>>,
314 cx: &mut Context<Self>,
315 ) {
316 let id = id.into();
317 if let Some(index) = self.index_of(&id) {
318 self.tabs[index].icon = icon.map(Into::into);
319 cx.notify();
320 }
321 }
322
323 pub fn request_add(&mut self, cx: &mut Context<Self>) {
325 cx.emit(TabsEvent::AddRequested);
326 }
327
328 pub fn rename(
331 &mut self,
332 id: impl Into<SharedString>,
333 label: impl Into<SharedString>,
334 cx: &mut Context<Self>,
335 ) {
336 let id = id.into();
337 let label = label.into();
338 let Some(index) = self.index_of(&id) else {
339 return;
340 };
341 if self.tabs[index].label == label {
342 return;
343 }
344 self.tabs[index].label = label;
345 cx.emit(TabsEvent::Renamed(id));
346 cx.notify();
347 }
348
349 pub fn start_rename(
354 &mut self,
355 id: impl Into<SharedString>,
356 window: &mut Window,
357 cx: &mut Context<Self>,
358 ) {
359 let id = id.into();
360 let Some(index) = self.index_of(&id) else {
361 return;
362 };
363 let label = self.tabs[index].label.clone();
364 let input = cx.new(|cx| InputState::new(window, cx).default_value(label));
365 input.update(cx, |input, cx| {
366 input.focus(window, cx);
367 input.select_all(window, cx);
368 });
369 let subscription = cx.subscribe_in(&input, window, |this, _, event, _, cx| {
370 if matches!(event, InputEvent::PressEnter { .. } | InputEvent::Blur) {
371 this.commit_rename(cx);
372 }
373 });
374 self.renaming = Some(Renaming {
375 id,
376 input,
377 _subscription: subscription,
378 });
379 cx.notify();
380 }
381
382 pub fn commit_rename(&mut self, cx: &mut Context<Self>) {
384 let Some(renaming) = self.renaming.take() else {
385 return;
386 };
387 let value = renaming.input.read(cx).value();
388 let name = value.trim();
389 if !name.is_empty() {
390 self.rename(renaming.id, name.to_owned(), cx);
391 }
392 cx.notify();
393 }
394
395 pub fn cancel_rename(&mut self, cx: &mut Context<Self>) {
397 if self.renaming.take().is_some() {
398 cx.notify();
399 }
400 }
401
402 pub fn renaming(&self) -> Option<&SharedString> {
404 self.renaming.as_ref().map(|renaming| &renaming.id)
405 }
406
407 fn settle(&mut self, id: &SharedString, cx: &mut Context<Self>) {
410 let before = (self.entering.len(), self.leaving.len());
411 self.entering.retain(|entering| entering != id);
412 self.leaving.retain(|leaving| &leaving.tab.id != id);
413 if before != (self.entering.len(), self.leaving.len()) {
414 cx.notify();
415 }
416 }
417
418 fn scroll_by(&mut self, delta: Pixels, cx: &mut Context<Self>) {
421 let from = self.scroll.offset().x;
422 self.glide_to(from, from - delta, cx);
423 }
424
425 fn wheel_by(&mut self, step: Pixels, cx: &mut Context<Self>) {
429 let from = self.scroll.offset().x;
430 let base = match self.glide {
431 Some(glide)
432 if (glide.from..=glide.to).contains(&from)
433 || (glide.to..=glide.from).contains(&from) =>
434 {
435 glide.to
436 }
437 _ => from,
438 };
439 self.glide_to(from, base + step, cx);
440 }
441
442 fn pan_by(&mut self, step: Pixels, cx: &mut Context<Self>) {
445 let now = self.scroll.offset();
446 let max = self.scroll.max_offset().x;
447 let to = (now.x + step).clamp(-max, px(0.));
448 self.glide = None;
449 if to != now.x {
450 self.scroll.set_offset(point(to, now.y));
451 cx.notify();
452 }
453 }
454
455 fn glide_to(&mut self, from: Pixels, to: Pixels, cx: &mut Context<Self>) {
456 let max = self.scroll.max_offset().x;
457 let to = to.clamp(-max, px(0.));
458 if to != from {
459 let serial = self.glide.map_or(0, |glide| glide.serial + 1);
460 self.glide = Some(Glide { serial, from, to });
461 cx.notify();
462 }
463 }
464}
465
466fn unique_ids(tabs: &[Tab]) -> bool {
467 tabs.iter()
468 .enumerate()
469 .all(|(index, tab)| !tabs[..index].iter().any(|other| other.id == tab.id))
470}
471
472fn next_selection(removed: usize, len: usize) -> Option<usize> {
475 if len == 0 {
476 None
477 } else if removed < len {
478 Some(removed)
479 } else {
480 Some(len - 1)
481 }
482}
483
484fn separator_after(index: usize, selected_index: Option<usize>, len: usize) -> bool {
487 if index + 1 >= len {
488 return false;
489 }
490 match selected_index {
491 Some(selected) => index != selected && index + 1 != selected,
492 None => true,
493 }
494}
495
496#[derive(IntoElement)]
525pub struct Tabs {
526 id: ElementId,
527 state: Entity<TabsState>,
528 style: StyleRefinement,
529 closable: bool,
530 confirm_close: bool,
531 addable: bool,
532 add_trigger: Option<AddTrigger>,
533 leading: Pixels,
534 renamable: bool,
535 context_menu: Option<(Entity<MenuState>, TabMenu)>,
536}
537
538type AddTrigger = Box<dyn FnOnce(Button) -> AnyElement>;
539
540type TabMenu = Rc<dyn Fn(&SharedString, &mut Window, &mut App) -> Vec<MenuEntry>>;
542
543impl Tabs {
544 pub fn new(id: impl Into<ElementId>, state: &Entity<TabsState>) -> Self {
546 Self {
547 id: id.into(),
548 state: state.clone(),
549 style: StyleRefinement::default(),
550 closable: true,
551 confirm_close: false,
552 addable: true,
553 add_trigger: None,
554 leading: px(0.),
555 renamable: false,
556 context_menu: None,
557 }
558 }
559
560 pub fn renamable(mut self, renamable: bool) -> Self {
563 self.renamable = renamable;
564 self
565 }
566
567 pub fn context_menu(
572 mut self,
573 state: &Entity<MenuState>,
574 items: impl Fn(&SharedString, &mut Window, &mut App) -> Vec<MenuEntry> + 'static,
575 ) -> Self {
576 self.context_menu = Some((state.clone(), Rc::new(items)));
577 self
578 }
579
580 pub fn with_leading(mut self, leading: impl Into<Pixels>) -> Self {
584 self.leading = leading.into();
585 self
586 }
587
588 pub fn closable(mut self, closable: bool) -> Self {
590 self.closable = closable;
591 self
592 }
593
594 pub fn confirm_close(mut self, confirm: bool) -> Self {
599 self.confirm_close = confirm;
600 self
601 }
602
603 pub fn addable(mut self, addable: bool) -> Self {
605 self.addable = addable;
606 self
607 }
608
609 pub fn add_trigger<E: IntoElement>(mut self, wrap: impl FnOnce(Button) -> E + 'static) -> Self {
614 self.add_trigger = Some(Box::new(move |button| wrap(button).into_any_element()));
615 self
616 }
617}
618
619impl Styled for Tabs {
620 fn style(&mut self) -> &mut StyleRefinement {
621 &mut self.style
622 }
623}
624
625struct Look {
627 touch: bool,
628 bar: Hsla,
629 outline: Hsla,
630 hover: Hsla,
631 foreground: Hsla,
632 muted_foreground: Hsla,
633 separator: Hsla,
634 ring: Hsla,
635 surface_inset: Pixels,
636 width: Pixels,
637 min_width: Pixels,
638 shoulder: Pixels,
639 radius: Pixels,
640 gutter: Pixels,
641 separator_inset: Pixels,
642 icon: Pixels,
643 gap: Pixels,
644 control: Pixels,
645 close: Pixels,
646 text_size: Pixels,
647 line_height: Pixels,
648 edge: Pixels,
650 control_gap: Pixels,
652}
653
654impl Look {
655 fn of(theme: &ThemeTokens) -> Self {
656 let metrics = &theme.metrics;
657 Self {
658 touch: theme.touch,
659 bar: theme.background(),
660 outline: theme.border(),
661 hover: theme.muted(),
662 foreground: theme.foreground(),
663 muted_foreground: theme.muted_foreground(),
664 separator: theme.tab_separator,
665 ring: theme.ring(),
666 surface_inset: metrics.tab_surface_inset,
667 width: metrics.tab_width,
668 min_width: metrics.tab_min_width,
669 shoulder: metrics.tab_shoulder,
670 radius: metrics.tab_radius,
671 gutter: metrics.tab_separator_gutter,
672 separator_inset: metrics.tab_separator_inset,
673 icon: metrics.tab_icon,
674 gap: metrics.tab_content_gap,
675 control: metrics.tab_control,
676 close: metrics.tab_close,
677 text_size: theme.text_control.size,
678 line_height: theme.text_control.line_height,
679 edge: theme.base.spacing.sm,
680 control_gap: theme.base.spacing.xs,
681 }
682 }
683}
684
685struct Slot<'a> {
688 tab: &'a Tab,
689 live: Option<usize>,
690 entering: bool,
691}
692
693#[derive(Clone, Copy, PartialEq)]
697struct Overflow {
698 overflowing: bool,
699 at_start: bool,
700 at_end: bool,
701}
702
703impl Overflow {
704 fn of(scroll: &ScrollHandle) -> Self {
705 let max = scroll.max_offset().x;
706 let offset = scroll.offset().x;
707 Self {
708 overflowing: max > px(0.),
709 at_start: offset >= px(0.),
710 at_end: offset <= -max,
711 }
712 }
713}
714
715fn slot(look: &Look, control: impl IntoElement) -> impl IntoElement {
719 h_flex()
720 .w(look.control)
721 .h_full()
722 .pt(look.surface_inset)
723 .flex_none()
724 .justify_center()
725 .border_b_1()
726 .border_color(look.outline)
727 .child(control)
728}
729
730fn room(look: &Look, width: Pixels) -> Div {
732 div()
733 .flex_none()
734 .h_full()
735 .w(width)
736 .border_b_1()
737 .border_color(look.outline)
738}
739
740fn hairline(look: &Look) -> impl IntoElement {
744 room(look, look.edge).flex_1().min_w(look.edge)
745}
746
747impl RenderOnce for Tabs {
748 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
749 let look = Look::of(cx.theme());
750 let strip: Arc<ElementId> = Arc::new(self.id.clone());
751 let child = |name: &'static str| ElementId::NamedChild(strip.clone(), name.into());
752 let state = self.state;
753 let (tabs, selected_index, scroll, glide, reveal, entering, leaving, renaming) = {
754 let state = state.read(cx);
755 (
756 state.tabs.clone(),
757 state.selected_index(),
758 state.scroll.clone(),
759 state.glide,
760 state.reveal,
761 state.entering.clone(),
762 state.leaving.clone(),
763 state
764 .renaming
765 .as_ref()
766 .map(|renaming| (renaming.id.clone(), renaming.input.clone())),
767 )
768 };
769 let renamable = self.renamable;
770 let context_menu = self.context_menu.clone();
771 let len = tabs.len();
772 let (motion, fast) = {
773 let motion = &Theme::global(cx).motion;
774 (motion.glide_transition(), motion.fast_transition())
775 };
776 if let Some(glide) = glide {
777 let key = (child("glide"), SharedString::from(glide.serial.to_string()));
780 let sample = Sequence::new(key, glide.from)
781 .with_step(glide.to, motion.clone())
782 .sample(window, cx);
783 let landed = window.use_keyed_state(child("landed"), cx, |_, _| None::<usize>);
784 if !sample.is_finished() || *landed.read(cx) != Some(glide.serial) {
785 scroll.set_offset(point(*sample.value(), scroll.offset().y));
786 if sample.is_finished() {
787 landed.update(cx, |landed, _| *landed = Some(glide.serial));
788 }
789 }
790 }
791 let overflow = Overflow::of(&scroll);
792 let has_overlay = TooltipHost::overlay(window, cx).is_some();
793
794 let focus = window
795 .use_keyed_state(child("focus"), cx, |_, cx| cx.focus_handle())
796 .read(cx)
797 .clone();
798 let focus_visible = focus.is_focused(window) && window.last_input_was_keyboard();
799
800 let mut slots: Vec<Slot> = tabs
801 .iter()
802 .enumerate()
803 .map(|(index, tab)| Slot {
804 tab,
805 live: Some(index),
806 entering: entering.contains(&tab.id),
807 })
808 .collect();
809 for leaving in &leaving {
810 let at = leaving.index.min(slots.len());
811 slots.insert(
812 at,
813 Slot {
814 tab: &leaving.tab,
815 live: None,
816 entering: false,
817 },
818 );
819 }
820 let selected_child = slots
823 .iter()
824 .position(|slot| slot.live == selected_index && slot.live.is_some())
825 .map(|position| position + 1);
826 if reveal {
827 if let Some(child) = selected_child {
828 scroll.scroll_to_item(child);
829 }
830 let revealed = state.clone();
831 cx.defer(move |cx| revealed.update(cx, |state, cx| state.revealed(cx)));
832 }
833 let settle_state = state.clone();
834 let items = slots.into_iter().map(|slot| {
835 let tab = slot.tab;
836 let index = slot.live.unwrap_or(0);
837 let live = slot.live.is_some();
838 let selected = live && selected_index == Some(index);
839 let tab_id = Arc::new(ElementId::NamedChild(strip.clone(), tab.id.clone()));
840 let width = if slot.entering || !live {
843 let (from, to) = if live {
844 (px(0.), look.width)
845 } else {
846 (look.width, px(0.))
847 };
848 let sample = Sequence::new((tab_id.as_ref().clone(), "width"), from)
849 .with_step(to, motion.clone())
850 .sample(window, cx);
851 if sample.is_finished() {
852 let settle_state = settle_state.clone();
853 let id = tab.id.clone();
854 cx.defer(move |cx| settle_state.update(cx, |state, cx| state.settle(&id, cx)));
855 }
856 Some(*sample.value())
857 } else {
858 None
859 };
860 let part = |name: &'static str| ElementId::NamedChild(tab_id.clone(), name.into());
861 let group = tab.id.clone();
862 let separator = live && separator_after(index, selected_index, len);
863 let gutter = if separator { look.gutter } else { px(0.) };
864 let hovered = window
868 .use_keyed_state(part("hovered"), cx, |_, _| false)
869 .clone();
870 let hovers = live && !selected && !look.touch && *hovered.read(cx);
871 let selection = transition(
872 part("selection"),
873 f32::from(selected),
874 fast.clone(),
875 window,
876 cx,
877 );
878 let hover = transition(part("hover"), f32::from(hovers), fast.clone(), window, cx);
879 let text = look
880 .muted_foreground
881 .interpolate(&look.foreground, selection);
882 let outline_color = if selected && focus_visible {
883 look.ring
884 } else {
885 look.outline
886 }
887 .opacity(selection);
888 let hairline_color = look.outline.opacity(1. - selection);
889 let hover_color = look.hover.opacity(hover);
890 let shoulder = |side_left: bool| {
891 div()
892 .absolute()
893 .bottom_0()
894 .size(look.shoulder)
895 .overflow_hidden()
896 .bg(hover_color)
897 .map(|this| {
898 if side_left {
899 this.left_0()
900 } else {
901 this.right(gutter)
902 }
903 })
904 .child(
905 div()
906 .absolute()
907 .size(look.shoulder * 2.)
908 .rounded_full()
909 .top(-look.shoulder)
910 .bg(look.bar)
911 .border_1()
912 .border_color(outline_color)
913 .map(|this| {
914 if side_left {
915 this.left(-look.shoulder)
916 } else {
917 this.right(-look.shoulder)
918 }
919 }),
920 )
921 };
922 let label_id = part("label");
923 let tooltip = TooltipTrigger::text(label_id.clone(), None, tab.label.clone());
924 let select_state = state.clone();
925 let select_id = tab.id.clone();
926 let close_state = state.clone();
927 let close_id = tab.id.clone();
928 let confirm_close = self.confirm_close;
929 let editing = renaming
930 .as_ref()
931 .filter(|(id, _)| live && id == &tab.id)
932 .map(|(_, input)| input.clone());
933 let is_editing = editing.is_some();
934 let menu_id = tab.id.clone();
935 let menu = context_menu.clone();
936 let label = match editing {
937 Some(input) => {
938 let cancel_state = state.clone();
939 div()
940 .flex_1()
941 .min_w_0()
942 .capture_key_down(move |event: &KeyDownEvent, _, cx| {
945 if event.keystroke.key == "escape" {
946 cx.stop_propagation();
947 cancel_state.update(cx, |state, cx| state.cancel_rename(cx));
948 }
949 })
950 .child(
951 Input::new(&input)
952 .id(part("rename"))
953 .accessibility_label("Tab name")
954 .h(look.close)
955 .px(look.control_gap)
956 .text_size(look.text_size),
957 )
958 .into_any_element()
959 }
960 None => div()
961 .flex_1()
962 .min_w_0()
963 .truncate()
964 .text_size(look.text_size)
965 .line_height(look.line_height)
966 .child(tab.label.clone())
967 .into_any_element(),
968 };
969 base::Tab::new(tab_id.as_ref().clone())
970 .selected(selected)
971 .set_position(index + 1, len)
972 .accessibility_label(tab.label.clone())
973 .group(group.clone())
974 .relative()
975 .flex_shrink(1.)
976 .h_full()
977 .map(|this| match width {
978 Some(width) => this
979 .w(width)
980 .min_w(width.min(look.min_width))
981 .overflow_hidden(),
982 None => this.w(look.width).min_w(look.min_width),
983 })
984 .border_b_1()
985 .border_color(hairline_color)
986 .when(live, |this| {
987 this.on_hover(move |is_hovered, _, cx| {
988 hovered.update(cx, |hovered, cx| {
989 if *hovered != *is_hovered {
990 *hovered = *is_hovered;
991 cx.notify();
992 }
993 })
994 })
995 })
996 .when(live, |this| {
997 this.on_click(move |event, window, cx| {
998 if is_editing {
999 return;
1000 }
1001 select_state.update(cx, |state, cx| {
1002 state.select(select_id.clone(), cx);
1003 if renamable && event.click_count() >= 2 {
1004 state.start_rename(select_id.clone(), window, cx);
1005 }
1006 })
1007 })
1008 })
1009 .when_some(menu.filter(|_| live), |this, (menu, items)| {
1010 this.on_mouse_down(MouseButton::Right, move |event, window, cx| {
1011 cx.stop_propagation();
1012 let entries = items(&menu_id, window, cx);
1013 if entries.is_empty() {
1014 return;
1015 }
1016 let anchor = MenuAnchor::Point(event.position);
1017 menu.update(cx, |menu, cx| menu.open(entries, anchor, window, cx));
1018 })
1019 })
1020 .child(
1021 div()
1022 .absolute()
1023 .top(look.surface_inset)
1024 .bottom_0()
1025 .left_0()
1026 .right_0()
1027 .child(shoulder(true))
1028 .child(shoulder(false))
1029 .child(
1032 div()
1033 .absolute()
1034 .top_0()
1035 .bottom(look.shoulder)
1036 .left(look.shoulder - px(1.))
1037 .right(look.shoulder + gutter - px(1.))
1038 .rounded_t(look.radius)
1039 .border_1()
1040 .border_b_0()
1041 .border_color(outline_color),
1042 )
1043 .child(
1044 h_flex()
1045 .id(label_id)
1046 .test_support()
1047 .absolute()
1048 .top_0()
1049 .bottom_0()
1050 .left(look.shoulder)
1051 .right(look.shoulder + gutter)
1052 .min_w_0()
1053 .rounded_t(look.radius)
1054 .overflow_hidden()
1055 .bg(hover_color)
1056 .px_2()
1057 .gap(look.gap)
1058 .text_color(text)
1059 .map(|this| {
1060 if has_overlay {
1061 this.on_prepaint(tooltip.track_bounds())
1062 .on_hover(move |hovered, window, cx| {
1063 tooltip.hovered(*hovered, window, cx)
1064 })
1065 .on_mouse_down(MouseButton::Left, |_, window, cx| {
1066 TooltipTrigger::pressed(window, cx)
1067 })
1068 } else {
1069 this.tooltip(tooltip.native())
1070 }
1071 })
1072 .when_some(tab.icon.clone(), |this, icon| {
1073 this.child(icon.size(look.icon).flex_none())
1074 })
1075 .child(label)
1076 .when(self.closable && live && !is_editing, |this| {
1077 this.child(
1078 h_flex()
1079 .size(look.close)
1080 .flex_none()
1081 .justify_center()
1082 .when(!selected && !look.touch, |this| {
1083 this.invisible()
1084 .group_hover(group, |style| style.visible())
1085 })
1086 .child(
1087 Button::new(part("close"))
1088 .ghost()
1089 .size(ButtonSize::Xs)
1090 .icon(IconName::Close)
1091 .rounded_full()
1092 .accessibility_label("Close tab")
1093 .tooltip("Close tab")
1094 .tab_stop(false)
1095 .on_click(move |_, _, cx| {
1096 cx.stop_propagation();
1097 close_state.update(cx, |state, cx| {
1098 if confirm_close {
1099 cx.emit(TabsEvent::CloseRequested(
1100 close_id.clone(),
1101 ));
1102 } else {
1103 state.remove(close_id.clone(), cx);
1104 }
1105 })
1106 }),
1107 ),
1108 )
1109 }),
1110 ),
1111 )
1112 .when(separator, |this| {
1113 this.child(
1114 div()
1115 .absolute()
1116 .top(look.separator_inset)
1117 .bottom(look.separator_inset)
1118 .right((look.gutter - px(1.)) / 2.)
1119 .w(px(1.))
1120 .bg(look.separator),
1121 )
1122 })
1123 });
1124
1125 let prepaint_scroll = scroll.clone();
1126 let wheel_scroll = scroll.clone();
1127 let wheel_state = state.clone();
1128 let viewport = div()
1129 .on_children_prepainted(move |_, window, _| {
1130 if Overflow::of(&prepaint_scroll) != overflow {
1131 if let Some(child) = selected_child {
1134 prepaint_scroll.scroll_to_item(child);
1135 }
1136 window.request_animation_frame();
1137 }
1138 })
1139 .id(child("scroll"))
1140 .test_support()
1141 .flex()
1142 .items_end()
1143 .h_full()
1144 .min_w_0()
1145 .overflow_x_scroll()
1146 .track_scroll(&scroll)
1147 .child(room(&look, look.edge))
1150 .children(items);
1151
1152 let back_state = state.clone();
1153 let forward_state = state.clone();
1154 let add_state = state.clone();
1155 let key_state = state;
1156
1157 div()
1158 .id(self.id)
1159 .test_support()
1160 .w_full()
1161 .h_full()
1162 .min_w_0()
1163 .bg(look.bar)
1164 .track_focus(&focus)
1165 .tab_index(0)
1166 .on_key_down(move |event: &KeyDownEvent, _, cx| {
1167 if event.keystroke.modifiers.modified() || key_state.read(cx).renaming.is_some() {
1169 return;
1170 }
1171 match event.keystroke.key.as_str() {
1172 "left" => key_state.update(cx, |state, cx| state.select_neighbor(-1, cx)),
1173 "right" => key_state.update(cx, |state, cx| state.select_neighbor(1, cx)),
1174 "home" => key_state.update(cx, |state, cx| state.select_edge(false, cx)),
1175 "end" => key_state.update(cx, |state, cx| state.select_edge(true, cx)),
1176 _ => return,
1177 }
1178 cx.stop_propagation();
1179 })
1180 .relative()
1181 .refine_style(&self.style)
1182 .child(
1187 canvas(
1188 |bounds, window, _| window.insert_hitbox(bounds, HitboxBehavior::Normal),
1189 move |_, hitbox, window, _| {
1190 window.on_mouse_event(move |event: &ScrollEvent, phase, window, cx| {
1191 if phase != DispatchPhase::Capture
1192 || !hitbox.should_handle_scroll(window)
1193 || wheel_scroll.max_offset().x <= px(0.)
1194 {
1195 return;
1196 }
1197 cx.stop_propagation();
1198 let delta = event.delta.pixel_delta(window.line_height());
1199 let step = if delta.x != px(0.) { delta.x } else { delta.y };
1200 if step == px(0.) {
1201 return;
1202 }
1203 wheel_state.update(cx, |state, cx| {
1204 if event.delta.precise() {
1205 state.pan_by(step, cx);
1206 } else {
1207 state.wheel_by(step, cx);
1208 }
1209 });
1210 });
1211 },
1212 )
1213 .absolute()
1214 .inset_0(),
1215 )
1216 .child(
1217 base::Tabs::new(child("list"))
1218 .flex()
1219 .items_end()
1220 .size_full()
1221 .when(self.leading > px(0.), |this| {
1222 this.child(room(&look, self.leading))
1223 })
1224 .when(overflow.overflowing, |this| {
1225 this.child(room(&look, look.edge))
1226 .child(slot(
1227 &look,
1228 Button::new(child("back"))
1229 .ghost()
1230 .size(ButtonSize::Sm)
1231 .icon(IconName::ChevronLeft)
1232 .accessibility_label("Scroll tabs left")
1233 .tooltip("Scroll tabs left")
1234 .tab_stop(false)
1235 .disabled(overflow.at_start)
1236 .on_click(move |_, _, cx| {
1237 back_state.update(cx, |state, cx| {
1238 state.scroll_by(-look.width, cx)
1239 })
1240 }),
1241 ))
1242 .child(room(&look, look.control_gap))
1243 .child(slot(
1244 &look,
1245 Button::new(child("forward"))
1246 .ghost()
1247 .size(ButtonSize::Sm)
1248 .icon(IconName::ChevronRight)
1249 .accessibility_label("Scroll tabs right")
1250 .tooltip("Scroll tabs right")
1251 .tab_stop(false)
1252 .disabled(overflow.at_end)
1253 .on_click(move |_, _, cx| {
1254 forward_state
1255 .update(cx, |state, cx| state.scroll_by(look.width, cx))
1256 }),
1257 ))
1258 .child(room(&look, look.edge))
1259 })
1260 .child(viewport)
1261 .when(self.addable, |this| {
1262 let button = Button::new(child("add"))
1263 .ghost()
1264 .size(ButtonSize::Sm)
1265 .icon(IconName::Plus)
1266 .accessibility_label("New tab")
1267 .tooltip("New tab");
1268 let control = match self.add_trigger {
1269 Some(wrap) => wrap(button),
1270 None => button
1271 .on_click(move |_, _, cx| {
1272 add_state.update(cx, |state, cx| state.request_add(cx))
1273 })
1274 .into_any_element(),
1275 };
1276 this.child(room(&look, look.edge))
1277 .child(slot(&look, control))
1278 })
1279 .child(hairline(&look)),
1280 )
1281 .when_some(self.context_menu, |this, (menu, _)| {
1282 this.child(MenuPanels::new(child("menu"), &menu))
1283 })
1284 }
1285}
1286
1287#[cfg(test)]
1288mod tests {
1289 use super::*;
1290 use gpui_kit::TestAppContext;
1291
1292 fn ids(state: &TabsState) -> Vec<&str> {
1293 state.tabs.iter().map(|tab| tab.id.as_ref()).collect()
1294 }
1295
1296 #[test]
1297 fn removing_the_selected_tab_selects_the_next_or_the_previous() {
1298 assert_eq!(next_selection(0, 2), Some(0), "the tab that took its place");
1299 assert_eq!(next_selection(1, 2), Some(1));
1300 assert_eq!(
1301 next_selection(2, 2),
1302 Some(1),
1303 "the last tab: the one before"
1304 );
1305 assert_eq!(next_selection(0, 0), None, "nothing left");
1306 }
1307
1308 #[test]
1309 fn a_separator_follows_a_tab_away_from_the_selection_and_the_end() {
1310 assert!(separator_after(0, Some(2), 5));
1312 assert!(!separator_after(1, Some(2), 5), "before the selected tab");
1313 assert!(!separator_after(2, Some(2), 5), "the selected tab");
1314 assert!(separator_after(3, Some(2), 5));
1315 assert!(!separator_after(4, Some(2), 5), "the last tab");
1316 assert!(separator_after(0, None, 2), "no selection");
1317 assert!(!separator_after(0, None, 1));
1318 }
1319
1320 #[gpui_kit::test]
1321 fn the_edges_are_selectable(cx: &mut TestAppContext) {
1322 let state = cx
1323 .new(|_| TabsState::new([Tab::new("a", "A"), Tab::new("b", "B"), Tab::new("c", "C")]));
1324 state.update(cx, |state, cx| state.select_edge(true, cx));
1325 assert_eq!(
1326 state.read_with(cx, |state, _| state.selected().cloned()),
1327 Some("c".into())
1328 );
1329 state.update(cx, |state, cx| state.select_edge(false, cx));
1330 assert_eq!(
1331 state.read_with(cx, |state, _| state.selected().cloned()),
1332 Some("a".into())
1333 );
1334 }
1335
1336 #[gpui_kit::test]
1337 fn inserting_puts_a_tab_in_place_without_selecting_it(cx: &mut TestAppContext) {
1338 let state = cx.new(|_| TabsState::new([Tab::new("a", "A"), Tab::new("c", "C")]));
1339 state.update(cx, |state, cx| {
1340 state.insert(1, Tab::new("b", "B"), cx);
1341 state.insert(99, Tab::new("d", "D"), cx);
1342 });
1343 state.read_with(cx, |state, _| {
1344 assert_eq!(ids(state), ["a", "b", "c", "d"]);
1345 assert_eq!(state.selected().map(AsRef::as_ref), Some("a"));
1346 });
1347 }
1348
1349 #[gpui_kit::test]
1350 fn the_state_selects_pushes_and_removes(cx: &mut TestAppContext) {
1351 let state = cx.new(|_| TabsState::new([Tab::new("a", "A"), Tab::new("b", "B")]));
1352 let events = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
1353 cx.update({
1354 let events = events.clone();
1355 let state = state.clone();
1356 move |cx| {
1357 cx.subscribe(&state, move |_, event: &TabsEvent, _| {
1358 events.borrow_mut().push(event.clone())
1359 })
1360 .detach();
1361 }
1362 });
1363 assert_eq!(
1364 state.read_with(cx, |state, _| state.selected().cloned()),
1365 Some("a".into())
1366 );
1367 state.update(cx, |state, cx| {
1368 state.select("b", cx);
1369 state.select("b", cx);
1370 state.select("missing", cx);
1371 state.push(Tab::new("c", "C"), cx);
1372 state.remove("c", cx);
1373 state.remove("a", cx);
1374 state.request_add(cx);
1375 assert_eq!(ids(state), ["b"]);
1376 assert_eq!(state.selected(), Some(&"b".into()));
1377 state.remove("b", cx);
1378 assert!(state.selected().is_none());
1379 });
1380 cx.run_until_parked();
1381 assert_eq!(
1382 *events.borrow(),
1383 [
1384 TabsEvent::Selected("b".into()),
1385 TabsEvent::Selected("c".into()),
1386 TabsEvent::Closed("c".into()),
1387 TabsEvent::Selected("b".into()),
1388 TabsEvent::Closed("a".into()),
1389 TabsEvent::AddRequested,
1390 TabsEvent::Closed("b".into()),
1391 ]
1392 );
1393 }
1394
1395 #[gpui_kit::test]
1396 fn a_neighbor_step_wraps_at_both_ends(cx: &mut TestAppContext) {
1397 let state = cx.new(|_| TabsState::new([Tab::new("a", "A"), Tab::new("b", "B")]));
1398 state.update(cx, |state, cx| {
1399 state.select_neighbor(1, cx);
1400 assert_eq!(state.selected(), Some(&"b".into()));
1401 state.select_neighbor(1, cx);
1402 assert_eq!(state.selected(), Some(&"a".into()), "wraps");
1403 state.select_neighbor(-1, cx);
1404 assert_eq!(state.selected(), Some(&"b".into()), "wraps back");
1405 });
1406 let empty = cx.new(|_| TabsState::new([]));
1407 empty.update(cx, |state, cx| state.select_neighbor(1, cx));
1408 }
1409
1410 #[test]
1411 fn a_tab_reads_back_and_debugs_without_its_icon_bytes() {
1412 let tab = Tab::new("t", "Terminal").with_icon(IconName::SquareTerminal);
1413 assert_eq!(tab.id(), &SharedString::from("t"));
1414 assert_eq!(tab.label(), &SharedString::from("Terminal"));
1415 assert!(tab.icon().is_some());
1416 assert!(format!("{tab:?}").contains("square-terminal"));
1417 }
1418}