Refactor Spacing into DynamicSpacing using proc macro (#20504)

Nate Butler created

Density tracking issue: #18078 

This PR refactors our spacing system to use a more flexible and
maintainable approach. We've replaced the static `Spacing` enum with a
dynamically generated `DynamicSpacing` enum using a proc macro.

Enum variants now use a `BaseXX` format, where XX = the pixel value @
default rem size and the default UI density.

For example:

`CustomSpacing::Base16` would return 16px at the default UI scale &
density.

I'd love to find another name other than `Base` that is clear (to avoid
base_10, etc confusion), let me know if you have any ideas!

Changes:

- Introduced a new `derive_dynamic_spacing` proc macro to generate the
`DynamicSpacing` enum
- Updated all usages of `Spacing` to use the new `DynamicSpacing`
- Removed the `custom_spacing` function, mapping previous usages to
appropriate `DynamicSpacing` variants
- Improved documentation and type safety for spacing values

New usage example:

```rust
.child(
    div()
        .flex()
        .flex_none()
        .m(DynamicSpacing::Base04.px(cx))
        .size(DynamicSpacing::Base16.rems(cx))
        .children(icon),
)
```

vs old usage example:

```
.child(
    div()
        .flex()
        .flex_none()
        .m(Spacing::Small.px(cx))
        .size(custom_spacing(px(16.)))
        .children(icon),
)
```

Release Notes:

- N/A

Change summary

crates/assistant/src/assistant_panel.rs          |   8 
crates/assistant/src/prompt_library.rs           |  16 
crates/outline_panel/src/outline_panel.rs        |   8 
crates/quick_action_bar/src/quick_action_bar.rs  |   2 
crates/recent_projects/src/ssh_connections.rs    |   6 
crates/repl/src/notebook/cell.rs                 |  10 
crates/repl/src/notebook/notebook_ui.rs          |  14 
crates/ui/src/components/button/button.rs        |   8 
crates/ui/src/components/button/button_like.rs   |  10 
crates/ui/src/components/checkbox.rs             |   8 
crates/ui/src/components/icon.rs                 |   8 
crates/ui/src/components/keybinding.rs           |   2 
crates/ui/src/components/list/list.rs            |   2 
crates/ui/src/components/list/list_header.rs     |   6 
crates/ui/src/components/list/list_item.rs       |   8 
crates/ui/src/components/list/list_separator.rs  |   2 
crates/ui/src/components/list/list_sub_header.rs |   4 
crates/ui/src/components/modal.rs                |  32 +-
crates/ui/src/components/radio.rs                |   2 
crates/ui/src/components/tab.rs                  |   4 
crates/ui/src/components/tab_bar.rs              |   8 
crates/ui/src/components/tool_strip.rs           |   4 
crates/ui/src/prelude.rs                         |   2 
crates/ui/src/styles/spacing.rs                  | 125 ++----------
crates/ui_macros/src/dynamic_spacing.rs          | 163 ++++++++++++++++++
crates/ui_macros/src/ui_macros.rs                |   7 
crates/workspace/src/pane.rs                     |   2 
crates/workspace/src/status_bar.rs               |  10 
crates/workspace/src/toolbar.rs                  |   6 
29 files changed, 294 insertions(+), 193 deletions(-)

Detailed changes

crates/assistant/src/assistant_panel.rs πŸ”—

@@ -447,7 +447,7 @@ impl AssistantPanel {
                     );
                 let _pane = cx.view().clone();
                 let right_children = h_flex()
