Dark mode was persistent in `AppearanceSettings` but never reached any paint code: every call site in the shell read `colors::INK` etc. as a `pub const u32`, so toggling `ThemeMode::Dark` mutated state nothing sampled. Root-cause fix is to invert the contract — the design-system exports functions that resolve through a thread-local `Mode`, and the GPUI render impl sets that mode each frame. What lands: - `ely_design_system::colors::Mode` + thread-local + `set_mode` / `mode` accessors. Every ink shade, glass surface, stroke, divider, hairline, canvas, success / error chip now picks the warm-dark counterpart when the active mode is `Mode::Dark`. - `ElyShell::render` resolves `ThemeMode::System` against `Window::appearance()` and pushes the mode before traversing the tree, so widgets lower down read the right shade without owning a `Mode` parameter. - `render_wallpaper` + `panel_bg` now branch on `colors::mode()` so the gradient base, panel tint, and overlay highlights flip to warm-graphite when dark mode is active. - Mechanical conversion across 687 call sites in 55 files from `colors::FOO` constants to `colors::foo()` accessors. The `Theme` / `ELY_THEME` const surface (unused outside the design system) is removed; the function surface is the new contract.
453 lines
17 KiB
Rust
453 lines
17 KiB
Rust
use ely_browser_core::BrowserSnapshot;
|
||
use ely_design_system::{colors, spacing};
|
||
use ely_domain::BrowserTab;
|
||
use gpui::{
|
||
AnyElement, Context, FontWeight, ImageSource, InteractiveElement, IntoElement, ObjectFit,
|
||
ParentElement, SharedString, StatefulInteractiveElement, Styled, StyledImage, div, hsla, img,
|
||
linear_color_stop, linear_gradient, prelude::FluentBuilder, px, rgb, rgba,
|
||
};
|
||
use gpui_component::{IconName, StyledExt, scroll::ScrollableElement};
|
||
|
||
use crate::shell::ElyShell;
|
||
use crate::shell::chrome::sidebar_chrome::{
|
||
ACTIVE_NAV_BG, ACTIVE_NAV_BG_HOVER, CLOSE_HOVER_BG, HIGHLIGHT_BORDER, HOVER_NAV_BG,
|
||
ROW_CLOSE_SIZE, panel_bg, panel_shadow, profile_initial, render_sidebar_resize_handle,
|
||
render_unread_badge, section_label, section_tabs_label, soft_shadow,
|
||
};
|
||
use crate::shell::chrome::{render_glyph_for, render_sidebar_header};
|
||
|
||
// `panel_bg` and `panel_shadow` are re-exported from `chrome::mod` via
|
||
// `sidebar_chrome` so external callers (`shell::render`, command overlay)
|
||
// keep their existing import paths.
|
||
|
||
impl ElyShell {
|
||
pub(crate) fn render_expanded_sidebar(
|
||
&mut self,
|
||
snapshot: &BrowserSnapshot,
|
||
sidebar_width: f32,
|
||
cx: &mut Context<Self>,
|
||
) -> AnyElement {
|
||
let panel_color = panel_bg(snapshot);
|
||
// Outer wrapper holds the resize handle outside the rounded
|
||
// panel's overflow_hidden clip; inner panel keeps the rounded
|
||
// glass treatment without swallowing the column-resize column.
|
||
div()
|
||
.w(px(sidebar_width))
|
||
.h_full()
|
||
.relative()
|
||
.child(
|
||
div()
|
||
.size_full()
|
||
.flex()
|
||
.flex_col()
|
||
.rounded(px(spacing::RADIUS_CARD))
|
||
.bg(rgba(panel_color))
|
||
.border_1()
|
||
.border_color(rgba(HIGHLIGHT_BORDER))
|
||
.shadow(panel_shadow())
|
||
.overflow_hidden()
|
||
.child(render_sidebar_header(self, snapshot, cx))
|
||
.child(
|
||
div()
|
||
.flex_1()
|
||
.overflow_y_scrollbar()
|
||
.p(px(10.0))
|
||
.flex()
|
||
.flex_col()
|
||
.gap(px(2.0))
|
||
.child(self.render_home_anchor_row(snapshot, cx))
|
||
.children(snapshot.favorites.iter().map(|tab| {
|
||
self.render_launcher_row(
|
||
tab,
|
||
tab.id() == &snapshot.active_tab_id,
|
||
cx,
|
||
)
|
||
}))
|
||
.child(section_label("PINNED"))
|
||
.children(snapshot.pinned_tabs.iter().map(|tab| {
|
||
self.render_launcher_row(
|
||
tab,
|
||
tab.id() == &snapshot.active_tab_id,
|
||
cx,
|
||
)
|
||
}))
|
||
.child(section_tabs_label(snapshot.tabs.len()))
|
||
.children(self.render_sidebar_tab_rows(snapshot, cx))
|
||
.child(self.render_new_tab_row(cx)),
|
||
)
|
||
.child(self.render_sidebar_footer(snapshot, cx)),
|
||
)
|
||
.child(render_sidebar_resize_handle(cx))
|
||
.into_any_element()
|
||
}
|
||
|
||
fn render_sidebar_footer(
|
||
&mut self,
|
||
snapshot: &BrowserSnapshot,
|
||
cx: &mut Context<Self>,
|
||
) -> AnyElement {
|
||
div()
|
||
.px(px(10.0))
|
||
.py(px(8.0))
|
||
.flex()
|
||
.flex_col()
|
||
.gap(px(2.0))
|
||
.flex_shrink_0()
|
||
.border_t_1()
|
||
.border_color(rgba(colors::divider()))
|
||
.child(self.render_settings_row(cx))
|
||
.child(self.render_profile_row(snapshot, cx))
|
||
.into_any_element()
|
||
}
|
||
|
||
fn render_settings_row(&mut self, cx: &mut Context<Self>) -> AnyElement {
|
||
// Settings is never an "active" row — only ever rest or hover —
|
||
// so it uses HOVER_NAV_BG directly. Keeping it lighter than the
|
||
// active nav card means the eye still finds the active selection
|
||
// first when both are visible.
|
||
div()
|
||
.id(SharedString::from("nav-settings"))
|
||
.rounded(px(spacing::RADIUS_NAV))
|
||
.px(px(10.0))
|
||
.py(px(7.0))
|
||
.gap(px(10.0))
|
||
.flex()
|
||
.items_center()
|
||
.text_size(px(13.0))
|
||
.text_color(rgb(colors::ink_2()))
|
||
.cursor_pointer()
|
||
.hover(|style| style.bg(rgba(HOVER_NAV_BG)).text_color(rgb(colors::ink())))
|
||
.active(|style| style.opacity(0.82))
|
||
.on_click(cx.listener(|shell, _, window, cx| {
|
||
shell.open_internal_tab("ely://settings", window, cx);
|
||
}))
|
||
.child(div().text_color(rgb(colors::ink_3())).child(IconName::Settings))
|
||
.child("Settings")
|
||
.into_any_element()
|
||
}
|
||
|
||
fn render_profile_row(
|
||
&mut self,
|
||
snapshot: &BrowserSnapshot,
|
||
cx: &mut Context<Self>,
|
||
) -> AnyElement {
|
||
let profile_name = snapshot.active_profile_name.clone();
|
||
|
||
div()
|
||
.id(SharedString::from("nav-profile"))
|
||
.rounded(px(spacing::RADIUS_NAV))
|
||
.px(px(10.0))
|
||
.py(px(6.0))
|
||
.gap(px(10.0))
|
||
.flex()
|
||
.items_center()
|
||
.cursor_pointer()
|
||
.hover(|style| style.bg(rgba(HOVER_NAV_BG)))
|
||
.active(|style| style.opacity(0.82))
|
||
.on_click(cx.listener(|shell, _, window, cx| {
|
||
shell.open_internal_tab("ely://settings/profiles", window, cx);
|
||
}))
|
||
.child(
|
||
div()
|
||
.size(px(26.0))
|
||
.rounded_full()
|
||
.bg(linear_gradient(
|
||
135.0,
|
||
linear_color_stop(hsla(20.0 / 360.0, 0.6, 0.84, 1.0), 0.0),
|
||
linear_color_stop(hsla(15.0 / 360.0, 0.55, 0.53, 1.0), 1.0),
|
||
))
|
||
.flex()
|
||
.items_center()
|
||
.justify_center()
|
||
.child(
|
||
div()
|
||
.text_size(px(10.0))
|
||
.font_semibold()
|
||
.text_color(rgb(0xffffff))
|
||
.child(profile_initial(&profile_name)),
|
||
),
|
||
)
|
||
.child(
|
||
div()
|
||
.flex_1()
|
||
.min_w_0()
|
||
.truncate()
|
||
.text_size(px(13.0))
|
||
.font_weight(FontWeight(500.0))
|
||
.text_color(rgb(colors::ink_2()))
|
||
.child(profile_name),
|
||
)
|
||
.child(
|
||
// The profile chip navigates to settings/profiles —
|
||
// it's not a popover. Use a right-chevron so the icon
|
||
// promises "this opens a page" instead of the down
|
||
// chevron that promises "this opens a menu inline".
|
||
div().text_color(rgb(colors::ink_4())).child(IconName::ChevronRight),
|
||
)
|
||
.into_any_element()
|
||
}
|
||
|
||
fn render_home_anchor_row(
|
||
&mut self,
|
||
snapshot: &BrowserSnapshot,
|
||
cx: &mut Context<Self>,
|
||
) -> AnyElement {
|
||
let active_tab = snapshot.tabs.iter().find(|tab| tab.id() == &snapshot.active_tab_id);
|
||
let active = active_tab.map(|tab| tab.url().as_str() == "ely://new-tab").unwrap_or(false);
|
||
let palette = nav_row_palette(active);
|
||
|
||
div()
|
||
.id(SharedString::from("nav-home"))
|
||
.rounded(px(spacing::RADIUS_NAV))
|
||
.px(px(10.0))
|
||
.py(px(7.0))
|
||
.gap(px(10.0))
|
||
.flex()
|
||
.items_center()
|
||
.cursor_pointer()
|
||
.hover(move |style| style.bg(rgba(palette.hover_bg)))
|
||
.active(|style| style.opacity(0.82))
|
||
.bg(rgba(palette.bg))
|
||
.when(active, |el| el.shadow(soft_shadow()))
|
||
.on_click(cx.listener(|shell, _, window, cx| {
|
||
shell.open_internal_tab("ely://new-tab", window, cx);
|
||
}))
|
||
.child(div().text_color(rgb(colors::ink_3())).child(IconName::Frame))
|
||
.child(
|
||
div()
|
||
.flex_1()
|
||
.text_size(px(13.0))
|
||
.font_weight(FontWeight(500.0))
|
||
.text_color(rgb(palette.text))
|
||
.child("Home"),
|
||
)
|
||
.into_any_element()
|
||
}
|
||
|
||
fn render_launcher_row(
|
||
&mut self,
|
||
tab: &BrowserTab,
|
||
active: bool,
|
||
cx: &mut Context<Self>,
|
||
) -> AnyElement {
|
||
let tab_id = tab.id().clone();
|
||
let close_tab_id = tab.id().clone();
|
||
let palette = nav_row_palette(active);
|
||
let host = tab.url().host().map(|host| host.to_string());
|
||
let title = tab.title().to_string();
|
||
let initial = title.chars().next().unwrap_or('?').to_string();
|
||
let unread = tab.unread_count();
|
||
let group_name = SharedString::from(format!("launcher-{}", tab.id().as_str()));
|
||
let close_id = SharedString::from(format!("launcher-close-{}", tab.id().as_str()));
|
||
|
||
div()
|
||
.id(SharedString::from(format!("nav-{}", tab.id().as_str())))
|
||
.group(group_name.clone())
|
||
.rounded(px(spacing::RADIUS_NAV))
|
||
.px(px(10.0))
|
||
.py(px(7.0))
|
||
.gap(px(10.0))
|
||
.flex()
|
||
.items_center()
|
||
.cursor_pointer()
|
||
.hover(move |style| style.bg(rgba(palette.hover_bg)))
|
||
.active(|style| style.opacity(0.82))
|
||
.bg(rgba(palette.bg))
|
||
.when(active, |el| el.shadow(soft_shadow()))
|
||
.on_click(cx.listener(move |shell, _, window, cx| {
|
||
shell.select_tab(&tab_id, window, cx);
|
||
}))
|
||
.child(render_glyph_for(host.as_deref(), &initial, 18.0))
|
||
.child(
|
||
div()
|
||
.flex_1()
|
||
.min_w_0()
|
||
.overflow_hidden()
|
||
.truncate()
|
||
.text_size(px(13.0))
|
||
.font_weight(FontWeight(500.0))
|
||
.text_color(rgb(palette.text))
|
||
.child(title),
|
||
)
|
||
.when(unread > 0, |el| el.child(render_unread_badge(unread)))
|
||
.child(render_row_close_button(
|
||
close_id,
|
||
group_name,
|
||
cx.listener(move |shell, _, window, cx| {
|
||
shell.close_tab_by_id(&close_tab_id, window, cx);
|
||
cx.stop_propagation();
|
||
}),
|
||
))
|
||
.into_any_element()
|
||
}
|
||
|
||
fn render_new_tab_row(&mut self, cx: &mut Context<Self>) -> AnyElement {
|
||
div()
|
||
.id(SharedString::from("nav-new-tab"))
|
||
.rounded(px(spacing::RADIUS_NAV))
|
||
.px(px(10.0))
|
||
.py(px(7.0))
|
||
.gap(px(10.0))
|
||
.flex()
|
||
.items_center()
|
||
.text_color(rgb(colors::ink_3()))
|
||
.text_size(px(13.0))
|
||
.cursor_pointer()
|
||
.hover(|style| style.bg(rgba(HOVER_NAV_BG)).text_color(rgb(colors::ink())))
|
||
.active(|style| style.opacity(0.82))
|
||
.on_click(cx.listener(|shell, _, window, cx| {
|
||
shell.open_new_tab(window, cx);
|
||
}))
|
||
.child(div().text_color(rgb(colors::ink_4())).child(IconName::Plus))
|
||
.child("New Tab")
|
||
.into_any_element()
|
||
}
|
||
|
||
pub(crate) fn render_tab_row(
|
||
&mut self,
|
||
tab: &BrowserTab,
|
||
active: bool,
|
||
cx: &mut Context<Self>,
|
||
) -> AnyElement {
|
||
let tab_id = tab.id().clone();
|
||
let close_tab_id = tab.id().clone();
|
||
let palette = nav_row_palette(active);
|
||
let group_name = SharedString::from(format!("tab-{}", tab.id().as_str()));
|
||
let close_id = SharedString::from(format!("tab-close-{}", tab.id().as_str()));
|
||
let title = tab.title().to_string();
|
||
let initial = title.chars().next().unwrap_or('?').to_string();
|
||
|
||
div()
|
||
.id(SharedString::from(tab.id().as_str().to_string()))
|
||
.group(group_name.clone())
|
||
.rounded(px(spacing::RADIUS_NAV))
|
||
.px(px(10.0))
|
||
.py(px(7.0))
|
||
.gap(px(8.0))
|
||
.flex()
|
||
.items_center()
|
||
.cursor_pointer()
|
||
.hover(move |style| style.bg(rgba(palette.hover_bg)))
|
||
.active(|style| style.opacity(0.82))
|
||
.bg(rgba(palette.bg))
|
||
.when(active, |el| el.shadow(soft_shadow()))
|
||
.on_click(cx.listener(move |shell, _, window, cx| {
|
||
shell.select_tab(&tab_id, window, cx);
|
||
}))
|
||
.child(render_tab_favicon(tab, &initial))
|
||
.child(
|
||
div()
|
||
.flex_1()
|
||
.min_w_0()
|
||
.flex()
|
||
.flex_col()
|
||
.gap(px(2.0))
|
||
.child(
|
||
div()
|
||
.text_size(px(13.0))
|
||
.font_weight(FontWeight(500.0))
|
||
.text_color(rgb(palette.text))
|
||
.truncate()
|
||
.child(tab.title().to_string()),
|
||
)
|
||
.child(
|
||
div()
|
||
.text_size(px(11.0))
|
||
.text_color(rgb(colors::ink_4()))
|
||
.truncate()
|
||
.child(tab.display_url()),
|
||
),
|
||
)
|
||
.child(render_row_close_button(
|
||
close_id,
|
||
group_name,
|
||
cx.listener(move |shell, _, window, cx| {
|
||
shell.close_tab_by_id(&close_tab_id, window, cx);
|
||
cx.stop_propagation();
|
||
}),
|
||
))
|
||
.into_any_element()
|
||
}
|
||
}
|
||
|
||
/// Resolve the three colors a selectable nav row paints in based on
|
||
/// its active state. Centralising the choice means launcher row, tab
|
||
/// row, and home anchor row can never disagree on what "active" looks
|
||
/// like — and a future tweak to the contrast ladder lands in one
|
||
/// place rather than three transparent-sentinel triples.
|
||
fn nav_row_palette(active: bool) -> NavRowPalette {
|
||
if active {
|
||
NavRowPalette { bg: ACTIVE_NAV_BG, hover_bg: ACTIVE_NAV_BG_HOVER, text: colors::ink() }
|
||
} else {
|
||
NavRowPalette { bg: 0x00000000, hover_bg: HOVER_NAV_BG, text: colors::ink_2() }
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Copy)]
|
||
struct NavRowPalette {
|
||
bg: u32,
|
||
hover_bg: u32,
|
||
text: u32,
|
||
}
|
||
|
||
/// Per-row close (×) button shared by the launcher and tab rows.
|
||
///
|
||
/// Centralising the recipe keeps both rows obeying the same physical
|
||
/// rules: an 18 px circle (a coin, not a glyph), invisible until the
|
||
/// row is hovered, with a warm-dark wash on direct hover so the
|
||
/// button itself acknowledges the cursor before the click. The close
|
||
/// hit target stays `flex_shrink_0` so a long title can never push
|
||
/// the button into a sub-pixel sliver.
|
||
fn render_row_close_button<F>(
|
||
close_id: SharedString,
|
||
group_name: SharedString,
|
||
on_click: F,
|
||
) -> impl IntoElement
|
||
where
|
||
F: Fn(&gpui::ClickEvent, &mut gpui::Window, &mut gpui::App) + 'static,
|
||
{
|
||
div()
|
||
.id(close_id)
|
||
.size(px(ROW_CLOSE_SIZE))
|
||
.rounded_full()
|
||
.flex_shrink_0()
|
||
.flex()
|
||
.items_center()
|
||
.justify_center()
|
||
.text_color(rgb(colors::ink_4()))
|
||
.opacity(0.0)
|
||
.group_hover(group_name, |style| style.opacity(1.0))
|
||
.hover(|style| style.bg(rgba(CLOSE_HOVER_BG)).text_color(rgb(colors::ink())))
|
||
.cursor_pointer()
|
||
.on_click(on_click)
|
||
.child(IconName::Close)
|
||
}
|
||
|
||
/// Resolve the favicon glyph for a tab row. Prefers the favicon URL
|
||
/// the Servo runtime derived from the loaded URL; falls back to the
|
||
/// initial-letter chip used everywhere else when the tab has no live
|
||
/// favicon (yet to load, internal page, file URL, etc.).
|
||
fn render_tab_favicon(tab: &BrowserTab, initial: &str) -> AnyElement {
|
||
if let Some(favicon_url) = tab.favicon_key()
|
||
&& favicon_url.starts_with("http")
|
||
{
|
||
return div()
|
||
.size(px(FAVICON_SIZE))
|
||
.flex_shrink_0()
|
||
.rounded(px(FAVICON_RADIUS))
|
||
.overflow_hidden()
|
||
.child(
|
||
img(ImageSource::from(favicon_url.to_string()))
|
||
.size(px(FAVICON_SIZE))
|
||
.object_fit(ObjectFit::Cover),
|
||
)
|
||
.into_any_element();
|
||
}
|
||
|
||
let host = tab.url().host();
|
||
render_glyph_for(host.as_deref(), initial, FAVICON_SIZE)
|
||
}
|
||
|
||
const FAVICON_SIZE: f32 = 16.0;
|
||
const FAVICON_RADIUS: f32 = 4.0;
|