use std::{
cell::{Cell, RefCell},
collections::HashMap,
rc::Rc,
};
use gpui_kit::{
App, Bounds, Context, Entity, EventEmitter, FocusHandle, Focusable, ListAlignment, ListState,
Pixels, Point, SharedString, Subscription, Window,
base::{Align, DeferredPopover, GlobalState, Placement},
};
use super::entry::{MenuEntry, PendingActivation};
use crate::ActiveTheme as _;
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum MenuAnchor {
Trigger(Bounds<Pixels>, Align),
Point(Point<Pixels>),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum MenuEvent {
Opened,
Closed,
Activated(SharedString),
}
pub(crate) struct Level {
parent_row: Option<usize>,
anchor: Bounds<Pixels>,
highlighted: Option<usize>,
list: ListState,
bounds: Rc<Cell<Bounds<Pixels>>>,
submenu_rows: Rc<RefCell<HashMap<usize, Bounds<Pixels>>>>,
}
impl Level {
fn new(
parent_row: Option<usize>,
anchor: Bounds<Pixels>,
rows: usize,
overdraw: Pixels,
) -> Self {
Self {
parent_row,
anchor,
highlighted: None,
list: ListState::new(rows, ListAlignment::Top, overdraw),
bounds: Rc::new(Cell::new(Bounds::default())),
submenu_rows: Rc::new(RefCell::new(HashMap::new())),
}
}
pub(crate) fn list(&self) -> &ListState {
&self.list
}
pub(crate) fn anchor(&self) -> Bounds<Pixels> {
self.anchor
}
pub(crate) fn bounds(&self) -> &Rc<Cell<Bounds<Pixels>>> {
&self.bounds
}
pub(crate) fn submenu_rows(&self) -> &Rc<RefCell<HashMap<usize, Bounds<Pixels>>>> {
&self.submenu_rows
}
pub(crate) fn highlighted(&self) -> Option<usize> {
self.highlighted
}
}
struct OpenMenu {
entries: Vec<MenuEntry>,
anchor: MenuAnchor,
placement: Placement,
levels: Vec<Level>,
restore_focus: Option<FocusHandle>,
action_context: Option<FocusHandle>,
_deferred: DeferredPopover,
_focus_watch: Subscription,
}
pub struct MenuState {
open: Option<OpenMenu>,
focus: FocusHandle,
trigger_bounds: Rc<Cell<Bounds<Pixels>>>,
step_handler: Option<StepHandler>,
}
pub(crate) type StepHandler = Rc<dyn Fn(isize, &mut Window, &mut App)>;
impl EventEmitter<MenuEvent> for MenuState {}
impl Focusable for MenuState {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus.clone()
}
}
impl MenuState {
pub fn new(cx: &mut Context<Self>) -> Self {
Self {
open: None,
focus: cx.focus_handle(),
trigger_bounds: Rc::new(Cell::new(Bounds::default())),
step_handler: None,
}
}
pub fn is_open(&self) -> bool {
self.open.is_some()
}
pub fn highlighted(&self) -> Option<SharedString> {
let open = self.open.as_ref()?;
let depth = open.active_depth();
let row = open.levels[depth].highlighted?;
open.entries_at(depth)?.get(row)?.key().cloned()
}
pub fn open(
&mut self,
entries: impl IntoIterator<Item = impl Into<MenuEntry>>,
anchor: MenuAnchor,
window: &mut Window,
cx: &mut Context<Self>,
) {
let context = self.restore_target(window, cx);
if context.is_none() {
let this = cx.weak_entity();
window.on_next_frame(move |_, cx| {
this.update(cx, |_, cx| cx.notify()).ok();
});
}
self.open_with_context(entries, anchor, context, window, cx);
}
pub fn set_entries(
&mut self,
entries: impl IntoIterator<Item = impl Into<MenuEntry>>,
cx: &mut Context<Self>,
) {
let Some(open) = &mut self.open else {
return;
};
let highlighted = open.levels[0]
.highlighted
.and_then(|row| open.entries.get(row))
.and_then(|entry| entry.key().cloned());
open.entries = normalize(entries.into_iter().map(Into::into).collect());
open.levels.truncate(1);
let root = &mut open.levels[0];
root.list.reset(open.entries.len());
root.highlighted = highlighted.and_then(|key| {
open.entries
.iter()
.position(|entry| entry.key() == Some(&key) && entry.is_selectable())
});
if let Some(row) = root.highlighted {
root.list.scroll_to_reveal_item(row);
}
cx.notify();
}
pub fn open_for(
&mut self,
entries: impl IntoIterator<Item = impl Into<MenuEntry>>,
anchor: MenuAnchor,
action_context: &FocusHandle,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.open_with_context(entries, anchor, Some(action_context.clone()), window, cx);
}
pub fn close(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(open) = self.open.take() else {
return;
};
if let Some(focus) = open.restore_focus {
focus.focus(window, cx);
}
cx.emit(MenuEvent::Closed);
cx.notify();
}
fn restore_target(&self, window: &Window, cx: &App) -> Option<FocusHandle> {
match &self.open {
Some(open) => open.restore_focus.clone(),
None => window.focused(cx),
}
}
fn open_with_context(
&mut self,
entries: impl IntoIterator<Item = impl Into<MenuEntry>>,
anchor: MenuAnchor,
action_context: Option<FocusHandle>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let restore_focus = self.restore_target(window, cx);
let entries = normalize(entries.into_iter().map(Into::into).collect());
let (max_height, gap) = {
let metrics = &cx.theme().metrics;
(metrics.menu_max_height, metrics.menu_gap)
};
let placement = match anchor {
MenuAnchor::Trigger(bounds, _) => {
let below = window.viewport_size().height - bounds.bottom();
let above = bounds.top();
if below >= max_height + gap || below >= above {
Placement::Bottom
} else {
Placement::Top
}
}
MenuAnchor::Point(_) => Placement::Bottom,
};
let root = Level::new(None, Bounds::default(), entries.len(), max_height);
let focus_watch = cx.on_focus_out(&self.focus, window, |this, _, window, cx| {
this.close(window, cx);
});
let was_open = self.open.is_some();
self.open = Some(OpenMenu {
entries,
anchor,
placement,
levels: vec![root],
restore_focus,
action_context,
_deferred: GlobalState::register_deferred_popover(cx),
_focus_watch: focus_watch,
});
self.focus.focus(window, cx);
if !was_open {
cx.emit(MenuEvent::Opened);
}
cx.notify();
}
pub(crate) fn trigger_bounds(&self) -> &Rc<Cell<Bounds<Pixels>>> {
&self.trigger_bounds
}
pub(crate) fn anchor(&self) -> Option<(MenuAnchor, Placement)> {
let open = self.open.as_ref()?;
Some((open.anchor, open.placement))
}
pub(crate) fn levels(&self) -> &[Level] {
self.open.as_ref().map_or(&[], |open| &open.levels)
}
pub(crate) fn entries_at(&self, depth: usize) -> Option<&[MenuEntry]> {
self.open.as_ref()?.entries_at(depth)
}
pub(crate) fn open_child_row(&self, depth: usize) -> Option<usize> {
self.open.as_ref()?.levels.get(depth + 1)?.parent_row
}
pub(crate) fn action_context(&self) -> &FocusHandle {
self.open
.as_ref()
.and_then(|open| open.action_context.as_ref())
.unwrap_or(&self.focus)
}
pub(crate) fn contains(&self, position: Point<Pixels>) -> bool {
self.levels()
.iter()
.any(|level| level.bounds.get().contains(&position))
}
pub(crate) fn hover_row(&mut self, depth: usize, row: usize, cx: &mut Context<Self>) {
let Some(open) = &mut self.open else {
return;
};
if !open.is_selectable(depth, row) {
return;
}
let unchanged = open.levels.get(depth).and_then(|level| level.highlighted) == Some(row)
&& (open.levels.len() == depth + 1 || open.levels[depth + 1].parent_row == Some(row));
if unchanged {
return;
}
open.levels.truncate(depth + 1);
open.levels[depth].highlighted = Some(row);
open.open_submenu(depth, row, cx);
cx.notify();
}
pub(crate) fn move_highlight(&mut self, delta: isize, cx: &mut Context<Self>) {
let Some(open) = &mut self.open else {
return;
};
let depth = open.active_depth();
let count = open.entries_at(depth).map_or(0, <[_]>::len);
if count == 0 {
return;
}
let start = open.levels[depth]
.highlighted
.map(|row| row as isize)
.unwrap_or(if delta > 0 { -1 } else { count as isize });
let mut row = start;
for _ in 0..count {
row = (row + delta).rem_euclid(count as isize);
if open.is_selectable(depth, row as usize) {
open.highlight(depth, row as usize);
cx.notify();
return;
}
}
}
pub(crate) fn highlight_edge(&mut self, last: bool, cx: &mut Context<Self>) {
let Some(open) = &mut self.open else {
return;
};
let depth = open.active_depth();
let count = open.entries_at(depth).map_or(0, <[_]>::len);
let mut rows: Box<dyn Iterator<Item = usize>> = if last {
Box::new((0..count).rev())
} else {
Box::new(0..count)
};
if let Some(row) = rows.find(|row| open.is_selectable(depth, *row)) {
open.highlight(depth, row);
cx.notify();
}
}
pub(crate) fn enter_submenu(&mut self, cx: &mut Context<Self>) -> bool {
let Some(open) = &mut self.open else {
return false;
};
let depth = open.active_depth();
let Some(row) = open.levels[depth].highlighted else {
return false;
};
let is_submenu = open
.entries_at(depth)
.and_then(|entries| entries.get(row))
.is_some_and(|entry| entry.submenu().is_some() && entry.is_selectable());
if !is_submenu {
return false;
}
open.levels.truncate(depth + 1);
open.open_submenu(depth, row, cx);
let child = depth + 1;
if let Some(first) = (0..open.entries_at(child).map_or(0, <[_]>::len))
.find(|r| open.is_selectable(child, *r))
{
open.highlight(child, first);
}
cx.notify();
true
}
pub(crate) fn leave_submenu(&mut self, cx: &mut Context<Self>) -> bool {
let Some(open) = &mut self.open else {
return false;
};
let depth = open.active_depth();
if depth == 0 {
return false;
}
open.levels.truncate(depth);
cx.notify();
true
}
pub(crate) fn set_step_handler(&mut self, handler: StepHandler) {
self.step_handler = Some(handler);
}
pub(crate) fn step(this: &Entity<Self>, delta: isize, window: &mut Window, cx: &mut App) {
let handler = this.read(cx).step_handler.clone();
if let Some(handler) = handler {
handler(delta, window, cx);
}
}
pub(crate) fn dismiss(&mut self, window: &mut Window, cx: &mut Context<Self>) {
match &mut self.open {
Some(open) if open.levels.len() > 1 => {
open.levels.pop();
cx.notify();
}
_ => self.close(window, cx),
}
}
pub(crate) fn choose(
&mut self,
depth: usize,
row: usize,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<PendingActivation> {
let open = self.open.as_mut()?;
if !open.is_selectable(depth, row) {
return None;
}
let entry = open.entries_at(depth)?.get(row)?.clone();
match entry {
MenuEntry::Submenu(_) => {
if open
.levels
.get(depth + 1)
.and_then(|level| level.parent_row)
!= Some(row)
{
open.levels.truncate(depth + 1);
open.levels[depth].highlighted = Some(row);
open.open_submenu(depth, row, cx);
cx.notify();
}
None
}
MenuEntry::Item(item) => {
let pending = item.pending(
open.action_context
.clone()
.unwrap_or_else(|| self.focus.clone()),
);
cx.emit(MenuEvent::Activated(item.key().clone()));
self.close(window, cx);
Some(pending)
}
MenuEntry::Separator | MenuEntry::Label(_) => None,
}
}
pub(crate) fn confirm(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<PendingActivation> {
if self.enter_submenu(cx) {
return None;
}
let open = self.open.as_ref()?;
let depth = open.active_depth();
let row = open.levels[depth].highlighted?;
self.choose(depth, row, window, cx)
}
pub(crate) fn activate(
this: &Entity<Self>,
choose: impl FnOnce(&mut Self, &mut Window, &mut Context<Self>) -> Option<PendingActivation>,
window: &mut Window,
cx: &mut App,
) {
let pending = this.update(cx, |state, cx| choose(state, window, cx));
if let Some(pending) = pending {
pending.run(window, cx);
}
}
}
impl OpenMenu {
fn entries_at(&self, depth: usize) -> Option<&[MenuEntry]> {
let mut entries = self.entries.as_slice();
for level in self.levels.get(1..=depth)? {
entries = entries.get(level.parent_row?)?.submenu()?.children();
}
Some(entries)
}
fn active_depth(&self) -> usize {
self.levels
.iter()
.rposition(|level| level.highlighted.is_some())
.unwrap_or(0)
}
fn is_selectable(&self, depth: usize, row: usize) -> bool {
self.entries_at(depth)
.and_then(|entries| entries.get(row))
.is_some_and(MenuEntry::is_selectable)
}
fn highlight(&mut self, depth: usize, row: usize) {
self.levels.truncate(depth + 1);
let level = &mut self.levels[depth];
level.highlighted = Some(row);
level.list.scroll_to_reveal_item(row);
}
fn open_submenu(&mut self, depth: usize, row: usize, cx: &App) {
let Some(count) = self
.entries_at(depth)
.and_then(|entries| entries.get(row))
.and_then(MenuEntry::submenu)
.map(|submenu| submenu.children().len())
else {
return;
};
let level = &self.levels[depth];
let row_bounds = level
.submenu_rows
.borrow()
.get(&row)
.copied()
.unwrap_or_default();
let panel = level.bounds.get();
let anchor = Bounds::from_corners(
gpui_kit::point(panel.left(), row_bounds.top()),
gpui_kit::point(panel.right(), row_bounds.bottom()),
);
let overdraw = cx.theme().metrics.menu_max_height;
self.levels
.push(Level::new(Some(row), anchor, count, overdraw));
}
}
pub(crate) fn normalize(entries: Vec<MenuEntry>) -> Vec<MenuEntry> {
let mut groups: Vec<Vec<MenuEntry>> = vec![Vec::new()];
for entry in entries {
match entry {
MenuEntry::Separator => groups.push(Vec::new()),
MenuEntry::Submenu(submenu) => groups
.last_mut()
.expect("one group at least")
.push(MenuEntry::Submenu(submenu.normalized())),
entry => groups.last_mut().expect("one group at least").push(entry),
}
}
let mut rows = Vec::new();
for group in groups.into_iter().filter(|group| {
group
.iter()
.any(|entry| matches!(entry, MenuEntry::Item(_) | MenuEntry::Submenu(_)))
}) {
if !rows.is_empty() {
rows.push(MenuEntry::Separator);
}
rows.extend(group);
}
rows
}