ui: Refine `TitleBar` component (#9415)

Marshall Bowers created

This PR continues the refinements to the `TitleBar` component.

Here are the notable changes:

- `KeyBindingDisplay` and `PlatformStyle` have been unified into a
single `PlatformStyle`.
- This provides us a consistent way for adapting UI to different
platform styles.
- `PlatformTitlebar` has been renamed to `TitleBar`.
  - The `Platform` prefix was irrelevant.
- The Windows window controls have been factored out into a separate
module and have been componentized.

<img width="1283" alt="Screenshot 2024-03-15 at 3 34 38 PM"
src="https://github.com/zed-industries/zed/assets/1486634/07da391f-828b-48bf-8849-58863f4ccce7">

> I'm missing the Segoe Fluent Icons font, so that's why the aren't
rendering properly.

Release Notes:

- N/A

Change summary

crates/collab_ui/src/collab_titlebar_item.rs                  |   5 
crates/storybook/src/story_selector.rs                        |   4 
crates/ui/src/components.rs                                   |   4 
crates/ui/src/components/keybinding.rs                        |  90 -
crates/ui/src/components/platform_titlebar.rs                 | 215 -----
crates/ui/src/components/stories.rs                           |   4 
crates/ui/src/components/stories/keybinding.rs                |  22 
crates/ui/src/components/stories/platform_titlebar.rs         |  46 -
crates/ui/src/components/stories/title_bar.rs                 |  56 +
crates/ui/src/components/title_bar.rs                         |   4 
crates/ui/src/components/title_bar/title_bar.rs               |  96 ++
crates/ui/src/components/title_bar/windows_window_controls.rs | 127 ++
crates/ui/src/prelude.rs                                      |   2 
crates/ui/src/styles.rs                                       |   2 
crates/ui/src/styles/platform.rs                              |  25 
crates/workspace/src/workspace.rs                             |   2 
16 files changed, 369 insertions(+), 335 deletions(-)

Detailed changes

crates/collab_ui/src/collab_titlebar_item.rs 🔗

@@ -14,7 +14,7 @@ use std::sync::Arc;
 use theme::ActiveTheme;
 use ui::{
     h_flex, popover_menu, prelude::*, Avatar, AvatarAudioStatusIndicator, Button, ButtonLike,
-    ButtonStyle, ContextMenu, Icon, IconButton, IconName, PlatformTitlebar, TintColor, Tooltip,
+    ButtonStyle, ContextMenu, Icon, IconButton, IconName, TintColor, TitleBar, Tooltip,
 };
 use util::ResultExt;
 use vcs_menu::{build_branch_list, BranchList, OpenRecent as ToggleVcsMenu};
@@ -58,8 +58,7 @@ impl Render for CollabTitlebarItem {
         let project_id = self.project.read(cx).remote_id();
         let workspace = self.workspace.upgrade();
 
-        PlatformTitlebar::new("collab-titlebar")
-            .background(cx.theme().colors().title_bar_background)
+        TitleBar::new("collab-titlebar")
             // note: on windows titlebar behaviour is handled by the platform implementation
             .when(cfg!(not(windows)), |this| {
                 this.on_click(|event, cx| {

crates/storybook/src/story_selector.rs 🔗

@@ -29,10 +29,10 @@ pub enum ComponentStory {
     ListHeader,
     ListItem,
     OverflowScroll,
-    PlatformTitlebar,
     Scroll,
     Tab,
     TabBar,
+    TitleBar,
     ToggleButton,
     Text,
     ViewportUnits,
@@ -61,11 +61,11 @@ impl ComponentStory {
             Self::ListHeader => cx.new_view(|_| ui::ListHeaderStory).into(),
             Self::ListItem => cx.new_view(|_| ui::ListItemStory).into(),
             Self::OverflowScroll => cx.new_view(|_| crate::stories::OverflowScrollStory).into(),
-            Self::PlatformTitlebar => cx.new_view(|_| ui::PlatformTitlebarStory).into(),
             Self::Scroll => ScrollStory::view(cx).into(),
             Self::Text => TextStory::view(cx).into(),
             Self::Tab => cx.new_view(|_| ui::TabStory).into(),
             Self::TabBar => cx.new_view(|_| ui::TabBarStory).into(),
+            Self::TitleBar => cx.new_view(|_| ui::TitleBarStory).into(),
             Self::ToggleButton => cx.new_view(|_| ui::ToggleButtonStory).into(),
             Self::ViewportUnits => cx.new_view(|_| crate::stories::ViewportUnitsStory).into(),
             Self::Picker => PickerStory::new(cx).into(),

crates/ui/src/components.rs 🔗

@@ -9,13 +9,13 @@ mod indicator;
 mod keybinding;
 mod label;
 mod list;
-mod platform_titlebar;
 mod popover;
 mod popover_menu;
 mod right_click_menu;
 mod stack;
 mod tab;
 mod tab_bar;
+mod title_bar;
 mod tooltip;
 
 #[cfg(feature = "stories")]
@@ -32,13 +32,13 @@ pub use indicator::*;
 pub use keybinding::*;
 pub use label::*;
 pub use list::*;
-pub use platform_titlebar::*;
 pub use popover::*;
 pub use popover_menu::*;
 pub use right_click_menu::*;
 pub use stack::*;
 pub use tab::*;
 pub use tab_bar::*;
+pub use title_bar::*;
 pub use tooltip::*;
 
 #[cfg(feature = "stories")]

crates/ui/src/components/keybinding.rs 🔗

@@ -1,30 +1,6 @@
 use crate::{h_flex, prelude::*, Icon, IconName, IconSize};
 use gpui::{relative, Action, FocusHandle, IntoElement, Keystroke};
 
-/// The way a [`KeyBinding`] should be displayed.
-#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
-pub enum KeyBindingDisplay {
-    /// Display in macOS style.
-    Mac,
-    /// Display in Linux style.
-    Linux,
-    /// Display in Windows style.
-    Windows,
-}
-
-impl KeyBindingDisplay {
-    /// Returns the [`KeyBindingDisplay`] for the current platform.
-    pub const fn platform() -> Self {
-        if cfg!(target_os = "linux") {
-            KeyBindingDisplay::Linux
-        } else if cfg!(target_os = "windows") {
-            KeyBindingDisplay::Windows
-        } else {
-            KeyBindingDisplay::Mac
-        }
-    }
-}
-
 #[derive(IntoElement, Clone)]
 pub struct KeyBinding {
     /// A keybinding consists of a key and a set of modifier keys.
@@ -33,8 +9,8 @@ pub struct KeyBinding {
     /// This should always contain at least one element.
     key_binding: gpui::KeyBinding,
 
-    /// How keybindings should be displayed.
-    display: KeyBindingDisplay,
+    /// The [`PlatformStyle`] to use when displaying this keybinding.
+    platform_style: PlatformStyle,
 }
 
 impl KeyBinding {
@@ -76,13 +52,13 @@ impl KeyBinding {
     pub fn new(key_binding: gpui::KeyBinding) -> Self {
         Self {
             key_binding,
-            display: KeyBindingDisplay::platform(),
+            platform_style: PlatformStyle::platform(),
         }
     }
 
-    /// Sets how this [`KeyBinding`] should be displayed.
-    pub fn display(mut self, display: KeyBindingDisplay) -> Self {
-        self.display = display;
+    /// Sets the [`PlatformStyle`] for this [`KeyBinding`].
+    pub fn platform_style(mut self, platform_style: PlatformStyle) -> Self {
+        self.platform_style = platform_style;
         self
     }
 }
@@ -97,43 +73,49 @@ impl RenderOnce for KeyBinding {
 
                 h_flex()
                     .flex_none()
-                    .map(|el| match self.display {
-                        KeyBindingDisplay::Mac => el.gap_0p5(),
-                        KeyBindingDisplay::Linux | KeyBindingDisplay::Windows => el,
+                    .map(|el| match self.platform_style {
+                        PlatformStyle::Mac => el.gap_0p5(),
+                        PlatformStyle::Linux | PlatformStyle::Windows => el,
                     })
                     .p_0p5()
                     .rounded_sm()
                     .text_color(cx.theme().colors().text_muted)
-                    .when(keystroke.modifiers.function, |el| match self.display {
-                        KeyBindingDisplay::Mac => el.child(Key::new("fn")),
-                        KeyBindingDisplay::Linux | KeyBindingDisplay::Windows => {
-                            el.child(Key::new("Fn")).child(Key::new("+"))
+                    .when(keystroke.modifiers.function, |el| {
+                        match self.platform_style {
+                            PlatformStyle::Mac => el.child(Key::new("fn")),
+                            PlatformStyle::Linux | PlatformStyle::Windows => {
+                                el.child(Key::new("Fn")).child(Key::new("+"))
+                            }
                         }
                     })
-                    .when(keystroke.modifiers.control, |el| match self.display {
-                        KeyBindingDisplay::Mac => el.child(KeyIcon::new(IconName::Control)),
-                        KeyBindingDisplay::Linux | KeyBindingDisplay::Windows => {
-                            el.child(Key::new("Ctrl")).child(Key::new("+"))
+                    .when(keystroke.modifiers.control, |el| {
+                        match self.platform_style {
+                            PlatformStyle::Mac => el.child(KeyIcon::new(IconName::Control)),
+                            PlatformStyle::Linux | PlatformStyle::Windows => {
+                                el.child(Key::new("Ctrl")).child(Key::new("+"))
+                            }
                         }
                     })
-                    .when(keystroke.modifiers.alt, |el| match self.display {
-                        KeyBindingDisplay::Mac => el.child(KeyIcon::new(IconName::Option)),
-                        KeyBindingDisplay::Linux | KeyBindingDisplay::Windows => {
+                    .when(keystroke.modifiers.alt, |el| match self.platform_style {
+                        PlatformStyle::Mac => el.child(KeyIcon::new(IconName::Option)),
+                        PlatformStyle::Linux | PlatformStyle::Windows => {
                             el.child(Key::new("Alt")).child(Key::new("+"))
                         }
                     })
-                    .when(keystroke.modifiers.command, |el| match self.display {
-                        KeyBindingDisplay::Mac => el.child(KeyIcon::new(IconName::Command)),
-                        KeyBindingDisplay::Linux => {
-                            el.child(Key::new("Super")).child(Key::new("+"))
-                        }
-                        KeyBindingDisplay::Windows => {
-                            el.child(Key::new("Win")).child(Key::new("+"))
+                    .when(keystroke.modifiers.command, |el| {
+                        match self.platform_style {
+                            PlatformStyle::Mac => el.child(KeyIcon::new(IconName::Command)),
+                            PlatformStyle::Linux => {
+                                el.child(Key::new("Super")).child(Key::new("+"))
+                            }
+                            PlatformStyle::Windows => {
+                                el.child(Key::new("Win")).child(Key::new("+"))
+                            }
                         }
                     })
-                    .when(keystroke.modifiers.shift, |el| match self.display {
-                        KeyBindingDisplay::Mac => el.child(KeyIcon::new(IconName::Shift)),
-                        KeyBindingDisplay::Linux | KeyBindingDisplay::Windows => {
+                    .when(keystroke.modifiers.shift, |el| match self.platform_style {
+                        PlatformStyle::Mac => el.child(KeyIcon::new(IconName::Shift)),
+                        PlatformStyle::Linux | PlatformStyle::Windows => {
                             el.child(Key::new("Shift")).child(Key::new("+"))
                         }
                     })

crates/ui/src/components/platform_titlebar.rs 🔗

@@ -1,215 +0,0 @@
-use gpui::{transparent_black, AnyElement, Fill, Interactivity, Rgba, Stateful, WindowAppearance};
-use smallvec::SmallVec;
-
-use crate::prelude::*;
-
-pub enum PlatformStyle {
-    Linux,
-    Windows,
-    MacOs,
-}
-
-pub fn titlebar_height(cx: &mut WindowContext) -> Pixels {
-    (1.75 * cx.rem_size()).max(px(32.))
-}
-
-impl PlatformStyle {
-    pub fn platform() -> Self {
-        if cfg!(target_os = "windows") {
-            Self::Windows
-        } else if cfg!(target_os = "macos") {
-            Self::MacOs
-        } else {
-            Self::Linux
-        }
-    }
-
-    pub fn windows(&self) -> bool {
-        matches!(self, Self::Windows)
-    }
-
-    pub fn macos(&self) -> bool {
-        matches!(self, Self::MacOs)
-    }
-}
-
-#[derive(IntoElement)]
-pub struct PlatformTitlebar {
-    platform: PlatformStyle,
-    background: Fill,
-    content: Stateful<Div>,
-    children: SmallVec<[AnyElement; 2]>,
-}
-
-impl PlatformTitlebar {
-    pub fn new(id: impl Into<ElementId>) -> Self {
-        Self {
-            platform: PlatformStyle::platform(),
-            background: transparent_black().into(),
-            content: div().id(id.into()),
-            children: SmallVec::new(),
-        }
-    }
-
-    /// Sets the platform style.
-    pub fn platform_style(mut self, style: PlatformStyle) -> Self {
-        self.platform = style;
-        self
-    }
-
-    /// Sets the background color of the titlebar.
-    pub fn background<F>(mut self, fill: F) -> Self
-    where
-        F: Into<Fill>,
-        Self: Sized,
-    {
-        self.background = fill.into();
-        self
-    }
-
-    fn top_padding(&self, cx: &WindowContext) -> Pixels {
-        if self.platform.windows() && cx.is_maximized() {
-            // todo(windows): get padding from win32 api, need HWND from window context somehow
-            // should be GetSystemMetricsForDpi(SM_CXPADDEDBORDER, dpi) * 2
-            px(8.0)
-        } else {
-            px(0.0)
-        }
-    }
-
-    fn windows_caption_button_width(_cx: &WindowContext) -> Pixels {
-        // todo(windows): get padding from win32 api, need HWND from window context somehow
-        // should be GetSystemMetricsForDpi(SM_CXSIZE, dpi)
-        px(36.0)
-    }
-
-    fn render_window_controls_right(&self, cx: &mut WindowContext) -> impl Element {
-        if !self.platform.windows() {
-            return div().id("caption-buttons-windows");
-        }
-
-        let button_height = titlebar_height(cx) - self.top_padding(cx);
-        let close_button_hover_color = Rgba {
-            r: 232.0 / 255.0,
-            g: 17.0 / 255.0,
-            b: 32.0 / 255.0,
-            a: 1.0,
-        };
-
-        let button_hover_color = match cx.appearance() {
-            WindowAppearance::Light | WindowAppearance::VibrantLight => Rgba {
-                r: 0.1,
-                g: 0.1,
-                b: 0.1,
-                a: 0.2,
-            },
-            WindowAppearance::Dark | WindowAppearance::VibrantDark => Rgba {
-                r: 0.9,
-                g: 0.9,
-                b: 0.9,
-                a: 0.1,
-            },
-        };
-
-        fn windows_caption_button(
-            id: &'static str,
-            icon_text: &'static str,
-            hover_color: Rgba,
-            cx: &WindowContext,
-        ) -> Stateful<Div> {
-            let mut active_color = hover_color;
-            active_color.a *= 0.2;
-            h_flex()
-                .id(id)
-                .h_full()
-                .justify_center()
-                .content_center()
-                .items_center()
-                .w(PlatformTitlebar::windows_caption_button_width(cx))
-                .hover(|style| style.bg(hover_color))
-                .active(|style| style.bg(active_color))
-                .child(icon_text)
-        }
-
-        const MINIMIZE_ICON: &str = "\u{e921}";
-        const RESTORE_ICON: &str = "\u{e923}";
-        const MAXIMIZE_ICON: &str = "\u{e922}";
-        const CLOSE_ICON: &str = "\u{e8bb}";
-
-        div()
-            .id("caption-buttons-windows")
-            .flex()
-            .flex_row()
-            .justify_center()
-            .content_stretch()
-            .max_h(button_height)
-            .min_h(button_height)
-            .font("Segoe Fluent Icons")
-            .text_size(px(10.0))
-            .children(vec![
-                windows_caption_button("minimize", MINIMIZE_ICON, button_hover_color, cx),
-                windows_caption_button(
-                    "maximize",
-                    if cx.is_maximized() {
-                        RESTORE_ICON
-                    } else {
-                        MAXIMIZE_ICON
-                    },
-                    button_hover_color,
-                    cx,
-                ),
-                windows_caption_button("close", CLOSE_ICON, close_button_hover_color, cx),
-            ])
-    }
-}
-
-impl RenderOnce for PlatformTitlebar {
-    fn render(self, cx: &mut WindowContext) -> impl IntoElement {
-        let titlebar_height = titlebar_height(cx);
-        let titlebar_top_padding = self.top_padding(cx);
-        let window_controls_right = self.render_window_controls_right(cx);
-
-        h_flex()
-            .id("titlebar")
-            .w_full()
-            .pt(titlebar_top_padding)
-            .h(titlebar_height)
-            .map(|this| {
-                if cx.is_fullscreen() {
-                    this.pl_2()
-                } else if self.platform.macos() {
-                    // Use pixels here instead of a rem-based size because the macOS traffic
-                    // lights are a static size, and don't scale with the rest of the UI.
-                    this.pl(px(80.))
-                } else {
-                    this.pl_2()
-                }
-            })
-            .bg(self.background)
-            .content_stretch()
-            .child(
-                self.content
-                    .id("titlebar-content")
-                    .flex()
-                    .flex_row()
-                    .justify_between()
-                    .w_full()
-                    .children(self.children),
-            )
-            .child(window_controls_right)
-    }
-}
-
-impl InteractiveElement for PlatformTitlebar {
-    fn interactivity(&mut self) -> &mut Interactivity {
-        self.content.interactivity()
-    }
-}
-
-impl StatefulInteractiveElement for PlatformTitlebar {}
-
-impl ParentElement for PlatformTitlebar {
-    fn extend(&mut self, elements: impl Iterator<Item = AnyElement>) {
-        self.children.extend(elements)
-    }
-}

crates/ui/src/components/stories.rs 🔗

@@ -10,9 +10,9 @@ mod label;
 mod list;
 mod list_header;
 mod list_item;
-mod platform_titlebar;
 mod tab;
 mod tab_bar;
+mod title_bar;
 mod toggle_button;
 
 pub use avatar::*;
@@ -27,7 +27,7 @@ pub use label::*;
 pub use list::*;
 pub use list_header::*;
 pub use list_item::*;
-pub use platform_titlebar::*;
 pub use tab::*;
 pub use tab_bar::*;
+pub use title_bar::*;
 pub use toggle_button::*;

crates/ui/src/components/stories/keybinding.rs 🔗

@@ -3,7 +3,7 @@ use gpui::Render;
 use itertools::Itertools;
 use story::{Story, StoryContainer};
 
-use crate::{prelude::*, KeyBinding, KeyBindingDisplay};
+use crate::{prelude::*, KeyBinding};
 
 pub struct KeybindingStory;
 
@@ -59,18 +59,22 @@ impl Render for KeybindingStory {
         .child(KeyBinding::new(binding("ctrl-a shift-z")))
         .child(KeyBinding::new(binding("fn-s")))
         .child(Story::label("Single Key with All Modifiers (Linux)"))
-        .child(KeyBinding::new(binding("ctrl-alt-cmd-shift-z")).display(KeyBindingDisplay::Linux))
+        .child(
+            KeyBinding::new(binding("ctrl-alt-cmd-shift-z")).platform_style(PlatformStyle::Linux),
+        )
         .child(Story::label("Chord (Linux)"))
-        .child(KeyBinding::new(binding("a z")).display(KeyBindingDisplay::Linux))
+        .child(KeyBinding::new(binding("a z")).platform_style(PlatformStyle::Linux))
         .child(Story::label("Chord with Modifier (Linux)"))
-        .child(KeyBinding::new(binding("ctrl-a shift-z")).display(KeyBindingDisplay::Linux))
-        .child(KeyBinding::new(binding("fn-s")).display(KeyBindingDisplay::Linux))
+        .child(KeyBinding::new(binding("ctrl-a shift-z")).platform_style(PlatformStyle::Linux))
+        .child(KeyBinding::new(binding("fn-s")).platform_style(PlatformStyle::Linux))
         .child(Story::label("Single Key with All Modifiers (Windows)"))
-        .child(KeyBinding::new(binding("ctrl-alt-cmd-shift-z")).display(KeyBindingDisplay::Windows))
+        .child(
+            KeyBinding::new(binding("ctrl-alt-cmd-shift-z")).platform_style(PlatformStyle::Windows),
+        )
         .child(Story::label("Chord (Windows)"))
-        .child(KeyBinding::new(binding("a z")).display(KeyBindingDisplay::Windows))
+        .child(KeyBinding::new(binding("a z")).platform_style(PlatformStyle::Windows))
         .child(Story::label("Chord with Modifier (Windows)"))
-        .child(KeyBinding::new(binding("ctrl-a shift-z")).display(KeyBindingDisplay::Windows))
-        .child(KeyBinding::new(binding("fn-s")).display(KeyBindingDisplay::Windows))
+        .child(KeyBinding::new(binding("ctrl-a shift-z")).platform_style(PlatformStyle::Windows))
+        .child(KeyBinding::new(binding("fn-s")).platform_style(PlatformStyle::Windows))
     }
 }

crates/ui/src/components/stories/platform_titlebar.rs 🔗

@@ -1,46 +0,0 @@
-use gpui::Render;
-use story::{StoryContainer, StoryItem, StorySection};
-
-use crate::{prelude::*, PlatformStyle, PlatformTitlebar};
-
-pub struct PlatformTitlebarStory;
-
-impl Render for PlatformTitlebarStory {
-    fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
-        StoryContainer::new(
-            "Platform Titlebar",
-            "crates/ui/src/components/stories/platform_titlebar.rs",
-        )
-        .child(
-            StorySection::new().child(
-                StoryItem::new(
-                    "Default (macOS)",
-                    PlatformTitlebar::new("macos").platform_style(PlatformStyle::MacOs),
-                )
-                .description("")
-                .usage(""),
-            ),
-        )
-        .child(
-            StorySection::new().child(
-                StoryItem::new(
-                    "Default (Linux)",
-                    PlatformTitlebar::new("linux").platform_style(PlatformStyle::Linux),
-                )
-                .description("")
-                .usage(""),
-            ),
-        )
-        .child(
-            StorySection::new().child(
-                StoryItem::new(
-                    "Default (Windows)",
-                    PlatformTitlebar::new("windows").platform_style(PlatformStyle::Windows),
-                )
-                .description("")
-                .usage(""),
-            ),
-        )
-        .into_element()
-    }
-}

crates/ui/src/components/stories/title_bar.rs 🔗

@@ -0,0 +1,56 @@
+use gpui::Render;
+use story::{StoryContainer, StoryItem, StorySection};
+
+use crate::{prelude::*, PlatformStyle, TitleBar};
+
+pub struct TitleBarStory;
+
+impl Render for TitleBarStory {
+    fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
+        fn add_sample_children(titlebar: TitleBar) -> TitleBar {
+            titlebar
+                .child(div().size_2().bg(gpui::red()))
+                .child(div().size_2().bg(gpui::blue()))
+                .child(div().size_2().bg(gpui::green()))
+        }
+
+        StoryContainer::new("TitleBar", "crates/ui/src/components/stories/title_bar.rs")
+            .child(
+                StorySection::new().child(
+                    StoryItem::new(
+                        "Default (macOS)",
+                        TitleBar::new("macos")
+                            .platform_style(PlatformStyle::Mac)
+                            .map(add_sample_children),
+                    )
+                    .description("")
+                    .usage(""),
+                ),
+            )
+            .child(
+                StorySection::new().child(
+                    StoryItem::new(
+                        "Default (Linux)",
+                        TitleBar::new("linux")
+                            .platform_style(PlatformStyle::Linux)
+                            .map(add_sample_children),
+                    )
+                    .description("")
+                    .usage(""),
+                ),
+            )
+            .child(
+                StorySection::new().child(
+                    StoryItem::new(
+                        "Default (Windows)",
+                        TitleBar::new("windows")
+                            .platform_style(PlatformStyle::Windows)
+                            .map(add_sample_children),
+                    )
+                    .description("")
+                    .usage(""),
+                ),
+            )
+            .into_element()
+    }
+}

crates/ui/src/components/title_bar/title_bar.rs 🔗

@@ -0,0 +1,96 @@
+use gpui::{AnyElement, Interactivity, Stateful};
+use smallvec::SmallVec;
+
+use crate::components::title_bar::windows_window_controls::WindowsWindowControls;
+use crate::prelude::*;
+
+#[derive(IntoElement)]
+pub struct TitleBar {
+    platform_style: PlatformStyle,
+    content: Stateful<Div>,
+    children: SmallVec<[AnyElement; 2]>,
+}
+
+impl TitleBar {
+    pub fn height(cx: &mut WindowContext) -> Pixels {
+        (1.75 * cx.rem_size()).max(px(32.))
+    }
+
+    pub fn new(id: impl Into<ElementId>) -> Self {
+        Self {
+            platform_style: PlatformStyle::platform(),
+            content: div().id(id.into()),
+            children: SmallVec::new(),
+        }
+    }
+
+    /// Sets the platform style.
+    pub fn platform_style(mut self, style: PlatformStyle) -> Self {
+        self.platform_style = style;
+        self
+    }
+
+    fn top_padding(&self, cx: &WindowContext) -> Pixels {
+        if self.platform_style == PlatformStyle::Windows && cx.is_maximized() {
+            // todo(windows): get padding from win32 api, need HWND from window context somehow
+            // should be GetSystemMetricsForDpi(SM_CXPADDEDBORDER, dpi) * 2
+            px(8.)
+        } else {
+            px(0.)
+        }
+    }
+}
+
+impl InteractiveElement for TitleBar {
+    fn interactivity(&mut self) -> &mut Interactivity {
+        self.content.interactivity()
+    }
+}
+
+impl StatefulInteractiveElement for TitleBar {}
+
+impl ParentElement for TitleBar {
+    fn extend(&mut self, elements: impl Iterator<Item = AnyElement>) {
+        self.children.extend(elements)
+    }
+}
+
+impl RenderOnce for TitleBar {
+    fn render(self, cx: &mut WindowContext) -> impl IntoElement {
+        let height = Self::height(cx);
+        let top_padding = self.top_padding(cx);
+
+        h_flex()
+            .id("titlebar")
+            .w_full()
+            .pt(top_padding)
+            .h(height)
+            .map(|this| {
+                if cx.is_fullscreen() {
+                    this.pl_2()
+                } else if self.platform_style == PlatformStyle::Mac {
+                    // Use pixels here instead of a rem-based size because the macOS traffic
+                    // lights are a static size, and don't scale with the rest of the UI.
+                    this.pl(px(80.))
+                } else {
+                    this.pl_2()
+                }
+            })
+            .bg(cx.theme().colors().title_bar_background)
+            .content_stretch()
+            .child(
+                self.content
+                    .id("titlebar-content")
+                    .flex()
+                    .flex_row()
+                    .justify_between()
+                    .w_full()
+                    .children(self.children),
+            )
+            .when(self.platform_style == PlatformStyle::Windows, |title_bar| {
+                let button_height = Self::height(cx) - top_padding;
+
+                title_bar.child(WindowsWindowControls::new(button_height))
+            })
+    }
+}

crates/ui/src/components/title_bar/windows_window_controls.rs 🔗

@@ -0,0 +1,127 @@
+use gpui::{prelude::*, Rgba, WindowAppearance};
+
+use crate::prelude::*;
+
+#[derive(IntoElement)]
+pub struct WindowsWindowControls {
+    button_height: Pixels,
+}
+
+impl WindowsWindowControls {
+    pub fn new(button_height: Pixels) -> Self {
+        Self { button_height }
+    }
+}
+
+impl RenderOnce for WindowsWindowControls {
+    fn render(self, cx: &mut WindowContext) -> impl IntoElement {
+        let close_button_hover_color = Rgba {
+            r: 232.0 / 255.0,
+            g: 17.0 / 255.0,
+            b: 32.0 / 255.0,
+            a: 1.0,
+        };
+
+        let button_hover_color = match cx.appearance() {
+            WindowAppearance::Light | WindowAppearance::VibrantLight => Rgba {
+                r: 0.1,
+                g: 0.1,
+                b: 0.1,
+                a: 0.2,
+            },
+            WindowAppearance::Dark | WindowAppearance::VibrantDark => Rgba {
+                r: 0.9,
+                g: 0.9,
+                b: 0.9,
+                a: 0.1,
+            },
+        };
+
+        div()
+            .id("windows-window-controls")
+            .flex()
+            .flex_row()
+            .justify_center()
+            .content_stretch()
+            .max_h(self.button_height)
+            .min_h(self.button_height)
+            .font("Segoe Fluent Icons")
+            .text_size(px(10.0))
+            .child(WindowsCaptionButton::new(
+                "minimize",
+                WindowsCaptionButtonIcon::Minimize,
+                button_hover_color,
+            ))
+            .child(WindowsCaptionButton::new(
+                "maximize-or-restore",
+                if cx.is_maximized() {
+                    WindowsCaptionButtonIcon::Restore
+                } else {
+                    WindowsCaptionButtonIcon::Maximize
+                },
+                button_hover_color,
+            ))
+            .child(WindowsCaptionButton::new(
+                "close",
+                WindowsCaptionButtonIcon::Close,
+                close_button_hover_color,
+            ))
+    }
+}
+
+#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
+enum WindowsCaptionButtonIcon {
+    Minimize,
+    Restore,
+    Maximize,
+    Close,
+}
+
+#[derive(IntoElement)]
+struct WindowsCaptionButton {
+    id: ElementId,
+    icon: WindowsCaptionButtonIcon,
+    hover_background_color: Rgba,
+}
+
+impl WindowsCaptionButton {
+    pub fn new(
+        id: impl Into<ElementId>,
+        icon: WindowsCaptionButtonIcon,
+        hover_background_color: Rgba,
+    ) -> Self {
+        Self {
+            id: id.into(),
+            icon,
+            hover_background_color,
+        }
+    }
+}
+
+impl RenderOnce for WindowsCaptionButton {
+    fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
+        // todo(windows): get padding from win32 api, need HWND from window context somehow
+        // should be GetSystemMetricsForDpi(SM_CXSIZE, dpi)
+        let width = px(36.0);
+
+        h_flex()
+            .id(self.id)
+            .justify_center()
+            .content_center()
+            .w(width)
+            .h_full()
+            .hover(|style| style.bg(self.hover_background_color))
+            .active(|style| {
+                let mut active_color = self.hover_background_color;
+                active_color.a *= 0.2;
+
+                style.bg(active_color)
+            })
+            .child(match self.icon {
+                WindowsCaptionButtonIcon::Minimize => "\u{e921}",
+                WindowsCaptionButtonIcon::Restore => "\u{e923}",
+                WindowsCaptionButtonIcon::Maximize => "\u{e922}",
+                WindowsCaptionButtonIcon::Close => "\u{e8bb}",
+            })
+    }
+}

crates/ui/src/prelude.rs 🔗

@@ -11,7 +11,7 @@ pub use crate::clickable::*;
 pub use crate::disableable::*;
 pub use crate::fixed::*;
 pub use crate::selectable::*;
-pub use crate::styles::{rems_from_px, vh, vw};
+pub use crate::styles::{rems_from_px, vh, vw, PlatformStyle};
 pub use crate::visible_on_hover::*;
 pub use crate::{h_flex, v_flex};
 pub use crate::{Button, ButtonSize, ButtonStyle, IconButton, SelectableButton};

crates/ui/src/styles.rs 🔗

@@ -1,9 +1,11 @@
 mod color;
 mod elevation;
+mod platform;
 mod typography;
 mod units;
 
 pub use color::*;
 pub use elevation::*;
+pub use platform::*;
 pub use typography::*;
 pub use units::*;

crates/ui/src/styles/platform.rs 🔗

@@ -0,0 +1,25 @@
+/// The platform style to use when rendering UI.
+///
+/// This can be used to abstract over platform differences.
+#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
+pub enum PlatformStyle {
+    /// Display in macOS style.
+    Mac,
+    /// Display in Linux style.
+    Linux,
+    /// Display in Windows style.
+    Windows,
+}
+
+impl PlatformStyle {
+    /// Returns the [`PlatformStyle`] for the current platform.
+    pub const fn platform() -> Self {
+        if cfg!(target_os = "linux") {
+            Self::Linux
+        } else if cfg!(target_os = "windows") {
+            Self::Windows
+        } else {
+            Self::Mac
+        }
+    }
+}

crates/workspace/src/workspace.rs 🔗

@@ -4809,7 +4809,7 @@ impl Element for DisconnectedOverlay {
             .bg(background)
             .absolute()
             .left_0()
-            .top(ui::titlebar_height(cx))
+            .top(ui::TitleBar::height(cx))
             .size_full()
             .flex()
             .items_center()