Refinement

Nate Butler created

Change summary

crates/onboarding_ui/src/onboarding_ui.rs |  71 ++++---
crates/onboarding_ui/src/theme_preview.rs |   4 
crates/ui/src/components.rs               |   2 
crates/ui/src/components/ring.rs          |  66 +++++++
crates/ui/src/utils.rs                    |   2 
crates/ui/src/utils/corner_solver.rs      | 221 ++++++++++++++++++++----
6 files changed, 287 insertions(+), 79 deletions(-)

Detailed changes

crates/onboarding_ui/src/onboarding_ui.rs 🔗

@@ -8,8 +8,8 @@ use client::{Client, TelemetrySettings};
 use command_palette_hooks::CommandPaletteFilter;
 use feature_flags::FeatureFlagAppExt as _;
 use gpui::{
-    Action, Entity, EventEmitter, FocusHandle, Focusable, KeyBinding, Task, UpdateGlobal,
-    WeakEntity, actions, prelude::*, svg, transparent_black,
+    Action, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, KeyBinding, Task,
+    UpdateGlobal, WeakEntity, actions, prelude::*, svg, transparent_black,
 };
 use menu;
 use persistence::ONBOARDING_DB;
@@ -20,7 +20,7 @@ use settings::{Settings, SettingsStore};
 use settings_ui::SettingsUiFeatureFlag;
 use std::sync::Arc;
 use theme::{Theme, ThemeRegistry, ThemeSettings};
-use ui::{ListItem, ToggleState, Vector, VectorName, prelude::*};
+use ui::{KeybindingHint, ListItem, Ring, ToggleState, Vector, VectorName, prelude::*};
 use util::ResultExt;
 use vim_mode_setting::VimModeSetting;
 use welcome::BaseKeymap;
