Files
ELY-Browser/crates/ely_app/src/shell/chrome/sidebar.rs
T
ZacharyZhang-NY 3108f3265b Add translucency setting + apply to runtime panel alpha
Domain:
- AppearanceSettings gains translucency_pct (u8, 0..=100, default 40)
  with a clamping setter and serde round-trip coverage.
- DEFAULT_TRANSLUCENCY_PCT and MAX_TRANSLUCENCY_PCT exported for the
  shell.

Core:
- BrowserCore::set_translucency_pct delegates to the appearance struct;
  the existing reset_appearance covers the reset path.
- Integration test covers persistence into snapshot.appearance.

Render:
- chrome::sidebar::panel_bg(snapshot) replaces the static PANEL_BG
  constant, mapping the user's translucency_pct linearly into the alpha
  byte 0xff..0xb3. Sidebar (expanded + compact) and main pane consume
  the helper so changing the setting at runtime updates every glass
  surface in lock-step.

Form:
- Translucency row in chrome::appearance_form mirrors the design's
  static track + thumb visual driven by the persisted percentage, plus
  three preset chips (Solid 0 / Default 40 / Glassy 75) that mutate the
  setting through shell.set_translucency_pct.

Strict UX rule preserved: alpha never drops below 0xb3 so panels stay
readable without backdrop blur (which GPUI 0.2.2 doesn't expose).
2026-05-09 19:39:11 -04:00

460 lines
15 KiB
Rust

use ely_browser_core::BrowserSnapshot;
use ely_design_system::{colors, spacing};
use ely_domain::BrowserTab;
use gpui::{
AnyElement, BoxShadow, Context, InteractiveElement, IntoElement, ParentElement, SharedString,
StatefulInteractiveElement, Styled, div, hsla, linear_color_stop, linear_gradient, point,
prelude::FluentBuilder, px, rgb, rgba,
};
use gpui_component::{IconName, StyledExt, scroll::ScrollableElement};
use crate::shell::ElyShell;
use crate::shell::chrome::{render_glyph_for, render_sidebar_header};
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);
div()
.w(px(sidebar_width))
.h_full()
.flex()
.flex_col()
.rounded(px(spacing::RADIUS_CARD))
.bg(rgba(panel_color))
.shadow(panel_shadow())
.overflow_hidden()
.child(render_sidebar_header(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))
.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 {
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(ACTIVE_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(ACTIVE_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(gpui::FontWeight(500.0))
.text_color(rgb(colors::INK_2))
.child(profile_name),
)
.child(
div()
.text_color(rgb(colors::INK_4))
.child(IconName::ChevronDown),
)
.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 bg_color = if active { ACTIVE_NAV_BG } else { 0x00000000 };
let text_color = if active { colors::INK } else { colors::INK_2 };
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(|style| style.bg(rgba(ACTIVE_NAV_BG)))
.active(|style| style.opacity(0.82))
.bg(rgba(bg_color))
.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(gpui::FontWeight(500.0))
.text_color(rgb(text_color))
.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 bg_color = if active { ACTIVE_NAV_BG } else { 0x00000000 };
let text_color = if active { colors::INK } else { colors::INK_2 };
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(|style| style.bg(rgba(ACTIVE_NAV_BG)))
.active(|style| style.opacity(0.82))
.bg(rgba(bg_color))
.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(gpui::FontWeight(500.0))
.text_color(rgb(text_color))
.child(title),
)
.when(unread > 0, |el| el.child(render_unread_badge(unread)))
.child(
div()
.id(close_id)
.size(px(16.0))
.rounded(px(4.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(0x281e1414)).text_color(rgb(colors::INK)))
.cursor_pointer()
.on_click(cx.listener(move |shell, _, window, cx| {
shell.select_tab(&close_tab_id, window, cx);
shell.close_active_tab(window, cx);
}))
.child(IconName::Close),
)
.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(ACTIVE_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 bg_color = if active { ACTIVE_NAV_BG } else { 0x00000000 };
let text_color = if active { colors::INK } else { colors::INK_2 };
div()
.id(SharedString::from(tab.id().as_str().to_string()))
.rounded(px(spacing::RADIUS_NAV))
.px(px(10.0))
.py(px(7.0))
.gap(px(6.0))
.flex()
.flex_col()
.cursor_pointer()
.hover(|style| style.bg(rgba(ACTIVE_NAV_BG)))
.active(|style| style.opacity(0.82))
.bg(rgba(bg_color))
.when(active, |el| el.shadow(soft_shadow()))
.on_click(cx.listener(move |shell, _, window, cx| {
shell.select_tab(&tab_id, window, cx);
}))
.child(
div()
.text_size(px(13.0))
.font_weight(gpui::FontWeight(500.0))
.text_color(rgb(text_color))
.overflow_hidden()
.child(tab.title().to_string()),
)
.child(
div()
.text_size(px(11.0))
.text_color(rgb(colors::INK_4))
.overflow_hidden()
.child(tab.display_url()),
)
.into_any_element()
}
}
fn profile_initial(name: &str) -> String {
name.chars()
.next()
.unwrap_or('P')
.to_uppercase()
.to_string()
}
fn render_unread_badge(count: u32) -> impl IntoElement {
let label = if count > 99 {
"99+".to_string()
} else {
count.to_string()
};
div()
.px(px(6.0))
.py(px(1.0))
.rounded(px(999.0))
.bg(rgba(UNREAD_BADGE_BG))
.text_size(px(10.0))
.font_weight(gpui::FontWeight(500.0))
.text_color(rgb(colors::INK_3))
.child(label)
}
fn section_tabs_label(count: usize) -> impl IntoElement {
div()
.pt(px(12.0))
.pb(px(4.0))
.px(px(10.0))
.flex()
.items_center()
.gap(px(6.0))
.child(
div()
.text_color(rgb(colors::INK_4))
.child(IconName::Frame),
)
.child(
div()
.text_size(px(10.5))
.font_weight(gpui::FontWeight(500.0))
.text_color(rgb(colors::INK_4))
.child(format!("TABS · {count}")),
)
}
fn section_label(label: &'static str) -> impl IntoElement {
div()
.pt(px(8.0))
.pb(px(4.0))
.px(px(10.0))
.text_size(px(10.5))
.font_weight(gpui::FontWeight(500.0))
.text_color(rgb(colors::INK_4))
.child(label)
}
pub(crate) const ACTIVE_NAV_BG: u32 = 0xffffffd9;
const UNREAD_BADGE_BG: u32 = 0x281e140f;
/// Maps appearance translucency_pct to a panel rgba u32.
///
/// translucency_pct 0 → alpha 0xff (fully opaque)
/// translucency_pct 100 → alpha 0xb3 (mostly opaque, ~70%) so panels stay
/// readable without backdrop blur.
pub(crate) fn panel_bg(snapshot: &BrowserSnapshot) -> u32 {
let pct = snapshot.appearance.translucency_pct().min(100) as u32;
let max_alpha: u32 = 0xff;
let min_alpha: u32 = 0xb3;
let alpha = max_alpha - (pct * (max_alpha - min_alpha)) / 100;
0xffffff00 | alpha
}
pub(crate) fn panel_shadow() -> Vec<BoxShadow> {
vec![
BoxShadow {
color: hsla(25.0 / 360.0, 0.33, 0.12, 0.30),
offset: point(px(0.), px(20.)),
blur_radius: px(50.),
spread_radius: px(-15.),
},
BoxShadow {
color: hsla(0., 0., 1., 0.5),
offset: point(px(0.), px(0.)),
blur_radius: px(0.),
spread_radius: px(1.),
},
]
}
pub(crate) fn soft_shadow() -> Vec<BoxShadow> {
vec![
BoxShadow {
color: hsla(0., 0., 1., 0.7),
offset: point(px(0.), px(1.)),
blur_radius: px(0.),
spread_radius: px(0.),
},
BoxShadow {
color: hsla(25.0 / 360.0, 0.33, 0.12, 0.08),
offset: point(px(0.), px(0.)),
blur_radius: px(0.),
spread_radius: px(1.),
},
]
}