Improve styling of tabs

Max Brunsfeld and Nathan Sobo created

* Enforce a min width per tab
* Center the title within tab, regardless of icon
* Render icon over the top of the tab title
* Ensure there is always a fixed minimum amount of filler to the right of all tabs

Co-Authored-By: Nathan Sobo <nathan@zed.dev>

Change summary

gpui/src/elements/align.rs           | 11 ++
gpui/src/elements/constrained_box.rs | 12 +++
gpui/src/elements/container.rs       | 12 +++
gpui/src/elements/flex.rs            | 10 ++
zed/src/file_finder.rs               |  2 
zed/src/workspace/pane.rs            | 93 +++++++++++++++++------------
6 files changed, 95 insertions(+), 45 deletions(-)

Detailed changes

gpui/src/elements/align.rs 🔗

@@ -3,7 +3,7 @@ use crate::{
     LayoutContext, PaintContext, SizeConstraint,
 };
 use json::ToJson;
-use pathfinder_geometry::vector::{vec2f, Vector2F};
+use pathfinder_geometry::vector::Vector2F;
 use serde_json::json;
 
 pub struct Align {
@@ -19,8 +19,13 @@ impl Align {
         }
     }
 
-    pub fn top_center(mut self) -> Self {
-        self.alignment = vec2f(0.0, -1.0);
+    pub fn top(mut self) -> Self {
+        self.alignment.set_y(-1.0);
+        self
+    }
+
+    pub fn right(mut self) -> Self {
+        self.alignment.set_x(1.0);
         self
     }
 }

gpui/src/elements/constrained_box.rs 🔗

@@ -23,6 +23,11 @@ impl ConstrainedBox {
         }
     }
 
