use crate::control::{PressReleaseExt as _, composed_button};
use crate::motion::VisibleAnimationExt as _;
use crate::theme::SemanticStyledExt as _;
use gpui::{
App, Div, ElementId, FontWeight, Hsla, InteractiveElement as _, IntoElement,
ParentElement as _, Pixels, RenderOnce, SharedString, Stateful,
StatefulInteractiveElement as _, StyleRefinement, Styled, Window, div,
prelude::FluentBuilder as _,
};
use gpui_base::{Button, StyledExt as _};
use gpui_component::{ActiveTheme as _, Icon, IconNamed, Sizable as _, tooltip::Tooltip, v_flex};
pub(crate) trait CardFrameExt: Sized {
fn card_frame(self, cx: &App) -> Self;
}
impl<E: gpui::Styled + Sized> CardFrameExt for E {
fn card_frame(self, cx: &App) -> Self {
let tokens = cx.theme().semantic_tokens();
self.bg(tokens.colors.surface)
.border_1()
.border_color(cx.theme().border)
.rounded(tokens.radius.lg)
}
}
pub(crate) fn card(id: impl Into<ElementId>, cx: &App) -> Stateful<Div> {
let tokens = cx.theme().semantic_tokens();
v_flex()
.id(id)
.w_full()
.min_w_0()
.gap(tokens.spacing.md)
.p(tokens.spacing.lg)
.card_frame(cx)
}
pub(crate) fn skeleton_rows(
id: impl Into<ElementId>,
rows: usize,
columns: usize,
cx: &App,
) -> impl IntoElement {
let tokens = cx.theme().semantic_tokens();
let block = cx.theme().muted;
let gap = tokens.spacing.lg;
let row_gap = tokens.spacing.sm;
let radius = tokens.radius.sm;
let full = crate::motion::motion_is_full(cx);
let spec = crate::motion::MotionTokens::read(cx).breathing();
v_flex().w_full().gap(row_gap).with_visible_animation(
id,
spec.looping_synced(),
move |body, delta| {
let delta = if full { delta } else { 0.5 };
let wave = (delta * 2.0 - 1.0).abs();
let pulse = 0.55 + 0.3 * wave;
body.opacity(pulse).children((0..rows).map(|row| {
gpui::div()
.flex()
.w_full()
.items_center()
.gap(gap)
.children((0..columns).map(move |column| {
let fraction = 0.5 + 0.4 * (((row * 7 + column * 3) % 5) as f32 / 4.0);
gpui::div().flex_1().child(
gpui::div()
.h(gpui::rems(0.75))
.w(gpui::relative(fraction))
.rounded(radius)
.bg(block),
)
}))
}))
},
)
}
pub(crate) fn hairline(cx: &App) -> gpui::Hsla {
cx.theme().border.opacity(0.6)
}
pub(crate) fn empty_state(
icon: impl IconNamed,
title: impl Into<SharedString>,
note: Option<SharedString>,
cx: &App,
) -> Div {
let tokens = cx.theme().semantic_tokens();
v_flex()
.w_full()
.items_center()
.gap(tokens.spacing.xs)
.p(tokens.spacing.lg)
.child(
Icon::new(icon)
.small()
.text_color(cx.theme().muted_foreground),
)
.child(div().text_token(tokens.typography.sm).child(title.into()))
.when_some(note, |this, note| this.child(hint(note, cx)))
}
pub(crate) fn nested_radius(container: Pixels, inset: Pixels, floor: Pixels) -> Pixels {
if container > inset {
(container - inset).max(floor)
} else {
floor
}
}
pub(crate) type RowGlide<'a> =
Option<(&'a SharedString, &'a gpui::Entity<crate::glide::GlideHover>)>;
pub(crate) fn selection_surface<E>(
row: E,
selected: bool,
container_radius: Pixels,
inset: Pixels,
glide: RowGlide<'_>,
cx: &App,
) -> E
where
E: gpui::StatefulInteractiveElement + gpui::ParentElement + gpui::Styled,
{
let tokens = cx.theme().semantic_tokens();
let radius = nested_radius(container_radius, inset, tokens.radius.sm);
let row = row.rounded(radius);
match glide {
Some((key, state)) => {
let row = if selected {
row.bg(cx.theme().list_active)
} else {
row
};
crate::glide::glide_row(row, key.clone(), state)
}
None if selected => row
.bg(cx.theme().list_active)
.hover(|style| style.bg(cx.theme().list_active.opacity(0.85))),
None => row.hover(|style| style.bg(cx.theme().list_hover)),
}
}
pub(crate) fn leading_glyph_slot(slot: Pixels, glyph: impl IntoElement) -> Div {
leading_control_slot(slot, slot, glyph)
}
pub(crate) fn leading_control_slot(line: Pixels, width: Pixels, control: impl IntoElement) -> Div {
div()
.flex_none()
.w(width)
.h(line)
.flex()
.items_center()
.justify_start()
.child(control)
}
pub(crate) fn inset(cx: &App) -> Div {
let tokens = cx.theme().semantic_tokens();
div()
.w_full()
.min_w_0()
.p(tokens.spacing.md)
.bg(cx.theme().muted.opacity(0.45))
.rounded(tokens.radius.md)
}
pub(crate) fn title(text: impl Into<SharedString>, cx: &App) -> Div {
let tokens = cx.theme().semantic_tokens();
div()
.text_token(tokens.typography.md)
.font_weight(FontWeight::SEMIBOLD)
.child(text.into())
}
pub(crate) fn description(text: impl Into<SharedString>, cx: &App) -> Quiet {
Quiet::new(text, cx.theme().semantic_tokens().typography.sm)
}
#[derive(IntoElement)]
pub(crate) struct Quiet {
text: SharedString,
token: gpui_base::theme_tokens::TextStyleToken,
family: Option<SharedString>,
semibold: bool,
style: StyleRefinement,
}
impl Quiet {
fn new(text: impl Into<SharedString>, token: gpui_base::theme_tokens::TextStyleToken) -> Self {
Self {
text: text.into(),
token,
family: None,
semibold: false,
style: StyleRefinement::default(),
}
}
fn family(mut self, family: SharedString) -> Self {
self.family = Some(family);
self
}
fn semibold(mut self) -> Self {
self.semibold = true;
self
}
}
impl Styled for Quiet {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl RenderOnce for Quiet {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
div()
.text_token(self.token)
.when_some(self.family, |text, family| text.font_family(family))
.when(self.semibold, |text| text.font_weight(FontWeight::SEMIBOLD))
.text_color(quiet_ink(window.text_style().color, cx))
.refine_style(&self.style)
.child(self.text)
}
}
fn quiet_ink(ink: Hsla, cx: &App) -> Hsla {
if ink == cx.theme().foreground {
cx.theme().muted_foreground
} else {
ink
}
}
pub(crate) fn eyebrow(text: impl Into<SharedString>, cx: &App) -> Quiet {
Quiet::new(text, cx.theme().semantic_tokens().typography.xs).semibold()
}
pub(crate) fn meta(text: impl Into<SharedString>, cx: &App) -> Quiet {
Quiet::new(text, cx.theme().semantic_tokens().typography.xs)
.family(cx.theme().mono_font_family.clone())
}
pub(crate) fn disclosure_chevron(disclosure: f32) -> gpui_component::Icon {
gpui_component::Sizable::xsmall(gpui_component::Icon::new(
gpui_component::IconName::ChevronRight,
))
.rotate(gpui::percentage(0.25 * disclosure))
}
pub(crate) fn hint(text: impl Into<SharedString>, cx: &App) -> Quiet {
Quiet::new(text, cx.theme().semantic_tokens().typography.xs)
}
pub(crate) fn subtitle(text: impl Into<SharedString>, cx: &App) -> Div {
let tokens = cx.theme().semantic_tokens();
div()
.text_token(tokens.typography.sm)
.font_weight(FontWeight::SEMIBOLD)
.child(text.into())
}
pub(crate) fn field_unit(unit: impl Into<SharedString>, cx: &App) -> Quiet {
hint(unit, cx).flex_none()
}
pub(crate) fn initial_badge(initial: impl Into<SharedString>, cx: &App) -> Div {
let tokens = cx.theme().semantic_tokens();
div()
.flex_none()
.size(crate::sizing::slot_sm(cx))
.overflow_hidden()
.flex()
.items_center()
.justify_center()
.rounded(tokens.radius.sm)
.bg(cx.theme().primary.opacity(0.14))
.text_token(tokens.typography.xs)
.font_weight(FontWeight::SEMIBOLD)
.text_color(cx.theme().primary)
.child(initial.into())
}
pub(crate) fn initial_of(text: &str) -> String {
text.chars()
.find(|character| character.is_alphanumeric())
.map(|character| character.to_uppercase().collect())
.unwrap_or_else(|| "•".to_owned())
}
pub(crate) fn icon_button(
id: impl Into<ElementId>,
icon: impl IconNamed,
accessibility_label: impl Into<SharedString>,
window: &mut gpui::Window,
cx: &mut App,
) -> Button {
icon_button_turned(id, icon, 0.0, accessibility_label, window, cx)
}
pub(crate) fn icon_button_turned(
id: impl Into<ElementId>,
icon: impl IconNamed,
turns: f32,
accessibility_label: impl Into<SharedString>,
window: &mut gpui::Window,
cx: &mut App,
) -> Button {
let tokens = cx.theme().semantic_tokens();
let id = id.into();
let (pressed, fade) = crate::control::press_release_state(&id, window, cx);
let name = accessibility_label.into();
let tooltip = name.clone();
composed_button(id.clone(), name)
.tooltip(move |window, cx| Tooltip::new(tooltip.clone()).build(window, cx))
.flex()
.flex_none()
.items_center()
.justify_center()
.size(crate::sizing::control_sm(cx))
.rounded(tokens.radius.sm)
.border_1()
.border_color(cx.theme().transparent)
.text_color(cx.theme().muted_foreground)
.hover(|style| {
style
.bg(cx.theme().accent.opacity(0.6))
.text_color(cx.theme().accent_foreground)
})
.active(|style| style.bg(cx.theme().accent))
.focus_visible(|style| style.border_color(cx.theme().ring))
.styles(|styles| {
styles.selected(|style| {
style
.bg(cx.theme().accent)
.text_color(cx.theme().accent_foreground)
})
})
.press_release(id.clone(), tokens.radius.sm, window, cx)
.child(Icon::new(icon).xsmall().transform({
let intensity = if pressed { 1.0 } else { fade };
let scale = 1.0 - 0.03 * intensity;
gpui::Transformation::rotate(gpui::percentage(turns))
.with_scaling(gpui::size(scale, scale))
}))
}
#[cfg(test)]
mod tests {
use super::nested_radius;
use gpui::px;
#[test]
fn the_nesting_rule_subtracts_the_inset_and_floors() {
assert_eq!(nested_radius(px(8.), px(4.), px(3.)), px(4.));
assert_eq!(nested_radius(px(8.), px(6.), px(3.)), px(3.), "floored");
assert_eq!(
nested_radius(px(6.), px(8.), px(3.)),
px(3.),
"inset past the corner"
);
assert_eq!(nested_radius(px(10.), px(2.), px(3.)), px(8.));
}
}