use std::rc::Rc; use gpui::{ Animation, AnimationExt, AnyElement, App, Bounds, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, Pixels, Point, Styled, Window, anchored, canvas, div, prelude::*, }; use super::{ draw, model::{Kind, Menu}, }; use crate::{ forms::{Down, Enter, Run, TextInput, Up, float}, motion, primitives::{Icon, IconName, Takeover, give_back, raise, take_focus}, theme::{ActiveTheme, IconSize}, }; /// The menu open at `depth`, following the marked rows above it; `None` once those rows changed. pub(super) fn at_depth<'a>(menu: &'a Menu, levels: &[Level], depth: usize) -> Option<&'a Menu> { levels.get(..depth)?.iter().try_fold(menu, |menu, level| { match level .at .and_then(|ix| menu.item_at(ix, level.column)) .map(|item| &item.kind) { Some(Kind::Sub(sub)) => Some(sub), _ => None, } }) } /// Fits open levels to the menu as it is now: a mark on a row gone or disabled clears, and levels under a row that no longer opens them close. fn settle(menu: &Menu, levels: &mut Vec) { let mut here = menu; for depth in 0..levels.len() { let column = levels[depth].column; let spot = levels[depth] .at .and_then(|ix| here.settled(ix, column).map(|column| (ix, column))); levels[depth].at = spot.map(|(ix, _)| ix); levels[depth].column = spot.map_or(0, |(_, column)| column); match spot .and_then(|(ix, column)| here.item_at(ix, column)) .map(|item| &item.kind) { Some(Kind::Sub(sub)) if depth + 1 < levels.len() => here = sub, _ => { levels.truncate(depth + 1); return; } } } } /// An open level: marked row, button, and their boxes. #[derive(Clone, Copy, Debug, Default, PartialEq)] pub(super) struct Level { pub(super) at: Option, pub(super) column: usize, pub(super) row: Bounds, pub(super) panel: Bounds, } /// Where an open menu hangs: under its anchor box, or at a point. #[derive(Clone, Copy, Debug, PartialEq)] pub(crate) enum Spot { Under, At(Point), } /// A host's menu: where it hangs, its open levels root first, the host's box, the box it hangs under, and the focus to hand back. #[derive(Default)] pub(crate) struct Open { spot: Option, pub(super) levels: Vec, host: Bounds, anchor: Bounds, takeover: Option>, } impl Open { pub(crate) fn is_open(&self) -> bool { self.spot.is_some() } /// Hangs the menu under `anchor`, kept current as its owner moves. pub(crate) fn set_anchor(state: &Entity, anchor: Bounds, cx: &mut App) { if state.read(cx).anchor != anchor { state.update(cx, |open, cx| { open.anchor = anchor; cx.notify(); }); } } /// Opens at `spot`, with the first row marked when `marked`. pub(crate) fn show(state: &Entity, menu: &Menu, spot: Spot, marked: bool, cx: &mut App) { let first = menu.step(None, 1).filter(|_| marked); state.update(cx, |open, cx| { open.spot = Some(spot); open.levels = vec![first_of(menu, first)]; cx.notify(); }); } /// Starts over on new rows with the first marked; the menu stays where it hangs. pub(crate) fn restart(state: &Entity, menu: &Menu, cx: &mut App) { let first = menu.step(None, 1); state.update(cx, |open, cx| { open.levels = vec![first_of(menu, first)]; cx.notify(); }); } /// Closes and hands focus back to whatever held it before the menu opened. pub(crate) fn close(state: &Entity, window: &mut Window, cx: &mut App) { let takeover = state.update(cx, |open, cx| { open.spot = None; open.levels.clear(); cx.notify(); open.takeover.take() }); if let Some(takeover) = takeover { give_back(&takeover, window, cx); } } } /// A level marking row `at` at its first enabled button. fn first_of(menu: &Menu, at: Option) -> Level { Level { at, column: match at { Some(ix) => menu.settled(ix, 0).expect("a stepped row runs"), None => 0, }, ..Level::default() } } /// Marks a spot at `depth`; `open_sub` opens its submenu. pub(super) fn mark( state: &Entity, menu: &Menu, depth: usize, (at, column): (usize, usize), open_sub: bool, cx: &mut App, ) { state.update(cx, |open, cx| { let Some(here) = at_depth(menu, &open.levels, depth) else { return; }; let sub = matches!( here.item_at(at, column).map(|item| &item.kind), Some(Kind::Sub(_)) ); open.levels.truncate(depth + 1); let level = &mut open.levels[depth]; if level.at != Some(at) { level.row = Bounds::default(); } (level.at, level.column) = (Some(at), column); if open_sub && sub { open.levels.push(Level::default()); } cx.notify(); }); } /// Runs a spot; a submenu opens, others close first. pub(super) fn choose( state: &Entity, menu: &Menu, depth: usize, (at, column): (usize, usize), close: &Run, window: &mut Window, cx: &mut App, ) { let levels = &state.read(cx).levels; let Some(item) = at_depth(menu, levels, depth) .filter(|_| levels.len() > depth) .and_then(|here| here.item_at(at, column)) .cloned() else { log::debug!( "menu: a press from an older frame found row {at}:{column} of level {depth} gone" ); return; }; if item.disabled { return; } if let Kind::Sub(sub) = &item.kind { let first = first_of(sub, sub.step(None, 1)); mark(state, menu, depth, (at, column), false, cx); state.update(cx, |open, cx| { open.levels.push(first); cx.notify(); }); return; } log::info!("menu: {}", item.label); close(window, cx); if let Some(on_click) = &item.on_click { on_click(window, cx); } } /// Arrows, Enter, Space and Escape drive the deepest level. fn keys( menu: &Menu, state: &Entity, key: &str, close: &Run, window: &mut Window, cx: &mut App, ) -> bool { let levels = state.read(cx).levels.clone(); let Some(depth) = levels.len().checked_sub(1) else { return false; }; let Some(here) = at_depth(menu, &levels, depth) else { return false; }; let (at, column) = (levels[depth].at, levels[depth].column); let sub = at .and_then(|at| here.item_at(at, column)) .is_some_and(|item| matches!(item.kind, Kind::Sub(_))); let across = |by| at.and_then(|at| here.across(at, column, by).map(|to| (at, to))); match (key, at) { ("down" | "up", _) => { let by = if key == "down" { 1 } else { -1 }; if let Some(to) = here.step(at, by) { let column = here.settled(to, 0).expect("a stepped row runs"); mark(state, menu, depth, (to, column), false, cx); } } ("left" | "right", Some(_)) if across(1).is_some() => { let to = across(if key == "right" { 1 } else { -1 }).expect("a row with a button"); mark(state, menu, depth, to, false, cx); } ("right", Some(at)) if sub => choose(state, menu, depth, (at, column), close, window, cx), ("enter" | "space", Some(at)) => { choose(state, menu, depth, (at, column), close, window, cx) } ("left" | "escape", _) if depth > 0 => state.update(cx, |open, cx| { open.levels.pop(); cx.notify(); }), ("escape", _) => close(window, cx), _ => return false, } true } /// Records a box into the host's state: the host's own, a marked row's or a panel's. pub(super) fn measure( state: Entity, write: impl Fn(&mut Open, Bounds) -> bool + 'static, ) -> impl IntoElement { canvas( move |bounds, window, cx| { if state.update(cx, |open, _| write(open, bounds)) { state.update(cx, |_, cx| cx.notify()); window.request_animation_frame(); } }, |_, _, _, _| {}, ) .absolute() .top_0() .left_0() .size_full() } /// Measures the host's box: presses on it are the host's, and when `hangs_under` the menu hangs under it. pub(crate) fn measure_host(state: Entity, hangs_under: bool) -> impl IntoElement { measure(state, move |open, bounds| { let changed = open.host != bounds || (hangs_under && open.anchor != bounds); open.host = bounds; if hangs_under { open.anchor = bounds; } changed }) } /// The open menu of host `id`, placed at its spot. It holds focus while open and gives it back on every close. pub(crate) fn hang( id: &ElementId, menu: &Menu, state: &Entity, field: Option<&Entity>, window: &mut Window, cx: &mut App, ) -> Option { let spot = state.read(cx).spot?; let takeover = take_focus((id.clone(), "takeover"), window, cx); let focus = takeover.read(cx).focus.clone(); if state.read(cx).takeover.is_none() { state.update(cx, |open, _| open.takeover = Some(takeover)); } let close: Run = { let (state, id) = (state.clone(), id.clone()); Rc::new(move |window, cx| { log::info!("menu {id:?}: closed"); Open::close(&state, window, cx); }) }; if !focus.contains_focused(window, cx) { log::info!("menu {id:?}: focus left"); close(window, cx); return None; } let typing = field.map(|field| field.read(cx).focus().clone()); if let Some(typing) = &typing && focus.is_focused(window) { window.focus(typing, cx); } let mut levels = state.read(cx).levels.clone(); settle(menu, &mut levels); if levels != state.read(cx).levels { log::info!("menu {id:?}: rows changed while open"); state.update(cx, |open, _| open.levels = levels); } let depth = state.read(cx).levels.len(); let enter = Animation::new(motion::duration(motion::FAST, cx)).with_easing(motion::ease_out_cubic); let (keyed, key_menu, key_close) = (state.clone(), menu.clone(), close.clone()); let (lifted, lift_menu, lift_close) = (state.clone(), menu.clone(), close.clone()); let (out, out_close) = (state.clone(), close.clone()); let spaced = typing.is_none(); let confirm = move |key: &str| key == "enter" || (spaced && key == "space"); let head = field.map(|field| { let theme = cx.theme(); div() .flex() .items_center() .gap_2() .px_2() .pb_1() .mb_1() .border_b_1() .border_color(theme.colors.border) .child( Icon::new(IconName::Search) .size(IconSize::Sm) .color(theme.colors.fg_subtle), ) .child(div().flex_1().min_w_0().child(field.clone())) .into_any_element() }); let (up_menu, up_state, up_close) = (menu.clone(), state.clone(), close.clone()); let (down_menu, down_state, down_close) = (menu.clone(), state.clone(), close.clone()); let root = draw::panel(id, menu, state, 0, &close, head, cx) .track_focus(&focus) .when(!spaced, |root| { root.capture_action(move |_: &Up, window, cx| { cx.stop_propagation(); keys(&up_menu, &up_state, "up", &up_close, window, cx); }) .capture_action(move |_: &Down, window, cx| { cx.stop_propagation(); keys(&down_menu, &down_state, "down", &down_close, window, cx); }) .capture_action(|_: &Enter, _, cx| cx.stop_propagation()) }) .on_key_down(move |event, window, cx| { let stroke = &event.keystroke; let key = stroke.key.as_str(); if matches!(key, "enter" | "space") { if confirm(key) && !stroke.modifiers.modified() { cx.stop_propagation(); } return; } if keys(&key_menu, &keyed, key, &key_close, window, cx) { cx.stop_propagation(); } }) .on_key_up(move |event, window, cx| { let stroke = &event.keystroke; let key = stroke.key.as_str(); if confirm(key) && !stroke.modifiers.modified() && keys(&lift_menu, &lifted, key, &lift_close, window, cx) { cx.stop_propagation(); } }) .on_mouse_down_out(move |event, window, cx| { let open = out.read(cx); let on_host = open.host.contains(&event.position); if !on_host && !open .levels .iter() .any(|level| level.panel.contains(&event.position)) { out_close(window, cx); } }); let placed = match spot { Spot::Under => float( (id.clone(), "panel"), state.read(cx).anchor, menu.entries.len(), root, window, cx, ), Spot::At(point) => raise( (id.clone(), "panel"), anchored() .position(point) .snap_to_window() .child( root.with_animation((id.clone(), "in"), enter.clone(), |root, t| { root.opacity(t) }), ), ) .with_priority(1) .into_any_element(), }; let subs = (1..depth).filter_map(|level| { let row = state.read(cx).levels[level - 1].row; (row != Bounds::default()).then(|| { raise( (id.clone(), format!("level-{level}")), anchored().position(row.top_right()).snap_to_window().child( draw::panel(id, menu, state, level, &close, None, cx) .mt_neg_1() .with_animation( (id.clone(), format!("sub-{level}")), enter.clone(), |panel, t| panel.opacity(t).ml(motion::NUDGE * (t - 1.0)), ), ), ) .with_priority(2) .into_any_element() }) }); Some( div() .child(placed) .children(subs.collect::>()) .into_any_element(), ) }