+    pub fn with_min_width(mut self, min_width: f32) -> Self {
+        self.constraint.min.set_x(min_width);
+        self
+    }
+
     pub fn with_max_width(mut self, max_width: f32) -> Self {
         self.constraint.max.set_x(max_width);
         self
@@ -33,6 +38,12 @@ impl ConstrainedBox {
         self
     }
 
+    pub fn with_width(mut self, width: f32) -> Self {
+        self.constraint.min.set_x(width);
+        self.constraint.max.set_x(width);
+        self
+    }
+
     pub fn with_height(mut self, height: f32) -> Self {
         self.constraint.min.set_y(height);
         self.constraint.max.set_y(height);
@@ -51,6 +62,7 @@ impl Element for ConstrainedBox {
     ) -> (Vector2F, Self::LayoutState) {
         constraint.min = constraint.min.max(self.constraint.min);
         constraint.max = constraint.max.min(self.constraint.max);
+        constraint.max = constraint.max.max(constraint.min);
         let size = self.child.layout(constraint, ctx);
         (size, ())
     }

gpui/src/elements/container.rs 🔗

@@ -43,6 +43,18 @@ impl Container {
         self
     }
 
+    pub fn with_horizontal_padding(mut self, padding: f32) -> Self {
+        self.padding.left = padding;
+        self.padding.right = padding;
+        self
+    }
+
+    pub fn with_vertical_padding(mut self, padding: f32) -> Self {
+        self.padding.top = padding;
+        self.padding.bottom = padding;
+        self
+    }
+
     pub fn with_uniform_padding(mut self, padding: f32) -> Self {
         self.padding = Padding {
             top: padding,

gpui/src/elements/flex.rs 🔗

@@ -1,4 +1,4 @@
-use std::any::Any;
+use std::{any::Any, f32::INFINITY};
 
 use crate::{
     json::{self, ToJson, Value},
@@ -88,9 +88,13 @@ impl Element for Flex {
             let mut remaining_space = constraint.max_along(self.axis) - fixed_space;
             let mut remaining_flex = total_flex;
             for child in &mut self.children {
-                let space_per_flex = remaining_space / remaining_flex;
                 if let Some(flex) = Self::child_flex(&child) {
-                    let child_max = space_per_flex * flex;
+                    let child_max = if remaining_flex == 0.0 {
+                        remaining_space
+                    } else {
+                        let space_per_flex = remaining_space / remaining_flex;
+                        space_per_flex * flex
+                    };
                     let child_constraint = match self.axis {
                         Axis::Horizontal => SizeConstraint::new(
                             vec2f(0.0, constraint.min.y()),

zed/src/file_finder.rs 🔗

@@ -77,7 +77,7 @@ impl View for FileFinder {
             .with_max_height(400.0)
             .boxed(),
         )
-        .top_center()
+        .top()
         .named("file finder")
     }
 

zed/src/workspace/pane.rs 🔗

@@ -192,33 +192,28 @@ impl Pane {
 
             let padding = 6.;
             let mut container = Container::new(
-                Align::new(
-                    Flex::row()
-                        .with_child(
+                Stack::new()
+                    .with_child(
+                        Align::new(
                             Label::new(title, settings.ui_font_family, settings.ui_font_size)
                                 .boxed(),
                         )
-                        .with_child(
-                            Container::new(
-                                LineBox::new(
-                                    settings.ui_font_family,
-                                    settings.ui_font_size,
-                                    ConstrainedBox::new(Self::render_modified_icon(
-                                        item.is_dirty(app),
-                                    ))
-                                    .with_max_width(12.)
-                                    .boxed(),
-                                )
+                        .boxed(),
+                    )
+                    .with_child(
+                        LineBox::new(
+                            settings.ui_font_family,
+                            settings.ui_font_size,
+                            Align::new(Self::render_modified_icon(item.is_dirty(app)))
+                                .right()
                                 .boxed(),
-                            )
-                            .with_margin_left(20.)
-                            .boxed(),
                         )
                         .boxed(),
-                )
-                .boxed(),
+                    )
+                    .boxed(),
             )
-            .with_uniform_padding(padding)
+            .with_vertical_padding(padding)
+            .with_horizontal_padding(10.)
             .with_border(border);
 
             if ix == self.active_item {
@@ -240,6 +235,7 @@ impl Pane {
                             })
                             .boxed(),
                     )
+                    .with_min_width(80.0)
                     .with_max_width(264.0)
                     .boxed(),
                 )
@@ -247,9 +243,29 @@ impl Pane {
             );
         }
 
+        // Ensure there's always a minimum amount of space after the last tab,
+        // so that the tab's border doesn't abut the window's border.
+        row.add_child(
+            ConstrainedBox::new(
+                Container::new(
+                    LineBox::new(
+                        settings.ui_font_family,
+                        settings.ui_font_size,
+                        Empty::new().boxed(),
+                    )
+                    .boxed(),
+                )
+                .with_uniform_padding(6.0)
+                .with_border(Border::bottom(1.0, border_color))
+                .boxed(),
+            )
+            .with_min_width(20.)
+            .named("fixed-filler"),
+        );
+
         row.add_child(
             Expanded::new(
-                1.0,
+                0.0,
                 Container::new(
                     LineBox::new(
                         settings.ui_font_family,
@@ -269,23 +285,24 @@ impl Pane {
     }
 
     fn render_modified_icon(is_modified: bool) -> ElementBox {
-        Canvas::new(move |bounds, ctx| {
-            if is_modified {
-                let padding = if bounds.height() < bounds.width() {
-                    vec2f(bounds.width() - bounds.height(), 0.0)
-                } else {
-                    vec2f(0.0, bounds.height() - bounds.width())
-                };
-                let square = RectF::new(bounds.origin() + padding / 2., bounds.size() - padding);
-                ctx.scene.push_quad(Quad {
-                    bounds: square,
-                    background: Some(ColorF::new(0.639, 0.839, 1.0, 1.0).to_u8()),
-                    border: Default::default(),
-                    corner_radius: square.width() / 2.,
-                });
-            }
-        })
-        .boxed()
+        let diameter = 8.;
+        ConstrainedBox::new(
+            Canvas::new(move |bounds, ctx| {
+                if is_modified {
+                    let square = RectF::new(bounds.origin(), vec2f(diameter, diameter));
+                    ctx.scene.push_quad(Quad {
+                        bounds: square,
+                        background: Some(ColorF::new(0.639, 0.839, 1.0, 1.0).to_u8()),
+                        border: Default::default(),
+                        corner_radius: diameter / 2.,
+                    });
+                }
+            })
+            .boxed(),
+        )
+        .with_width(diameter)
+        .with_height(diameter)
+        .named("tab-right-icon")
     }
 }