use crate::{icons, popover, tooltip::Tooltip};
use gpui::{Context, MouseDownEvent, Pixels, Point, SharedString, Window, div, prelude::*, px};
use icons::Icon;
use std::{cell::Cell, rc::Rc};
use theme::{TextStyle, Theme, Typeset};
const GLYPH: f32 = 13.0;
const PANEL_MIN: f32 = 180.0;
const PANEL_DESCRIBED: f32 = 280.0;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Item {
Action {
label: SharedString,
description: Option<SharedString>,
tooltip: Option<SharedString>,
icon: Option<Icon>,
keystroke: Option<SharedString>,
checked: bool,
enabled: bool,
},
Submenu {
label: SharedString,
icon: Option<Icon>,
enabled: bool,
items: Vec<Item>,
},
Separator,
}
impl Item {
pub fn action(label: impl Into<SharedString>) -> Self {
Item::Action {
label: label.into(),
description: None,
tooltip: None,
icon: None,
keystroke: None,
checked: false,
enabled: true,
}
}
pub fn submenu(label: impl Into<SharedString>, items: Vec<Item>) -> Self {
Item::Submenu {
label: label.into(),
icon: None,
enabled: true,
items,
}
}
pub fn with_icon(mut self, icon: impl Into<Icon>) -> Self {
match &mut self {
Item::Action { icon: slot, .. } | Item::Submenu { icon: slot, .. } => {
*slot = Some(icon.into())
}
Item::Separator => {}
}
self
}
pub fn with_description(mut self, description: impl Into<SharedString>) -> Self {
if let Item::Action {
description: slot, ..
} = &mut self
{
*slot = Some(description.into());
}
self
}
pub fn with_long_description(self, description: impl Into<SharedString>) -> Self {
let description = description.into();
self.with_description(description.clone())
.with_tooltip(description)
}
pub fn with_tooltip(mut self, tooltip: impl Into<SharedString>) -> Self {
if let Item::Action { tooltip: slot, .. } = &mut self {
*slot = Some(tooltip.into());
}
self
}
pub fn with_keystroke(mut self, keystroke: impl Into<SharedString>) -> Self {
if let Item::Action {
keystroke: slot, ..
} = &mut self
{
*slot = Some(keystroke.into());
}
self
}
pub fn checked(mut self, checked: bool) -> Self {
if let Item::Action { checked: slot, .. } = &mut self {
*slot = checked;
}
self
}
pub fn disabled(mut self) -> Self {
match &mut self {
Item::Action { enabled, .. } | Item::Submenu { enabled, .. } => *enabled = false,
Item::Separator => {}
}
self
}
pub fn selectable(&self) -> bool {
match self {
Item::Action { enabled, .. } => *enabled,
Item::Submenu { enabled, items, .. } => *enabled && items.iter().any(Item::selectable),
Item::Separator => false,
}
}
fn has_description(&self) -> bool {
matches!(
self,
Item::Action {
description: Some(_),
..
}
)
}
fn has_icon(&self) -> bool {
matches!(
self,
Item::Action { icon: Some(_), .. } | Item::Submenu { icon: Some(_), .. }
)
}
fn opens(&self) -> Option<&[Item]> {
match self {
Item::Submenu {
enabled: true,
items,
..
} => Some(items),
_ => None,
}
}
}
pub fn at<'a>(items: &'a [Item], path: &[usize]) -> Option<&'a Item> {
let (&row, above) = path.split_last()?;
items_at(items, above)?.get(row)
}
pub fn items_at<'a>(items: &'a [Item], path: &[usize]) -> Option<&'a [Item]> {
let mut level = items;
for &row in path {
level = level.get(row)?.opens()?;
}
Some(level)
}
fn open_depth(items: &[Item], open: &[usize]) -> usize {
let mut level = items;
for (depth, &row) in open.iter().enumerate() {
match level.get(row).and_then(Item::opens) {
Some(inner) => level = inner,
None => return depth,
}
}
open.len()
}
pub fn next_selectable(items: &[Item], from: Option<usize>, delta: isize) -> Option<usize> {
let count = items.len();
if count == 0 {
return None;
}
let step = if delta >= 0 { 1 } else { -1 };
let wrap = |at: usize| (at as isize + step).rem_euclid(count as isize) as usize;
let mut at = match from {
None if step > 0 => 0,
None => count - 1,
Some(at) => wrap(at.min(count - 1)),
};
for _ in 0..count {
if items[at].selectable() {
return Some(at);
}
at = wrap(at);
}
None
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Cursor {
open: Vec<usize>,
row: Option<usize>,
}
impl Cursor {
pub fn open(&self) -> &[usize] {
&self.open
}
pub fn row(&self) -> Option<usize> {
self.row
}
pub fn nested(&self) -> bool {
!self.open.is_empty()
}
pub fn path(&self) -> Option<Vec<usize>> {
let row = self.row?;
let mut path = self.open.clone();
path.push(row);
Some(path)
}
pub fn clear(&mut self) {
self.open.clear();
self.row = None;
}
pub fn lit(&self, depth: usize) -> Option<usize> {
match depth.cmp(&self.open.len()) {
std::cmp::Ordering::Less => Some(self.open[depth]),
std::cmp::Ordering::Equal => self.row,
std::cmp::Ordering::Greater => None,
}
}
pub fn step(&mut self, root: &[Item], delta: isize) {
let Some(items) = items_at(root, &self.open) else {
return;
};
self.row = next_selectable(items, self.row, delta);
}
pub fn descend(&mut self, root: &[Item]) -> bool {
let Some(row) = self.row else { return false };
let Some(inner) = items_at(root, &self.open)
.and_then(|items| items.get(row))
.and_then(Item::opens)
else {
return false;
};
self.row = next_selectable(inner, None, 1);
self.open.push(row);
true
}
pub fn ascend(&mut self) -> bool {
match self.open.pop() {
Some(row) => {
self.row = Some(row);
true
}
None => false,
}
}
pub fn point_at(&mut self, root: &[Item], path: &[usize]) -> bool {
let (open, row) = match path.split_last() {
None => (Vec::new(), None),
Some((&row, above)) if at(root, path).and_then(Item::opens).is_some() => {
(above.iter().copied().chain([row]).collect(), None)
}
Some((&row, above)) => (above.to_vec(), Some(row)),
};
if self.open == open && self.row == row {
return false;
}
self.open = open;
self.row = row;
true
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Hit {
Point(Vec<usize>),
Choose(Vec<usize>),
Dismiss,
}
pub fn card<V: 'static>(
theme: &Theme,
id: impl Into<SharedString>,
items: &[Item],
cursor: &Cursor,
cx: &mut Context<V>,
on: impl Fn(&mut V, Hit, &mut Window, &mut Context<V>) + 'static,
) -> gpui::Div {
let tree = Tree {
id: id.into(),
panels: 1 + open_depth(items, cursor.open()),
outside: Rc::new(Cell::new((None, 0))),
on: Rc::new(on),
};
tree.panel(theme, items, cursor, 0, &[], cx)
}
type Listener<E> = Box<dyn Fn(&E, &mut Window, &mut gpui::App)>;
type Reporter<V> = Rc<dyn Fn(&mut V, Hit, &mut Window, &mut Context<V>)>;
struct Tree<V: 'static> {
id: SharedString,
panels: usize,
outside: Rc<Cell<(Option<Point<Pixels>>, usize)>>,
on: Reporter<V>,
}
impl<V: 'static> Tree<V> {
fn panel(
&self,
theme: &Theme,
items: &[Item],
cursor: &Cursor,
depth: usize,
prefix: &[usize],
cx: &mut Context<V>,
) -> gpui::Div {
let lit = cursor.lit(depth);
let down = cursor.open().get(depth).copied();
let gutter = items.iter().any(Item::has_icon);
let described = items.iter().any(Item::has_description);
popover::popover_card(theme)
.map(|card| match described {
true => card.w(px(PANEL_DESCRIBED)),
false => card.min_w(px(PANEL_MIN)),
})
.on_mouse_down_out(self.dismissal(cx))
.children(items.iter().enumerate().map(|(row, item)| {
if matches!(item, Item::Separator) {
return popover::divider().into_any_element();
}
let path: Vec<usize> = prefix.iter().copied().chain([row]).collect();
let id = row_id(&self.id, &path);
let (label, icon, enabled) = match item {
Item::Action {
label,
icon,
enabled,
..
}
| Item::Submenu {
label,
icon,
enabled,
..
} => (label.clone(), icon.clone(), *enabled),
Item::Separator => unreachable!("separators returned above"),
};
let (description, hint) = match item {
Item::Action {
description,
tooltip,
..
} => (description.clone(), tooltip.clone()),
_ => (None, None),
};
let row = if enabled {
popover::menu_row(theme, lit == Some(row), None)
.id(id.clone())
.on_mouse_move(self.reports(Hit::Point(path.clone()), cx))
.on_click(self.reports(
match item {
Item::Submenu { .. } => Hit::Point(path.clone()),
_ => Hit::Choose(path.clone()),
},
cx,
))
} else {
disabled_row(theme).id(id.clone())
};
let row = row.when_some(hint, |row, hint| {
row.tooltip(move |window, cx| Tooltip::text(hint.clone(), window, cx))
});
row.when(gutter, |row| row.child(glyph_slot(theme, icon, enabled)))
.child(
div()
.flex_1()
.min_w_0()
.flex()
.flex_col()
.child(label)
.children(
description.map(|description| {
description_line(theme, description, enabled)
}),
),
)
.map(|row| match item {
Item::Action {
keystroke, checked, ..
} => row
.when(*checked, |row| {
row.child(
icons::icon(icons::glyph::Check)
.size(px(GLYPH))
.text_color(theme.text),
)
})
.when_some(keystroke.clone(), |row, keystroke| {
row.child(popover::kbd_hint(theme, &keystroke))
}),
_ => row.child(
icons::icon(icons::glyph::ChevronRight)
.size(px(GLYPH))
.text_color(theme.text_faint),
),
})
.when_some(
item.opens().filter(|_| down == Some(path[depth])),
|parent, inner| {
let panel = self
.panel(theme, inner, cursor, depth + 1, &path, cx)
.into_any_element();
parent.relative().child(popover::anchored_submenu(
SharedString::from(format!("{id}-panel")),
panel,
))
},
)
.into_any_element()
}))
}
fn dismissal(&self, cx: &mut Context<V>) -> Listener<MouseDownEvent> {
let outside = self.outside.clone();
let panels = self.panels;
let on = self.on.clone();
Box::new(
cx.listener(move |view, event: &MouseDownEvent, window, cx| {
let (at, count) = outside.get();
let count = if at == Some(event.position) {
count + 1
} else {
1
};
outside.set((Some(event.position), count));
if count >= panels {
outside.set((None, 0));
on(view, Hit::Dismiss, window, cx);
}
}),
)
}
fn reports<E: 'static>(&self, hit: Hit, cx: &mut Context<V>) -> Listener<E> {
let on = self.on.clone();
Box::new(cx.listener(move |view, _: &E, window, cx| on(view, hit.clone(), window, cx)))
}
}
fn row_id(id: &SharedString, path: &[usize]) -> SharedString {
let mut out = id.to_string();
for row in path {
out.push('-');
out.push_str(&row.to_string());
}
SharedString::from(out)
}
fn glyph_slot(theme: &Theme, icon: Option<Icon>, enabled: bool) -> gpui::Div {
div()
.flex_none()
.size(px(GLYPH))
.flex()
.items_center()
.justify_center()
.children(icon.map(|glyph| {
icons::icon(glyph).size(px(GLYPH)).text_color(if enabled {
theme.text_faint
} else {
theme.text_faint.opacity(0.5)
})
}))
}
fn description_line(theme: &Theme, description: SharedString, enabled: bool) -> gpui::Div {
div()
.mt(px(2.0))
.truncate()
.text_style(TextStyle::Subheadline)
.text_color(if enabled {
theme.text_muted
} else {
theme.text_faint.opacity(0.5)
})
.child(description)
}
fn disabled_row(theme: &Theme) -> gpui::Div {
div()
.flex()
.flex_row()
.items_center()
.gap(px(10.0))
.px(px(8.0))
.py(px(6.0))
.rounded(px(Theme::inset_radius(
Theme::surface_radius(),
popover::MENU_PAD,
)))
.text_style(TextStyle::Body)
.text_color(theme.text_faint)
}