From 97512be378a0246b40279881ec51d3fbdff76a1c Mon Sep 17 00:00:00 2001 From: Nate Butler Date: Wed, 1 May 2024 14:28:52 -0400 Subject: [PATCH] Add wiring for UI density (#11260) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Note: You shouldn't use the `unstable.ui_density` setting – it is only being added for testing and to enable new UI components to be built with density in mind. Don't expect this to work well, or at all right now. Adds some of the basic wiring we'll need to start scaling UI elements throughout the app based on a desired density setting. Release Notes: - N/A --- crates/theme/src/settings.rs | 71 +++++++++++++++ crates/ui/src/components/button/button.rs | 8 +- .../ui/src/components/button/button_like.rs | 8 +- .../ui/src/components/button/icon_button.rs | 10 +-- crates/ui/src/components/checkbox/checkbox.rs | 40 +-------- .../checkbox/checkbox_with_label.rs | 4 +- crates/ui/src/components/tab.rs | 18 ++-- crates/ui/src/components/tab_bar.rs | 14 +-- crates/ui/src/prelude.rs | 1 + crates/ui/src/styles.rs | 2 + crates/ui/src/styles/spacing.rs | 87 +++++++++++++++++++ crates/workspace/src/status_bar.rs | 16 ++-- crates/workspace/src/toolbar.rs | 8 +- 13 files changed, 209 insertions(+), 78 deletions(-) create mode 100644 crates/ui/src/styles/spacing.rs diff --git a/crates/theme/src/settings.rs b/crates/theme/src/settings.rs index e5a6b6b2c7..5d7d486187 100644 --- a/crates/theme/src/settings.rs +++ b/crates/theme/src/settings.rs @@ -21,6 +21,65 @@ use util::ResultExt as _; const MIN_FONT_SIZE: Pixels = px(6.0); const MIN_LINE_HEIGHT: f32 = 1.0; +#[derive( + Debug, + Default, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Clone, + Copy, + Serialize, + Deserialize, + JsonSchema, +)] +#[serde(rename_all = "snake_case")] +pub enum UiDensity { + /// A denser UI with tighter spacing and smaller elements. + #[serde(alias = "compact")] + Compact, + #[default] + #[serde(alias = "default")] + /// The default UI density. + Default, + #[serde(alias = "comfortable")] + /// A looser UI with more spacing and larger elements. + Comfortable, +} + +impl UiDensity { + pub fn spacing_ratio(self) -> f32 { + match self { + UiDensity::Compact => 0.75, + UiDensity::Default => 1.0, + UiDensity::Comfortable => 1.25, + } + } +} + +impl From for UiDensity { + fn from(s: String) -> Self { + match s.as_str() { + "compact" => Self::Compact, + "default" => Self::Default, + "comfortable" => Self::Comfortable, + _ => Self::default(), + } + } +} + +impl Into for UiDensity { + fn into(self) -> String { + match self { + UiDensity::Compact => "compact".to_string(), + UiDensity::Default => "default".to_string(), + UiDensity::Comfortable => "comfortable".to_string(), + } + } +} + #[derive(Clone)] pub struct ThemeSettings { pub ui_font_size: Pixels, @@ -31,6 +90,7 @@ pub struct ThemeSettings { pub theme_selection: Option, pub active_theme: Arc, pub theme_overrides: Option, + pub ui_density: UiDensity, } impl ThemeSettings { @@ -183,6 +243,12 @@ pub struct ThemeSettingsContent { #[serde(default)] pub theme: Option, + /// UNSTABLE: Expect many elements to be broken. + /// + // Controls the density of the UI. + #[serde(rename = "unstable.ui_density", default)] + pub ui_density: Option, + /// EXPERIMENTAL: Overrides for the current theme. /// /// These values will override the ones on the current theme specified in `theme`. @@ -343,9 +409,14 @@ impl settings::Settings for ThemeSettings { .or(themes.get(&one_dark().name)) .unwrap(), theme_overrides: None, + ui_density: defaults.ui_density.unwrap_or(UiDensity::Default), }; for value in sources.user.into_iter().chain(sources.release_channel) { + if let Some(value) = value.ui_density { + this.ui_density = value; + } + if let Some(value) = value.buffer_font_family.clone() { this.buffer_font.family = value.into(); } diff --git a/crates/ui/src/components/button/button.rs b/crates/ui/src/components/button/button.rs index 3ca6d28672..cc1766dc0c 100644 --- a/crates/ui/src/components/button/button.rs +++ b/crates/ui/src/components/button/button.rs @@ -1,6 +1,6 @@ use gpui::{AnyView, DefiniteLength}; -use crate::{prelude::*, IconPosition, KeyBinding}; +use crate::{prelude::*, IconPosition, KeyBinding, Spacing}; use crate::{ ButtonCommon, ButtonLike, ButtonSize, ButtonStyle, IconName, IconSize, Label, LineHeightStyle, }; @@ -344,7 +344,7 @@ impl ButtonCommon for Button { impl RenderOnce for Button { #[allow(refining_impl_trait)] - fn render(self, _cx: &mut WindowContext) -> ButtonLike { + fn render(self, cx: &mut WindowContext) -> ButtonLike { let is_disabled = self.base.disabled; let is_selected = self.base.selected; @@ -363,7 +363,7 @@ impl RenderOnce for Button { self.base.child( h_flex() - .gap_1() + .gap(Spacing::Small.rems(cx)) .when(self.icon_position == Some(IconPosition::Start), |this| { this.children(self.icon.map(|icon| { ButtonIcon::new(icon) @@ -376,7 +376,7 @@ impl RenderOnce for Button { }) .child( h_flex() - .gap_2() + .gap(Spacing::Medium.rems(cx)) .justify_between() .child( Label::new(label) diff --git a/crates/ui/src/components/button/button_like.rs b/crates/ui/src/components/button/button_like.rs index eaff3a6e73..af6cd15417 100644 --- a/crates/ui/src/components/button/button_like.rs +++ b/crates/ui/src/components/button/button_like.rs @@ -2,7 +2,7 @@ use gpui::{relative, DefiniteLength, MouseButton}; use gpui::{transparent_black, AnyElement, AnyView, ClickEvent, Hsla, Rems}; use smallvec::SmallVec; -use crate::prelude::*; +use crate::{prelude::*, Spacing}; /// A trait for buttons that can be Selected. Enables setting the [`ButtonStyle`] of a button when it is selected. pub trait SelectableButton: Selectable { @@ -431,10 +431,10 @@ impl RenderOnce for ButtonLike { ButtonLikeRounding::Left => this.rounded_l_md(), ButtonLikeRounding::Right => this.rounded_r_md(), }) - .gap_1() + .gap(Spacing::Small.rems(cx)) .map(|this| match self.size { - ButtonSize::Large => this.px_2(), - ButtonSize::Default | ButtonSize::Compact => this.px_1(), + ButtonSize::Large => this.px(Spacing::Medium.rems(cx)), + ButtonSize::Default | ButtonSize::Compact => this.px(Spacing::Small.rems(cx)), ButtonSize::None => this, }) .bg(style.enabled(cx).background) diff --git a/crates/ui/src/components/button/icon_button.rs b/crates/ui/src/components/button/icon_button.rs index 6de32c0eab..e7872cfe03 100644 --- a/crates/ui/src/components/button/icon_button.rs +++ b/crates/ui/src/components/button/icon_button.rs @@ -1,6 +1,6 @@ use gpui::{AnyView, DefiniteLength}; -use crate::{prelude::*, SelectableButton}; +use crate::{prelude::*, SelectableButton, Spacing}; use crate::{ButtonCommon, ButtonLike, ButtonSize, ButtonStyle, IconName, IconSize}; use super::button_icon::ButtonIcon; @@ -139,10 +139,10 @@ impl RenderOnce for IconButton { IconButtonShape::Square => { let icon_size = self.icon_size.rems() * cx.rem_size(); let padding = match self.icon_size { - IconSize::Indicator => px(0.), - IconSize::XSmall => px(0.), - IconSize::Small => px(2.), - IconSize::Medium => px(2.), + IconSize::Indicator => Spacing::None.px(cx), + IconSize::XSmall => Spacing::None.px(cx), + IconSize::Small => Spacing::XSmall.px(cx), + IconSize::Medium => Spacing::XSmall.px(cx), }; this.width((icon_size + padding * 2.).into()) diff --git a/crates/ui/src/components/checkbox/checkbox.rs b/crates/ui/src/components/checkbox/checkbox.rs index a04a0a4a90..3b53dfbe9c 100644 --- a/crates/ui/src/components/checkbox/checkbox.rs +++ b/crates/ui/src/components/checkbox/checkbox.rs @@ -61,28 +61,9 @@ impl RenderOnce for Checkbox { Selection::Unselected => None, }; - // A checkbox could be in an indeterminate state, - // for example the indeterminate state could represent: - // - a group of options of which only some are selected - // - an enabled option that is no longer available - // - a previously agreed to license that has been updated - // - // For the sake of styles we treat the indeterminate state as selected, - // but its icon will be different. let selected = self.checked == Selection::Selected || self.checked == Selection::Indeterminate; - // We could use something like this to make the checkbox background when selected: - // - // ```rs - // ... - // .when(selected, |this| { - // this.bg(cx.theme().colors().element_selected) - // }) - // ``` - // - // But we use a match instead here because the checkbox might be disabled, - // and it could be disabled _while_ it is selected, as well as while it is not selected. let (bg_color, border_color) = match (self.disabled, selected) { (true, _) => ( cx.theme().colors().ghost_element_disabled, @@ -102,36 +83,21 @@ impl RenderOnce for Checkbox { .id(self.id) .justify_center() .items_center() - // Rather than adding `px_1()` to add some space around the checkbox, - // we use a larger parent element to create a slightly larger - // click area for the checkbox. - .size_5() - // Because we've enlarged the click area, we need to create a - // `group` to pass down interactivity events to the checkbox. + .size(crate::styles::custom_spacing(cx, 20.)) .group(group_id.clone()) .child( div() .flex() - // This prevent the flex element from growing - // or shrinking in response to any size changes .flex_none() - // The combo of `justify_center()` and `items_center()` - // is used frequently to center elements in a flex container. - // - // We use this to center the icon in the checkbox. .justify_center() .items_center() - .m_1() - .size_4() + .m(Spacing::Small.px(cx)) + .size(crate::styles::custom_spacing(cx, 16.)) .rounded_sm() .bg(bg_color) .border() .border_color(border_color) - // We only want the interactivity states to fire when we - // are in a checkbox that isn't disabled. .when(!self.disabled, |this| { - // Here instead of `hover()` we use `group_hover()` - // to pass it the group id. this.group_hover(group_id.clone(), |el| { el.bg(cx.theme().colors().element_hover) }) diff --git a/crates/ui/src/components/checkbox/checkbox_with_label.rs b/crates/ui/src/components/checkbox/checkbox_with_label.rs index 91cb80d068..2cf8fc2832 100644 --- a/crates/ui/src/components/checkbox/checkbox_with_label.rs +++ b/crates/ui/src/components/checkbox/checkbox_with_label.rs @@ -28,9 +28,9 @@ impl CheckboxWithLabel { } impl RenderOnce for CheckboxWithLabel { - fn render(self, _cx: &mut WindowContext) -> impl IntoElement { + fn render(self, cx: &mut WindowContext) -> impl IntoElement { h_flex() - .gap_2() + .gap(Spacing::Large.rems(cx)) .child(Checkbox::new(self.id.clone(), self.checked).on_click({ let on_click = self.on_click.clone(); move |checked, cx| { diff --git a/crates/ui/src/components/tab.rs b/crates/ui/src/components/tab.rs index 2c9eeefa49..b2ca5e0c67 100644 --- a/crates/ui/src/components/tab.rs +++ b/crates/ui/src/components/tab.rs @@ -146,32 +146,30 @@ impl RenderOnce for Tab { .group("") .relative() .h(rems(Self::CONTENT_HEIGHT_IN_REMS)) - .px_5() - .gap_1() + .px(crate::custom_spacing(cx, 20.)) + .gap(Spacing::Small.rems(cx)) .text_color(text_color) // .hover(|style| style.bg(tab_hover_bg)) // .active(|style| style.bg(tab_active_bg)) .child( h_flex() - .w_3() - .h_3() + .size_3() .justify_center() .absolute() .map(|this| match self.close_side { - TabCloseSide::Start => this.right_1(), - TabCloseSide::End => this.left_1(), + TabCloseSide::Start => this.right(Spacing::Small.rems(cx)), + TabCloseSide::End => this.left(Spacing::Small.rems(cx)), }) .children(self.start_slot), ) .child( h_flex() - .w_3() - .h_3() + .size_3() .justify_center() .absolute() .map(|this| match self.close_side { - TabCloseSide::Start => this.left_1(), - TabCloseSide::End => this.right_1(), + TabCloseSide::Start => this.left(Spacing::Small.rems(cx)), + TabCloseSide::End => this.right(Spacing::Small.rems(cx)), }) .visible_on_hover("") .children(self.end_slot), diff --git a/crates/ui/src/components/tab_bar.rs b/crates/ui/src/components/tab_bar.rs index ce8eca033e..58c1277ad9 100644 --- a/crates/ui/src/components/tab_bar.rs +++ b/crates/ui/src/components/tab_bar.rs @@ -96,14 +96,18 @@ impl RenderOnce for TabBar { .flex() .flex_none() .w_full() - .h(rems_from_px(29.)) + .h( + // TODO: This should scale with [UiDensity], however tabs, + // and other tab bar tools need to scale dynamically first. + rems_from_px(29.), + ) .bg(cx.theme().colors().tab_bar_background) .when(!self.start_children.is_empty(), |this| { this.child( h_flex() .flex_none() - .gap_1() - .px_1() + .gap(Spacing::Small.rems(cx)) + .px(Spacing::Small.rems(cx)) .border_b() .border_r() .border_color(cx.theme().colors().border) @@ -140,8 +144,8 @@ impl RenderOnce for TabBar { this.child( h_flex() .flex_none() - .gap_1() - .px_1() + .gap(Spacing::Small.rems(cx)) + .px(Spacing::Medium.rems(cx)) .border_b() .border_l() .border_color(cx.theme().colors().border) diff --git a/crates/ui/src/prelude.rs b/crates/ui/src/prelude.rs index 1e1c28cc59..d51564e383 100644 --- a/crates/ui/src/prelude.rs +++ b/crates/ui/src/prelude.rs @@ -13,6 +13,7 @@ pub use crate::fixed::*; pub use crate::selectable::*; pub use crate::styles::{rems_from_px, vh, vw, PlatformStyle, StyledTypography}; pub use crate::visible_on_hover::*; +pub use crate::Spacing; pub use crate::{h_flex, v_flex}; pub use crate::{Button, ButtonSize, ButtonStyle, IconButton, SelectableButton}; pub use crate::{ButtonCommon, Color, StyledExt}; diff --git a/crates/ui/src/styles.rs b/crates/ui/src/styles.rs index ad02ae9756..02b9c9b1a0 100644 --- a/crates/ui/src/styles.rs +++ b/crates/ui/src/styles.rs @@ -1,11 +1,13 @@ mod color; mod elevation; mod platform; +mod spacing; mod typography; mod units; pub use color::*; pub use elevation::*; pub use platform::*; +pub use spacing::*; pub use typography::*; pub use units::*; diff --git a/crates/ui/src/styles/spacing.rs b/crates/ui/src/styles/spacing.rs new file mode 100644 index 0000000000..40dc548c82 --- /dev/null +++ b/crates/ui/src/styles/spacing.rs @@ -0,0 +1,87 @@ +use gpui::*; +use settings::Settings; +use theme::{ThemeSettings, UiDensity}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Spacing { + /// No spacing + None, + /// Usually a one pixel spacing. Grows to 2px in comfortable density. + /// @16px/rem: `1px`|`1px`|`2px` + XXSmall, + /// Extra small spacing - @16px/rem: `1px`|`2px`|`4px` + /// + /// Relative to the user's `ui_font_size` and [UiDensity] setting. + XSmall, + /// Small spacing - @16px/rem: `2px`|`4px`|`6px` + /// + /// Relative to the user's `ui_font_size` and [UiDensity] setting. + Small, + /// Medium spacing - @16px/rem: `3px`|`6px`|`8px` + /// + /// Relative to the user's `ui_font_size` and [UiDensity] setting. + Medium, + /// Large spacing - @16px/rem: `4px`|`8px`|`10px` + /// + /// Relative to the user's `ui_font_size` and [UiDensity] setting. + Large, + XLarge, + XXLarge, +} + +impl Spacing { + pub fn spacing_ratio(self, cx: &WindowContext) -> f32 { + match ThemeSettings::get_global(cx).ui_density { + UiDensity::Compact => match self { + Spacing::None => 0.0, + Spacing::XXSmall => 1. / 16., + Spacing::XSmall => 1. / 16., + Spacing::Small => 2. / 16., + Spacing::Medium => 3. / 16., + Spacing::Large => 4. / 16., + Spacing::XLarge => 8. / 16., + Spacing::XXLarge => 12. / 16., + }, + UiDensity::Default => match self { + Spacing::None => 0.0, + Spacing::XXSmall => 1. / 16., + Spacing::XSmall => 2. / 16., + Spacing::Small => 4. / 16., + Spacing::Medium => 6. / 16., + Spacing::Large => 8. / 16., + Spacing::XLarge => 12. / 16., + #[allow(clippy::eq_op)] + Spacing::XXLarge => 16. / 16., + }, + UiDensity::Comfortable => match self { + Spacing::None => 0.0, + Spacing::XXSmall => 2. / 16., + Spacing::XSmall => 3. / 16., + Spacing::Small => 6. / 16., + Spacing::Medium => 8. / 16., + Spacing::Large => 10. / 16., + #[allow(clippy::eq_op)] + Spacing::XLarge => 16. / 16., + Spacing::XXLarge => 20. / 16., + }, + } + } + + pub fn rems(self, cx: &WindowContext) -> Rems { + rems(self.spacing_ratio(cx)) + } + + pub fn px(self, cx: &WindowContext) -> Pixels { + let ui_font_size_f32: f32 = ThemeSettings::get_global(cx).ui_font_size.into(); + + px(ui_font_size_f32 * self.spacing_ratio(cx)) + } +} + +pub fn user_spacing_style(cx: &WindowContext) -> UiDensity { + ThemeSettings::get_global(cx).ui_density +} + +pub fn custom_spacing(cx: &WindowContext, size: f32) -> Rems { + crate::rems_from_px(size * user_spacing_style(cx).spacing_ratio()) +} diff --git a/crates/workspace/src/status_bar.rs b/crates/workspace/src/status_bar.rs index f575feef7e..0b80126163 100644 --- a/crates/workspace/src/status_bar.rs +++ b/crates/workspace/src/status_bar.rs @@ -37,10 +37,10 @@ impl Render for StatusBar { h_flex() .w_full() .justify_between() - .gap_2() - .py_0p5() - .px_1() - .h_8() + .gap(Spacing::Large.rems(cx)) + .py(Spacing::Small.rems(cx)) + .px(Spacing::Large.rems(cx)) + // .h_8() .bg(cx.theme().colors().status_bar_background) .child(self.render_left_tools(cx)) .child(self.render_right_tools(cx)) @@ -48,16 +48,16 @@ impl Render for StatusBar { } impl StatusBar { - fn render_left_tools(&self, _: &mut ViewContext) -> impl IntoElement { + fn render_left_tools(&self, cx: &mut ViewContext) -> impl IntoElement { h_flex() - .gap_2() + .gap(Spacing::Large.rems(cx)) .overflow_x_hidden() .children(self.left_items.iter().map(|item| item.to_any())) } - fn render_right_tools(&self, _: &mut ViewContext) -> impl IntoElement { + fn render_right_tools(&self, cx: &mut ViewContext) -> impl IntoElement { h_flex() - .gap_2() + .gap(Spacing::Large.rems(cx)) .children(self.right_items.iter().rev().map(|item| item.to_any())) } } diff --git a/crates/workspace/src/toolbar.rs b/crates/workspace/src/toolbar.rs index 1377c5519b..0e2628aab4 100644 --- a/crates/workspace/src/toolbar.rs +++ b/crates/workspace/src/toolbar.rs @@ -104,15 +104,17 @@ impl Render for Toolbar { let has_right_items = self.right_items().count() > 0; v_flex() - .p_2() - .when(has_left_items || has_right_items, |this| this.gap_2()) + .p(Spacing::Large.rems(cx)) + .when(has_left_items || has_right_items, |this| { + this.gap(Spacing::Large.rems(cx)) + }) .border_b() .border_color(cx.theme().colors().border_variant) .bg(cx.theme().colors().toolbar_background) .child( h_flex() .justify_between() - .gap_2() + .gap(Spacing::Large.rems(cx)) .when(has_left_items, |this| { this.child( h_flex()