From 8c03934b261984f273c847e5da3dff29c8d9ea07 Mon Sep 17 00:00:00 2001 From: Nate Butler Date: Wed, 30 Apr 2025 13:46:11 -0400 Subject: [PATCH] welcome: Theme preview tile (#29689) ![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 --- 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 + .../welcome/src/welcome_ui/theme_preview.rs | 280 ++++++++++++++++++ 9 files changed, 361 insertions(+), 6 deletions(-) create mode 100644 crates/ui/src/utils/corner_solver.rs create mode 100644 crates/welcome/src/welcome_ui.rs create mode 100644 crates/welcome/src/welcome_ui/theme_preview.rs diff --git a/Cargo.lock b/Cargo.lock index 3a0117bfb1..faf38b65bf 100644 --- a/Cargo.lock +++ b/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", diff --git a/Cargo.toml b/Cargo.toml index 234a0cbf49..743beb6ff0 100644 --- a/Cargo.toml +++ b/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", ] diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 62549226ab..23320045b8 100644 --- a/crates/ui/Cargo.toml +++ b/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] diff --git a/crates/ui/src/utils.rs b/crates/ui/src/utils.rs index 70541bef08..26a59001f6 100644 --- a/crates/ui/src/utils.rs +++ b/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::*; diff --git a/crates/ui/src/utils/corner_solver.rs b/crates/ui/src/utils/corner_solver.rs new file mode 100644 index 0000000000..c49bccc445 --- /dev/null +++ b/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 + } +} diff --git a/crates/welcome/Cargo.toml b/crates/welcome/Cargo.toml index 5cebbffc63..78a1bb11d1 100644 --- a/crates/welcome/Cargo.toml +++ b/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"] } diff --git a/crates/welcome/src/welcome.rs b/crates/welcome/src/welcome.rs index 52e7c0ea5d..a399692cf3 100644 --- a/crates/welcome/src/welcome.rs +++ b/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"; diff --git a/crates/welcome/src/welcome_ui.rs b/crates/welcome/src/welcome_ui.rs new file mode 100644 index 0000000000..622b6f448d --- /dev/null +++ b/crates/welcome/src/welcome_ui.rs @@ -0,0 +1 @@ +mod theme_preview; diff --git a/crates/welcome/src/welcome_ui/theme_preview.rs b/crates/welcome/src/welcome_ui/theme_preview.rs new file mode 100644 index 0000000000..b3a80c74c3 --- /dev/null +++ b/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, + selected: bool, + seed: f32, +} + +impl ThemePreviewTile { + pub fn new(theme: Arc, 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::>(); + + 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, 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::>(); + + h_flex().gap(px(2.)).ml(relative(indent)).children(blocks) + }) + .collect::>(); + + 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 { + 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::>(); + + 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::>(), + ) + .into_any_element(), + )]) + .grow(), + ) + .into_any_element(), + ) + } +}