use std::time::Duration;
use gpui_kit::{
AnyElement, App, BoxShadow, Div, ElementId, Entity, Hsla, IntoElement, ParentElement as _,
Pixels, SharedString, Styled as _, Window,
assets::IconName,
base::{
Presence, PresencePhase, Transition, h_flex,
input::{Input, InputEditorStyle, InputState},
v_flex,
},
div,
prelude::FluentBuilder as _,
px, rems,
};
use crate::{Icon, Theme, ThemeTokens, touch_selection};
#[derive(Clone)]
pub(crate) struct MenuLook {
pub(crate) surface: Hsla,
pub(crate) border: Hsla,
pub(crate) accent: Hsla,
pub(crate) separator: Hsla,
pub(crate) foreground: Hsla,
pub(crate) muted_foreground: Hsla,
pub(crate) description: Hsla,
pub(crate) indicator: Hsla,
pub(crate) destructive: Hsla,
pub(crate) shadow: Vec<BoxShadow>,
pub(crate) radius: Pixels,
pub(crate) row_radius: Pixels,
pub(crate) row_height: Pixels,
pub(crate) row_padding: Pixels,
pub(crate) search_height: Pixels,
pub(crate) padding: Pixels,
pub(crate) text_size: Pixels,
pub(crate) line_height: Pixels,
pub(crate) min_width: Pixels,
pub(crate) search_min_width: Pixels,
pub(crate) max_width: Pixels,
pub(crate) max_height: Pixels,
pub(crate) gap: Pixels,
pub(crate) enter_offset: Pixels,
pub(crate) touch: bool,
}
impl MenuLook {
pub(crate) fn of(theme: &ThemeTokens, rem_size: Pixels) -> Self {
let metrics = &theme.metrics;
Self {
surface: theme.popover,
border: theme.popover_border,
accent: theme.popover_accent,
separator: theme.popover_separator,
foreground: theme.popover_foreground,
muted_foreground: theme.muted_foreground(),
description: theme.popover_muted_foreground,
indicator: theme.select_indicator,
destructive: theme.destructive(),
shadow: theme.base.shadow.md.clone(),
radius: theme.radius_xl(),
row_radius: theme.radius_sm(),
row_height: metrics.row_sm,
row_padding: metrics.control_padding_md,
search_height: metrics.control_md,
padding: rems(0.25).to_pixels(rem_size),
text_size: theme.text_control.size,
line_height: theme.text_control.line_height,
min_width: metrics.menu_min_width,
search_min_width: metrics.menu_search_min_width,
max_width: metrics.menu_max_width,
max_height: metrics.menu_max_height,
gap: metrics.menu_gap,
enter_offset: -rems(0.5).to_pixels(rem_size),
touch: theme.touch,
}
}
pub(crate) fn rows_max_height(&self, has_search: bool) -> Pixels {
let border = px(2.);
let search = if has_search {
self.search_height + self.padding * 2. + px(1.)
} else {
px(0.)
};
self.max_height - self.padding * 2. - border - search
}
pub(crate) fn shadow(&self, strength: f32) -> Vec<BoxShadow> {
self.shadow
.iter()
.map(|shadow| BoxShadow {
color: shadow.color.opacity(strength),
..*shadow
})
.collect()
}
}
#[derive(Clone, Copy)]
pub(crate) struct MenuMotion {
pub(crate) opacity: f32,
pub(crate) shadow_strength: f32,
pub(crate) offset: Pixels,
}
impl MenuMotion {
pub(crate) fn sample(
id: impl Into<ElementId>,
open: bool,
slides: bool,
look: &MenuLook,
window: &mut Window,
cx: &mut App,
) -> Option<Self> {
let enter = Theme::global(cx).motion.enter_transition();
let presence = Presence::new(id.into(), open)
.transition(if open {
enter
} else {
Transition::new(Duration::ZERO)
})
.sample(window, cx);
if !presence.should_render() {
return None;
}
let progress = match presence.phase {
PresencePhase::Entering => presence.progress,
PresencePhase::Present => 1.,
PresencePhase::Exiting | PresencePhase::Absent => 0.,
};
Some(Self {
opacity: progress,
shadow_strength: progress * progress * progress,
offset: if slides {
look.enter_offset * (1. - progress)
} else {
px(0.)
},
})
}
}
pub(crate) fn label_block(
label: SharedString,
description: Option<SharedString>,
look: &MenuLook,
) -> AnyElement {
v_flex()
.flex_1()
.min_w_0()
.gap_1p5()
.child(div().overflow_hidden().text_ellipsis().child(label))
.when_some(description, |this, text| {
this.child(div().text_color(look.description).child(text))
})
.into_any_element()
}
pub(crate) fn line_slot(look: &MenuLook) -> gpui_kit::Div {
div()
.flex_shrink_0()
.min_h(look.line_height)
.flex()
.items_center()
}
pub(crate) fn row_line() -> gpui_kit::Div {
h_flex().flex_1().min_w_0().items_start().gap_2()
}
pub(crate) fn separator(look: &MenuLook) -> AnyElement {
div()
.w_full()
.py(look.padding)
.px(look.padding * 2.)
.child(div().h_px().w_full().bg(look.separator))
.into_any_element()
}
pub(crate) fn search_row(
input: &Entity<InputState>,
look: &MenuLook,
window: &mut Window,
cx: &mut App,
) -> Div {
h_flex()
.flex_shrink_0()
.items_center()
.gap_2()
.h(look.search_height)
.px(look.row_padding)
.child(
Icon::from(IconName::Search)
.size_4()
.text_color(look.muted_foreground),
)
.child(div().flex_1().min_w_0().child(Input::new(input)))
.children(touch_selection::for_state(input, window, cx))
}
pub(crate) fn search_style(theme: &ThemeTokens) -> InputEditorStyle {
InputEditorStyle {
foreground: theme.popover_foreground,
muted_foreground: theme.muted_foreground(),
selection: theme.selection(),
caret: theme.popover_foreground,
..Default::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::theme::{ThemeConfig, to_hex};
use gpui_kit::base::ThemeAppearance;
#[test]
fn the_menu_reads_the_reference_values() {
let theme = crate::theme::test_tokens(&ThemeConfig::dark(), ThemeAppearance::Dark);
let menu = MenuLook::of(&theme, px(16.));
assert_eq!(to_hex(menu.surface), "#2d2d2d");
assert_eq!(to_hex(menu.accent), "#3d3d3d");
assert_eq!(to_hex(menu.indicator), "#cacaca");
assert_eq!(menu.row_height, px(28.));
assert_eq!(menu.radius, px(14.));
assert_eq!(menu.row_radius, px(6.));
assert_eq!(menu.padding, px(4.));
assert_eq!(menu.enter_offset, px(-8.));
assert_eq!(menu.rows_max_height(false), px(380.));
assert_eq!(menu.rows_max_height(true), px(343.));
}
}