Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
493 lines
16 KiB
Rust
493 lines
16 KiB
Rust
use std::rc::Rc;
|
|
|
|
use gpui::{
|
|
Animation, AnimationExt, AnyElement, App, Bounds, ElementId, Entity, InteractiveElement,
|
|
IntoElement, ParentElement, Pixels, Point, SharedString, Styled, Window, anchored, canvas, div,
|
|
prelude::*,
|
|
};
|
|
|
|
use super::{
|
|
draw,
|
|
model::{Kind, Menu},
|
|
};
|
|
use crate::{
|
|
forms::{Down, Enter, Left, Right, 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<Level>) {
|
|
let mut here = menu;
|
|
for depth in 0..levels.len() {
|
|
let (column, name) = (levels[depth].column, levels[depth].name.clone());
|
|
// A mark holds its item, not just its place.
|
|
let at = levels[depth].at.filter(|&ix| {
|
|
here.item_at(ix, column)
|
|
.is_some_and(|item| !item.disabled && item.label == name)
|
|
});
|
|
levels[depth].at = at;
|
|
match at
|
|
.and_then(|ix| 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, its label, and boxes.
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
pub(super) struct Level {
|
|
pub(super) at: Option<usize>,
|
|
pub(super) column: usize,
|
|
pub(super) name: SharedString,
|
|
pub(super) row: Bounds<Pixels>,
|
|
pub(super) panel: Bounds<Pixels>,
|
|
}
|
|
|
|
/// 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<Pixels>),
|
|
}
|
|
|
|
/// 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<Spot>,
|
|
pub(super) levels: Vec<Level>,
|
|
host: Bounds<Pixels>,
|
|
anchor: Bounds<Pixels>,
|
|
takeover: Option<Entity<Takeover>>,
|
|
}
|
|
|
|
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<Open>, anchor: Bounds<Pixels>, 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<Open>, 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<Open>, 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<Open>, 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<usize>) -> Level {
|
|
let Some(ix) = at else {
|
|
return Level::default();
|
|
};
|
|
let column = menu.first(ix).expect("a stepped row runs");
|
|
Level {
|
|
at,
|
|
column,
|
|
name: menu
|
|
.item_at(ix, column)
|
|
.expect("its first spot")
|
|
.label
|
|
.clone(),
|
|
..Level::default()
|
|
}
|
|
}
|
|
|
|
/// Marks a spot at `depth`; `open_sub` opens its submenu.
|
|
pub(super) fn mark(
|
|
state: &Entity<Open>,
|
|
menu: &Menu,
|
|
depth: usize,
|
|
(at, column): (usize, usize),
|
|
open_sub: bool,
|
|
cx: &mut App,
|
|
) {
|
|
state.update(cx, |open, cx| {
|
|
if open.levels.len() <= depth {
|
|
return log::debug!("menu: level {depth} closed before its mark");
|
|
}
|
|
let Some(item) =
|
|
at_depth(menu, &open.levels, depth).and_then(|here| here.item_at(at, column))
|
|
else {
|
|
return;
|
|
};
|
|
let (sub, name) = (matches!(item.kind, Kind::Sub(_)), item.label.clone());
|
|
open.levels.truncate(depth + 1);
|
|
let level = &mut open.levels[depth];
|
|
if level.at != Some(at) {
|
|
level.row = Bounds::default();
|
|
}
|
|
(level.at, level.column, level.name) = (Some(at), column, name);
|
|
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<Open>,
|
|
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<Open>,
|
|
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.first(to).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
|
|
}
|
|
|
|
/// Whether the deepest level marks a button row.
|
|
fn on_buttons(menu: &Menu, state: &Entity<Open>, cx: &App) -> bool {
|
|
let levels = &state.read(cx).levels;
|
|
let Some(depth) = levels.len().checked_sub(1) else {
|
|
return false;
|
|
};
|
|
let here = at_depth(menu, levels, depth);
|
|
here.zip(levels[depth].at)
|
|
.is_some_and(|(here, ix)| here.is_buttons(ix))
|
|
}
|
|
|
|
/// 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<Open>,
|
|
write: impl Fn(&mut Open, Bounds<Pixels>) -> 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<Open>, 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<Open>,
|
|
field: Option<&Entity<TextInput>>,
|
|
window: &mut Window,
|
|
cx: &mut App,
|
|
) -> Option<AnyElement> {
|
|
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 (left_menu, left_state, left_close) = (menu.clone(), state.clone(), close.clone());
|
|
let (right_menu, right_state, right_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())
|
|
// A button row takes the arrows from the filter.
|
|
.capture_action(move |_: &Left, window, cx| {
|
|
if on_buttons(&left_menu, &left_state, cx) {
|
|
cx.stop_propagation();
|
|
keys(&left_menu, &left_state, "left", &left_close, window, cx);
|
|
}
|
|
})
|
|
.capture_action(move |_: &Right, window, cx| {
|
|
if on_buttons(&right_menu, &right_state, cx) {
|
|
cx.stop_propagation();
|
|
keys(&right_menu, &right_state, "right", &right_close, window, cx);
|
|
}
|
|
})
|
|
})
|
|
.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::<Vec<_>>())
|
|
.into_any_element(),
|
|
)
|
|
}
|