welcome: Theme preview tile (#29689)

Nate Butler created

![CleanShot 2025-04-30 at 13 26
44@2x](https://github.com/user-attachments/assets/f68fefe2-84a1-48b7-b9a2-47c2547cd06b)


- Adds the ThemePreviewTile component, used for upcoming onboarding UI
- Adds the CornerSolver utility for resolving correct nested corner
radii

Release Notes:

- N/A

Change summary

Cargo.lock                                     |   4 
Cargo.toml                                     |   2 
crates/ui/Cargo.toml                           |   2 
crates/ui/src/utils.rs                         |   2 
crates/ui/src/utils/corner_solver.rs           |  61 ++++
crates/welcome/Cargo.toml                      |   6 
crates/welcome/src/welcome.rs                  |   9 
crates/welcome/src/welcome_ui.rs               |   1 
crates/welcome/src/welcome_ui/theme_preview.rs | 280 ++++++++++++++++++++
9 files changed, 361 insertions(+), 6 deletions(-)

Detailed changes

Cargo.lock ๐Ÿ”—

@@ -16878,18 +16878,22 @@ version = "0.1.0"
 dependencies = [
  "anyhow",
  "client",
+ "component",
  "db",
+ "documented",
  "editor",
  "fuzzy",
  "gpui",
  "install_cli",
  "language",
+ "linkme",
  "picker",
  "project",
  "schemars",
  "serde",
  "settings",
  "telemetry",
+ "theme",
  "ui",
  "util",
  "vim_mode_setting",

Cargo.toml ๐Ÿ”—

@@ -435,6 +435,7 @@ dap-types = { git = "https://github.com/zed-industries/dap-types", rev = "be69a0
 dashmap = "6.0"
 derive_more = "0.99.17"
 dirs = "4.0"
+documented = "0.9.1"
 dotenv = "0.15.0"
 ec4rs = "1.1"
 emojis = "0.6.1"
@@ -797,5 +798,6 @@ ignored = [
     "serde",
     "component",
     "linkme",
+    "documented",
     "workspace-hack",
 ]

crates/ui/Cargo.toml ๐Ÿ”—

@@ -15,6 +15,7 @@ path = "src/ui.rs"
 [dependencies]
 chrono.workspace = true
 component.workspace = true
+documented.workspace = true
 gpui.workspace = true
 icons.workspace = true
 itertools.workspace = true
@@ -28,7 +29,6 @@ strum.workspace = true
 theme.workspace = true
 ui_macros.workspace = true
 util.workspace = true
-documented = "0.9.1"
 workspace-hack.workspace = true
 
 [target.'cfg(windows)'.dependencies]

crates/ui/src/utils.rs ๐Ÿ”—

@@ -4,11 +4,13 @@ use gpui::App;
 use theme::ActiveTheme;
 
 mod color_contrast;
+mod corner_solver;
 mod format_distance;
 mod search_input;
 mod with_rem_size;
 
 pub use color_contrast::*;
+pub use corner_solver::{CornerSolver, inner_corner_radius};
 pub use format_distance::*;
 pub use search_input::*;
 pub use with_rem_size::*;

crates/ui/src/utils/corner_solver.rs ๐Ÿ”—

@@ -0,0 +1,61 @@
+use gpui::Pixels;
+
+/// Calculates the childโ€™s content-corner radius for a single nested level.
+///
+/// child_content_radius = max(0, parent_radius - parent_border - parent_padding + self_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)
+}
+
+/// 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)
+}
+
+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(),
+        }
+    }
+
+    pub fn add_child(mut self, border: Pixels, padding: Pixels) -> Self {
+        self.children.push((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);
+        }
+        if level >= self.children.len() {
+            return Pixels::ZERO;
+        }
+        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);
+        }
+        r
+    }
+}

crates/welcome/Cargo.toml ๐Ÿ”—

@@ -17,23 +17,27 @@ test-support = []
 [dependencies]
 anyhow.workspace = true
 client.workspace = true
+component.workspace = true
 db.workspace = true
+documented.workspace = true
 fuzzy.workspace = true
 gpui.workspace = true
 install_cli.workspace = true
 language.workspace = true
+linkme.workspace = true
 picker.workspace = true
 project.workspace = true
 schemars.workspace = true
 serde.workspace = true
 settings.workspace = true
 telemetry.workspace = true
+theme.workspace = true
 ui.workspace = true
 util.workspace = true
 vim_mode_setting.workspace = true
+workspace-hack.workspace = true
 workspace.workspace = true
 zed_actions.workspace = true
-workspace-hack.workspace = true
 
 [dev-dependencies]
 editor = { workspace = true, features = ["test-support"] }

