settings_ui: Add theme settings controls (#15115)

Marshall Bowers created

This PR adds settings controls for the theme settings.

Release Notes:

- N/A

Change summary

crates/editor/src/editor_settings_controls.rs          |  18 
crates/settings/src/editable_setting_control.rs        |  10 
crates/settings_ui/src/appearance_settings_controls.rs | 257 ++++++++++++
crates/settings_ui/src/settings_ui.rs                  |   6 
crates/settings_ui/src/theme_settings_controls.rs      | 112 -----
crates/theme/src/settings.rs                           |  75 +++
crates/theme_selector/src/theme_selector.rs            |  22 
crates/title_bar/src/application_menu.rs               |   2 
crates/ui/src/components/dropdown_menu.rs              |   8 
crates/ui/src/components/numeric_stepper.rs            |  12 
crates/ui/src/components/popover_menu.rs               |  26 
11 files changed, 392 insertions(+), 156 deletions(-)

Detailed changes

crates/editor/src/editor_settings_controls.rs 🔗

@@ -44,7 +44,11 @@ impl EditableSettingControl for BufferFontSizeControl {
         settings.buffer_font_size
     }
 
-    fn apply(settings: &mut <Self::Settings as Settings>::FileContent, value: Self::Value) {
+    fn apply(
+        settings: &mut <Self::Settings as Settings>::FileContent,
+        value: Self::Value,
+        _cx: &AppContext,
+    ) {
         settings.buffer_font_size = Some(value.into());
     }
 }
@@ -84,7 +88,11 @@ impl EditableSettingControl for BufferFontWeightControl {
         settings.buffer_font.weight
     }
 
-    fn apply(settings: &mut <Self::Settings as Settings>::FileContent, value: Self::Value) {
+    fn apply(
+        settings: &mut <Self::Settings as Settings>::FileContent,
+        value: Self::Value,
+        _cx: &AppContext,
+    ) {
         settings.buffer_font_weight = Some(value.0);
     }
 }
@@ -133,7 +141,11 @@ impl EditableSettingControl for InlineGitBlameControl {
         settings.git.inline_blame_enabled()
     }
 
-    fn apply(settings: &mut <Self::Settings as Settings>::FileContent, value: Self::Value) {
+    fn apply(
+        settings: &mut <Self::Settings as Settings>::FileContent,
+        value: Self::Value,
+        _cx: &AppContext,
+    ) {
         if let Some(inline_blame) = settings.git.inline_blame.as_mut() {
             inline_blame.enabled = value;
         } else {

crates/settings/src/editable_setting_control.rs 🔗

@@ -20,14 +20,18 @@ pub trait EditableSettingControl: RenderOnce {
     /// Applies the given setting file to the settings file contents.
     ///
     /// This will be called when writing the setting value back to the settings file.
-    fn apply(settings: &mut <Self::Settings as Settings>::FileContent, value: Self::Value);
+    fn apply(
+        settings: &mut <Self::Settings as Settings>::FileContent,
+        value: Self::Value,
+        cx: &AppContext,
+    );
 
     /// Writes the given setting value to the settings files.
     fn write(value: Self::Value, cx: &AppContext) {
         let fs = <dyn Fs>::global(cx);
 
-        update_settings_file::<Self::Settings>(fs, cx, move |settings, _cx| {
-            Self::apply(settings, value);
+        update_settings_file::<Self::Settings>(fs, cx, move |settings, cx| {
+            Self::apply(settings, value, cx);
         });
     }
 }

crates/settings_ui/src/appearance_settings_controls.rs 🔗

@@ -0,0 +1,257 @@
+use gpui::{AppContext, FontWeight};
+use settings::{EditableSettingControl, Settings};
+use theme::{SystemAppearance, ThemeMode, ThemeRegistry, ThemeSettings};
+use ui::{
+    prelude::*, ContextMenu, DropdownMenu, NumericStepper, SettingsContainer, SettingsGroup,
+    ToggleButton,
+};
+
+#[derive(IntoElement)]
+pub struct AppearanceSettingsControls {}
+
+impl AppearanceSettingsControls {
+    pub fn new() -> Self {
+        Self {}
+    }
+}
+
+impl RenderOnce for AppearanceSettingsControls {
+    fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
+        SettingsContainer::new()
+            .child(
+                SettingsGroup::new("Theme").child(
+                    h_flex()
+                        .gap_2()
+                        .justify_between()
+                        .child(ThemeControl)
+                        .child(ThemeModeControl),
+                ),
+            )
+            .child(
+                SettingsGroup::new("Font")
+                    .child(UiFontSizeControl)
+                    .child(UiFontWeightControl),
+            )
+    }
+}
+
+#[derive(IntoElement)]
+struct ThemeControl;
+
+impl EditableSettingControl for ThemeControl {
+    type Value = String;
+    type Settings = ThemeSettings;
+
+    fn name(&self) -> SharedString {
+        "Theme".into()
+    }
+
+    fn read(cx: &AppContext) -> Self::Value {
+        let settings = ThemeSettings::get_global(cx);
+        let appearance = SystemAppearance::global(cx);
+        settings
+            .theme_selection
+            .as_ref()
+            .map(|selection| selection.theme(appearance.0).to_string())
+            .unwrap_or_else(|| ThemeSettings::default_theme(*appearance).to_string())
+    }
+
+    fn apply(
+        settings: &mut <Self::Settings as Settings>::FileContent,
+        value: Self::Value,
+        cx: &AppContext,
+    ) {
+        let appearance = SystemAppearance::global(cx);
+        settings.set_theme(value, appearance.0);
+    }
+}
+
+impl RenderOnce for ThemeControl {
+    fn render(self, cx: &mut WindowContext) -> impl IntoElement {
+        let value = Self::read(cx);
+
+        DropdownMenu::new(
+            "theme",
+            value.clone(),
+            ContextMenu::build(cx, |mut menu, cx| {
+                let theme_registry = ThemeRegistry::global(cx);
+
+                for theme in theme_registry.list_names(false) {
+                    menu = menu.custom_entry(
+                        {
+                            let theme = theme.clone();
+                            move |_cx| Label::new(theme.clone()).into_any_element()
+                        },
+                        {
+                            let theme = theme.clone();
+                            move |cx| {
+                                Self::write(theme.to_string(), cx);
+                            }
+                        },
+                    )
+                }
+
+                menu
+            }),
+        )
+        .full_width(true)
+    }
+}
+
+#[derive(IntoElement)]
+struct ThemeModeControl;
+
+impl EditableSettingControl for ThemeModeControl {
+    type Value = ThemeMode;
+    type Settings = ThemeSettings;
+
+    fn name(&self) -> SharedString {
+        "Theme Mode".into()
+    }
+
+    fn read(cx: &AppContext) -> Self::Value {
+        let settings = ThemeSettings::get_global(cx);
+        settings
+            .theme_selection
+            .as_ref()
+            .and_then(|selection| selection.mode())
+            .unwrap_or_default()
+    }
+
+    fn apply(
+        settings: &mut <Self::Settings as Settings>::FileContent,
+        value: Self::Value,
+        _cx: &AppContext,
+    ) {
+        settings.set_mode(value);
+    }
+}
+
+impl RenderOnce for ThemeModeControl {
+    fn render(self, cx: &mut WindowContext) -> impl IntoElement {
+        let value = Self::read(cx);
+
+        h_flex()
+            .child(
+                ToggleButton::new("light", "Light")
+                    .style(ButtonStyle::Filled)
+                    .size(ButtonSize::Large)
+                    .selected(value == ThemeMode::Light)
+                    .on_click(|_, cx| Self::write(ThemeMode::Light, cx))
+                    .first(),
+            )
+            .child(
+                ToggleButton::new("system", "System")
+                    .style(ButtonStyle::Filled)
+                    .size(ButtonSize::Large)
+                    .selected(value == ThemeMode::System)
+                    .on_click(|_, cx| Self::write(ThemeMode::System, cx))
+                    .middle(),
+            )
+            .child(
+                ToggleButton::new("dark", "Dark")
+                    .style(ButtonStyle::Filled)
+                    .size(ButtonSize::Large)
+                    .selected(value == ThemeMode::Dark)
+                    .on_click(|_, cx| Self::write(ThemeMode::Dark, cx))
+                    .last(),
+            )
+    }
+}
+
+#[derive(IntoElement)]
+struct UiFontSizeControl;
+
+impl EditableSettingControl for UiFontSizeControl {
+    type Value = Pixels;
+    type Settings = ThemeSettings;
+
+    fn name(&self) -> SharedString {
+        "UI Font Size".into()
+    }
+
+    fn read(cx: &AppContext) -> Self::Value {
+        let settings = ThemeSettings::get_global(cx);
+        settings.ui_font_size
+    }
+
+    fn apply(
+        settings: &mut <Self::Settings as Settings>::FileContent,
+        value: Self::Value,
+        _cx: &AppContext,
+    ) {
+        settings.ui_font_size = Some(value.into());
+    }
+}
+
+impl RenderOnce for UiFontSizeControl {
+    fn render(self, cx: &mut WindowContext) -> impl IntoElement {
+        let value = Self::read(cx);
+
+        h_flex()
+            .gap_2()
+            .child(Icon::new(IconName::FontSize))
+            .child(NumericStepper::new(
+                value.to_string(),
+                move |_, cx| {
+                    Self::write(value - px(1.), cx);
+                },
+                move |_, cx| {
+                    Self::write(value + px(1.), cx);
+                },
+            ))
+    }
+}
+
+#[derive(IntoElement)]
+struct UiFontWeightControl;
+
+impl EditableSettingControl for UiFontWeightControl {
+    type Value = FontWeight;
+    type Settings = ThemeSettings;
+
+    fn name(&self) -> SharedString {
+        "UI Font Weight".into()
+    }
+
+    fn read(cx: &AppContext) -> Self::Value {
+        let settings = ThemeSettings::get_global(cx);
+        settings.ui_font.weight
+    }
+
+    fn apply(
+        settings: &mut <Self::Settings as Settings>::FileContent,
+        value: Self::Value,
+        _cx: &AppContext,
+    ) {
+        settings.ui_font_weight = Some(value.0);
+    }
+}
+
+impl RenderOnce for UiFontWeightControl {
+    fn render(self, cx: &mut WindowContext) -> impl IntoElement {
+        let value = Self::read(cx);
+
+        h_flex()
+            .gap_2()
+            .child(Icon::new(IconName::FontWeight))
+            .child(DropdownMenu::new(
+                "ui-font-weight",
+                value.0.to_string(),
+                ContextMenu::build(cx, |mut menu, _cx| {
+                    for weight in FontWeight::ALL {
+                        menu = menu.custom_entry(
+                            move |_cx| Label::new(weight.0.to_string()).into_any_element(),
+                            {
+                                move |cx| {
+                                    Self::write(weight, cx);
+                                }
+                            },
+                        )
+                    }
+
+                    menu
+                }),
+            ))
+    }
+}

crates/settings_ui/src/settings_ui.rs 🔗

@@ -1,4 +1,4 @@
-mod theme_settings_controls;
+mod appearance_settings_controls;
 
 use std::any::TypeId;
 
@@ -10,7 +10,7 @@ use ui::prelude::*;
 use workspace::item::{Item, ItemEvent};
 use workspace::Workspace;
 
-use crate::theme_settings_controls::ThemeSettingsControls;
+use crate::appearance_settings_controls::AppearanceSettingsControls;
 
 pub struct SettingsUiFeatureFlag;
 
@@ -110,7 +110,7 @@ impl Render for SettingsPage {
                 v_flex()
                     .gap_1()
                     .child(Label::new("Appearance"))
-                    .child(ThemeSettingsControls::new()),
+                    .child(AppearanceSettingsControls::new()),
             )
             .child(
                 v_flex()

crates/settings_ui/src/theme_settings_controls.rs 🔗

@@ -1,112 +0,0 @@
-use gpui::{AppContext, FontWeight};
-use settings::{EditableSettingControl, Settings};
-use theme::ThemeSettings;
-use ui::{prelude::*, ContextMenu, DropdownMenu, NumericStepper, SettingsContainer, SettingsGroup};
-
-#[derive(IntoElement)]
-pub struct ThemeSettingsControls {}
-
-impl ThemeSettingsControls {
-    pub fn new() -> Self {
-        Self {}
-    }
-}
-
-impl RenderOnce for ThemeSettingsControls {
-    fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
-        SettingsContainer::new().child(
-            SettingsGroup::new("Font")
-                .child(UiFontSizeControl)
-                .child(UiFontWeightControl),
-        )
-    }
-}
-
-#[derive(IntoElement)]
-struct UiFontSizeControl;
-
-impl EditableSettingControl for UiFontSizeControl {
-    type Value = Pixels;
-    type Settings = ThemeSettings;
-
-    fn name(&self) -> SharedString {
-        "UI Font Size".into()
-    }
-
-    fn read(cx: &AppContext) -> Self::Value {
-        let settings = ThemeSettings::get_global(cx);
-        settings.ui_font_size
-    }
-
-    fn apply(settings: &mut <Self::Settings as Settings>::FileContent, value: Self::Value) {
-        settings.ui_font_size = Some(value.into());
-    }
-}
-
-impl RenderOnce for UiFontSizeControl {
-    fn render(self, cx: &mut WindowContext) -> impl IntoElement {
-        let value = Self::read(cx);
-
-        h_flex()
-            .gap_2()
-            .child(Icon::new(IconName::FontSize))
-            .child(NumericStepper::new(
-                value.to_string(),
-                move |_, cx| {
-                    Self::write(value - px(1.), cx);
-                },
-                move |_, cx| {
-                    Self::write(value + px(1.), cx);
-                },
-            ))
-    }
-}
-
-#[derive(IntoElement)]
-struct UiFontWeightControl;
-
-impl EditableSettingControl for UiFontWeightControl {
-    type Value = FontWeight;
-    type Settings = ThemeSettings;
-
-    fn name(&self) -> SharedString {
-        "UI Font Weight".into()
-    }
-
-    fn read(cx: &AppContext) -> Self::Value {
-        let settings = ThemeSettings::get_global(cx);
-        settings.ui_font.weight
-    }
-
-    fn apply(settings: &mut <Self::Settings as Settings>::FileContent, value: Self::Value) {
-        settings.ui_font_weight = Some(value.0);
-    }
-}
-
-impl RenderOnce for UiFontWeightControl {
-    fn render(self, cx: &mut WindowContext) -> impl IntoElement {
-        let value = Self::read(cx);
-
-        h_flex()
-            .gap_2()
-            .child(Icon::new(IconName::FontWeight))
-            .child(DropdownMenu::new(
-                "ui-font-weight",
-                value.0.to_string(),
-                ContextMenu::build(cx, |mut menu, _cx| {
-                    for weight in FontWeight::ALL {
-                        menu = menu.custom_entry(
-                            move |_cx| Label::new(weight.0.to_string()).into_any_element(),
-                            {
-                                move |cx| {
-                                    Self::write(weight, cx);
-                                }
-                            },
-                        )
-                    }
-
-                    menu
-                }),
-            ))
-    }
-}

crates/theme/src/settings.rs 🔗

@@ -94,6 +94,17 @@ pub struct ThemeSettings {
 }
 
 impl ThemeSettings {
+    const DEFAULT_LIGHT_THEME: &'static str = "One Light";
+    const DEFAULT_DARK_THEME: &'static str = "One Dark";
+
+    /// Returns the name of the default theme for the given [`Appearance`].
+    pub fn default_theme(appearance: Appearance) -> &'static str {
+        match appearance {
+            Appearance::Light => Self::DEFAULT_LIGHT_THEME,
+            Appearance::Dark => Self::DEFAULT_DARK_THEME,
+        }
+    }
+
     /// Reloads the current theme.
     ///
     /// Reads the [`ThemeSettings`] to know which theme should be loaded,
@@ -109,10 +120,7 @@ impl ThemeSettings {
             // based on the system appearance.
             let theme_registry = ThemeRegistry::global(cx);
             if theme_registry.get(theme_name).ok().is_none() {
-                theme_name = match *system_appearance {
-                    Appearance::Light => "One Light",
-                    Appearance::Dark => "One Dark",
-                };
+                theme_name = Self::default_theme(*system_appearance);
             };
 
             if let Some(_theme) = theme_settings.switch_theme(theme_name, cx) {
@@ -190,7 +198,7 @@ fn theme_name_ref(_: &mut SchemaGenerator) -> Schema {
     Schema::new_ref("#/definitions/ThemeName".into())
 }
 
-#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, PartialEq, Eq, Clone, Copy, Default, Serialize, Deserialize, JsonSchema)]
 #[serde(rename_all = "snake_case")]
 pub enum ThemeMode {
     /// Use the specified `light` theme.
@@ -218,6 +226,13 @@ impl ThemeSelection {
             },
         }
     }
+
+    pub fn mode(&self) -> Option<ThemeMode> {
+        match self {
+            ThemeSelection::Static(_) => None,
+            ThemeSelection::Dynamic { mode, .. } => Some(*mode),
+        }
+    }
 }
 
 /// Settings for rendering text in UI and text buffers.
@@ -267,6 +282,56 @@ pub struct ThemeSettingsContent {
     pub theme_overrides: Option<ThemeStyleContent>,
 }
 
+impl ThemeSettingsContent {
+    /// Sets the theme for the given appearance to the theme with the specified name.
+    pub fn set_theme(&mut self, theme_name: String, appearance: Appearance) {
+        if let Some(selection) = self.theme.as_mut() {
+            let theme_to_update = match selection {
+                ThemeSelection::Static(theme) => theme,
+                ThemeSelection::Dynamic { mode, light, dark } => match mode {
+                    ThemeMode::Light => light,
+                    ThemeMode::Dark => dark,
+                    ThemeMode::System => match appearance {
+                        Appearance::Light => light,
+                        Appearance::Dark => dark,
+                    },
+                },
+            };
+
+            *theme_to_update = theme_name.to_string();
+        } else {
+            self.theme = Some(ThemeSelection::Static(theme_name.to_string()));
+        }
+    }
+
+    pub fn set_mode(&mut self, mode: ThemeMode) {
+        if let Some(selection) = self.theme.as_mut() {
+            match selection {
+                ThemeSelection::Static(theme) => {
+                    // If the theme was previously set to a single static theme,
+                    // we don't know whether it was a light or dark theme, so we
+                    // just use it for both.
+                    self.theme = Some(ThemeSelection::Dynamic {
+                        mode,
+                        light: theme.clone(),
+                        dark: theme.clone(),
+                    });
+                }
+                ThemeSelection::Dynamic {
+                    mode: mode_to_update,
+                    ..
+                } => *mode_to_update = mode,
+            }
+        } else {
+            self.theme = Some(ThemeSelection::Dynamic {
+                mode,
+                light: ThemeSettings::DEFAULT_LIGHT_THEME.into(),
+                dark: ThemeSettings::DEFAULT_DARK_THEME.into(),
+            });
+        }
+    }
+}
+
 #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, JsonSchema, Default)]
 #[serde(rename_all = "snake_case")]
 pub enum BufferLineHeight {

crates/theme_selector/src/theme_selector.rs 🔗

@@ -10,9 +10,7 @@ use picker::{Picker, PickerDelegate};
 use serde::Deserialize;
 use settings::{update_settings_file, SettingsStore};
 use std::sync::Arc;
-use theme::{
-    Appearance, Theme, ThemeMeta, ThemeMode, ThemeRegistry, ThemeSelection, ThemeSettings,
-};
+use theme::{Appearance, Theme, ThemeMeta, ThemeRegistry, ThemeSettings};
 use ui::{prelude::*, v_flex, ListItem, ListItemSpacing};
 use util::ResultExt;
 use workspace::{ui::HighlightedLabel, ModalView, Workspace};
@@ -197,23 +195,7 @@ impl PickerDelegate for ThemeSelectorDelegate {
         let appearance = Appearance::from(cx.appearance());
 
         update_settings_file::<ThemeSettings>(self.fs.clone(), cx, move |settings, _| {
-            if let Some(selection) = settings.theme.as_mut() {
-                let theme_to_update = match selection {
-                    ThemeSelection::Static(theme) => theme,
-                    ThemeSelection::Dynamic { mode, light, dark } => match mode {
-                        ThemeMode::Light => light,
-                        ThemeMode::Dark => dark,
-                        ThemeMode::System => match appearance {
-                            Appearance::Light => light,
-                            Appearance::Dark => dark,
-                        },
-                    },
-                };
-
-                *theme_to_update = theme_name.to_string();
-            } else {
-                settings.theme = Some(ThemeSelection::Static(theme_name.to_string()));
-            }
+            settings.set_theme(theme_name.to_string(), appearance);
         });
 
         self.view

crates/title_bar/src/application_menu.rs 🔗

@@ -38,6 +38,7 @@ impl RenderOnce for ApplicationMenu {
                                             ))
                                         },
                                     )
+                                    .reserve_space_for_reset(true)
                                     .when(
                                         theme::has_adjusted_buffer_font_size(cx),
                                         |stepper| {
@@ -72,6 +73,7 @@ impl RenderOnce for ApplicationMenu {
                                             ))
                                         },
                                     )
+                                    .reserve_space_for_reset(true)
                                     .when(
                                         theme::has_adjusted_ui_font_size(cx),
                                         |stepper| {

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

@@ -42,8 +42,9 @@ impl Disableable for DropdownMenu {
 impl RenderOnce for DropdownMenu {
     fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
         PopoverMenu::new(self.id)
+            .full_width(self.full_width)
             .menu(move |_cx| Some(self.menu.clone()))
-            .trigger(DropdownMenuTrigger::new(self.label))
+            .trigger(DropdownMenuTrigger::new(self.label).full_width(self.full_width))
     }
 }
 
@@ -68,6 +69,11 @@ impl DropdownMenuTrigger {
             on_click: None,
         }
     }
+
+    pub fn full_width(mut self, full_width: bool) -> Self {
+        self.full_width = full_width;
+        self
+    }
 }
 
 impl Disableable for DropdownMenuTrigger {

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

@@ -7,6 +7,8 @@ pub struct NumericStepper {
     value: SharedString,
     on_decrement: Box<dyn Fn(&ClickEvent, &mut WindowContext) + 'static>,
     on_increment: Box<dyn Fn(&ClickEvent, &mut WindowContext) + 'static>,
+    /// Whether to reserve space for the reset button.
+    reserve_space_for_reset: bool,
     on_reset: Option<Box<dyn Fn(&ClickEvent, &mut WindowContext) + 'static>>,
 }
 
@@ -20,10 +22,16 @@ impl NumericStepper {
             value: value.into(),
             on_decrement: Box::new(on_decrement),
             on_increment: Box::new(on_increment),
+            reserve_space_for_reset: false,
             on_reset: None,
         }
     }
 
+    pub fn reserve_space_for_reset(mut self, reserve_space_for_reset: bool) -> Self {
+        self.reserve_space_for_reset = reserve_space_for_reset;
+        self
+    }
+
     pub fn on_reset(
         mut self,
         on_reset: impl Fn(&ClickEvent, &mut WindowContext) + 'static,
@@ -48,13 +56,15 @@ impl RenderOnce for NumericStepper {
                             .icon_size(icon_size)
                             .on_click(on_reset),
                     )
-                } else {
+                } else if self.reserve_space_for_reset {
                     element.child(
                         h_flex()
                             .size(icon_size.square(cx))
                             .flex_none()
                             .into_any_element(),
                     )
+                } else {
+                    element
                 }
             })
             .child(

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

@@ -1,10 +1,10 @@
 use std::{cell::RefCell, rc::Rc};
 
 use gpui::{
-    anchored, deferred, div, point, prelude::FluentBuilder, px, AnchorCorner, AnyElement, Bounds,
-    DismissEvent, DispatchPhase, Element, ElementId, GlobalElementId, HitboxId, InteractiveElement,
-    IntoElement, LayoutId, ManagedView, MouseDownEvent, ParentElement, Pixels, Point, View,
-    VisualContext, WindowContext,
+    anchored, deferred, div, point, prelude::FluentBuilder, px, size, AnchorCorner, AnyElement,
+    Bounds, DismissEvent, DispatchPhase, Element, ElementId, GlobalElementId, HitboxId,
+    InteractiveElement, IntoElement, LayoutId, Length, ManagedView, MouseDownEvent, ParentElement,
+    Pixels, Point, Style, View, VisualContext, WindowContext,
 };
 
 use crate::prelude::*;
@@ -74,6 +74,7 @@ pub struct PopoverMenu<M: ManagedView> {
     attach: Option<AnchorCorner>,
     offset: Option<Point<Pixels>>,
     trigger_handle: Option<PopoverMenuHandle<M>>,
+    full_width: bool,
 }
 
 impl<M: ManagedView> PopoverMenu<M> {
@@ -87,9 +88,15 @@ impl<M: ManagedView> PopoverMenu<M> {
             attach: None,
             offset: None,
             trigger_handle: None,
+            full_width: false,
         }
     }
 
+    pub fn full_width(mut self, full_width: bool) -> Self {
+        self.full_width = full_width;
+        self
+    }
+
     pub fn menu(mut self, f: impl Fn(&mut WindowContext) -> Option<View<M>> + 'static) -> Self {
         self.menu_builder = Some(Rc::new(f));
         self
@@ -258,10 +265,13 @@ impl<M: ManagedView> Element for PopoverMenu<M> {
                     .as_mut()
                     .map(|child_element| child_element.request_layout(cx));
 
-                let layout_id = cx.request_layout(
-                    gpui::Style::default(),
-                    menu_layout_id.into_iter().chain(child_layout_id),
-                );
+                let mut style = Style::default();
+                if self.full_width {
+                    style.size = size(relative(1.).into(), Length::Auto);
+                }
+
+                let layout_id =
+                    cx.request_layout(style, menu_layout_id.into_iter().chain(child_layout_id));
 
                 (
                     (