From 277ae8c689c1990533b8abbcbc265477ff2bb64d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 8 May 2026 20:48:50 -0400 Subject: [PATCH] Add appearance settings surface --- crates/ely_app/src/shell/internal_pages.rs | 2 + .../src/shell/internal_pages/appearance.rs | 235 ++++++++++++++++++ .../src/shell/internal_pages/settings.rs | 6 + crates/ely_browser_core/src/navigation.rs | 4 + .../ely_browser_core/tests/settings_routes.rs | 21 ++ 5 files changed, 268 insertions(+) create mode 100644 crates/ely_app/src/shell/internal_pages/appearance.rs diff --git a/crates/ely_app/src/shell/internal_pages.rs b/crates/ely_app/src/shell/internal_pages.rs index 1a657a2..c993b05 100644 --- a/crates/ely_app/src/shell/internal_pages.rs +++ b/crates/ely_app/src/shell/internal_pages.rs @@ -1,4 +1,5 @@ mod about; +mod appearance; mod bookmarks; mod crash; mod download_actions; @@ -73,6 +74,7 @@ impl ElyShell { } "ely://about" => self.render_about_page(snapshot), "ely://settings" => self.render_settings_page(snapshot, cx), + "ely://settings/appearance" => self.render_appearance_page(snapshot), "ely://settings/general" => self.render_general_page(snapshot, cx), "ely://settings/sidebar-tabs" => self.render_sidebar_tabs_page(snapshot, cx), "ely://settings/search" => self.render_search_page(snapshot, cx), diff --git a/crates/ely_app/src/shell/internal_pages/appearance.rs b/crates/ely_app/src/shell/internal_pages/appearance.rs new file mode 100644 index 0000000..0688e05 --- /dev/null +++ b/crates/ely_app/src/shell/internal_pages/appearance.rs @@ -0,0 +1,235 @@ +use ely_browser_core::BrowserSnapshot; +use ely_design_system::colors; +use gpui::{AnyElement, IntoElement, ParentElement, Styled, div, px, rgb}; +use gpui_component::{IconName, StyledExt, scroll::ScrollableElement}; + +use super::{ElyShell, render_canvas_surface}; + +struct ColorToken { + label: &'static str, + detail: &'static str, + value: u32, +} + +const THEME_TOKENS: &[ColorToken] = &[ + ColorToken { label: "Canvas", detail: "Browser frame background", value: colors::CANVAS }, + ColorToken { + label: "Canvas Soft", + detail: "Internal page backdrop", + value: colors::CANVAS_SOFT, + }, + ColorToken { label: "Surface", detail: "Cards and panels", value: colors::SURFACE_CARD }, + ColorToken { label: "Ink", detail: "Primary text", value: colors::INK }, + ColorToken { label: "Muted", detail: "Secondary text", value: colors::MUTED }, + ColorToken { label: "Hairline", detail: "Borders and dividers", value: colors::HAIRLINE }, + ColorToken { label: "Accent", detail: "Primary actions", value: colors::PRIMARY }, + ColorToken { label: "Success", detail: "Resolved state", value: colors::SUCCESS }, + ColorToken { label: "Error", detail: "Attention state", value: colors::ERROR }, +]; + +impl ElyShell { + pub(super) fn render_appearance_page(&mut self, snapshot: &BrowserSnapshot) -> AnyElement { + render_canvas_surface( + div() + .size_full() + .p_8() + .flex() + .flex_col() + .gap_5() + .child(render_appearance_header(snapshot)) + .child(render_theme_summary(snapshot)) + .child(render_color_sections(snapshot)), + ) + } +} + +fn render_appearance_header(snapshot: &BrowserSnapshot) -> AnyElement { + div() + .flex() + .items_end() + .justify_between() + .gap_4() + .child( + div() + .min_w_0() + .flex() + .flex_col() + .gap_2() + .child(div().text_size(px(26.0)).text_color(rgb(colors::INK)).child("Appearance")) + .child( + div() + .text_sm() + .truncate() + .text_color(rgb(colors::MUTED)) + .child(format!("Space: {}", snapshot.active_space_name)), + ), + ) + .child( + div() + .flex() + .items_center() + .gap_2() + .text_xs() + .font_semibold() + .text_color(rgb(colors::MUTED)) + .child(IconName::Palette) + .child(format!("{} tokens", THEME_TOKENS.len())), + ) + .into_any_element() +} + +fn render_theme_summary(snapshot: &BrowserSnapshot) -> AnyElement { + div() + .rounded_md() + .border_1() + .border_color(rgb(colors::HAIRLINE)) + .bg(rgb(colors::CANVAS_SOFT)) + .px_4() + .py_3() + .flex() + .items_center() + .justify_between() + .gap_4() + .child( + div() + .min_w_0() + .flex() + .items_center() + .gap_3() + .child(div().text_color(rgb(colors::PRIMARY)).child(IconName::Palette)) + .child( + div() + .min_w_0() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_sm() + .font_semibold() + .text_color(rgb(colors::INK)) + .child("ELY Theme"), + ) + .child(div().text_xs().truncate().text_color(rgb(colors::MUTED)).child( + format!( + "{} profile on {} space", + snapshot.active_profile_name, snapshot.active_space_name + ), + )), + ), + ) + .child( + div() + .flex() + .items_center() + .gap_2() + .text_xs() + .font_semibold() + .text_color(rgb(colors::MUTED)) + .child(color_swatch(colors::PRIMARY)) + .child(hex_label(colors::PRIMARY)), + ) + .into_any_element() +} + +fn render_color_sections(snapshot: &BrowserSnapshot) -> AnyElement { + div() + .flex_1() + .min_h_0() + .flex() + .flex_col() + .overflow_y_scrollbar() + .border_t_1() + .border_color(rgb(colors::HAIRLINE)) + .child(render_section_label("Theme Tokens")) + .children(THEME_TOKENS.iter().map(render_token_row)) + .child(render_section_label("Current Context")) + .child(render_active_space_color(snapshot)) + .child(render_active_profile_color(snapshot)) + .into_any_element() +} + +fn render_section_label(label: &'static str) -> AnyElement { + div() + .pt_4() + .pb_2() + .text_xs() + .font_semibold() + .text_color(rgb(colors::MUTED)) + .child(label) + .into_any_element() +} + +fn render_token_row(token: &ColorToken) -> AnyElement { + render_color_row(token.label, token.detail.to_string(), token.value) +} + +fn render_active_space_color(snapshot: &BrowserSnapshot) -> AnyElement { + let detail = format!("Space: {}", snapshot.active_space_name); + let value = snapshot + .spaces + .iter() + .find(|space| space.id() == &snapshot.active_space_id) + .map_or(colors::ERROR, |space| space.accent_hex()); + + render_color_row("Active Space Accent", detail, value) +} + +fn render_active_profile_color(snapshot: &BrowserSnapshot) -> AnyElement { + let detail = format!("Profile: {}", snapshot.active_profile_name); + let value = snapshot + .profiles + .iter() + .find(|profile| profile.id() == &snapshot.active_profile_id) + .map_or(colors::ERROR, |profile| profile.color_hex()); + + render_color_row("Active Profile Color", detail, value) +} + +fn render_color_row(label: &'static str, detail: String, value: u32) -> AnyElement { + div() + .py_3() + .border_b_1() + .border_color(rgb(colors::HAIRLINE)) + .flex() + .items_center() + .justify_between() + .gap_4() + .child( + div().min_w_0().flex().items_center().gap_3().child(color_swatch(value)).child( + div() + .min_w_0() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_sm() + .font_semibold() + .truncate() + .text_color(rgb(colors::INK)) + .child(label), + ) + .child(div().text_xs().truncate().text_color(rgb(colors::MUTED)).child(detail)), + ), + ) + .child( + div().text_xs().font_semibold().text_color(rgb(colors::BODY)).child(hex_label(value)), + ) + .into_any_element() +} + +fn color_swatch(value: u32) -> AnyElement { + div() + .w(px(30.0)) + .h(px(22.0)) + .rounded_md() + .border_1() + .border_color(rgb(colors::HAIRLINE_STRONG)) + .bg(rgb(value)) + .into_any_element() +} + +fn hex_label(value: u32) -> String { + format!("#{value:06X}") +} diff --git a/crates/ely_app/src/shell/internal_pages/settings.rs b/crates/ely_app/src/shell/internal_pages/settings.rs index a9bccc5..98216ec 100644 --- a/crates/ely_app/src/shell/internal_pages/settings.rs +++ b/crates/ely_app/src/shell/internal_pages/settings.rs @@ -24,6 +24,12 @@ const SETTINGS_ROUTES: &[SettingsRoute] = &[ detail: "New Tab destination and browser startup defaults.", route: "ely://settings/general", }, + SettingsRoute { + icon: IconName::Palette, + title: "Appearance", + detail: "Theme tokens, Space accent, and Profile color.", + route: "ely://settings/appearance", + }, SettingsRoute { icon: IconName::LayoutDashboard, title: "Sidebar & Tabs", diff --git a/crates/ely_browser_core/src/navigation.rs b/crates/ely_browser_core/src/navigation.rs index a56031e..675dbc3 100644 --- a/crates/ely_browser_core/src/navigation.rs +++ b/crates/ely_browser_core/src/navigation.rs @@ -30,6 +30,7 @@ fn internal_page_title(url: &str) -> Option<&'static str> { "ely://about" => Some("About ELY Browser"), "ely://settings" => Some("Settings"), "ely://settings/general" => Some("General Settings"), + "ely://settings/appearance" => Some("Appearance Settings"), "ely://settings/sidebar-tabs" => Some("Sidebar & Tabs Settings"), "ely://settings/search" => Some("Search Settings"), "ely://settings/privacy-security" => Some("Privacy & Security Settings"), @@ -233,6 +234,9 @@ fn settings_page_route(query: &str) -> Option<&'static str> { match query { "settings" => Some("ely://settings"), "general" | "browser" | "new tab" | "new-tab" | "startup" => Some("ely://settings/general"), + "appearance" | "theme" | "visual" | "design" | "colors" => { + Some("ely://settings/appearance") + } "about" | "about ely browser" => Some("ely://about"), "sidebar" | "tabs" | "sidebar tabs" | "sidebar & tabs" => { Some("ely://settings/sidebar-tabs") diff --git a/crates/ely_browser_core/tests/settings_routes.rs b/crates/ely_browser_core/tests/settings_routes.rs index 78e6d85..5935911 100644 --- a/crates/ely_browser_core/tests/settings_routes.rs +++ b/crates/ely_browser_core/tests/settings_routes.rs @@ -45,6 +45,27 @@ fn settings_scoped_search_opens_general_page() -> Result<(), Box> { Ok(()) } +#[test] +fn settings_scoped_search_opens_appearance_page() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + + core.set_command_query("@settings appearance"); + let intent = core.submit_command()?; + let active_tab = core.active_tab()?; + + assert_eq!( + intent, + Some(CommandIntent::ScopedSearch { + scope: CommandScope::Settings, + query: "appearance".to_string(), + }) + ); + assert_eq!(active_tab.title(), "Appearance Settings"); + assert_eq!(active_tab.url().as_str(), "ely://settings/appearance"); + assert_eq!(core.snapshot()?.command_query, ""); + Ok(()) +} + #[test] fn settings_scoped_search_opens_shortcuts_page() -> Result<(), Box> { let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;