crates/welcome/src/welcome.rs ๐Ÿ”—

@@ -1,7 +1,3 @@
-mod base_keymap_picker;
-mod base_keymap_setting;
-mod multibuffer_hint;
-
 use client::{TelemetrySettings, telemetry::Telemetry};
 use db::kvp::KEY_VALUE_STORE;
 use gpui::{
@@ -24,6 +20,11 @@ use workspace::{
 pub use base_keymap_setting::BaseKeymap;
 pub use multibuffer_hint::*;
 
+mod base_keymap_picker;
+mod base_keymap_setting;
+mod multibuffer_hint;
+mod welcome_ui;
+
 actions!(welcome, [ResetHints]);
 
 pub const FIRST_OPEN: &str = "first_open";

crates/welcome/src/welcome_ui/theme_preview.rs ๐Ÿ”—

@@ -0,0 +1,280 @@
+#![allow(unused, dead_code)]
+use gpui::{Hsla, Length};
+use std::sync::Arc;
+use theme::{Theme, ThemeRegistry};
+use ui::{
+    IntoElement, RenderOnce, component_prelude::Documented, prelude::*, utils::inner_corner_radius,
+};
+
+/// Shows a preview of a theme as an abstract illustration
+/// of a thumbnail-sized editor.
+#[derive(IntoElement, RegisterComponent, Documented)]
+pub struct ThemePreviewTile {
+    theme: Arc<Theme>,
+    selected: bool,
+    seed: f32,
+}
+
+impl ThemePreviewTile {
+    pub fn new(theme: Arc<Theme>, selected: bool, seed: f32) -> Self {
+        Self {
+            theme,
+            selected,
+            seed,
+        }
+    }
+
+    pub fn selected(mut self, selected: bool) -> Self {
+        self.selected = selected;
+        self
+    }
+}
+
+impl RenderOnce for ThemePreviewTile {
+    fn render(self, _window: &mut ui::Window, _cx: &mut ui::App) -> impl IntoElement {
+        let color = self.theme.colors();
+
+        let root_radius = px(8.0);
+        let root_border = px(2.0);
+        let root_padding = px(2.0);
+        let child_border = px(1.0);
+        let inner_radius =
+            inner_corner_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);
+
+        let skeleton_height = px(4.);
+
+        let sidebar_seeded_width = |seed: f32, index: usize| {
+            let value = (seed * 1000.0 + index as f32 * 10.0).sin() * 0.5 + 0.5;
+            0.5 + value * 0.45
+        };
+
+        let sidebar_skeleton_items = 8;
+
+        let sidebar_skeleton = (0..sidebar_skeleton_items)
+            .map(|i| {
+                let width = sidebar_seeded_width(self.seed, i);
+                item_skeleton(
+                    relative(width).into(),
+                    skeleton_height,
+                    color.text.alpha(0.45),
+                )
+            })
+            .collect::<Vec<_>>();
+
+        let sidebar = div()
+            .h_full()
+            .w(relative(0.25))
+            .border_r(px(1.))
+            .border_color(color.border_transparent)
+            .bg(color.panel_background)
+            .child(
+                div()
+                    .p_2()
+                    .flex()
+                    .flex_col()
+                    .size_full()
+                    .gap(px(4.))
+                    .children(sidebar_skeleton),
+            );
+
+        let pseudo_code_skeleton = |theme: Arc<Theme>, seed: f32| -> AnyElement {
+            let colors = theme.colors();
+            let syntax = theme.syntax();
+
+            let keyword_color = syntax.get("keyword").color;
+            let function_color = syntax.get("function").color;
+            let string_color = syntax.get("string").color;
+            let comment_color = syntax.get("comment").color;
+            let variable_color = syntax.get("variable").color;
+            let type_color = syntax.get("type").color;
+            let punctuation_color = syntax.get("punctuation").color;
+
+            let syntax_colors = [
+                keyword_color,
+                function_color,
+                string_color,
+                variable_color,
+                type_color,
+                punctuation_color,
+                comment_color,
+            ];
+
+            let line_width = |line_idx: usize, block_idx: usize| -> f32 {
+                let val = (seed * 100.0 + line_idx as f32 * 20.0 + block_idx as f32 * 5.0).sin()
+                    * 0.5
+                    + 0.5;
+                0.05 + val * 0.2
+            };
+
+            let indentation = |line_idx: usize| -> f32 {
+                let step = line_idx % 6;
+                if step < 3 {
+                    step as f32 * 0.1
+                } else {
+                    (5 - step) as f32 * 0.1
+                }
+            };
+
+            let pick_color = |line_idx: usize, block_idx: usize| -> Hsla {
+                let idx = ((seed * 10.0 + line_idx as f32 * 7.0 + block_idx as f32 * 3.0).sin()
+                    * 3.5)
+                    .abs() as usize
+                    % syntax_colors.len();
+                syntax_colors[idx].unwrap_or(colors.text)
+            };
+
+            let line_count = 13;
+
+            let lines = (0..line_count)
+                .map(|line_idx| {
+                    let block_count = (((seed * 30.0 + line_idx as f32 * 12.0).sin() * 0.5 + 0.5)
+                        * 3.0)
+                        .round() as usize
+                        + 2;
+
+                    let indent = indentation(line_idx);
+
+                    let blocks = (0..block_count)
+                        .map(|block_idx| {
+                            let width = line_width(line_idx, block_idx);
+                            let color = pick_color(line_idx, block_idx);
+                            item_skeleton(relative(width).into(), skeleton_height, color)
+                        })
+                        .collect::<Vec<_>>();
+
+                    h_flex().gap(px(2.)).ml(relative(indent)).children(blocks)
+                })
+                .collect::<Vec<_>>();
+
+            v_flex()
+                .size_full()
+                .p_1()
+                .gap(px(6.))
+                .children(lines)
+                .into_any_element()
+        };
+
+        let pane = div()
+            .h_full()
+            .flex_grow()
+            .flex()
+            .flex_col()
+            // .child(
+            //     div()
+            //         .w_full()
+            //         .border_color(color.border)
+            //         .border_b(px(1.))
+            //         .h(relative(0.1))
+            //         .bg(color.tab_bar_background),
+            // )
+            .child(
+                div()
+                    .size_full()
+                    .overflow_hidden()
+                    .bg(color.editor_background)
+                    .p_2()
+                    .child(pseudo_code_skeleton(self.theme.clone(), self.seed)),
+            );
+
+        let content = div().size_full().flex().child(sidebar).child(pane);
+
+        div()
+            .size_full()
+            .rounded(root_radius)
+            .p(root_padding)
+            .border(root_border)
+            .border_color(color.border_transparent)
+            .when(self.selected, |this| {
+                this.border_color(color.border_selected)
+            })
+            .child(
+                div()
+                    .size_full()
+                    .rounded(inner_radius)
+                    .border(child_border)
+                    .border_color(color.border)
+                    .bg(color.background)
+                    .child(content),
+            )
+    }
+}
+
+impl Component for ThemePreviewTile {
+    fn description() -> Option<&'static str> {
+        Some(Self::DOCS)
+    }
+
+    fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
+        let theme_registry = ThemeRegistry::global(cx);
+
+        let one_dark = theme_registry.get("One Dark");
+        let one_light = theme_registry.get("One Light");
+        let gruvbox_dark = theme_registry.get("Gruvbox Dark");
+        let gruvbox_light = theme_registry.get("Gruvbox Light");
+
+        let themes_to_preview = vec![
+            one_dark.clone().ok(),
+            one_light.clone().ok(),
+            gruvbox_dark.clone().ok(),
+            gruvbox_light.clone().ok(),
+        ]
+        .into_iter()
+        .flatten()
+        .collect::<Vec<_>>();
+
+        Some(
+            v_flex()
+                .gap_6()
+                .p_4()
+                .children({
+                    if let Some(one_dark) = one_dark.ok() {
+                        vec![example_group(vec![
+                            single_example(
+                                "Default",
+                                div()
+                                    .w(px(240.))
+                                    .h(px(180.))
+                                    .child(ThemePreviewTile::new(one_dark.clone(), false, 0.42))
+                                    .into_any_element(),
+                            ),
+                            single_example(
+                                "Selected",
+                                div()
+                                    .w(px(240.))
+                                    .h(px(180.))
+                                    .child(ThemePreviewTile::new(one_dark, true, 0.42))
+                                    .into_any_element(),
+                            ),
+                        ])]
+                    } else {
+                        vec![]
+                    }
+                })
+                .child(
+                    example_group(vec![single_example(
+                        "Default Themes",
+                        h_flex()
+                            .gap_4()
+                            .children(
+                                themes_to_preview
+                                    .iter()
+                                    .enumerate()
+                                    .map(|(i, theme)| {
+                                        div().w(px(200.)).h(px(140.)).child(ThemePreviewTile::new(
+                                            theme.clone(),
+                                            false,
+                                            0.42,
+                                        ))
+                                    })
+                                    .collect::<Vec<_>>(),
+                            )
+                            .into_any_element(),
+                    )])
+                    .grow(),
+                )
+                .into_any_element(),
+        )
+    }
+}