-                    .gap(Spacing::XSmall.rems(cx))
+                    .gap(DynamicSpacing::Base02.rems(cx))
                     .child(
                         IconButton::new("new-chat", IconName::Plus)
                             .on_click(
@@ -4838,7 +4838,7 @@ impl ConfigurationView {
             )
             .child(
                 div()
-                    .p(Spacing::Large.rems(cx))
+                    .p(DynamicSpacing::Base08.rems(cx))
                     .bg(cx.theme().colors().surface_background)
                     .border_1()
                     .border_color(cx.theme().colors().border_variant)
@@ -4872,7 +4872,7 @@ impl Render for ConfigurationView {
             .overflow_y_scroll()
             .child(
                 v_flex()
-                    .p(Spacing::XXLarge.rems(cx))
+                    .p(DynamicSpacing::Base16.rems(cx))
                     .border_b_1()
                     .border_color(cx.theme().colors().border)
                     .gap_1()
@@ -4886,7 +4886,7 @@ impl Render for ConfigurationView {
             )
             .child(
                 v_flex()
-                    .p(Spacing::XXLarge.rems(cx))
+                    .p(DynamicSpacing::Base16.rems(cx))
                     .mt_1()
                     .gap_6()
                     .flex_1()

crates/assistant/src/prompt_library.rs πŸ”—

@@ -830,7 +830,7 @@ impl PromptLibrary {
             .overflow_x_hidden()
             .child(
                 h_flex()
-                    .p(Spacing::Small.rems(cx))
+                    .p(DynamicSpacing::Base04.rems(cx))
                     .h_9()
                     .w_full()
                     .flex_none()
@@ -871,17 +871,17 @@ impl PromptLibrary {
                         .size_full()
                         .relative()
                         .overflow_hidden()
-                        .pl(Spacing::XXLarge.rems(cx))
-                        .pt(Spacing::Large.rems(cx))
+                        .pl(DynamicSpacing::Base16.rems(cx))
+                        .pt(DynamicSpacing::Base08.rems(cx))
                         .on_click(cx.listener(move |_, _, cx| {
                             cx.focus(&focus_handle);
                         }))
                         .child(
                             h_flex()
                                 .group("active-editor-header")
-                                .pr(Spacing::XXLarge.rems(cx))
-                                .pt(Spacing::XSmall.rems(cx))
-                                .pb(Spacing::Large.rems(cx))
+                                .pr(DynamicSpacing::Base16.rems(cx))
+                                .pt(DynamicSpacing::Base02.rems(cx))
+                                .pb(DynamicSpacing::Base08.rems(cx))
                                 .justify_between()
                                 .child(
                                     h_flex().gap_1().child(
@@ -943,13 +943,13 @@ impl PromptLibrary {
                                         .child(
                                             h_flex()
                                                 .h_full()
-                                                .gap(Spacing::XXLarge.rems(cx))
+                                                .gap(DynamicSpacing::Base16.rems(cx))
                                                 .child(div()),
                                         )
                                         .child(
                                             h_flex()
                                                 .h_full()
-                                                .gap(Spacing::XXLarge.rems(cx))
+                                                .gap(DynamicSpacing::Base16.rems(cx))
                                                 .children(prompt_editor.token_count.map(
                                                     |token_count| {
                                                         let token_count: SharedString =

crates/outline_panel/src/outline_panel.rs πŸ”—

@@ -42,7 +42,7 @@ use serde::{Deserialize, Serialize};
 use settings::{Settings, SettingsStore};
 use smol::channel;
 use theme::{SyntaxTheme, ThemeSettings};
-use ui::{IndentGuideColors, IndentGuideLayout};
+use ui::{DynamicSpacing, IndentGuideColors, IndentGuideLayout};
 use util::{debug_panic, RangeExt, ResultExt, TryFutureExt};
 use workspace::{
     dock::{DockPosition, Panel, PanelEvent},
@@ -51,8 +51,8 @@ use workspace::{
     ui::{
         h_flex, v_flex, ActiveTheme, ButtonCommon, Clickable, Color, ContextMenu, FluentBuilder,
         HighlightedLabel, Icon, IconButton, IconButtonShape, IconName, IconSize, Label,
-        LabelCommon, ListItem, Scrollbar, ScrollbarState, Selectable, Spacing, StyledExt,
-        StyledTypography, Tooltip,
+        LabelCommon, ListItem, Scrollbar, ScrollbarState, Selectable, StyledExt, StyledTypography,
+        Tooltip,
     },
     OpenInTerminal, WeakItemHandle, Workspace,
 };
@@ -3867,7 +3867,7 @@ impl OutlinePanel {
                 })
                 .child(
                     h_flex()
-                        .pt(Spacing::Small.rems(cx))
+                        .pt(DynamicSpacing::Base04.rems(cx))
                         .justify_center()
                         .child({
                             let keystroke = match self.position(cx) {

crates/quick_action_bar/src/quick_action_bar.rs πŸ”—

@@ -303,7 +303,7 @@ impl Render for QuickActionBar {
 
         h_flex()
             .id("quick action bar")
-            .gap(Spacing::Medium.rems(cx))
+            .gap(DynamicSpacing::Base06.rems(cx))
             .children(self.render_repl_menu(cx))
             .children(self.render_toggle_markdown_preview(self.workspace.clone(), cx))
             .children(search_button)

crates/recent_projects/src/ssh_connections.rs πŸ”—

@@ -320,9 +320,9 @@ impl RenderOnce for SshConnectionHeader {
         };
 
         h_flex()
-            .px(Spacing::XLarge.rems(cx))
-            .pt(Spacing::Large.rems(cx))
-            .pb(Spacing::Small.rems(cx))
+            .px(DynamicSpacing::Base12.rems(cx))
+            .pt(DynamicSpacing::Base08.rems(cx))
+            .pb(DynamicSpacing::Base04.rems(cx))
             .rounded_t_md()
             .w_full()
             .gap_1p5()

crates/repl/src/notebook/cell.rs πŸ”—

@@ -260,7 +260,7 @@ pub trait RenderableCell: Render {
         if (cell_position == Some(&CellPosition::First) && is_first)
             || (cell_position == Some(&CellPosition::Last) && !is_first)
         {
-            Some(div().flex().w_full().h(Spacing::XLarge.px(cx)))
+            Some(div().flex().w_full().h(DynamicSpacing::Base12.px(cx)))
         } else {
             None
         }
@@ -389,7 +389,7 @@ impl Render for MarkdownCell {
                     .pr_6()
                     .rounded_sm()
                     .items_start()
-                    .gap(Spacing::Large.rems(cx))
+                    .gap(DynamicSpacing::Base08.rems(cx))
                     .bg(self.selected_bg_color(cx))
                     .child(self.gutter(cx))
                     .child(
@@ -564,7 +564,7 @@ impl Render for CodeCell {
                     .pr_6()
                     .rounded_sm()
                     .items_start()
-                    .gap(Spacing::Large.rems(cx))
+                    .gap(DynamicSpacing::Base08.rems(cx))
                     .bg(self.selected_bg_color(cx))
                     .child(self.gutter(cx))
                     .child(
@@ -590,7 +590,7 @@ impl Render for CodeCell {
                     .pr_6()
                     .rounded_sm()
                     .items_start()
-                    .gap(Spacing::Large.rems(cx))
+                    .gap(DynamicSpacing::Base08.rems(cx))
                     .bg(self.selected_bg_color(cx))
                     .child(self.gutter_output(cx))
                     .child(
@@ -710,7 +710,7 @@ impl Render for RawCell {
                     .pr_2()
                     .rounded_sm()
                     .items_start()
-                    .gap(Spacing::Large.rems(cx))
+                    .gap(DynamicSpacing::Base08.rems(cx))
                     .bg(self.selected_bg_color(cx))
                     .child(self.gutter(cx))
                     .child(

crates/repl/src/notebook/notebook_ui.rs πŸ”—

@@ -273,7 +273,7 @@ impl NotebookEditor {
 
     fn button_group(cx: &ViewContext<Self>) -> Div {
         v_flex()
-            .gap(Spacing::Small.rems(cx))
+            .gap(DynamicSpacing::Base04.rems(cx))
             .items_center()
             .w(px(CONTROL_SIZE + 4.0))
             .overflow_hidden()
@@ -299,14 +299,14 @@ impl NotebookEditor {
         v_flex()
             .max_w(px(CONTROL_SIZE + 4.0))
             .items_center()
-            .gap(Spacing::XXLarge.rems(cx))
+            .gap(DynamicSpacing::Base16.rems(cx))
             .justify_between()
             .flex_none()
             .h_full()
-            .py(Spacing::XLarge.px(cx))
+            .py(DynamicSpacing::Base12.px(cx))
             .child(
                 v_flex()
-                    .gap(Spacing::Large.rems(cx))
+                    .gap(DynamicSpacing::Base08.rems(cx))
                     .child(
                         Self::button_group(cx)
                             .child(
@@ -390,7 +390,7 @@ impl NotebookEditor {
             )
             .child(
                 v_flex()
-                    .gap(Spacing::Large.rems(cx))
+                    .gap(DynamicSpacing::Base08.rems(cx))
                     .items_center()
                     .child(Self::render_notebook_control(
                         "more-menu",
@@ -468,8 +468,8 @@ impl Render for NotebookEditor {
             .items_start()
             .size_full()
             .overflow_hidden()
-            .px(Spacing::XLarge.px(cx))
-            .gap(Spacing::XLarge.px(cx))
+            .px(DynamicSpacing::Base12.px(cx))
+            .gap(DynamicSpacing::Base12.px(cx))
             .bg(cx.theme().colors().tab_bar_background)
             .child(
                 v_flex()

crates/ui/src/components/button/button.rs πŸ”—

@@ -1,7 +1,9 @@
 #![allow(missing_docs)]
 use gpui::{AnyView, DefiniteLength};
 
-use crate::{prelude::*, ElevationIndex, IconPosition, KeyBinding, Spacing, TintColor};
+use crate::{
+    prelude::*, Color, DynamicSpacing, ElevationIndex, IconPosition, KeyBinding, TintColor,
+};
 use crate::{
     ButtonCommon, ButtonLike, ButtonSize, ButtonStyle, IconName, IconSize, Label, LineHeightStyle,
 };
@@ -398,7 +400,7 @@ impl RenderOnce for Button {
 
         self.base.child(
             h_flex()
-                .gap(Spacing::Small.rems(cx))
+                .gap(DynamicSpacing::Base04.rems(cx))
                 .when(self.icon_position == Some(IconPosition::Start), |this| {
                     this.children(self.icon.map(|icon| {
                         ButtonIcon::new(icon)
@@ -412,7 +414,7 @@ impl RenderOnce for Button {
                 })
                 .child(
                     h_flex()
-                        .gap(Spacing::Medium.rems(cx))
+                        .gap(DynamicSpacing::Base06.rems(cx))
                         .justify_between()
                         .child(
                             Label::new(label)

crates/ui/src/components/button/button_like.rs πŸ”—

@@ -3,7 +3,7 @@ use gpui::{relative, CursorStyle, DefiniteLength, MouseButton};
 use gpui::{transparent_black, AnyElement, AnyView, ClickEvent, Hsla, Rems};
 use smallvec::SmallVec;
 
-use crate::{prelude::*, ElevationIndex, Spacing};
+use crate::{prelude::*, DynamicSpacing, ElevationIndex};
 
 /// A trait for buttons that can be Selected. Enables setting the [`ButtonStyle`] of a button when it is selected.
 pub trait SelectableButton: Selectable {
@@ -491,10 +491,12 @@ impl RenderOnce for ButtonLike {
                 ButtonLikeRounding::Left => this.rounded_l_md(),
                 ButtonLikeRounding::Right => this.rounded_r_md(),
             })
-            .gap(Spacing::Small.rems(cx))
+            .gap(DynamicSpacing::Base04.rems(cx))
             .map(|this| match self.size {
-                ButtonSize::Large => this.px(Spacing::Medium.rems(cx)),
-                ButtonSize::Default | ButtonSize::Compact => this.px(Spacing::Small.rems(cx)),
+                ButtonSize::Large => this.px(DynamicSpacing::Base06.rems(cx)),
+                ButtonSize::Default | ButtonSize::Compact => {
+                    this.px(DynamicSpacing::Base04.rems(cx))
+                }
                 ButtonSize::None => this,
             })
             .bg(style.enabled(self.layer, cx).background)

crates/ui/src/components/checkbox.rs πŸ”—

@@ -85,7 +85,7 @@ impl RenderOnce for Checkbox {
             .id(self.id)
             .justify_center()
             .items_center()
-            .size(crate::styles::custom_spacing(cx, 20.))
+            .size(DynamicSpacing::Base20.rems(cx))
             .group(group_id.clone())
             .child(
                 div()
@@ -93,8 +93,8 @@ impl RenderOnce for Checkbox {
                     .flex_none()
                     .justify_center()
                     .items_center()
-                    .m(Spacing::Small.px(cx))
-                    .size(crate::styles::custom_spacing(cx, 16.))
+                    .m(DynamicSpacing::Base04.px(cx))
+                    .size(DynamicSpacing::Base16.rems(cx))
                     .rounded_sm()
                     .bg(bg_color)
                     .border_1()
@@ -191,7 +191,7 @@ impl CheckboxWithLabel {
 impl RenderOnce for CheckboxWithLabel {
     fn render(self, cx: &mut WindowContext) -> impl IntoElement {
         h_flex()
-            .gap(Spacing::Large.rems(cx))
+            .gap(DynamicSpacing::Base08.rems(cx))
             .child(Checkbox::new(self.id.clone(), self.checked).on_click({
                 let on_click = self.on_click.clone();
                 move |checked, cx| {

crates/ui/src/components/icon.rs πŸ”—

@@ -90,10 +90,10 @@ impl IconSize {
     pub fn square_components(&self, cx: &mut WindowContext) -> (Pixels, Pixels) {
         let icon_size = self.rems() * cx.rem_size();
         let padding = match self {
-            IconSize::Indicator => Spacing::None.px(cx),
-            IconSize::XSmall => Spacing::XSmall.px(cx),
-            IconSize::Small => Spacing::XSmall.px(cx),
-            IconSize::Medium => Spacing::XSmall.px(cx),
+            IconSize::Indicator => DynamicSpacing::Base00.px(cx),
+            IconSize::XSmall => DynamicSpacing::Base02.px(cx),
+            IconSize::Small => DynamicSpacing::Base02.px(cx),
+            IconSize::Medium => DynamicSpacing::Base02.px(cx),
         };
 
         (icon_size, padding)

crates/ui/src/components/keybinding.rs πŸ”—

@@ -84,7 +84,7 @@ impl RenderOnce for KeyBinding {
                         .join(" ")
                 )
             })
-            .gap(Spacing::Small.rems(cx))
+            .gap(DynamicSpacing::Base04.rems(cx))
             .flex_none()
             .children(self.key_binding.keystrokes().iter().map(|keystroke| {
                 let key_icon = self.icon_for_key(keystroke);

crates/ui/src/components/list/list.rs πŸ”—

@@ -80,7 +80,7 @@ impl RenderOnce for List {
     fn render(self, cx: &mut WindowContext) -> impl IntoElement {
         v_flex()
             .w_full()
-            .py(Spacing::Small.rems(cx))
+            .py(DynamicSpacing::Base04.rems(cx))
             .children(self.header)
             .map(|this| match (self.children.is_empty(), self.toggle) {
                 (false, _) => this.children(self.children),

crates/ui/src/components/list/list_header.rs πŸ”—

@@ -104,10 +104,10 @@ impl RenderOnce for ListHeader {
                     .items_center()
                     .justify_between()
                     .w_full()
-                    .gap(Spacing::Small.rems(cx))
+                    .gap(DynamicSpacing::Base04.rems(cx))
                     .child(
                         h_flex()
-                            .gap(Spacing::Small.rems(cx))
+                            .gap(DynamicSpacing::Base04.rems(cx))
                             .children(self.toggle.map(|is_open| {
                                 Disclosure::new("toggle", is_open).on_toggle(self.on_toggle.clone())
                             }))
@@ -115,7 +115,7 @@ impl RenderOnce for ListHeader {
                                 div()
                                     .id("label_container")
                                     .flex()
-                                    .gap(Spacing::Small.rems(cx))
+                                    .gap(DynamicSpacing::Base04.rems(cx))
                                     .items_center()
                                     .children(self.start_slot)
                                     .child(Label::new(self.label.clone()).color(Color::Muted))

crates/ui/src/components/list/list_item.rs πŸ”—

@@ -171,7 +171,7 @@ impl RenderOnce for ListItem {
             // When an item is inset draw the indent spacing outside of the item
             .when(self.inset, |this| {
                 this.ml(self.indent_level as f32 * self.indent_step_size)
-                    .px(Spacing::Small.rems(cx))
+                    .px(DynamicSpacing::Base04.rems(cx))
             })
             .when(!self.inset && !self.disabled, |this| {
                 this
@@ -195,7 +195,7 @@ impl RenderOnce for ListItem {
                     .relative()
                     .items_center()
                     .gap_1()
-                    .px(Spacing::Medium.rems(cx))
+                    .px(DynamicSpacing::Base06.rems(cx))
                     .map(|this| match self.spacing {
                         ListItemSpacing::Dense => this,
                         ListItemSpacing::Sparse => this.py_1(),
@@ -248,7 +248,7 @@ impl RenderOnce for ListItem {
                             .flex_grow()
                             .flex_shrink_0()
                             .flex_basis(relative(0.25))
-                            .gap(Spacing::Medium.rems(cx))
+                            .gap(DynamicSpacing::Base06.rems(cx))
                             .map(|list_content| {
                                 if self.overflow_x {
                                     list_content
@@ -276,7 +276,7 @@ impl RenderOnce for ListItem {
                             h_flex()
                                 .h_full()
                                 .absolute()
-                                .right(Spacing::Medium.rems(cx))
+                                .right(DynamicSpacing::Base06.rems(cx))
                                 .top_0()
                                 .visible_on_hover("list_item")
                                 .child(end_hover_slot),

crates/ui/src/components/list/list_sub_header.rs πŸ”—

@@ -45,8 +45,8 @@ impl RenderOnce for ListSubHeader {
             .flex_1()
             .w_full()
             .relative()
-            .pb(Spacing::Small.rems(cx))
-            .px(Spacing::XSmall.rems(cx))
+            .pb(DynamicSpacing::Base04.rems(cx))
+            .px(DynamicSpacing::Base02.rems(cx))
             .child(
                 div()
                     .h_6()

crates/ui/src/components/modal.rs πŸ”—

@@ -1,8 +1,8 @@
 #![allow(missing_docs)]
 
 use crate::{
-    h_flex, v_flex, Clickable, Color, Headline, HeadlineSize, IconButton, IconButtonShape,
-    IconName, Label, LabelCommon, LabelSize, Spacing,
+    h_flex, v_flex, Clickable, Color, DynamicSpacing, Headline, HeadlineSize, IconButton,
+    IconButtonShape, IconName, Label, LabelCommon, LabelSize,
 };
 use gpui::{prelude::FluentBuilder, *};
 use smallvec::SmallVec;
@@ -78,7 +78,7 @@ impl RenderOnce for Modal {
                     .id(self.container_id.clone())
                     .w_full()
                     .flex_1()
-                    .gap(Spacing::Large.rems(cx))
+                    .gap(DynamicSpacing::Base08.rems(cx))
                     .when_some(
                         self.container_scroll_handler,
                         |this, container_scroll_handle| {
@@ -160,10 +160,10 @@ impl RenderOnce for ModalHeader {
             .flex_none()
             .justify_between()
             .w_full()
-            .px(Spacing::XLarge.rems(cx))
-            .pt(Spacing::Large.rems(cx))
-            .pb(Spacing::Small.rems(cx))
-            .gap(Spacing::Large.rems(cx))
+            .px(DynamicSpacing::Base12.rems(cx))
+            .pt(DynamicSpacing::Base08.rems(cx))
+            .pb(DynamicSpacing::Base04.rems(cx))
+            .gap(DynamicSpacing::Base08.rems(cx))
             .when(self.show_back_button, |this| {
                 this.child(
                     IconButton::new("back", IconName::ArrowLeft)
@@ -253,7 +253,7 @@ impl RenderOnce for ModalFooter {
         h_flex()
             .flex_none()
             .w_full()
-            .p(Spacing::Large.rems(cx))
+            .p(DynamicSpacing::Base08.rems(cx))
             .justify_between()
             .child(div().when_some(self.start_slot, |this, start_slot| this.child(start_slot)))
             .child(div().when_some(self.end_slot, |this, end_slot| this.child(end_slot)))
@@ -330,7 +330,7 @@ impl RenderOnce for Section {
         let children = if self.contained {
             v_flex()
                 .flex_1()
-                .when(self.padded, |this| this.px(Spacing::XLarge.rems(cx)))
+                .when(self.padded, |this| this.px(DynamicSpacing::Base12.rems(cx)))
                 .child(
                     v_flex()
                         .w_full()
@@ -338,17 +338,17 @@ impl RenderOnce for Section {
                         .border_1()
                         .border_color(cx.theme().colors().border)
                         .bg(section_bg)
-                        .py(Spacing::Medium.rems(cx))
-                        .gap_y(Spacing::Small.rems(cx))
+                        .py(DynamicSpacing::Base06.rems(cx))
+                        .gap_y(DynamicSpacing::Base04.rems(cx))
                         .child(div().flex().flex_1().size_full().children(self.children)),
                 )
         } else {
             v_flex()
                 .w_full()
                 .flex_1()
-                .gap_y(Spacing::Small.rems(cx))
+                .gap_y(DynamicSpacing::Base04.rems(cx))
                 .when(self.padded, |this| {
-                    this.px(Spacing::Medium.rems(cx) + Spacing::Medium.rems(cx))
+                    this.px(DynamicSpacing::Base06.rems(cx) + DynamicSpacing::Base06.rems(cx))
                 })
                 .children(self.children)
         };
@@ -359,7 +359,7 @@ impl RenderOnce for Section {
             .child(
                 v_flex()
                     .flex_none()
-                    .px(Spacing::XLarge.rems(cx))
+                    .px(DynamicSpacing::Base12.rems(cx))
                     .children(self.header)
                     .when_some(self.meta, |this, meta| {
                         this.child(Label::new(meta).size(LabelSize::Small).color(Color::Muted))
@@ -399,7 +399,7 @@ impl RenderOnce for SectionHeader {
         h_flex()
             .id(self.label.clone())
             .w_full()
-            .px(Spacing::Large.rems(cx))
+            .px(DynamicSpacing::Base08.rems(cx))
             .child(
                 div()
                     .h_7()
@@ -407,7 +407,7 @@ impl RenderOnce for SectionHeader {
                     .items_center()
                     .justify_between()
                     .w_full()
-                    .gap(Spacing::Small.rems(cx))
+                    .gap(DynamicSpacing::Base04.rems(cx))
                     .child(
                         div().flex_1().child(
                             Label::new(self.label.clone())

crates/ui/src/components/radio.rs πŸ”—

@@ -36,7 +36,7 @@ impl RenderOnce for RadioWithLabel {
         let border_width = rems_from_px(1.);
         h_flex()
             .id(self.id)
-            .gap(Spacing::Large.rems(cx))
+            .gap(DynamicSpacing::Base08.rems(cx))
             .group("")
             .child(
                 div()

crates/ui/src/components/tab.rs πŸ”—

@@ -158,8 +158,8 @@ impl RenderOnce for Tab {
                     .group("")
                     .relative()
                     .h(rems(Self::CONTENT_HEIGHT_IN_REMS))
-                    .px(crate::custom_spacing(cx, 4.))
-                    .gap(Spacing::Small.rems(cx))
+                    .px(DynamicSpacing::Base04.px(cx))
+                    .gap(DynamicSpacing::Base04.rems(cx))
                     .text_color(text_color)
                     .child(start_slot)
                     .children(self.children)

crates/ui/src/components/tab_bar.rs πŸ”—

@@ -107,8 +107,8 @@ impl RenderOnce for TabBar {
                 this.child(
                     h_flex()
                         .flex_none()
-                        .gap(Spacing::Small.rems(cx))
-                        .px(Spacing::Medium.rems(cx))
+                        .gap(DynamicSpacing::Base04.rems(cx))
+                        .px(DynamicSpacing::Base06.rems(cx))
                         .border_b_1()
                         .border_r_1()
                         .border_color(cx.theme().colors().border)
@@ -145,8 +145,8 @@ impl RenderOnce for TabBar {
                 this.child(
                     h_flex()
                         .flex_none()
-                        .gap(Spacing::Small.rems(cx))
-                        .px(Spacing::Medium.rems(cx))
+                        .gap(DynamicSpacing::Base04.rems(cx))
+                        .px(DynamicSpacing::Base06.rems(cx))
                         .border_b_1()
                         .border_l_1()
                         .border_color(cx.theme().colors().border)

crates/ui/src/components/tool_strip.rs πŸ”—

@@ -46,8 +46,8 @@ impl RenderOnce for ToolStrip {
                 Axis::Horizontal => element.h_flex(),
             })
             .flex_none()
-            .gap(Spacing::Small.rems(cx))
-            .p(Spacing::XSmall.rems(cx))
+            .gap(DynamicSpacing::Base04.rems(cx))
+            .p(DynamicSpacing::Base02.rems(cx))
             .border_1()
             .border_color(cx.theme().colors().border)
             .rounded(rems_from_px(6.0))

crates/ui/src/prelude.rs πŸ”—

@@ -15,7 +15,7 @@ pub use crate::traits::fixed::*;
 pub use crate::traits::selectable::*;
 pub use crate::traits::styled_ext::*;
 pub use crate::traits::visible_on_hover::*;
-pub use crate::Spacing;
+pub use crate::DynamicSpacing;
 pub use crate::{h_flex, v_flex};
 pub use crate::{Button, ButtonSize, ButtonStyle, IconButton, SelectableButton};
 pub use crate::{ButtonCommon, Color};

crates/ui/src/styles/spacing.rs πŸ”—

@@ -1,105 +1,32 @@
 use gpui::{px, rems, Pixels, Rems, WindowContext};
 use settings::Settings;
 use theme::{ThemeSettings, UiDensity};
-
-use crate::{rems_from_px, BASE_REM_SIZE_IN_PX};
-
-/// A dynamic spacing system that adjusts spacing based on
-/// [UiDensity].
+use ui_macros::derive_dynamic_spacing;
+
+// Derives [DynamicSpacing]. See [ui_macros::derive_dynamic_spacing].
+derive_dynamic_spacing![
+    (0, 0, 0),
+    (1, 1, 2),
+    (1, 2, 4),
+    (2, 3, 4),
+    (2, 4, 6),
+    (3, 6, 8),
+    (4, 8, 10),
+    (10, 12, 14),
+    (14, 16, 18),
+    (18, 20, 22),
+    24,
+    32,
+    40,
+    48
+];
+
+/// Returns the current [`UiDensity`] setting. Use this to
+/// modify or show something in the UI other than spacing.
 ///
-/// When possible, [Spacing] should be used over manual
-/// or built-in spacing values in places dynamic spacing is needed.
-#[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,
-    /// Extra Large spacing - @16px/rem: `8px`|`12px`|`16px`
-    ///
-    /// Relative to the user's `ui_font_size` and [UiDensity] setting.
-    XLarge,
-    /// 2X Large spacing - @16px/rem: `12px`|`16px`|`20px`
-    ///
-    /// Relative to the user's `ui_font_size` and [UiDensity] setting.
-    XXLarge,
-}
-
-impl Spacing {
-    /// Returns the spacing's scaling ratio in pixels.
-    pub fn spacing_ratio(self, cx: &WindowContext) -> f32 {
-        match ThemeSettings::get_global(cx).ui_density {
-            UiDensity::Compact => match self {
-                Spacing::None => 0.,
-                Spacing::XXSmall => 1. / BASE_REM_SIZE_IN_PX,
-                Spacing::XSmall => 1. / BASE_REM_SIZE_IN_PX,
-                Spacing::Small => 2. / BASE_REM_SIZE_IN_PX,
-                Spacing::Medium => 3. / BASE_REM_SIZE_IN_PX,
-                Spacing::Large => 4. / BASE_REM_SIZE_IN_PX,
-                Spacing::XLarge => 8. / BASE_REM_SIZE_IN_PX,
-                Spacing::XXLarge => 12. / BASE_REM_SIZE_IN_PX,
-            },
-            UiDensity::Default => match self {
-                Spacing::None => 0.,
-                Spacing::XXSmall => 1. / BASE_REM_SIZE_IN_PX,
-                Spacing::XSmall => 2. / BASE_REM_SIZE_IN_PX,
-                Spacing::Small => 4. / BASE_REM_SIZE_IN_PX,
-                Spacing::Medium => 6. / BASE_REM_SIZE_IN_PX,
-                Spacing::Large => 8. / BASE_REM_SIZE_IN_PX,
-                Spacing::XLarge => 12. / BASE_REM_SIZE_IN_PX,
-                Spacing::XXLarge => 16. / BASE_REM_SIZE_IN_PX,
-            },
-            UiDensity::Comfortable => match self {
-                Spacing::None => 0.,
-                Spacing::XXSmall => 2. / BASE_REM_SIZE_IN_PX,
-                Spacing::XSmall => 3. / BASE_REM_SIZE_IN_PX,
-                Spacing::Small => 6. / BASE_REM_SIZE_IN_PX,
-                Spacing::Medium => 8. / BASE_REM_SIZE_IN_PX,
-                Spacing::Large => 10. / BASE_REM_SIZE_IN_PX,
-                Spacing::XLarge => 16. / BASE_REM_SIZE_IN_PX,
-                Spacing::XXLarge => 20. / BASE_REM_SIZE_IN_PX,
-            },
-        }
-    }
-
-    /// Returns the spacing's value in rems.
-    pub fn rems(self, cx: &WindowContext) -> Rems {
-        rems(self.spacing_ratio(cx))
-    }
-
-    /// Returns the spacing's value in pixels.
-    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))
-    }
-}
-
-fn user_spacing_style(cx: &WindowContext) -> UiDensity {
-    ThemeSettings::get_global(cx).ui_density
-}
-
-/// Returns a custom spacing value based on the current [`UiDensity`].
+/// Do not use this to calculate spacing values.
 ///
-/// If you use this, talk to @iamnbutler and let me know what you're doing
-/// that needs custom spacing– I'd love to understand so we can extend the system further and remove the need for this.
-pub fn custom_spacing(cx: &WindowContext, size: f32) -> Rems {
-    rems_from_px(size * user_spacing_style(cx).spacing_ratio())
+/// Always use [DynamicSpacing] for spacing values.
+pub fn ui_density(cx: &WindowContext) -> UiDensity {
+    ThemeSettings::get_global(cx).ui_density
 }

crates/ui_macros/src/dynamic_spacing.rs πŸ”—

@@ -0,0 +1,163 @@
+use proc_macro::TokenStream;
+use quote::{format_ident, quote};
+use syn::{
+    parse::Parse, parse::ParseStream, parse_macro_input, punctuated::Punctuated, LitInt, Token,
+};
+
+struct DynamicSpacingInput {
+    values: Punctuated<DynamicSpacingValue, Token![,]>,
+}
+
+// The input for the derive macro is a list of values.
+//
+// When a single value is provided, the standard spacing formula is
+// used to derive the of spacing values.
+//
+// When a tuple of three values is provided, the values are used as
+// the spacing values directly.
+enum DynamicSpacingValue {
+    Single(LitInt),
+    Tuple(LitInt, LitInt, LitInt),
+}
+
+impl Parse for DynamicSpacingInput {
+    fn parse(input: ParseStream) -> syn::Result<Self> {
+        Ok(DynamicSpacingInput {
+            values: input.parse_terminated(DynamicSpacingValue::parse)?,
+        })
+    }
+}
+
+impl Parse for DynamicSpacingValue {
+    fn parse(input: ParseStream) -> syn::Result<Self> {
+        if input.peek(syn::token::Paren) {
+            let content;
+            syn::parenthesized!(content in input);
+            let a: LitInt = content.parse()?;
+            content.parse::<Token![,]>()?;
+            let b: LitInt = content.parse()?;
+            content.parse::<Token![,]>()?;
+            let c: LitInt = content.parse()?;
+            Ok(DynamicSpacingValue::Tuple(a, b, c))
+        } else {
+            Ok(DynamicSpacingValue::Single(input.parse()?))
+        }
+    }
+}
+
+/// Derives the spacing method for the `DynamicSpacing` enum.
+pub fn derive_spacing(input: TokenStream) -> TokenStream {
+    let input = parse_macro_input!(input as DynamicSpacingInput);
+
+    let spacing_ratios: Vec<_> = input
+        .values
+        .iter()
+        .map(|v| {
+            let variant = match v {
+                DynamicSpacingValue::Single(n) => {
+                    format_ident!("Base{:02}", n.base10_parse::<u32>().unwrap())
+                }
+                DynamicSpacingValue::Tuple(_, b, _) => {
+                    format_ident!("Base{:02}", b.base10_parse::<u32>().unwrap())
+                }
+            };
+            match v {
+                DynamicSpacingValue::Single(n) => {
+                    let n = n.base10_parse::<f32>().unwrap();
+                    quote! {
+                        DynamicSpacing::#variant => match ThemeSettings::get_global(cx).ui_density {
+                            UiDensity::Compact => (#n - 4.0).max(0.0) / BASE_REM_SIZE_IN_PX,
+                            UiDensity::Default => #n / BASE_REM_SIZE_IN_PX,
+                            UiDensity::Comfortable => (#n + 4.0) / BASE_REM_SIZE_IN_PX,
+                        }
+                    }
+                }
+                DynamicSpacingValue::Tuple(a, b, c) => {
+                    let a = a.base10_parse::<f32>().unwrap();
+                    let b = b.base10_parse::<f32>().unwrap();
+                    let c = c.base10_parse::<f32>().unwrap();
+                    quote! {
+                        DynamicSpacing::#variant => match ThemeSettings::get_global(cx).ui_density {
+                            UiDensity::Compact => #a / BASE_REM_SIZE_IN_PX,
+                            UiDensity::Default => #b / BASE_REM_SIZE_IN_PX,
+                            UiDensity::Comfortable => #c / BASE_REM_SIZE_IN_PX,
+                        }
+                    }
+                }
+            }
+        })
+        .collect();
+
+    let variant_docs: Vec<_> = input
+        .values
+        .iter()
+        .map(|v| {
+            let variant = match v {
+                DynamicSpacingValue::Single(n) => format_ident!("Base{:02}", n.base10_parse::<u32>().unwrap()),
+                DynamicSpacingValue::Tuple(_, b, _) => format_ident!("Base{:02}", b.base10_parse::<u32>().unwrap()),
+            };
+            match v {
+                DynamicSpacingValue::Single(n) => {
+                    // When a single value is passed in, derive the compact and comfortable values.
+                    let n = n.base10_parse::<f32>().unwrap();
+                    let compact = (n - 4.0).max(0.0);
+                    let comfortable = n + 4.0;
+                    quote! {
+                        #[doc = concat!("@16px/rem: `", stringify!(#compact), "px`|`", stringify!(#n), "px`|`", stringify!(#comfortable), "px` - Scales with the user's rem size.")]
+                        #variant,
+                    }
+                }
+                DynamicSpacingValue::Tuple(a, b, c) => {
+                    let a = a.base10_parse::<f32>().unwrap();
+                    let b = b.base10_parse::<f32>().unwrap();
+                    let c = c.base10_parse::<f32>().unwrap();
+                    quote! {
+                        #[doc = concat!("@16px/rem: `", stringify!(#a), "px`|`", stringify!(#b), "px`|`", stringify!(#c), "px` - Scales with the user's rem size.")]
+                        #variant,
+                    }
+                }
+            }
+        })
+        .collect();
+
+    let expanded = quote! {
+        /// A dynamic spacing system that adjusts spacing based on
+        /// [UiDensity].
+        ///
+        /// The number following "Base" refers to the base pixel size
+        /// at the default rem size and spacing settings.
+        ///
+        /// When possible, [DynamicSpacing] should be used over manual
+        /// or built-in spacing values in places dynamic spacing is needed.
+        #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
+        pub enum DynamicSpacing {
+            #(
+                #[doc = stringify!(#variant_docs)]
+                #variant_docs
+            )*
+        }
+
+        impl DynamicSpacing {
+            /// Returns the spacing ratio, should only be used internally.
+            fn spacing_ratio(&self, cx: &WindowContext) -> f32 {
+                const BASE_REM_SIZE_IN_PX: f32 = 16.0;
+                match self {
+                    #(#spacing_ratios,)*
+                }
+            }
+
+            /// Returns the spacing value in rems.
+            pub fn rems(&self, cx: &WindowContext) -> Rems {
+                rems(self.spacing_ratio(cx))
+            }
+
+            /// Returns the spacing value in pixels.
+            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))
+            }
+        }
+    };
+
+    TokenStream::from(expanded)
+}

crates/ui_macros/src/ui_macros.rs πŸ”—

@@ -1,4 +1,5 @@
 mod derive_path_str;
+mod dynamic_spacing;
 
 use proc_macro::TokenStream;
 
@@ -51,3 +52,9 @@ pub fn path_str(_args: TokenStream, input: TokenStream) -> TokenStream {
     // This attribute doesn't modify the input, it's just a marker
     input
 }
+
+/// Generates the DynamicSpacing enum used for density-aware spacing in the UI.
+#[proc_macro]
+pub fn derive_dynamic_spacing(input: TokenStream) -> TokenStream {
+    dynamic_spacing::derive_spacing(input)
+}

crates/workspace/src/pane.rs πŸ”—

@@ -421,7 +421,7 @@ impl Pane {
                 // `end_slot`, but due to needing a view here that isn't possible.
                 let right_children = h_flex()
                     // Instead we need to replicate the spacing from the [TabBar]'s `end_slot` here.
-                    .gap(Spacing::Small.rems(cx))
+                    .gap(DynamicSpacing::Base04.rems(cx))
                     .child(
                         PopoverMenu::new("pane-tab-bar-popover-menu")
                             .trigger(

crates/workspace/src/status_bar.rs πŸ”—

@@ -38,9 +38,9 @@ impl Render for StatusBar {
         h_flex()
             .w_full()
             .justify_between()
-            .gap(Spacing::Large.rems(cx))
-            .py(Spacing::Small.rems(cx))
-            .px(Spacing::Large.rems(cx))
+            .gap(DynamicSpacing::Base08.rems(cx))
+            .py(DynamicSpacing::Base04.rems(cx))
+            .px(DynamicSpacing::Base08.rems(cx))
             .bg(cx.theme().colors().status_bar_background)
             .map(|el| match cx.window_decorations() {
                 Decorations::Server => el,
@@ -64,14 +64,14 @@ impl Render for StatusBar {
 impl StatusBar {
     fn render_left_tools(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
         h_flex()
-            .gap(Spacing::Large.rems(cx))
+            .gap(DynamicSpacing::Base08.rems(cx))
             .overflow_x_hidden()
             .children(self.left_items.iter().map(|item| item.to_any()))
     }
 
     fn render_right_tools(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
         h_flex()
-            .gap(Spacing::Large.rems(cx))
+            .gap(DynamicSpacing::Base08.rems(cx))
             .children(self.right_items.iter().rev().map(|item| item.to_any()))
     }
 }

crates/workspace/src/toolbar.rs πŸ”—

@@ -97,9 +97,9 @@ impl Render for Toolbar {
 
         v_flex()
             .group("toolbar")
-            .p(Spacing::Large.rems(cx))
+            .p(DynamicSpacing::Base08.rems(cx))
             .when(has_left_items || has_right_items, |this| {
-                this.gap(Spacing::Large.rems(cx))
+                this.gap(DynamicSpacing::Base08.rems(cx))
             })
             .border_b_1()
             .border_color(cx.theme().colors().border_variant)
@@ -109,7 +109,7 @@ impl Render for Toolbar {
                     h_flex()
                         .min_h(rems_from_px(24.))
                         .justify_between()
-                        .gap(Spacing::Large.rems(cx))
+                        .gap(DynamicSpacing::Base08.rems(cx))
                         .when(has_left_items, |this| {
                             this.child(
                                 h_flex()