use gpui::{
Anchor, AnyElement, ElementId, IntoElement, Pixels, Point, SharedString, div, prelude::*, px,
};
use bezel_motion as motion;
use bezel_motion::PULSE;
use bezel_theme::{Theme, hairline, ink};
#[derive(Debug, Clone, PartialEq, Default)]
pub enum Loadable<T> {
#[default]
Idle,
Loading,
Ready(T),
Error(String),
}
impl<T> Loadable<T> {
pub fn ready(&self) -> Option<&T> {
match self {
Loadable::Ready(value) => Some(value),
_ => None,
}
}
pub fn is_loading(&self) -> bool {
matches!(self, Loadable::Loading)
}
pub fn error(&self) -> Option<&str> {
match self {
Loadable::Error(message) => Some(message),
_ => None,
}
}
}
pub struct Popup<T> {
inner: Option<(T, Option<std::time::Instant>)>,
pressed_while_open: bool,
}
impl<T> Default for Popup<T> {
fn default() -> Self {
Self {
inner: None,
pressed_while_open: false,
}
}
}
impl<T> Popup<T> {
pub fn open(&mut self, value: T) {
self.inner = Some((value, None));
}
pub fn is_open(&self) -> bool {
matches!(self.inner, Some((_, None)))
}
pub fn is_closing(&self) -> bool {
matches!(self.inner, Some((_, Some(_))))
}
pub fn closing_since(&self) -> Option<std::time::Instant> {
match &self.inner {
Some((_, Some(since))) => Some(*since),
_ => None,
}
}
pub fn get(&self) -> Option<&T> {
self.inner.as_ref().map(|(value, _)| value)
}
pub fn as_open(&self) -> Option<&T> {
match &self.inner {
Some((value, None)) => Some(value),
_ => None,
}
}
pub fn open_mut(&mut self) -> Option<&mut T> {
match &mut self.inner {
Some((value, None)) => Some(value),
_ => None,
}
}
pub fn begin_close(&mut self) -> bool {
match &mut self.inner {
Some((_, closing @ None)) => {
*closing = Some(std::time::Instant::now());
true
}
_ => false,
}
}
pub fn note_trigger_press(&mut self) {
self.note_trigger_press_matching(|_| true);
}
pub fn note_trigger_press_matching(&mut self, owns: impl FnOnce(&T) -> bool) {
self.pressed_while_open = self.inner.as_ref().is_some_and(|(value, _)| owns(value));
}
pub fn take_press_was_open(&mut self) -> bool {
std::mem::take(&mut self.pressed_while_open)
}
pub fn finish_close(&mut self) {
if let Some((_, Some(since))) = &self.inner
&& since.elapsed() >= motion::MENU_OUT.total().mul_f32(motion::speed_scale())
{
self.inner = None;
}
}
}
pub fn reap_popup<V: 'static, T: 'static>(
cx: &mut gpui::Context<V>,
popup: impl Fn(&mut V) -> &mut Popup<T> + 'static,
) {
cx.spawn(async move |view, cx| {
cx.background_executor()
.timer(
motion::MENU_OUT
.total()
.mul_f32(motion::speed_scale())
.saturating_add(std::time::Duration::from_millis(20)),
)
.await;
view.update(cx, |view, cx| {
popup(view).finish_close();
cx.notify();
})
.ok();
})
.detach();
}
pub fn menu_step(active: Option<usize>, count: usize, delta: isize) -> Option<usize> {
if count == 0 {
return None;
}
let count_i = count as isize;
let next = match active {
None => {
if delta >= 0 {
0
} else {
count_i - 1
}
}
Some(at) => (at as isize + delta).rem_euclid(count_i),
};
Some(next as usize)
}
pub fn match_rank(query: &str, label: &str) -> Option<usize> {
let query = query.trim().to_lowercase();
if query.is_empty() {
return Some(1);
}
let label = label.to_lowercase();
if label.starts_with(&query) {
Some(0)
} else if label.contains(&query) {
Some(1)
} else {
None
}
}
pub fn filter_indices<S: AsRef<str>>(query: &str, labels: &[S]) -> Vec<usize> {
let mut ranked: Vec<(usize, usize)> = labels
.iter()
.enumerate()
.filter_map(|(ix, label)| match_rank(query, label.as_ref()).map(|rank| (rank, ix)))
.collect();
ranked.sort_by_key(|&(rank, ix)| (rank, ix));
ranked.into_iter().map(|(_, ix)| ix).collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MenuKey {
Up,
Down,
Enter,
ModEnter,
Escape,
Backspace,
Other,
}
pub fn classify_key(key: &str, cmd: bool, ctrl: bool) -> MenuKey {
match key {
"up" => MenuKey::Up,
"down" => MenuKey::Down,
"n" if ctrl => MenuKey::Down,
"p" if ctrl => MenuKey::Up,
"enter" if cmd || ctrl => MenuKey::ModEnter,
"enter" => MenuKey::Enter,
"escape" => MenuKey::Escape,
"backspace" => MenuKey::Backspace,
_ => MenuKey::Other,
}
}
pub fn popover_card(theme: &Theme) -> gpui::Div {
let card = div()
.border_1()
.border_color(hairline(0.10))
.rounded(px(12.0))
.shadow_lg()
.p(px(4.0))
.overflow_hidden()
.text_size(px(13.0))
.text_color(theme.text);
if theme.is_glass() {
card.bg(theme.glass_overlay())
} else {
card.bg(theme.surface_overlay)
}
}
pub fn popover_card_flush(theme: &Theme) -> gpui::Div {
popover_card(theme).p(px(0.0))
}
fn pinned_layer(layer: AnyElement) -> AnyElement {
div()
.absolute()
.top_0()
.left_0()
.size_0()
.child(layer)
.into_any_element()
}
fn exit_progress(since: std::time::Instant) -> f32 {
let total = motion::MENU_OUT
.total()
.mul_f32(motion::speed_scale())
.as_secs_f32();
let raw = if total <= 0.0 {
1.0
} else {
(since.elapsed().as_secs_f32() / total).clamp(0.0, 1.0)
};
motion::MENU_OUT.progress(raw)
}
fn material_menu(exit: Option<f32>, content: AnyElement) -> AnyElement {
let blur = crate::material::MENU_BLUR * (1.0 - exit.unwrap_or(0.0));
crate::material::material(12.0, blur, content).into_any_element()
}
fn menu_motion(id: SharedString, exit: Option<f32>, inner: gpui::Div) -> AnyElement {
if let Some(t) = exit {
let inner = inner.relative().child(div().absolute().inset_0().occlude());
motion::menu_out(SharedString::from(format!("{id}-out")), t, inner).into_any_element()
} else {
motion::menu_in(id, inner).into_any_element()
}
}
pub fn anchored_menu(
id: impl Into<SharedString>,
content: AnyElement,
closing: Option<std::time::Instant>,
) -> AnyElement {
let exit = closing.map(exit_progress);
let content = material_menu(exit, content);
pinned_layer(
gpui::deferred(
gpui::anchored()
.anchor(Anchor::TopLeft)
.snap_to_window_with_margin(px(8.0))
.child(menu_motion(
id.into(),
exit,
div().occlude().pt(px(6.0)).child(content),
)),
)
.priority(1)
.into_any_element(),
)
}
pub fn anchored_menu_below(
id: impl Into<SharedString>,
content: AnyElement,
closing: Option<std::time::Instant>,
) -> AnyElement {
anchored_menu_below_gap(id, content, closing, 6.0)
}
pub fn anchored_menu_below_gap(
id: impl Into<SharedString>,
content: AnyElement,
closing: Option<std::time::Instant>,
gap: f32,
) -> AnyElement {
let exit = closing.map(exit_progress);
let content = material_menu(exit, content);
div()
.absolute()
.bottom_0()
.left_0()
.size_0()
.child(
gpui::deferred(
gpui::anchored()
.anchor(Anchor::TopLeft)
.snap_to_window_with_margin(px(8.0))
.child(menu_motion(
id.into(),
exit,
div().occlude().pt(px(gap)).child(content),
)),
)
.priority(1)
.into_any_element(),
)
.into_any_element()
}
pub fn anchored_menu_above(
id: impl Into<SharedString>,
content: AnyElement,
closing: Option<std::time::Instant>,
) -> AnyElement {
let exit = closing.map(exit_progress);
let content = material_menu(exit, content);
pinned_layer(
gpui::deferred(
gpui::anchored()
.anchor(Anchor::BottomLeft)
.snap_to_window_with_margin(px(8.0))
.child(menu_motion(
id.into(),
exit,
div().occlude().pb(px(6.0)).child(content),
)),
)
.priority(1)
.into_any_element(),
)
}
pub fn anchored_menu_above_at(
id: impl Into<SharedString>,
position: Point<Pixels>,
content: AnyElement,
closing: Option<std::time::Instant>,
) -> AnyElement {
div()
.absolute()
.left(position.x)
.top(position.y)
.size_0()
.child(anchored_menu_above(id, content, closing))
.into_any_element()
}
pub fn anchored_menu_above_end(
id: impl Into<SharedString>,
content: AnyElement,
closing: Option<std::time::Instant>,
) -> AnyElement {
let exit = closing.map(exit_progress);
let content = material_menu(exit, content);
div()
.absolute()
.top_0()
.right_0()
.size_0()
.child(
gpui::deferred(
gpui::anchored()
.anchor(Anchor::BottomRight)
.snap_to_window_with_margin(px(8.0))
.child(menu_motion(
id.into(),
exit,
div().occlude().pb(px(6.0)).child(content),
)),
)
.priority(1)
.into_any_element(),
)
.into_any_element()
}
pub fn menu_at(
id: impl Into<SharedString>,
position: Point<Pixels>,
content: AnyElement,
closing: Option<std::time::Instant>,
) -> AnyElement {
let exit = closing.map(exit_progress);
let content = material_menu(exit, content);
gpui::deferred(
gpui::anchored()
.position(position)
.anchor(Anchor::TopLeft)
.snap_to_window_with_margin(px(8.0))
.child(menu_motion(id.into(), exit, div().occlude().child(content))),
)
.priority(1)
.into_any_element()
}
pub(crate) fn scrim_alpha(alpha_dark: f32) -> gpui::Hsla {
bezel_theme::scrim(alpha_dark)
}
pub fn modal(
id: impl Into<ElementId>,
viewport: gpui::Size<Pixels>,
card: AnyElement,
) -> AnyElement {
modal_with(id, viewport, card, 16.0, 0.6)
}
pub fn modal_glass(
id: impl Into<ElementId>,
viewport: gpui::Size<Pixels>,
card: AnyElement,
corner_radius: f32,
) -> AnyElement {
modal_with(id, viewport, card, corner_radius, 0.35)
}
fn modal_with(
id: impl Into<ElementId>,
viewport: gpui::Size<Pixels>,
card: AnyElement,
corner_radius: f32,
scrim: f32,
) -> AnyElement {
let card = crate::material::material(corner_radius, crate::material::MENU_BLUR, card)
.into_any_element();
gpui::deferred(
gpui::anchored()
.position(gpui::point(px(0.0), px(0.0)))
.child(
div()
.occlude()
.w(viewport.width)
.h(viewport.height)
.bg(scrim_alpha(scrim))
.flex()
.items_center()
.justify_center()
.child(motion::dialog_in(id, div().child(card))),
),
)
.priority(2)
.into_any_element()
}
pub fn menu_row(theme: &Theme, active: bool, fade_key: impl Into<SharedString>) -> gpui::Div {
let row = div()
.flex()
.flex_row()
.items_center()
.gap(px(10.0))
.px(px(8.0))
.py(px(6.0))
.rounded(px(8.0))
.text_size(px(13.0))
.cursor_pointer();
if active {
row.bg(bezel_theme::card_selected_bg())
.text_color(theme.text)
} else {
let fade_key = fade_key.into();
let mut row = row
.text_color(motion::hover_blend(
&fade_key,
theme.text.opacity(0.9),
theme.text,
))
.bg(motion::hover_blend(
&fade_key,
bezel_theme::wash(0.0),
bezel_theme::card_selected_bg(),
));
row.interactivity()
.on_hover(motion::hover_listener(fade_key));
row
}
}
pub fn menu_row_nav(
theme: &Theme,
selected: bool,
highlighted: bool,
fade_key: impl Into<SharedString>,
) -> gpui::Div {
let row = menu_row(theme, selected, fade_key);
if !selected && highlighted {
row.bg(bezel_theme::card_selected_bg())
.text_color(theme.text)
} else {
row
}
}
pub fn menu_heading(theme: &Theme, label: &str) -> gpui::Div {
div()
.px(px(8.0))
.pb(px(4.0))
.pt(px(6.0))
.text_size(px(10.0))
.font_weight(gpui::FontWeight::MEDIUM)
.text_color(theme.text_muted.opacity(0.6))
.child(SharedString::from(tracked_upper(label)))
}
pub fn tracked_upper(label: &str) -> String {
let upper = label.to_uppercase();
let mut out = String::with_capacity(upper.len() * 2);
let mut first = true;
for ch in upper.chars() {
if !first {
out.push('\u{200A}'); }
out.push(ch);
first = false;
}
out
}
pub fn divider() -> gpui::Div {
div().h(px(1.0)).mx(px(-4.0)).my(px(4.0)).bg(hairline(0.07))
}
pub fn band() -> gpui::Hsla {
bezel_theme::band()
}
pub fn key_cap(_theme: &Theme) -> gpui::Div {
div()
.h(px(22.0))
.px(px(5.0))
.rounded(px(5.0))
.flex()
.flex_row()
.items_center()
.justify_center()
.gap(px(4.0))
.bg(ink(0.05))
}
fn key_hint_label(theme: &Theme, label: &'static str) -> gpui::Div {
div()
.text_size(px(10.5))
.text_color(theme.text_muted.opacity(0.45))
.child(SharedString::from(label))
}
pub fn key_hint(theme: &Theme, icon_path: &'static str, label: &'static str) -> gpui::Div {
div()
.flex()
.flex_row()
.items_center()
.gap(px(5.0))
.child(
key_cap(theme).child(
crate::icons::icon(icon_path)
.size(px(12.5))
.text_color(theme.text_muted.opacity(0.7)),
),
)
.child(key_hint_label(theme, label))
}
pub fn key_hint_text(theme: &Theme, cap: &'static str, label: &'static str) -> gpui::Div {
div()
.flex()
.flex_row()
.items_center()
.gap(px(5.0))
.child(
key_cap(theme)
.text_size(px(11.0))
.font_family(theme.font_mono.clone())
.text_color(theme.text_muted.opacity(0.7))
.child(SharedString::from(cap)),
)
.child(key_hint_label(theme, label))
}
pub fn key_hint_pair(
theme: &Theme,
first: &'static str,
second: &'static str,
label: &'static str,
) -> gpui::Div {
div()
.flex()
.flex_row()
.items_center()
.gap(px(5.0))
.child(
key_cap(theme)
.child(
crate::icons::icon(first)
.size(px(12.5))
.text_color(theme.text_muted.opacity(0.7)),
)
.child(div().w(px(1.0)).h(px(11.0)).bg(hairline(0.10)))
.child(
crate::icons::icon(second)
.size(px(12.5))
.text_color(theme.text_muted.opacity(0.7)),
),
)
.child(key_hint_label(theme, label))
}
pub fn kbd_hint(theme: &Theme, label: &str) -> gpui::Div {
div()
.flex_none()
.px(px(5.0))
.py(px(1.0))
.rounded(px(5.0))
.bg(ink(0.05))
.text_size(px(10.0))
.font_family(theme.font_mono.clone())
.text_color(theme.text_muted.opacity(0.6))
.child(SharedString::from(label.to_string()))
}
pub fn search_input_frame(_theme: &Theme, input: AnyElement) -> gpui::Div {
div()
.mb(px(4.0))
.px(px(10.0))
.py(px(6.0))
.rounded(px(8.0))
.bg(ink(0.04))
.text_size(px(13.0))
.child(input)
}
pub fn menu_section() -> gpui::Div {
div()
.mt(px(4.0))
.pt(px(4.0))
.border_t_1()
.border_color(hairline(0.06))
.flex()
.flex_col()
.gap(px(2.0))
}
pub fn dialog_card(theme: &Theme) -> gpui::Div {
div()
.w(px(360.0))
.p(px(20.0))
.rounded(px(16.0))
.bg(theme.surface_dialog)
.border_1()
.border_color(hairline(0.10))
.shadow_lg()
.flex()
.flex_col()
.text_color(theme.text)
}
pub fn dialog_title(theme: &Theme, title: &str) -> gpui::Div {
div()
.text_size(px(15.0))
.font_weight(gpui::FontWeight::SEMIBOLD)
.text_color(theme.text)
.child(SharedString::from(title.to_string()))
}
pub fn dialog_body(theme: &Theme, copy: impl Into<SharedString>) -> gpui::Div {
div()
.text_size(px(13.0))
.line_height(px(19.0))
.text_color(theme.text_muted)
.child(copy.into())
}
pub fn dialog_field(input: AnyElement) -> gpui::Div {
div()
.w_full()
.px(px(12.0))
.py(px(8.0))
.rounded(px(8.0))
.border_1()
.border_color(hairline(0.08))
.bg(ink(0.04))
.text_size(px(14.0))
.child(input)
}
pub fn button(theme: &Theme, label: &str, fade_key: impl Into<SharedString>) -> gpui::Div {
let fade_key = fade_key.into();
let mut btn = div()
.px(px(12.0))
.py(px(6.0))
.rounded(px(8.0))
.text_size(px(13.0))
.text_color(motion::hover_blend(&fade_key, theme.text_muted, theme.text))
.bg(motion::hover_blend(
&fade_key,
bezel_theme::wash(0.0),
ink(0.06),
))
.cursor_pointer()
.child(SharedString::from(label.to_string()));
btn.interactivity()
.on_hover(motion::hover_listener(fade_key));
btn
}
pub fn button_prominent(theme: &Theme, label: &str) -> gpui::Div {
div()
.px(px(12.0))
.py(px(6.0))
.rounded(px(8.0))
.bg(theme.text)
.text_size(px(13.0))
.font_weight(gpui::FontWeight::MEDIUM)
.text_color(theme.on_solid)
.cursor_pointer()
.hover(|s| s.opacity(0.9))
.child(SharedString::from(label.to_string()))
}
pub fn button_destructive(theme: &Theme, label: &str) -> gpui::Div {
div()
.px(px(12.0))
.py(px(6.0))
.rounded(px(8.0))
.bg(theme.danger_strong)
.text_size(px(13.0))
.font_weight(gpui::FontWeight::MEDIUM)
.text_color(gpui::white())
.cursor_pointer()
.hover(|s| s.opacity(0.9))
.child(SharedString::from(label.to_string()))
}
pub fn redacted_rows(
_id: &'static str,
_theme: &Theme,
count: usize,
view: gpui::EntityId,
cx: &mut gpui::App,
) -> AnyElement {
let wash = ink(0.04);
let delta = motion::pulse_delta(&PULSE, view, cx);
div()
.flex()
.flex_col()
.gap(px(6.0))
.py(px(4.0))
.children((0..count).map(move |i| {
let phase = motion::staggered_phase(delta, i, 0.08);
div()
.h(px(28.0))
.rounded(px(Theme::CONTROL_RADIUS))
.bg(wash)
.opacity(0.35 + 0.4 * motion::pulse_wave(phase))
}))
.into_any_element()
}
pub fn error_row(theme: &Theme, message: &str) -> gpui::Div {
div()
.flex()
.flex_col()
.gap(px(6.0))
.p(px(Theme::SPACE_SM))
.text_size(px(12.0))
.text_color(theme.danger)
.child(gpui::SharedString::from(message.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn trigger_press_note_distinguishes_dismiss_from_open() {
let mut popup: Popup<u8> = Popup::default();
popup.note_trigger_press();
assert!(!popup.take_press_was_open());
popup.open(1);
popup.note_trigger_press();
popup.begin_close();
assert!(popup.take_press_was_open());
popup.open(1);
popup.begin_close();
popup.note_trigger_press();
assert!(popup.take_press_was_open());
assert!(!popup.take_press_was_open());
let mut popup: Popup<u8> = Popup::default();
popup.open(1);
popup.note_trigger_press_matching(|kind| *kind == 2);
assert!(!popup.take_press_was_open());
popup.note_trigger_press_matching(|kind| *kind == 1);
assert!(popup.take_press_was_open());
}
#[test]
fn menu_step_wraps_and_enters() {
assert_eq!(menu_step(None, 0, 1), None);
assert_eq!(menu_step(Some(3), 0, 1), None);
assert_eq!(menu_step(None, 3, 1), Some(0));
assert_eq!(menu_step(None, 3, -1), Some(2));
assert_eq!(menu_step(Some(2), 3, 1), Some(0));
assert_eq!(menu_step(Some(0), 3, -1), Some(2));
assert_eq!(menu_step(Some(1), 3, 1), Some(2));
}
#[test]
fn filter_ranks_prefix_before_substring() {
let labels = ["main", "feature/main-sync", "master", "dev"];
assert_eq!(filter_indices("ma", &labels), vec![0, 2, 1]);
assert_eq!(filter_indices("MA", &labels), vec![0, 2, 1]);
assert!(filter_indices("zzz", &labels).is_empty());
assert_eq!(filter_indices("", &labels), vec![0, 1, 2, 3]);
assert_eq!(filter_indices(" ", &labels), vec![0, 1, 2, 3]);
}
#[test]
fn match_rank_kinds() {
assert_eq!(match_rank("re", "release"), Some(0));
assert_eq!(match_rank("lease", "release"), Some(1));
assert_eq!(match_rank("x", "release"), None);
assert_eq!(match_rank("", "anything"), Some(1));
}
#[test]
fn key_classification() {
assert_eq!(classify_key("up", false, false), MenuKey::Up);
assert_eq!(classify_key("down", false, false), MenuKey::Down);
assert_eq!(classify_key("enter", false, false), MenuKey::Enter);
assert_eq!(classify_key("enter", true, false), MenuKey::ModEnter);
assert_eq!(classify_key("enter", false, true), MenuKey::ModEnter);
assert_eq!(classify_key("escape", false, false), MenuKey::Escape);
assert_eq!(classify_key("backspace", false, false), MenuKey::Backspace);
assert_eq!(classify_key("a", false, false), MenuKey::Other);
assert_eq!(classify_key("n", false, true), MenuKey::Down);
assert_eq!(classify_key("p", false, true), MenuKey::Up);
assert_eq!(classify_key("n", false, false), MenuKey::Other);
assert_eq!(classify_key("p", true, false), MenuKey::Other);
}
#[test]
fn tracked_upper_spaces_letters() {
assert_eq!(tracked_upper("ab"), "A\u{200A}B");
assert_eq!(
tracked_upper("Question"),
"Q\u{200A}U\u{200A}E\u{200A}S\u{200A}T\u{200A}I\u{200A}O\u{200A}N"
);
assert_eq!(tracked_upper(""), "");
}
#[test]
fn loadable_accessors() {
let l: Loadable<u32> = Loadable::Ready(7);
assert_eq!(l.ready(), Some(&7));
assert!(!l.is_loading());
let e: Loadable<u32> = Loadable::Error("boom".into());
assert_eq!(e.error(), Some("boom"));
assert!(Loadable::<u32>::Loading.is_loading());
assert_eq!(Loadable::<u32>::default(), Loadable::Idle);
}
}