use crate::{
icons,
input::{FieldEvent, TextField},
keys, popover, scroll,
tooltip::Tooltip,
};
use gpui::{
Action, App, Axis, Context, Entity, Focusable as _, Global, KeyBinding, MouseDownEvent, Pixels,
Point, ScrollHandle, SharedString, Size, Subscription, Window, actions, div, prelude::*, px,
};
use icons::Icon;
use std::{cell::Cell, rc::Rc};
use theme::{TextStyle, Theme, Typeset};
const GLYPH: f32 = 13.0;
const SEGMENT_GLYPH: f32 = 16.0;
const PANEL_MIN: f32 = 180.0;
const PANEL_DESCRIBED: f32 = 280.0;
const SEARCH_ROWS: f32 = 10.0;
actions!(bezel_menu, [SelectNext, SelectPrevious, Confirm, Dismiss]);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct MenuIndent(pub Pixels);
impl Default for MenuIndent {
fn default() -> Self {
Self(px(12.0))
}
}
impl Global for MenuIndent {}
pub(crate) fn indent(cx: &App) -> MenuIndent {
cx.try_global::<MenuIndent>().copied().unwrap_or_default()
}
pub(crate) fn set_indent(indent: MenuIndent, cx: &mut App) {
cx.set_global(indent);
cx.refresh_windows();
}
pub const SEARCH_CONTEXT: &str = "MenuSearch";
pub fn bindings() -> Vec<KeyBinding> {
let ctx = Some(SEARCH_CONTEXT);
vec![
KeyBinding::new("down", SelectNext, ctx),
KeyBinding::new("up", SelectPrevious, ctx),
KeyBinding::new("ctrl-n", SelectNext, ctx),
KeyBinding::new("ctrl-p", SelectPrevious, ctx),
KeyBinding::new("enter", Confirm, ctx),
KeyBinding::new("escape", Dismiss, ctx),
]
}
pub fn init(cx: &mut App) {
cx.bind_keys(bindings());
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Item {
Action {
label: SharedString,
description: Option<SharedString>,
tooltip: Option<SharedString>,
icon: Option<Icon>,
indent: usize,
keystroke: Option<SharedString>,
checked: bool,
enabled: bool,
},
Submenu {
label: SharedString,
icon: Option<Icon>,
indent: usize,
enabled: bool,
items: Vec<Item>,
search: Option<SharedString>,
},
Segmented {
segments: Vec<Segment>,
selected: usize,
enabled: bool,
},
Separator,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Segment {
pub icon: Icon,
pub label: SharedString,
}
impl Segment {
pub fn new(icon: impl Into<Icon>, label: impl Into<SharedString>) -> Self {
Self {
icon: icon.into(),
label: label.into(),
}
}
}
impl Item {
pub fn action(label: impl Into<SharedString>) -> Self {
Item::Action {
label: label.into(),
description: None,
tooltip: None,
icon: None,
indent: 0,
keystroke: None,
checked: false,
enabled: true,
}
}
pub fn submenu(label: impl Into<SharedString>, items: Vec<Item>) -> Self {
Item::Submenu {
label: label.into(),
icon: None,
indent: 0,
enabled: true,
items,
search: None,
}
}
pub fn searchable(mut self, placeholder: impl Into<SharedString>) -> Self {
if let Item::Submenu { search, .. } = &mut self {
*search = Some(placeholder.into());
}
self
}
pub fn segmented(segments: impl IntoIterator<Item = Segment>, selected: usize) -> Self {
Item::Segmented {
segments: segments.into_iter().collect(),
selected,
enabled: true,
}
}
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::Segmented { .. } | Item::Separator => {}
}
self
}
pub fn indented(mut self, level: usize) -> Self {
match &mut self {
Item::Action { indent, .. } | Item::Submenu { indent, .. } => *indent = level,
Item::Segmented { .. } | 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 with_shortcut(self, action: &dyn Action, window: &Window) -> Self {
match keys::shortcut(action, window) {
Some(keystroke) => self.with_keystroke(keystroke),
None => self,
}
}
pub fn with_shortcut_in(self, action: &dyn Action, context: &str, window: &Window) -> Self {
match keys::shortcut_in(action, context, window) {
Some(keystroke) => self.with_keystroke(keystroke),
None => 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, .. }
| Item::Segmented { 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::Segmented {
enabled, segments, ..
} => *enabled && !segments.is_empty(),
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 landing(&self) -> Option<usize> {
match self {
Item::Segmented {
segments, selected, ..
} if !segments.is_empty() => Some((*selected).min(segments.len() - 1)),
_ => None,
}
}
fn keywords(&self) -> String {
match self {
Item::Action {
label, description, ..
} => match description {
Some(description) => format!("{label} {description}"),
None => label.to_string(),
},
Item::Submenu { label, .. } => label.to_string(),
Item::Segmented { .. } | Item::Separator => String::new(),
}
}
fn search(&self) -> Option<&SharedString> {
match self {
Item::Submenu { search, .. } => search.as_ref(),
_ => None,
}
}
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>,
segment: Option<usize>,
}
impl Cursor {
pub fn open(&self) -> &[usize] {
&self.open
}
pub fn row(&self) -> Option<usize> {
self.row
}
pub fn segment(&self) -> Option<usize> {
self.segment
}
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);
path.extend(self.segment);
Some(path)
}
pub fn clear(&mut self) {
self.open.clear();
self.row = None;
self.segment = 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);
self.segment = self.row.and_then(|row| items[row].landing());
}
pub fn slide(&mut self, root: &[Item], delta: isize) -> bool {
let Some(Item::Segmented { segments, .. }) = self
.row
.and_then(|row| items_at(root, &self.open)?.get(row))
else {
return false;
};
let at = self.segment.unwrap_or(0) as isize + delta.signum();
self.segment = Some(at.clamp(0, segments.len() as isize - 1) as usize);
true
}
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.segment = self.row.and_then(|row| inner[row].landing());
self.open.push(row);
true
}
pub fn ascend(&mut self) -> bool {
match self.open.pop() {
Some(row) => {
self.row = Some(row);
self.segment = None;
true
}
None => false,
}
}
pub fn point_at(&mut self, root: &[Item], path: &[usize]) -> bool {
let (open, row, segment) = match path {
[] => (Vec::new(), None, None),
[above @ .., row, segment]
if matches!(
at(root, &path[..path.len() - 1]),
Some(Item::Segmented { .. })
) =>
{
(above.to_vec(), Some(*row), Some(*segment))
}
_ if at(root, path).and_then(Item::opens).is_some() => (path.to_vec(), None, None),
[above @ .., row] => (
above.to_vec(),
Some(*row),
at(root, path).and_then(Item::landing),
),
};
if self.open == open && self.row == row && self.segment == segment {
return false;
}
self.open = open;
self.row = row;
self.segment = segment;
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,
window: &mut Window,
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))),
chain: popover::Chain::default(),
on: Rc::new(on),
};
tree.panel(theme, items, cursor, &[], None, window, cx)
}
struct Query {
field: Entity<TextField>,
focused: bool,
_changed: Subscription,
}
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)>>,
chain: popover::Chain,
on: Reporter<V>,
}
impl<V: 'static> Tree<V> {
#[allow(clippy::too_many_arguments)]
fn panel(
&self,
theme: &Theme,
items: &[Item],
cursor: &Cursor,
prefix: &[usize],
search: Option<&SharedString>,
window: &mut Window,
cx: &mut Context<V>,
) -> gpui::Div {
let depth = prefix.len();
let lit = cursor.lit(depth);
let down = cursor.open().get(depth).copied();
let rows_id = SharedString::from(format!("{}-rows", row_id(&self.id, prefix)));
let Scrolled(handle, shown) = window
.use_keyed_state(
SharedString::from(format!("{rows_id}-scrolled")),
cx,
|_, _| Scrolled::default(),
)
.read(cx)
.clone();
let query = search.map(|placeholder| self.query(&rows_id, placeholder, window, cx));
let text = query
.as_ref()
.map(|query| query.read(cx).field.read(cx).content().trim().to_owned())
.unwrap_or_default();
let visible: Vec<usize> = if text.is_empty() {
(0..items.len()).collect()
} else {
let words: Vec<String> = items.iter().map(Item::keywords).collect();
popover::filter_indices(&text, &words)
.into_iter()
.filter(|&row| items[row].selectable())
.collect()
};
let size = handle.bounds().size;
if size.height > Pixels::ZERO && shown.get() != Some((lit, size)) {
shown.set(Some((lit, size)));
if let Some(at) = lit.and_then(|lit| visible.iter().position(|&row| row == lit)) {
handle.scroll_to_item(at);
}
}
let inset = px(popover::SNAP) + window.client_inset().unwrap_or(Pixels::ZERO);
let cap = window.viewport_size().height - inset * 2.0;
let gutter = items.iter().any(Item::has_icon);
let MenuIndent(step) = indent(cx);
let described = items.iter().any(Item::has_description);
let rows = div()
.id(rows_id.clone())
.p(px(popover::MENU_PAD))
.children(visible.iter().map(|&row| {
let item = &items[row];
if matches!(item, Item::Separator) {
return popover::divider(theme).into_any_element();
}
let path: Vec<usize> = prefix.iter().copied().chain([row]).collect();
if let Item::Segmented {
segments,
selected,
enabled,
} = item
{
let live = cursor.segment().filter(|_| lit == Some(row));
return self
.segmented(theme, segments, *selected, *enabled, live, &path, cx)
.into_any_element();
}
let id = row_id(&self.id, &path);
let (label, icon, indent, enabled) = match item {
Item::Action {
label,
icon,
indent,
enabled,
..
}
| Item::Submenu {
label,
icon,
indent,
enabled,
..
} => (label.clone(), icon.clone(), *indent, *enabled),
Item::Segmented { .. } | Item::Separator => {
unreachable!("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(indent > 0, |row| {
row.pl(px(popover::MENU_ROW_INSET) + step * indent as f32)
})
.when(gutter, |row| row.child(glyph_slot(theme, icon, enabled)))
.child(
div()
.flex_1()
.min_w_0()
.flex()
.flex_col()
.child(div().truncate().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, &path, item.search(), window, cx)
.into_any_element();
parent.relative().child(popover::anchored_submenu(
SharedString::from(format!("{id}-panel")),
panel,
&self.chain,
))
},
)
.into_any_element()
}));
popover::popover_card(theme)
.p_0()
.flex()
.flex_col()
.max_h(cap)
.map(|card| match described {
true => card.w(px(PANEL_DESCRIBED)),
false => card.min_w(px(PANEL_MIN)),
})
.on_mouse_down_out(self.dismissal(cx))
.map(|card| match query {
None => card.child(
scroll::Viewport::new(rows_id, rows, Axis::Vertical)
.track_scroll(&handle)
.fill(),
),
Some(query) => {
let field = query.read(cx).field.clone();
let rows = rows.max_h(px(
SEARCH_ROWS * popover::menu_row_height() + 2.0 * popover::MENU_PAD
));
card.child(
div()
.key_context(SEARCH_CONTEXT)
.px(px(popover::MENU_PAD))
.pt(px(popover::MENU_PAD))
.map(|el| self.keys(el, items, &visible, lit, prefix, cx))
.child(popover::search_line(theme, field.into_any_element())),
)
.child(match visible.is_empty() {
true => div()
.px(px(popover::MENU_PAD + popover::MENU_ROW_INSET))
.pb(px(8.0))
.text_style(TextStyle::Body)
.text_color(theme.text_muted)
.child("No matches")
.into_any_element(),
false => scroll::Viewport::new(rows_id, rows, Axis::Vertical)
.track_scroll(&handle)
.into_any_element(),
})
}
})
}
fn query(
&self,
key: &SharedString,
placeholder: &SharedString,
window: &mut Window,
cx: &mut Context<V>,
) -> Entity<Query> {
let host = cx.entity_id();
let placeholder = placeholder.clone();
let query = window.use_keyed_state(
SharedString::from(format!("{key}-query")),
cx,
move |_, cx| {
let field = cx.new(|cx| crate::search::query_field(placeholder, cx));
let _changed = cx.subscribe(&field, move |_, _, _: &FieldEvent, cx| {
let app: &mut App = cx;
app.notify(host);
});
Query {
field,
focused: false,
_changed,
}
},
);
if !query.read(cx).focused {
query.update(cx, |query, _| query.focused = true);
let handle = query.read(cx).field.focus_handle(cx);
window.focus(&handle, cx);
}
query
}
fn keys(
&self,
el: gpui::Div,
items: &[Item],
shown: &[usize],
lit: Option<usize>,
prefix: &[usize],
cx: &mut Context<V>,
) -> gpui::Div {
let landable: Rc<Vec<usize>> = Rc::new(
shown
.iter()
.copied()
.filter(|&row| items[row].selectable())
.collect(),
);
let at = lit.and_then(|lit| landable.iter().position(|&row| row == lit));
let path = {
let prefix = prefix.to_vec();
move |row: usize| prefix.iter().copied().chain([row]).collect::<Vec<_>>()
};
let step = |delta: isize| {
let count = landable.len() as isize;
(count > 0).then(|| {
let next = match at {
Some(at) => (at as isize + delta).rem_euclid(count),
None if delta > 0 => 0,
None => count - 1,
};
landable[next as usize]
})
};
let next = step(1).map(&path);
let previous = step(-1).map(&path);
let chosen = at
.map(|at| landable[at])
.or_else(|| landable.first().copied())
.map(|row| match items[row].opens() {
Some(_) => Hit::Point(path(row)),
None => Hit::Choose(path(row)),
});
let on = self.on.clone();
let report = move |hit: Option<Hit>| {
let on = on.clone();
move |view: &mut V, window: &mut Window, cx: &mut Context<V>| {
if let Some(hit) = hit.clone() {
on(view, hit, window, cx);
}
}
};
let (down, up, enter, escape) = (
report(next.map(Hit::Point)),
report(previous.map(Hit::Point)),
report(chosen),
report(Some(Hit::Dismiss)),
);
el.on_action(cx.listener(move |view, _: &SelectNext, window, cx| down(view, window, cx)))
.on_action(
cx.listener(move |view, _: &SelectPrevious, window, cx| up(view, window, cx)),
)
.on_action(cx.listener(move |view, _: &Confirm, window, cx| enter(view, window, cx)))
.on_action(cx.listener(move |view, _: &Dismiss, window, cx| escape(view, window, cx)))
}
#[allow(clippy::too_many_arguments)]
fn segmented(
&self,
theme: &Theme,
segments: &[Segment],
selected: usize,
enabled: bool,
live: Option<usize>,
path: &[usize],
cx: &mut Context<V>,
) -> gpui::Stateful<gpui::Div> {
div()
.id(row_id(&self.id, path))
.flex()
.flex_row()
.gap(px(popover::MENU_PAD))
.children(segments.iter().enumerate().map(|(at, segment)| {
let path: Vec<usize> = path.iter().copied().chain([at]).collect();
let chosen = at == selected;
let ink = match (enabled, chosen || live == Some(at)) {
(false, _) => theme.text_faint.opacity(0.5),
(true, true) => theme.text,
(true, false) => theme.text_muted,
};
let cell = div()
.id(row_id(&self.id, &path))
.flex_1()
.min_w_0()
.flex()
.flex_col()
.items_center()
.gap(px(4.0))
.px(px(8.0))
.py(px(6.0))
.rounded(px(Theme::inset_radius(
Theme::surface_radius(),
popover::MENU_PAD,
)))
.text_style(TextStyle::Callout)
.text_color(ink)
.when(chosen, |cell| {
cell.bg(theme.element_active)
.font_weight(gpui::FontWeight::MEDIUM)
})
.when(!chosen && live == Some(at), |cell| {
cell.bg(theme.card_selected_bg())
})
.child(
icons::icon(segment.icon.clone())
.size(px(SEGMENT_GLYPH))
.text_color(ink),
)
.child(div().max_w_full().truncate().child(segment.label.clone()));
match enabled {
true => cell
.cursor_pointer()
.on_mouse_move(self.reports(Hit::Point(path.clone()), cx))
.on_click(self.reports(Hit::Choose(path), cx)),
false => cell,
}
}))
}
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);
cx.stop_propagation();
}
}),
)
}
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)))
}
}
#[derive(Clone, Default)]
struct Scrolled(ScrollHandle, Rc<Cell<Option<Shown>>>);
type Shown = (Option<usize>, Size<Pixels>);
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)
}
pub 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(popover::MENU_ROW_PAD_Y))
.rounded(px(Theme::inset_radius(
Theme::surface_radius(),
popover::MENU_PAD,
)))
.text_style(TextStyle::Body)
.text_color(theme.text_faint)
}