use std::rc::Rc; use gpui::{ AnyElement, App, ElementId, EntityId, InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, RenderOnce, Role, ScrollHandle, SharedString, StatefulInteractiveElement, Styled, Window, canvas, div, prelude::*, }; use super::TabOverflowMenu; use crate::{ buttons::{ButtonVariant, IconButton}, forms::Choice, i18n, primitives::{DragGhost, Icon, IconName}, theme::{ActiveTheme, ControlSize, IconSize, TextSize}, }; /// One open document: its id, title and state. #[derive(Clone, Debug)] pub struct EditorTab { id: SharedString, title: SharedString, icon: Option, dirty: bool, pinned: bool, preview: bool, } impl EditorTab { pub fn new(id: impl Into, title: impl Into) -> Self { Self { id: id.into(), title: title.into(), icon: None, dirty: false, pinned: false, preview: false, } } pub fn icon(mut self, icon: IconName) -> Self { self.icon = Some(icon); self } /// Unsaved work: a dot that turns into the close button on hover. pub fn dirty(mut self, dirty: bool) -> Self { self.dirty = dirty; self } /// A pin in place of the close button. pub fn pinned(mut self, pinned: bool) -> Self { self.pinned = pinned; self } /// A tab the next preview replaces, shown in italics. pub fn preview(mut self, preview: bool) -> Self { self.preview = preview; self } } struct TabDrag { owner: EntityId, from: usize, } type OnTab = Rc; type OnReorder = Rc; /// Document tabs: close, pin, drag to reorder, keep a preview. Every tab is listed at the end. #[derive(IntoElement)] pub struct EditorTabs { id: ElementId, tabs: Vec, selected: Option, on_select: Option, on_close: Option, on_pin: Option, on_keep: Option, on_reorder: Option, } impl EditorTabs { pub fn new(id: impl Into, tabs: impl IntoIterator) -> Self { Self { id: id.into(), tabs: tabs.into_iter().collect(), selected: None, on_select: None, on_close: None, on_pin: None, on_keep: None, on_reorder: None, } } pub fn selected(mut self, id: impl Into) -> Self { self.selected = Some(id.into()); self } pub fn on_select( mut self, handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, ) -> Self { self.on_select = Some(Rc::new(handler)); self } /// The close button, a middle click, or the button over a dirty dot. pub fn on_close( mut self, handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, ) -> Self { self.on_close = Some(Rc::new(handler)); self } /// The pin on a pinned tab, which unpins it. pub fn on_pin( mut self, handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, ) -> Self { self.on_pin = Some(Rc::new(handler)); self } /// A double click on a preview tab, which keeps it open. pub fn on_keep( mut self, handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, ) -> Self { self.on_keep = Some(Rc::new(handler)); self } /// Reports `(from, to)` when a tab is dropped on another. pub fn on_reorder( mut self, handler: impl Fn(usize, usize, &mut Window, &mut App) + 'static, ) -> Self { self.on_reorder = Some(Rc::new(handler)); self } } fn run(handler: &Option, what: &str, id: &SharedString, window: &mut Window, cx: &mut App) { log::info!("editor tabs: {what} {id}"); if let Some(handler) = handler { handler(id, window, cx); } } impl RenderOnce for EditorTabs { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let owner = window .use_keyed_state((self.id.clone(), "drag"), cx, |_, _| ()) .entity_id(); let scroll = window .use_keyed_state((self.id.clone(), "scroll"), cx, |_, _| ScrollHandle::new()) .read(cx) .clone(); let chosen = self .selected .as_ref() .and_then(|id| self.tabs.iter().position(|tab| tab.id == *id)); let followed = window.use_keyed_state((self.id.clone(), "followed"), cx, |_, _| { None::> }); let follow = (followed.read(cx).as_ref() != Some(&self.selected)) .then(|| (followed, self.selected.clone(), scroll.clone())); let theme = cx.theme(); let colors = &theme.colors; let wide = theme.tab_width().1; let tabs: Vec = self .tabs .iter() .enumerate() .map(|(ix, tab)| { let on = chosen == Some(ix); let group = SharedString::from(format!("editor-tab-{}", tab.id)); let fg = if on { colors.fg } else { colors.fg_muted }; let (select, keep, close) = ( self.on_select.clone(), self.on_keep.clone(), self.on_close.clone(), ); let (id, middle_id, preview) = (tab.id.clone(), tab.id.clone(), tab.preview); let slot_id = tab.id.clone(); let slot: AnyElement = if tab.pinned { let pin = self.on_pin.clone(); IconButton::new(("pin", ix), IconName::Pin) .size(ControlSize::Sm) .variant(ButtonVariant::Ghost) .tooltip("Unpin") .on_click(move |_, window, cx| { cx.stop_propagation(); run(&pin, "unpin", &slot_id, window, cx); }) .into_any_element() } else { let close_button = { let close = self.on_close.clone(); IconButton::new(("close", ix), IconName::X) .size(ControlSize::Sm) .variant(ButtonVariant::Ghost) .tooltip(i18n::text(cx, "dialog.close", &[])) .on_click(move |_, window, cx| { cx.stop_propagation(); run(&close, "close", &slot_id, window, cx); }) }; let hidden = tab.dirty || !on; div() .relative() .flex_none() .size(theme.control_height(ControlSize::Sm)) .when(tab.dirty, |slot| { slot.child( div() .absolute() .size_full() .flex() .items_center() .justify_center() .group_hover(group.clone(), |style| style.invisible()) .child(div().size(theme.status_dot()).rounded_full().bg(fg)), ) }) .child( div() .absolute() .size_full() // Clear, not hidden: it still takes a press. .when(hidden, |cover| { cover .opacity(0.) .group_hover(group.clone(), |style| style.opacity(1.)) }) .child(close_button), ) .into_any_element() }; let reorder = self.on_reorder.clone(); let (title, icon, edge) = (tab.title.clone(), tab.icon, colors.focus); div() .id(tab.id.clone()) .role(Role::Tab) .aria_selected(on) .aria_label(tab.title.clone()) .group(group) .relative() .flex() .flex_none() .items_center() .gap_1p5() .max_w(wide) .h(theme.control_height(ControlSize::Lg)) .pl_3() .pr_1() .border_r_1() .border_color(colors.border) .text_size(theme.text_size(TextSize::Sm)) .text_color(fg) .map(|el| { if on { el.bg(colors.bg).child( div() .absolute() .top_0() .left_0() .right_0() .h(theme.tab_indicator()) .bg(colors.accent), ) } else { el.hover(|style| style.text_color(colors.fg)) } }) .when(preview, |el| el.italic()) .cursor_pointer() .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) .on_mouse_down(MouseButton::Middle, move |_, window, cx| { run(&close, "close", &middle_id, window, cx) }) .on_click(move |event, window, cx| { if preview && event.click_count() == 2 { run(&keep, "keep", &id, window, cx); } else { run(&select, "select", &id, window, cx); } }) .when_some(reorder, |el, reorder| { el.on_drag(TabDrag { owner, from: ix }, move |_, _, _, cx| { DragGhost::new(title.clone(), icon, cx) }) .drag_over::(move |style, drag, _, _| { if drag.owner == owner { style.border_color(edge) } else { style } }) .on_drop(move |drag: &TabDrag, window, cx| { if drag.owner == owner && drag.from != ix { log::info!("editor tabs: move {} -> {ix}", drag.from); reorder(drag.from, ix, window, cx); } }) }) .when_some(tab.icon, |el, icon| { el.child(Icon::new(icon).size(IconSize::Sm).color(fg)) }) .child( div() .min_w_0() .overflow_hidden() .whitespace_nowrap() .text_ellipsis() .child(tab.title.clone()), ) .child(slot) .into_any_element() }) .collect(); let listed: Vec = self .tabs .iter() .map(|tab| { let choice = Choice::new(tab.id.clone(), tab.title.clone()); match tab.icon { Some(icon) => choice.icon(icon), None => choice, } }) .collect(); let overflow = (!listed.is_empty()).then(|| { let select = self.on_select.clone(); let menu = TabOverflowMenu::new((self.id.clone(), "overflow"), listed) .on_select(move |id, window, cx| run(&select, "select", id, window, cx)); match &self.selected { Some(id) => menu.selected(id.clone()), None => menu, } }); div() .id(self.id) .flex() .items_center() .bg(colors.sunken) .border_b_1() .border_color(colors.border) .child( div() .id("strip") .flex() .flex_1() .min_w_0() .overflow_x_scroll() .track_scroll(&scroll) .children(tabs) .children(follow.map(|(followed, selected, scroll)| { canvas( move |_, window, cx| { if scroll.bounds().size.width <= Pixels::ZERO { return; } followed.update(cx, |followed, _| *followed = Some(selected)); if let Some(ix) = chosen { scroll.scroll_to_item(ix); window.request_animation_frame(); } }, |_, _, _, _| {}, ) .absolute() })), ) .children(overflow.map(|menu| div().flex_none().px_1().child(menu))) } }