Rework tabs

Max Brunsfeld and Nate Butler created

* Tabs are no longer flexible in width
* Move status icons to the left side of the tab
* The close button is always visbile for the active tab

Co-Authored-By: Nate Butler <nate@zed.dev>

Change summary

gpui/src/elements/container.rs |   8 
zed/assets/themes/_base.toml   |  12 +
zed/src/theme.rs               |   5 
zed/src/workspace/pane.rs      | 245 +++++++++++++++++------------------
4 files changed, 133 insertions(+), 137 deletions(-)

Detailed changes

gpui/src/elements/container.rs 🔗

@@ -244,10 +244,10 @@ impl ToJson for ContainerStyle {
 
 #[derive(Clone, Debug, Default)]
 pub struct Margin {
-    top: f32,
-    left: f32,
-    bottom: f32,
-    right: f32,
+    pub top: f32,
+    pub left: f32,
+    pub bottom: f32,
+    pub right: f32,
 }
 
 impl ToJson for Margin {

zed/assets/themes/_base.toml 🔗

@@ -10,9 +10,13 @@ border = { width = 1, bottom = true, color = "$surface.1" }
 [workspace.tab]
 text = "$text.2"
 padding = { left = 10, right = 10 }
-icon_close = "$text.0.color"
+icon_width = 12
+spacing = 6
+icon_close = "$text.2.color"
+icon_close_active = "$text.0.color"
 icon_dirty = "$status.info"
 icon_conflict = "$status.warn"
+border = { left = true, bottom = true, width = 1, color = "$border.0" }
 
 [workspace.active_tab]
 extends = "$workspace.tab"
@@ -21,7 +25,7 @@ text = "$text.0"
 
 [workspace.sidebar]
 padding = { left = 10, right = 10 }
-border = { right = true, width = 1, color = "$border.0"}
+border = { right = true, width = 1, color = "$border.0" }
 
 [workspace.sidebar.resize_handle]
 padding = { left = 1 }
@@ -46,7 +50,7 @@ border = { width = 1, color = "$surface.1", left = true }
 [chat_panel]
 channel_name = { extends = "$text.0", weight = "bold" }
 channel_name_hash = { text = "$text.2", padding.right = 5 }
-border = { left = true, width = 1, color = "$border.0"}
+border = { left = true, width = 1, color = "$border.0" }
 padding = 10
 
 [chat_panel.message]
@@ -111,7 +115,7 @@ margin.top = 12
 corner_radius = 6
 shadow = { offset = [0, 2], blur = 16, color = "$shadow.0" }
 input_editor = "$chat_panel.input_editor"
-border = { width = 1, color = "$border.0"}
+border = { width = 1, color = "$border.0" }
 
 [selector.item]
 text = "$text.1"

zed/src/theme.rs 🔗

@@ -40,13 +40,16 @@ pub struct Workspace {
     pub right_sidebar: Sidebar,
 }
 
-#[derive(Deserialize)]
+#[derive(Clone, Deserialize)]
 pub struct Tab {
     #[serde(flatten)]
     pub container: ContainerStyle,
     #[serde(flatten)]
     pub label: LabelStyle,
+    pub spacing: f32,
+    pub icon_width: f32,
     pub icon_close: Color,
+    pub icon_close_active: Color,
     pub icon_dirty: Color,
     pub icon_conflict: Color,
 }

zed/src/workspace/pane.rs 🔗

@@ -1,5 +1,5 @@
 use super::{ItemViewHandle, SplitDirection};
-use crate::{settings::Settings, theme};
+use crate::settings::Settings;
 use gpui::{
     action,
     color::Color,
@@ -55,6 +55,8 @@ pub enum Event {
     Split(SplitDirection),
 }
 
+const MAX_TAB_TITLE_LEN: usize = 24;
+
 #[derive(Debug, Eq, PartialEq)]
 pub struct State {
     pub tabs: Vec<TabState>,
@@ -183,82 +185,137 @@ impl Pane {
         );
 
         let mut row = Flex::row();
-        let last_item_ix = self.items.len() - 1;
         for (ix, item) in self.items.iter().enumerate() {
             let is_active = ix == self.active_item;
 
             enum Tab {}
-            let border = &theme.workspace.tab.container.border;
-
             row.add_child(
-                Flexible::new(
-                    1.0,
-                    MouseEventHandler::new::<Tab, _, _, _>(item.id(), cx, |mouse_state, cx| {
-                        let title = item.title(cx);
-
-                        let mut border = border.clone();
-                        border.left = ix > 0;
-                        border.right = ix == last_item_ix;
-                        border.bottom = !is_active;
-
-                        let mut container = Container::new(
-                            Stack::new()
+                MouseEventHandler::new::<Tab, _, _, _>(item.id(), cx, |mouse_state, cx| {
+                    let mut title = item.title(cx);
+                    if title.len() > MAX_TAB_TITLE_LEN {
+                        let mut truncated_len = MAX_TAB_TITLE_LEN;
+                        while !title.is_char_boundary(truncated_len) {
+                            truncated_len -= 1;
+                        }
+                        title.truncate(truncated_len);
+                        title.push('…');
+                    }
+
+                    let mut style = theme.workspace.tab.clone();
+                    if is_active {
+                        style = theme.workspace.active_tab.clone();
+                        style.container.border.bottom = false;
+                        style.container.padding.bottom += style.container.border.width;
+                    }
+                    if ix == 0 {
+                        style.container.border.left = false;
+                    }
+
+                    EventHandler::new(
+                        Container::new(
+                            Flex::row()
                                 .with_child(
-                                    Align::new(
-                                        Label::new(
-                                            title,
-                                            if is_active {
-                                                theme.workspace.active_tab.label.clone()
-                                            } else {
-                                                theme.workspace.tab.label.clone()
-                                            },
+                                    Align::new({
+                                        let diameter = 6.0;
+                                        let icon_color = if item.has_conflict(cx) {
+                                            Some(style.icon_conflict)
+                                        } else if item.is_dirty(cx) {
+                                            Some(style.icon_dirty)
+                                        } else {
+                                            None
+                                        };
+
+                                        ConstrainedBox::new(
+                                            Canvas::new(move |bounds, _, cx| {
+                                                if let Some(color) = icon_color {
+                                                    let square = RectF::new(
+                                                        bounds.origin(),
+                                                        vec2f(diameter, diameter),
+                                                    );
+                                                    cx.scene.push_quad(Quad {
+                                                        bounds: square,
+                                                        background: Some(color),
+                                                        border: Default::default(),
+                                                        corner_radius: diameter / 2.,
+                                                    });
+                                                }
+                                            })
+                                            .boxed(),
+                                        )
+                                        .with_width(diameter)
+                                        .with_height(diameter)
+                                        .boxed()
+                                    })
+                                    .boxed(),
+                                )
+                                .with_child(
+                                    Container::new(
+                                        Align::new(
+                                            Label::new(
+                                                title,
+                                                if is_active {
+                                                    theme.workspace.active_tab.label.clone()
+                                                } else {
+                                                    theme.workspace.tab.label.clone()
+                                                },
+                                            )
+                                            .boxed(),
                                         )
                                         .boxed(),
                                     )
+                                    .with_style(&ContainerStyle {
+                                        margin: Margin {
+                                            left: style.spacing,
+                                            right: style.spacing,
+                                            ..Default::default()
+                                        },
+                                        ..Default::default()
+                                    })
                                     .boxed(),
                                 )
                                 .with_child(
-                                    Align::new(Self::render_tab_icon(
-                                        item.id(),
-                                        line_height - 2.,
-                                        mouse_state.hovered,
-                                        item.is_dirty(cx),
-                                        item.has_conflict(cx),
-                                        theme,
-                                        cx,
-                                    ))
-                                    .right()
+                                    Align::new(
+                                        ConstrainedBox::new(if is_active || mouse_state.hovered {
+                                            let item_id = item.id();
+                                            enum TabCloseButton {}
+                                            let icon = Svg::new("icons/x.svg");
+                                            MouseEventHandler::new::<TabCloseButton, _, _, _>(
+                                                item_id,
+                                                cx,
+                                                |mouse_state, _| {
+                                                    if mouse_state.hovered {
+                                                        icon.with_color(style.icon_close_active)
+                                                            .boxed()
+                                                    } else {
+                                                        icon.with_color(style.icon_close).boxed()
+                                                    }
+                                                },
+                                            )
+                                            .on_click(move |cx| {
+                                                cx.dispatch_action(CloseItem(item_id))
+                                            })
+                                            .named("close-tab-icon")
+                                        } else {
+                                            Empty::new().boxed()
+                                        })
+                                        .with_width(style.icon_width)
+                                        .boxed(),
+                                    )
                                     .boxed(),
                                 )
                                 .boxed(),
                         )
-                        .with_style(if is_active {
-                            &theme.workspace.active_tab.container
-                        } else {
-                            &theme.workspace.tab.container
-                        })
-                        .with_border(border);
-
-                        if is_active {
-                            container = container.with_padding_bottom(border.width);
-                        }
-
-                        ConstrainedBox::new(
-                            EventHandler::new(container.boxed())
-                                .on_mouse_down(move |cx| {
-                                    cx.dispatch_action(ActivateItem(ix));
-                                    true
-                                })
-                                .boxed(),
-                        )
-                        .with_min_width(80.0)
-                        .with_max_width(264.0)
-                        .boxed()
+                        .with_style(&style.container)
+                        .boxed(),
+                    )
+                    .on_mouse_down(move |cx| {
+                        cx.dispatch_action(ActivateItem(ix));
+                        true
                     })
-                    .boxed(),
-                )
-                .named("tab"),
-            );
+                    .boxed()
+                })
+                .boxed(),
+            )
         }
 
         // Ensure there's always a minimum amount of space after the last tab,
@@ -290,74 +347,6 @@ impl Pane {
             .with_height(line_height + 16.)
             .named("tabs")
     }
-
-    fn render_tab_icon(
-        item_id: usize,
-        close_icon_size: f32,
-        tab_hovered: bool,
-        is_dirty: bool,
-        has_conflict: bool,
-        theme: &theme::Theme,
-        cx: &mut RenderContext<Self>,
-    ) -> ElementBox {
-        enum TabCloseButton {}
-
-        let mut clicked_color = theme.workspace.tab.icon_dirty;
-        clicked_color.a = 180;
-
-        let current_color = if has_conflict {
-            Some(theme.workspace.tab.icon_conflict)
-        } else if is_dirty {
-            Some(theme.workspace.tab.icon_dirty)
-        } else {
-            None
-        };
-
-        let icon = if tab_hovered {
-            let close_color = current_color.unwrap_or(theme.workspace.tab.icon_close);
-            let icon = Svg::new("icons/x.svg").with_color(close_color);
-
-            MouseEventHandler::new::<TabCloseButton, _, _, _>(item_id, cx, |mouse_state, _| {
-                if mouse_state.hovered {
-                    Container::new(icon.with_color(Color::white()).boxed())
-                        .with_background_color(if mouse_state.clicked {
-                            clicked_color
-                        } else {
-                            theme.workspace.tab.icon_dirty
-                        })
-                        .with_corner_radius(close_icon_size / 2.)
-                        .boxed()
-                } else {
-                    icon.boxed()
-                }
-            })
-            .on_click(move |cx| cx.dispatch_action(CloseItem(item_id)))
-            .named("close-tab-icon")
-        } else {
-            let diameter = 8.;
-            ConstrainedBox::new(
-                Canvas::new(move |bounds, _, cx| {
-                    if let Some(current_color) = current_color {
-                        let square = RectF::new(bounds.origin(), vec2f(diameter, diameter));
-                        cx.scene.push_quad(Quad {
-                            bounds: square,
-                            background: Some(current_color),
-                            border: Default::default(),
-                            corner_radius: diameter / 2.,
-                        });
-                    }
-                })
-                .boxed(),
-            )
-            .with_width(diameter)
-            .with_height(diameter)
-            .named("unsaved-tab-icon")
-        };
-
-        ConstrainedBox::new(Align::new(icon).boxed())
-            .with_width(close_icon_size)
-            .named("tab-icon")
-    }
 }
 
 impl Entity for Pane {