From b31af62d423f0e6f2c0949cf77f80fb83de74472 Mon Sep 17 00:00:00 2001 From: Nate Butler Date: Thu, 3 Jul 2025 13:37:49 -0400 Subject: [PATCH] Refinement --- 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(-) create mode 100644 crates/ui/src/components/ring.rs diff --git a/crates/onboarding_ui/src/onboarding_ui.rs b/crates/onboarding_ui/src/onboarding_ui.rs index 84527baafd690eef860d1d9418039697dc0c026c..ea71a584f8f54cfde6b6200aaf7a5253a1e92142 100644 --- a/crates/onboarding_ui/src/onboarding_ui.rs +++ b/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, cx: &mut Context, ) -> 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, ) -> 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 { diff --git a/crates/onboarding_ui/src/theme_preview.rs b/crates/onboarding_ui/src/theme_preview.rs index e5d2accd76db8798e4b6420c97fc6ddf1ba594cb..28c9f0f2311523e0575bd78d999ac34e4ccfbfd1 100644 --- a/crates/onboarding_ui/src/theme_preview.rs +++ b/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); diff --git a/crates/ui/src/components.rs b/crates/ui/src/components.rs index e23d76a84b015dc35aa3256aa0d3a80bde9f16c2..176f1ec5c5c232ed328719ac80f95090bacfb597 100644 --- a/crates/ui/src/components.rs +++ b/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::*; diff --git a/crates/ui/src/components/ring.rs b/crates/ui/src/components/ring.rs new file mode 100644 index 0000000000000000000000000000000000000000..59047d2d6df381de903a86d985f8fdb734d0eed4 --- /dev/null +++ b/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) { + self.children.extend(elements) + } +} diff --git a/crates/ui/src/utils.rs b/crates/ui/src/utils.rs index 26a59001f6e59f5d675363b7c45b701514785e7a..bcb4241ea4525175fd85ffcfd574fe14334a3c2f 100644 --- a/crates/ui/src/utils.rs +++ b/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::*; diff --git a/crates/ui/src/utils/corner_solver.rs b/crates/ui/src/utils/corner_solver.rs index c49bccc44509d644650260819848aa92667964d5..38813c303cb0a11f9521281b36e74422694f289e 100644 --- a/crates/ui/src/utils/corner_solver.rs +++ b/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, } -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); } }