@@ -567,7 +567,7 @@ impl OnboardingUI {
                         v_flex()
                             .gap_px()
                             .py(px(16.))
-                            .gap(px(12.))
+                            .gap(px(2.))
                             .child(self.render_nav_item(
                                 OnboardingPage::Basics,
                                 "The Basics",
@@ -604,12 +604,13 @@ impl OnboardingUI {
         shortcut: impl Into<SharedString>,
         cx: &mut Context<Self>,
     ) -> impl gpui::IntoElement {
-        let selected = self.current_page == page;
+        let is_selected = self.current_page == page;
         let label = label.into();
         let shortcut = shortcut.into();
         let id = ElementId::Name(label.clone());
+        let corner_radius = px(4.);
 
-        let is_focused = match page {
+        let item_focused = match page {
             OnboardingPage::Basics => self.nav_focus == NavigationFocusItem::Basics,
             OnboardingPage::Editing => self.nav_focus == NavigationFocusItem::Editing,
             OnboardingPage::AiSetup => self.nav_focus == NavigationFocusItem::AiSetup,
@@ -618,35 +619,39 @@ impl OnboardingUI {
 
         let area_focused = self.focus_area == FocusArea::Navigation;
 
-        h_flex()
-            .id(id)
-            .h(rems(1.5))
-            .w_full()
-            .when(is_focused, |this| {
-                this.bg(if area_focused {
-                    cx.theme().colors().border_focused.opacity(0.16)
-                } else {
-                    cx.theme().colors().border.opacity(0.24)
-                })
-            })
-            .child(
-                div()
-                    .w(px(3.))
-                    .h_full()
-                    .when(selected, |this| this.bg(cx.theme().colors().border_focused)),
-            )
+        Ring::new(corner_radius, item_focused)
+            .active(area_focused && item_focused)
             .child(
                 h_flex()
-                    .pl(px(23.))
-                    .flex_1()
-                    .justify_between()
-                    .items_center()
-                    .child(Label::new(label).when(is_focused, |this| this.color(Color::Default)))
-                    .child(Label::new(format!("⌘{}", shortcut.clone())).color(Color::Muted)),
+                    .id(id)
+                    .h(rems(1.625))
+                    .w_full()
+                    .rounded(corner_radius)
+                    .px_3()
+                    .when(is_selected, |this| {
+                        this.bg(cx.theme().colors().border_focused.opacity(0.16))
+                    })
+                    .child(
+                        h_flex()
+                            .flex_1()
+                            .justify_between()
+                            .items_center()
+                            .child(
+                                Label::new(label)
+                                    .weight(FontWeight::MEDIUM)
+                                    .color(Color::Muted)
+                                    .when(item_focused, |this| this.color(Color::Default)),
+                            )
+                            .child(
+                                Label::new(format!("⌘{}", shortcut.clone()))
+                                    .color(Color::Placeholder)
+                                    .size(LabelSize::XSmall),
+                            ),
+                    )
+                    .on_click(cx.listener(move |this, _, window, cx| {
+                        this.jump_to_page(page, window, cx);
+                    })),
             )
-            .on_click(cx.listener(move |this, _, window, cx| {
-                this.jump_to_page(page, window, cx);
-            }))
     }
 
     fn render_bottom_controls(
@@ -654,7 +659,7 @@ impl OnboardingUI {
         window: &mut gpui::Window,
         cx: &mut Context<Self>,
     ) -> impl gpui::IntoElement {
-        h_flex().w_full().p(px(12.)).pl(px(24.)).child(
+        h_flex().w_full().p(px(12.)).child(
             JuicyButton::new(if self.current_page == OnboardingPage::Welcome {
                 "Get Started"
             } else {

crates/onboarding_ui/src/theme_preview.rs 🔗

@@ -2,7 +2,7 @@
 use gpui::{Hsla, Length};
 use std::sync::Arc;
 use theme::{Theme, ThemeRegistry};
-use ui::{IntoElement, RenderOnce, prelude::*, utils::inner_corner_radius};
+use ui::{IntoElement, RenderOnce, prelude::*, utils::CornerSolver};
 
 /// Shows a preview of a theme as an abstract illustration
 /// of a thumbnail-sized editor.
@@ -37,7 +37,7 @@ impl RenderOnce for ThemePreviewTile {
         let root_padding = px(0.0);
         let child_border = px(1.0);
         let inner_radius =
-            inner_corner_radius(root_radius, root_border, root_padding, child_border);
+            CornerSolver::child_radius(root_radius, root_border, root_padding, child_border);
 
         let item_skeleton = |w: Length, h: Pixels, bg: Hsla| div().w(w).h(h).rounded_full().bg(bg);
 

crates/ui/src/components.rs 🔗

@@ -27,6 +27,7 @@ mod popover_menu;
 mod progress;
 mod radio;
 mod right_click_menu;
+mod ring;
 mod scrollbar;
 mod settings_container;
 mod settings_group;
@@ -69,6 +70,7 @@ pub use popover_menu::*;
 pub use progress::*;
 pub use radio::*;
 pub use right_click_menu::*;
+pub use ring::*;
 pub use scrollbar::*;
 pub use settings_container::*;
 pub use settings_group::*;

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

@@ -0,0 +1,66 @@
+use gpui::{
+    AnyElement, IntoElement, ParentElement, Pixels, RenderOnce, Styled, px, transparent_black,
+};
+use smallvec::SmallVec;
+use theme::ActiveTheme;
+
+use crate::{h_flex, utils::CornerSolver};
+
+/// A ring is a stylistic focus indicator that draws a ring around
+/// an element with some space between the element and ring.
+#[derive(IntoElement)]
+pub struct Ring {
+    corner_radius: Pixels,
+    border_width: Pixels,
+    padding: Pixels,
+    focused: bool,
+    active: bool,
+    children: SmallVec<[AnyElement; 2]>,
+}
+
+impl Ring {
+    pub fn new(child_corner_radius: Pixels, focused: bool) -> Self {
+        let border_width = px(1.);
+        let padding = px(2.);
+        let corner_radius =
+            CornerSolver::parent_radius(child_corner_radius, border_width, padding, px(0.));
+        Self {
+            corner_radius,
+            border_width,
+            padding,
+            focused,
+            active: false,
+            children: SmallVec::new(),
+        }
+    }
+
+    pub fn active(mut self, active: bool) -> Self {
+        self.active = active;
+        self
+    }
+}
+
+impl RenderOnce for Ring {
+    fn render(self, window: &mut gpui::Window, cx: &mut gpui::App) -> impl IntoElement {
+        let border_color = if self.focused && self.active {
+            cx.theme().colors().border_focused.opacity(0.48)
+        } else if self.focused {
+            cx.theme().colors().border_variant
+        } else {
+            transparent_black()
+        };
+
+        h_flex()
+            .border(self.border_width)
+            .border_color(border_color)
+            .rounded(self.corner_radius)
+            .p(self.padding)
+            .children(self.children)
+    }
+}
+
+impl ParentElement for Ring {
+    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
+        self.children.extend(elements)
+    }
+}

crates/ui/src/utils.rs 🔗

@@ -10,7 +10,7 @@ mod search_input;
 mod with_rem_size;
 
 pub use color_contrast::*;
-pub use corner_solver::{CornerSolver, inner_corner_radius};
+pub use corner_solver::{CornerSolver, NestedCornerSolver};
 pub use format_distance::*;
 pub use search_input::*;
 pub use with_rem_size::*;

crates/ui/src/utils/corner_solver.rs 🔗

@@ -1,61 +1,196 @@
 use gpui::Pixels;
 
-/// Calculates the child’s content-corner radius for a single nested level.
+/// Calculates corner radii for nested elements in both directions.
 ///
-/// child_content_radius = max(0, parent_radius - parent_border - parent_padding + self_border)
+/// ## Forward calculation (parent → child)
+/// Given a parent's corner radius, calculates the child's corner radius:
+/// ```
+/// child_radius = max(0, parent_radius - parent_border - parent_padding + child_border)
+/// ```
 ///
-/// - parent_radius: outer corner radius of the parent element
-/// - parent_border: border width of the parent element
-/// - parent_padding: padding of the parent element
-/// - self_border: border width of this child element (for content inset)
-pub fn inner_corner_radius(
-    parent_radius: Pixels,
-    parent_border: Pixels,
-    parent_padding: Pixels,
-    self_border: Pixels,
-) -> Pixels {
-    (parent_radius - parent_border - parent_padding + self_border).max(Pixels::ZERO)
+/// ## Inverse calculation (child → parent)
+/// Given a child's desired corner radius, calculates the required parent radius:
+/// ```
+/// parent_radius = child_radius + parent_border + parent_padding - child_border
+/// ```
+pub struct CornerSolver;
+
+impl CornerSolver {
+    /// Calculates the child's corner radius given the parent's properties.
+    ///
+    /// # Arguments
+    /// - `parent_radius`: Outer corner radius of the parent element
+    /// - `parent_border`: Border width of the parent element
+    /// - `parent_padding`: Padding of the parent element
+    /// - `child_border`: Border width of the child element
+    pub fn child_radius(
+        parent_radius: Pixels,
+        parent_border: Pixels,
+        parent_padding: Pixels,
+        child_border: Pixels,
+    ) -> Pixels {
+        (parent_radius - parent_border - parent_padding + child_border).max(Pixels::ZERO)
+    }
+
+    /// Calculates the required parent radius to achieve a desired child radius.
+    ///
+    /// # Arguments
+    /// - `child_radius`: Desired corner radius for the child element
+    /// - `parent_border`: Border width of the parent element
+    /// - `parent_padding`: Padding of the parent element
+    /// - `child_border`: Border width of the child element
+    pub fn parent_radius(
+        child_radius: Pixels,
+        parent_border: Pixels,
+        parent_padding: Pixels,
+        child_border: Pixels,
+    ) -> Pixels {
+        child_radius + parent_border + parent_padding - child_border
+    }
 }
 
-/// Solver for arbitrarily deep nested corner radii.
-///
-/// Each nested level’s outer border-box radius is:
-///   R₀ = max(0, root_radius - root_border - root_padding)
-///   Rᵢ = max(0, Rᵢ₋₁ - childᵢ₋₁_border - childᵢ₋₁_padding) for i > 0
-pub struct CornerSolver {
-    root_radius: Pixels,
-    root_border: Pixels,
-    root_padding: Pixels,
-    children: Vec<(Pixels, Pixels)>, // (border, padding)
+/// Builder for calculating corner radii across multiple nested levels.
+pub struct NestedCornerSolver {
+    levels: Vec<Level>,
 }
 
-impl CornerSolver {
-    pub fn new(root_radius: Pixels, root_border: Pixels, root_padding: Pixels) -> Self {
-        Self {
-            root_radius,
-            root_border,
-            root_padding,
-            children: Vec::new(),
-        }
+#[derive(Debug, Clone, Copy)]
+struct Level {
+    border: Pixels,
+    padding: Pixels,
+}
+
+impl NestedCornerSolver {
+    /// Creates a new nested corner solver.
+    pub fn new() -> Self {
+        Self { levels: Vec::new() }
     }
 
-    pub fn add_child(mut self, border: Pixels, padding: Pixels) -> Self {
-        self.children.push((border, padding));
+    /// Adds a level to the nesting hierarchy.
+    ///
+    /// Levels should be added from outermost to innermost.
+    pub fn add_level(mut self, border: Pixels, padding: Pixels) -> Self {
+        self.levels.push(Level { border, padding });
         self
     }
 
-    pub fn corner_radius(&self, level: usize) -> Pixels {
-        if level == 0 {
-            return (self.root_radius - self.root_border - self.root_padding).max(Pixels::ZERO);
+    /// Calculates the corner radius at a specific nesting level given the root radius.
+    ///
+    /// # Arguments
+    /// - `root_radius`: The outermost corner radius
+    /// - `level`: The nesting level (0 = first child of root, 1 = grandchild, etc.)
+    pub fn radius_at_level(&self, root_radius: Pixels, level: usize) -> Pixels {
+        let mut radius = root_radius;
+
+        for i in 0..=level.min(self.levels.len().saturating_sub(1)) {
+            let current_level = &self.levels[i];
+            let next_border = if i < self.levels.len() - 1 {
+                self.levels[i + 1].border
+            } else {
+                Pixels::ZERO
+            };
+
+            radius = CornerSolver::child_radius(
+                radius,
+                current_level.border,
+                current_level.padding,
+                next_border,
+            );
         }
-        if level >= self.children.len() {
-            return Pixels::ZERO;
+
+        radius
+    }
+
+    /// Calculates the required root radius to achieve a desired radius at a specific level.
+    ///
+    /// # Arguments
+    /// - `target_radius`: The desired corner radius at the target level
+    /// - `target_level`: The nesting level where the target radius should be achieved
+    pub fn root_radius_for_level(&self, target_radius: Pixels, target_level: usize) -> Pixels {
+        if target_level >= self.levels.len() {
+            return target_radius;
         }
-        let mut r = (self.root_radius - self.root_border - self.root_padding).max(Pixels::ZERO);
-        for i in 0..level {
-            let (b, p) = self.children[i];
-            r = (r - b - p).max(Pixels::ZERO);
+
+        let mut radius = target_radius;
+
+        // Work backwards from the target level to the root
+        for i in (0..=target_level).rev() {
+            let current_level = &self.levels[i];
+            let child_border = if i < self.levels.len() - 1 {
+                self.levels[i + 1].border
+            } else {
+                Pixels::ZERO
+            };
+
+            radius = CornerSolver::parent_radius(
+                radius,
+                current_level.border,
+                current_level.padding,
+                child_border,
+            );
         }
-        r
+
+        radius
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_forward_calculation() {
+        let parent_radius = Pixels(20.0);
+        let parent_border = Pixels(2.0);
+        let parent_padding = Pixels(8.0);
+        let child_border = Pixels(1.0);
+
+        let child_radius =
+            CornerSolver::child_radius(parent_radius, parent_border, parent_padding, child_border);
+
+        assert_eq!(child_radius, Pixels(11.0)); // 20 - 2 - 8 + 1 = 11
+    }
+
+    #[test]
+    fn test_inverse_calculation() {
+        let child_radius = Pixels(11.0);
+        let parent_border = Pixels(2.0);
+        let parent_padding = Pixels(8.0);
+        let child_border = Pixels(1.0);
+
+        let parent_radius =
+            CornerSolver::parent_radius(child_radius, parent_border, parent_padding, child_border);
+
+        assert_eq!(parent_radius, Pixels(20.0)); // 11 + 2 + 8 - 1 = 20
+    }
+
+    #[test]
+    fn test_nested_forward() {
+        let solver = NestedCornerSolver::new()
+            .add_level(Pixels(2.0), Pixels(8.0)) // Root level
+            .add_level(Pixels(1.0), Pixels(4.0)) // First child
+            .add_level(Pixels(1.0), Pixels(2.0)); // Second child
+
+        let root_radius = Pixels(20.0);
+
+        assert_eq!(solver.radius_at_level(root_radius, 0), Pixels(11.0)); // 20 - 2 - 8 + 1
+        assert_eq!(solver.radius_at_level(root_radius, 1), Pixels(7.0)); // 11 - 1 - 4 + 1
+        assert_eq!(solver.radius_at_level(root_radius, 2), Pixels(4.0)); // 7 - 1 - 2 + 0
+    }
+
+    #[test]
+    fn test_nested_inverse() {
+        let solver = NestedCornerSolver::new()
+            .add_level(Pixels(2.0), Pixels(8.0)) // Root level
+            .add_level(Pixels(1.0), Pixels(4.0)) // First child
+            .add_level(Pixels(1.0), Pixels(2.0)); // Second child
+
+        let target_radius = Pixels(4.0);
+        let root_radius = solver.root_radius_for_level(target_radius, 2);
+
+        assert_eq!(root_radius, Pixels(20.0));
+
+        // Verify by calculating forward
+        assert_eq!(solver.radius_at_level(root_radius, 2), target_radius);
     }
 }