feat(desktop): add semantic GPUI styling utilities

Co-authored-by: Maze <mazeincoding@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-08-01 21:11:56 +00:00
parent 400f097bec
commit 3e59d553a1
No known key found for this signature in database
9 changed files with 235 additions and 44 deletions

View File

@ -1,9 +1,8 @@
use gpui::{ use gpui::{
App, FontWeight, IntoElement, RenderOnce, SharedString, Window, div, prelude::*, px, App, FontWeight, IntoElement, RenderOnce, SharedString, Window, div, px, transparent_black,
transparent_black,
}; };
use crate::theme::ActiveTheme; use crate::components::prelude::*;
#[allow(dead_code)] #[allow(dead_code)]
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
@ -37,7 +36,8 @@ impl Badge {
impl RenderOnce for Badge { impl RenderOnce for Badge {
fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement { fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement {
let colors = window.theme().colors; let theme = theme(window);
let colors = theme.colors;
let transparent = transparent_black(); let transparent = transparent_black();
let (background, foreground, border) = match self.variant { let (background, foreground, border) = match self.variant {
BadgeVariant::Default => (colors.primary, colors.primary_foreground, transparent), BadgeVariant::Default => (colors.primary, colors.primary_foreground, transparent),

View File

@ -1,9 +1,9 @@
use gpui::{ use gpui::{
App, ClickEvent, ElementId, FontWeight, IntoElement, MouseButton, RenderOnce, SharedString, App, ClickEvent, ElementId, FontWeight, IntoElement, MouseButton, RenderOnce, SharedString,
Window, div, prelude::*, px, transparent_black, Window, div, px, transparent_black,
}; };
use crate::theme::ActiveTheme; use crate::components::prelude::*;
#[allow(dead_code)] #[allow(dead_code)]
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
@ -115,7 +115,7 @@ impl Button {
impl RenderOnce for Button { impl RenderOnce for Button {
fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement { fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement {
let theme = window.theme(); let theme = theme(window);
let colors = theme.colors; let colors = theme.colors;
let transparent = transparent_black(); let transparent = transparent_black();
@ -177,7 +177,7 @@ impl RenderOnce for Button {
this.px(px(self.size.horizontal_padding())) this.px(px(self.size.horizontal_padding()))
}) })
.when(self.full_width, |this| this.w_full()) .when(self.full_width, |this| this.w_full())
.rounded(theme.radius) .rounded_theme(theme)
.border_1() .border_1()
.border_color(border) .border_color(border)
.bg(background) .bg(background)
@ -186,7 +186,7 @@ impl RenderOnce for Button {
.font_weight(FontWeight::MEDIUM) .font_weight(FontWeight::MEDIUM)
.whitespace_nowrap() .whitespace_nowrap()
.tab_index(0) .tab_index(0)
.focus(|style| style.border_color(colors.ring)) .focus(|style| style.border_ring(theme))
.child(self.label); .child(self.label);
if self.disabled { if self.disabled {

View File

@ -2,10 +2,10 @@ use std::rc::Rc;
use gpui::{ use gpui::{
AnyElement, App, ElementId, Entity, IntoElement, MouseButton, Pixels, Point, Render, AnyElement, App, ElementId, Entity, IntoElement, MouseButton, Pixels, Point, Render,
SharedString, Window, anchored, deferred, div, prelude::*, px, SharedString, Window, anchored, deferred, div, px,
}; };
use crate::theme::ActiveTheme; use crate::components::prelude::*;
type SelectHandler = Rc<dyn Fn(&mut Window, &mut App) + 'static>; type SelectHandler = Rc<dyn Fn(&mut Window, &mut App) + 'static>;
@ -152,7 +152,7 @@ impl Render for ContextMenu {
return div().into_any_element(); return div().into_any_element();
}; };
let theme = window.theme(); let theme = theme(window);
let colors = theme.colors; let colors = theme.colors;
let items = self let items = self
.items .items
@ -168,11 +168,6 @@ impl Render for ContextMenu {
variant, variant,
.. ..
} => { } => {
let foreground = if *variant == ContextMenuItemVariant::Destructive {
colors.destructive
} else {
colors.popover_foreground
};
let hover = if *variant == ContextMenuItemVariant::Destructive { let hover = if *variant == ContextMenuItemVariant::Destructive {
colors.destructive.opacity(0.1) colors.destructive.opacity(0.1)
} else { } else {
@ -189,7 +184,14 @@ impl Render for ContextMenu {
.py(px(4.0)) .py(px(4.0))
.rounded(px(5.0)) .rounded(px(5.0))
.text_xs() .text_xs()
.text_color(foreground) .when(
*variant == ContextMenuItemVariant::Destructive,
|this| this.text_destructive(theme),
)
.when(
*variant != ContextMenuItemVariant::Destructive,
|this| this.text_popover_foreground(theme),
)
.when(*disabled, |this| this.opacity(0.5).cursor_not_allowed()) .when(*disabled, |this| this.opacity(0.5).cursor_not_allowed())
.when(!*disabled, |this| { .when(!*disabled, |this| {
this.cursor_default() this.cursor_default()
@ -210,7 +212,7 @@ impl Render for ContextMenu {
this.child( this.child(
div() div()
.text_size(px(10.0)) .text_size(px(10.0))
.text_color(colors.muted_foreground) .text_muted_foreground(theme)
.child(shortcut), .child(shortcut),
) )
}) })
@ -220,14 +222,14 @@ impl Render for ContextMenu {
.px(px(8.0)) .px(px(8.0))
.py(px(6.0)) .py(px(6.0))
.text_xs() .text_xs()
.text_color(colors.muted_foreground) .text_muted_foreground(theme)
.child(label.clone()) .child(label.clone())
.into_any_element(), .into_any_element(),
ContextMenuItem::Separator => div() ContextMenuItem::Separator => div()
.h(px(1.0)) .h(px(1.0))
.mx(px(-4.0)) .mx(px(-4.0))
.my(px(4.0)) .my(px(4.0))
.bg(colors.border.opacity(0.5)) .bg_border_subtle(theme)
.into_any_element(), .into_any_element(),
} }
}) })
@ -245,11 +247,11 @@ impl Render for ContextMenu {
.max_h(window.viewport_size().height - px(16.0)) .max_h(window.viewport_size().height - px(16.0))
.overflow_y_scroll() .overflow_y_scroll()
.p(px(4.0)) .p(px(4.0))
.rounded(theme.radius) .rounded_theme(theme)
.bg(colors.popover) .bg_popover(theme)
.text_color(colors.popover_foreground) .text_popover_foreground(theme)
.border_1() .border_1()
.border_color(colors.foreground.opacity(0.1)) .border_foreground_muted(theme)
.shadow_md() .shadow_md()
.on_mouse_down_out(cx.listener(|this, _, _, cx| this.close(cx))) .on_mouse_down_out(cx.listener(|this, _, _, cx| this.close(cx)))
.children(items), .children(items),

View File

@ -1,6 +1,6 @@
use gpui::{App, FontWeight, IntoElement, RenderOnce, SharedString, Window, div, prelude::*}; use gpui::{App, FontWeight, IntoElement, RenderOnce, SharedString, Window, div};
use crate::theme::ActiveTheme; use crate::components::prelude::*;
#[derive(IntoElement)] #[derive(IntoElement)]
pub(crate) struct Label { pub(crate) struct Label {
@ -32,16 +32,13 @@ impl Label {
impl RenderOnce for Label { impl RenderOnce for Label {
fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement { fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement {
let colors = window.theme().colors; let theme = theme(window);
div() div()
.text_xs() .text_xs()
.font_weight(FontWeight::MEDIUM) .font_weight(FontWeight::MEDIUM)
.text_color(if self.muted { .when(self.muted, |this| this.text_muted_foreground(theme))
colors.muted_foreground .when(!self.muted, |this| this.text_foreground(theme))
} else {
colors.foreground
})
.when(self.disabled, |this| this.opacity(0.5)) .when(self.disabled, |this| this.opacity(0.5))
.child(self.text) .child(self.text)
} }

View File

@ -2,7 +2,9 @@ mod badge;
mod button; mod button;
mod context_menu; mod context_menu;
mod label; mod label;
pub(crate) mod prelude;
mod resizable; mod resizable;
mod semantic;
mod separator; mod separator;
pub(crate) use badge::{Badge, BadgeVariant}; pub(crate) use badge::{Badge, BadgeVariant};

View File

@ -0,0 +1,3 @@
pub use gpui::prelude::*;
pub use super::semantic::{SemanticStyled, theme};

View File

@ -1,9 +1,9 @@
use gpui::{ use gpui::{
AnyView, AppContext, ClickEvent, Context, DragMoveEvent, Entity, IntoElement, Render, Window, AnyView, AppContext, ClickEvent, Context, DragMoveEvent, Entity, IntoElement, Render, Window,
div, prelude::*, px, relative, div, px, relative,
}; };
use crate::theme::ActiveTheme; use crate::components::prelude::*;
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(crate) enum Orientation { pub(crate) enum Orientation {
@ -90,7 +90,7 @@ impl ResizablePanelGroup {
impl Render for ResizablePanelGroup { impl Render for ResizablePanelGroup {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = window.theme().colors; let theme = theme(window);
let orientation = self.orientation; let orientation = self.orientation;
let first_fraction = self.fraction; let first_fraction = self.fraction;
@ -105,8 +105,8 @@ impl Render for ResizablePanelGroup {
div() div()
.w(px(1.0)) .w(px(1.0))
.h_full() .h_full()
.bg(colors.border) .bg_border(theme)
.group_hover("resizable", |style| style.bg(colors.ring)), .group_hover("resizable", |style| style.bg_ring(theme)),
) )
}) })
.when(orientation == Orientation::Vertical, |this| { .when(orientation == Orientation::Vertical, |this| {
@ -114,8 +114,8 @@ impl Render for ResizablePanelGroup {
div() div()
.h(px(1.0)) .h(px(1.0))
.w_full() .w_full()
.bg(colors.border) .bg_border(theme)
.group_hover("resizable", |style| style.bg(colors.ring)), .group_hover("resizable", |style| style.bg_ring(theme)),
) )
}) })
.group("resizable") .group("resizable")

View File

@ -0,0 +1,186 @@
use gpui::{Rems, Styled, Window};
use crate::theme::{ActiveTheme, Theme};
/// Resolves the active theme for the current window. This is the sole bridge
/// from UI components to the runtime theme source.
pub fn theme(window: &Window) -> &'static Theme {
window.theme()
}
/// Tailwind-like semantic styling helpers for GPUI [`Styled`] elements.
pub trait SemanticStyled: Styled {
fn bg_background(self, theme: &Theme) -> Self {
self.bg(theme.colors.background)
}
fn bg_card(self, theme: &Theme) -> Self {
self.bg(theme.colors.card)
}
fn bg_popover(self, theme: &Theme) -> Self {
self.bg(theme.colors.popover)
}
fn bg_primary(self, theme: &Theme) -> Self {
self.bg(theme.colors.primary)
}
fn bg_secondary(self, theme: &Theme) -> Self {
self.bg(theme.colors.secondary)
}
fn bg_muted(self, theme: &Theme) -> Self {
self.bg(theme.colors.muted)
}
fn bg_accent(self, theme: &Theme) -> Self {
self.bg(theme.colors.accent)
}
fn bg_destructive(self, theme: &Theme) -> Self {
self.bg(theme.colors.destructive)
}
fn bg_input(self, theme: &Theme) -> Self {
self.bg(theme.colors.input)
}
fn bg_sidebar(self, theme: &Theme) -> Self {
self.bg(theme.colors.sidebar)
}
fn bg_sidebar_primary(self, theme: &Theme) -> Self {
self.bg(theme.colors.sidebar_primary)
}
fn bg_sidebar_accent(self, theme: &Theme) -> Self {
self.bg(theme.colors.sidebar_accent)
}
fn bg_chart_1(self, theme: &Theme) -> Self {
self.bg(theme.colors.chart_1)
}
fn bg_chart_2(self, theme: &Theme) -> Self {
self.bg(theme.colors.chart_2)
}
fn bg_chart_3(self, theme: &Theme) -> Self {
self.bg(theme.colors.chart_3)
}
fn bg_chart_4(self, theme: &Theme) -> Self {
self.bg(theme.colors.chart_4)
}
fn bg_chart_5(self, theme: &Theme) -> Self {
self.bg(theme.colors.chart_5)
}
fn bg_border_subtle(self, theme: &Theme) -> Self {
self.bg(theme.colors.border.opacity(0.5))
}
fn bg_border(self, theme: &Theme) -> Self {
self.bg(theme.colors.border)
}
fn bg_ring(self, theme: &Theme) -> Self {
self.bg(theme.colors.ring)
}
fn text_foreground(self, theme: &Theme) -> Self {
self.text_color(theme.colors.foreground)
}
fn text_card_foreground(self, theme: &Theme) -> Self {
self.text_color(theme.colors.card_foreground)
}
fn text_popover_foreground(self, theme: &Theme) -> Self {
self.text_color(theme.colors.popover_foreground)
}
fn text_primary(self, theme: &Theme) -> Self {
self.text_color(theme.colors.primary)
}
fn text_primary_foreground(self, theme: &Theme) -> Self {
self.text_color(theme.colors.primary_foreground)
}
fn text_secondary_foreground(self, theme: &Theme) -> Self {
self.text_color(theme.colors.secondary_foreground)
}
fn text_muted_foreground(self, theme: &Theme) -> Self {
self.text_color(theme.colors.muted_foreground)
}
fn text_accent_foreground(self, theme: &Theme) -> Self {
self.text_color(theme.colors.accent_foreground)
}
fn text_destructive(self, theme: &Theme) -> Self {
self.text_color(theme.colors.destructive)
}
fn text_sidebar_foreground(self, theme: &Theme) -> Self {
self.text_color(theme.colors.sidebar_foreground)
}
fn text_sidebar_primary_foreground(self, theme: &Theme) -> Self {
self.text_color(theme.colors.sidebar_primary_foreground)
}
fn text_sidebar_accent_foreground(self, theme: &Theme) -> Self {
self.text_color(theme.colors.sidebar_accent_foreground)
}
fn border_border(self, theme: &Theme) -> Self {
self.border_color(theme.colors.border)
}
fn border_input(self, theme: &Theme) -> Self {
self.border_color(theme.colors.input)
}
fn border_ring(self, theme: &Theme) -> Self {
self.border_color(theme.colors.ring)
}
fn border_sidebar(self, theme: &Theme) -> Self {
self.border_color(theme.colors.sidebar_border)
}
fn border_sidebar_ring(self, theme: &Theme) -> Self {
self.border_color(theme.colors.sidebar_ring)
}
fn border_foreground_muted(self, theme: &Theme) -> Self {
self.border_color(theme.colors.foreground.opacity(0.1))
}
fn rounded_theme(self, theme: &Theme) -> Self {
self.rounded(theme.radius)
}
fn rounded_sm(self, theme: &Theme) -> Self {
self.rounded(theme_radius(theme.radius, 0.6))
}
fn rounded_md(self, theme: &Theme) -> Self {
self.rounded(theme_radius(theme.radius, 0.8))
}
fn rounded_lg(self, theme: &Theme) -> Self {
self.rounded(theme.radius)
}
}
impl<E: Styled> SemanticStyled for E {}
fn theme_radius(base: Rems, scale: f32) -> Rems {
gpui::rems(base.0 * scale)
}

View File

@ -1,6 +1,7 @@
use gpui::{App, IntoElement, RenderOnce, Window, div, prelude::*, px}; use gpui::{App, IntoElement, RenderOnce, Window, div, px};
use crate::{components::Orientation, theme::ActiveTheme}; use crate::components::Orientation;
use crate::components::prelude::*;
#[derive(IntoElement)] #[derive(IntoElement)]
pub(crate) struct Separator { pub(crate) struct Separator {
@ -24,11 +25,11 @@ impl Separator {
impl RenderOnce for Separator { impl RenderOnce for Separator {
fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement { fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement {
let color = window.theme().colors.border.opacity(0.5); let theme = theme(window);
div() div()
.flex_none() .flex_none()
.bg(color) .bg_border_subtle(theme)
.map(|this| match self.orientation { .map(|this| match self.orientation {
Orientation::Horizontal => this.w_full().h(px(1.0)), Orientation::Horizontal => this.w_full().h(px(1.0)),
Orientation::Vertical => this.h_full().w(px(1.0)), Orientation::Vertical => this.h_full().w(px(1.0)),