diff --git a/AGENTS.md b/AGENTS.md index 84d12cc..9093039 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,6 +58,8 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - Floating lists go below their anchor, or above when only above has room (`forms::options::float`). gpui's own switch keeps the anchor point, so a flipped list would cover its trigger. - A trigger that opens a list goes through `forms::select::listing`: toggle, keys, blur and popup live there, for Select, TabOverflowMenu and Breadcrumb alike. - A palette goes through `navigation::palette::Palette`: query field, grouped rows, cursor, keys and focus live there. CommandPalette, QuickOpen, QuickSwitcher, SearchPalette and QuickLauncher only build rows. +- A menu goes through `menus::menu`: rows, submenus, keys, focus and presses outside live there, for DropdownMenu, OverflowMenu, SplitButton and ContextMenu. +- gpui clicks a focused element when Enter or Space is released. An overlay that hands focus back picks on release too; a pick on press returns focus first, and the release clicks the opener again. - A marker that slides between items (segment thumb, tab line) measures them with `motion::slide` and eases with `glide`. - A picker hands focus to its popup on open and back to its trigger on pick or Escape; it closes once focus leaves both (`forms::date::picker`). - Motion for a value change keys its animation on `motion::changes`, so it replays per change and stays still on first paint. diff --git a/TASKS.md b/TASKS.md index e5bf068..339832d 100644 --- a/TASKS.md +++ b/TASKS.md @@ -32,7 +32,8 @@ Per-component lines live in `tasks/`. Tags there: - [x] T07a Navigation · Tabs & paths - [x] T07b Navigation · Places - [x] T07c Navigation · Palettes & tour -- [ ] T08 Menus +- [x] T08a Menus · Menu & hosts +- [ ] T08b Menus · Bar, search & pie - [ ] T09 Overlays - [ ] T10 Feedback - [ ] T11a Loading — `tasks/ch11-20.md` @@ -145,3 +146,6 @@ Per-component lines live in `tasks/`. Tags there: | T07b | 2 | PASS | | | T07c | 1 | FAIL | SearchPalette let a disabled result run on Enter, and the palette tests compiled without test-support | | T07c | 2 | PASS | | +| T08a | 1 | FAIL | a marked row the owner removed panicked on Enter, the trigger's second click skipped give_back, Enter and Space reached parents on press, the harness never released keys so scripted picks failed, and the menus copy said key hints run rows | +| T08a | 2 | FAIL | Ctrl-Enter and Shift-Space reached keys() on press and picked at once | +| T08a | 3 | PASS | | diff --git a/examples/gallery/pages/menus.rs b/examples/gallery/pages/menus.rs new file mode 100644 index 0000000..1d92902 --- /dev/null +++ b/examples/gallery/pages/menus.rs @@ -0,0 +1,322 @@ +use ely_gpui_component::{ + buttons::ButtonVariant, + menus::{ContextMenu, DropdownMenu, Menu, MenuItem, OverflowMenu, SplitButton}, + primitives::IconName, + theme::{ActiveTheme, Radius}, +}; +use gpui::{ + AnyElement, App, Entity, IntoElement, ParentElement, SharedString, Styled, Window, div, px, +}; + +use super::Page; +use crate::{ + probe::probe, + script::Step, + ui::{keep, row, section, set, specimen}, +}; + +pub const PAGE: Page = Page { + number: 8, + slug: "menus", + title: "Menus", + summary: "Rows of actions that open from a button, a right click or an arrow, and nest.", + render, + script: SCRIPT, +}; + +const SCRIPT: &[Step] = &[ + Step::Rest, + Step::Click("file-menu"), + Step::Wait(300), + Step::Key("down"), + Step::Key("down"), + Step::Key("down"), + Step::Key("right"), + Step::Wait(400), + Step::Shot("submenu"), + Step::Key("escape"), + Step::Key("escape"), + Step::Click("view-menu"), + Step::Wait(400), + Step::Shot("checks"), + Step::Key("escape"), + Step::ClickEnd("merge"), + Step::Wait(400), + Step::Shot("split"), + Step::Key("escape"), + Step::Click("more"), + Step::Wait(400), + Step::Shot("overflow"), + Step::Key("escape"), + Step::RightAt("canvas", 180.0, 70.0), + Step::Wait(400), + Step::Shot("context"), + Step::Key("escape"), + Step::Rest, +]; + +/// Records the last row run, for the captions. +fn ran( + key: &'static str, + window: &mut Window, + cx: &mut App, +) -> (Entity>, String) { + let last = keep(key, || None::, window, cx); + let caption = last + .read(cx) + .clone() + .map_or("nothing run yet".to_string(), |name| format!("ran {name}")); + (last, caption) +} + +fn run( + last: &Entity>, + name: &'static str, +) -> impl Fn(&mut Window, &mut App) + 'static { + let last = last.clone(); + move |_, cx| set(&last, Some(name.into()), cx) +} + +fn file_menu(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let (last, caption) = ran("file-ran", window, cx); + let recent = Menu::new() + .item( + MenuItem::new("roadmap.md") + .icon(IconName::FileText) + .on_click(run(&last, "roadmap.md")), + ) + .item( + MenuItem::new("tokens.rs") + .icon(IconName::FileText) + .on_click(run(&last, "tokens.rs")), + ) + .separator() + .item(MenuItem::new("Clear recent").on_click(run(&last, "clear recent"))); + let menu = Menu::new() + .item( + MenuItem::new("New file") + .icon(IconName::FilePlus) + .keys("secondary-n") + .on_click(run(&last, "new file")), + ) + .item( + MenuItem::new("Open…") + .icon(IconName::FolderOpen) + .keys("secondary-o") + .on_click(run(&last, "open")), + ) + .item(MenuItem::submenu("Open recent", recent).icon(IconName::History)) + .separator() + .item( + MenuItem::new("Save") + .icon(IconName::Save) + .keys("secondary-s") + .on_click(run(&last, "save")), + ) + .item( + MenuItem::new("Save as…") + .keys("secondary-shift-s") + .on_click(run(&last, "save as")), + ) + .item(MenuItem::new("Revert").disabled(true)) + .group( + "Share", + [ + MenuItem::new("Copy link") + .icon(IconName::Link) + .on_click(run(&last, "copy link")), + MenuItem::new("Export PDF") + .icon(IconName::Download) + .on_click(run(&last, "export")), + ], + ); + section( + "Menu / MenuItem / MenuGroup / MenuSeparator / SubMenu / MenuItemShortcut / MenuItemIcon", + "Rows with an icon and a key hint, split by rules and titled groups. The hint only shows a chord; the app binds it. A row with an arrow opens the next menu beside it; Right and Left walk in and out.", + cx, + ) + .child(specimen( + caption, + probe("file-menu", DropdownMenu::new("file", "File", menu).icon(IconName::FileText)), + cx, + )) +} + +fn view_menu(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let shown = keep("view-shown", || [true, false, true], window, cx); + let density = keep("view-density", || 1usize, window, cx); + let (now, dense) = (*shown.read(cx), *density.read(cx)); + let flip = |ix: usize| { + let shown = shown.clone(); + move |_: &mut Window, cx: &mut App| { + let mut next = *shown.read(cx); + next[ix] = !next[ix]; + set(&shown, next, cx); + } + }; + let pick = |ix: usize| { + let density = density.clone(); + move |_: &mut Window, cx: &mut App| set(&density, ix, cx) + }; + let menu = Menu::new() + .item( + MenuItem::check("Sidebar", now[0]) + .keys("secondary-b") + .on_click(flip(0)), + ) + .item(MenuItem::check("Minimap", now[1]).on_click(flip(1))) + .item(MenuItem::check("Status bar", now[2]).on_click(flip(2))) + .group( + "Density", + ["Compact", "Standard", "Comfortable"] + .into_iter() + .enumerate() + .map(|(ix, name)| MenuItem::radio(name, dense == ix).on_click(pick(ix))), + ); + let names = ["sidebar", "minimap", "status bar"]; + let on: Vec<&str> = names + .iter() + .zip(now) + .filter(|(_, on)| *on) + .map(|(name, _)| *name) + .collect(); + section( + "CheckboxMenuItem / RadioMenuItem", + "A check flips on its own; a radio is one of its group. The owner holds both and redraws the menu.", + cx, + ) + .child(specimen( + format!("{}; {}", on.join(", "), ["compact", "standard", "comfortable"][dense]), + probe("view-menu", DropdownMenu::new("view", "View", menu).variant(ButtonVariant::Ghost)), + cx, + )) +} + +fn hosts(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let (last, caption) = ran("hosts-ran", window, cx); + let merge = Menu::new() + .item(MenuItem::new("Squash and merge").on_click(run(&last, "squash and merge"))) + .item(MenuItem::new("Rebase and merge").on_click(run(&last, "rebase and merge"))); + let more = Menu::new() + .item( + MenuItem::new("Rename") + .icon(IconName::Pencil) + .keys("f2") + .on_click(run(&last, "rename")), + ) + .item( + MenuItem::new("Duplicate") + .icon(IconName::Copy) + .on_click(run(&last, "duplicate")), + ) + .item( + MenuItem::new("Archive") + .icon(IconName::Archive) + .on_click(run(&last, "archive")), + ) + .separator() + .item( + MenuItem::new("Delete") + .icon(IconName::Trash2) + .on_click(run(&last, "delete")), + ); + let share = Menu::new() + .item( + MenuItem::new("Copy link") + .icon(IconName::Link) + .on_click(run(&last, "copy link")), + ) + .item( + MenuItem::new("Email") + .icon(IconName::Mail) + .on_click(run(&last, "email")), + ); + let main = last.clone(); + section( + "DropdownMenu / SplitButton / OverflowMenu", + "A button that opens a menu; a main action with related ones under its arrow; and More, for what did not fit.", + cx, + ) + .child(specimen( + caption, + row() + .child(probe("share", DropdownMenu::new("share", "Share", share).icon(IconName::Share2))) + .child(probe( + "merge", + SplitButton::new("merge", "Merge", merge) + .variant(ButtonVariant::Primary) + .on_click(move |_, _, cx| set(&main, Some("merge".into()), cx)), + )) + .child(probe("more", OverflowMenu::new("more", more))), + cx, + )) +} + +fn context(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let (last, caption) = ran("context-ran", window, cx); + let menu = Menu::new() + .item( + MenuItem::new("Back") + .icon(IconName::ArrowLeft) + .keys("secondary-[") + .on_click(run(&last, "back")), + ) + .item( + MenuItem::new("Forward") + .icon(IconName::ArrowRight) + .disabled(true), + ) + .item( + MenuItem::new("Reload") + .icon(IconName::RotateCw) + .keys("secondary-r") + .on_click(run(&last, "reload")), + ) + .separator() + .item( + MenuItem::new("Copy link") + .icon(IconName::Link) + .on_click(run(&last, "copy link")), + ) + .item( + MenuItem::new("Inspect") + .icon(IconName::Code) + .on_click(run(&last, "inspect")), + ); + let theme = cx.theme(); + section( + "ContextMenu", + "A right click opens the menu where it lands; it stays inside the window.", + cx, + ) + .child(specimen( + caption, + probe( + "canvas", + ContextMenu::new("canvas", menu).child( + div() + .flex() + .items_center() + .justify_center() + .w(px(420.0)) + .h(px(140.0)) + .border_1() + .border_dashed() + .border_color(theme.colors.border_strong) + .rounded(theme.radius(Radius::Lg)) + .text_color(theme.colors.fg_subtle) + .child("Right-click anywhere here"), + ), + ), + cx, + )) +} + +fn render(window: &mut Window, cx: &mut App) -> AnyElement { + div() + .child(file_menu(window, cx)) + .child(view_menu(window, cx)) + .child(hosts(window, cx)) + .child(context(window, cx)) + .into_any_element() +} diff --git a/examples/gallery/pages/mod.rs b/examples/gallery/pages/mod.rs index 7c53c37..9857f86 100644 --- a/examples/gallery/pages/mod.rs +++ b/examples/gallery/pages/mod.rs @@ -1,6 +1,7 @@ mod buttons; mod forms; mod layout; +mod menus; mod navigation; mod primitives; mod shell; @@ -29,6 +30,7 @@ pub const ALL: &[Page] = &[ buttons::PAGE, forms::PAGE, navigation::PAGE, + menus::PAGE, theme::PAGE, ]; diff --git a/examples/gallery/script.rs b/examples/gallery/script.rs index e9667ba..b43dfc2 100644 --- a/examples/gallery/script.rs +++ b/examples/gallery/script.rs @@ -20,6 +20,8 @@ pub enum Step { ClickEnd(&'static str), /// Presses at an offset from the probe's top-left corner. DownAt(&'static str, f32, f32), + /// Right-clicks at an offset from the probe's top-left corner. + RightAt(&'static str, f32, f32), /// Drags from the last point to an offset, in small steps. DragTo(&'static str, f32, f32), UpAt(&'static str, f32, f32), @@ -54,6 +56,8 @@ enum Mouse { Down, Drag, Up, + RightDown, + RightUp, } /// Parks the pointer on empty sidebar space. @@ -107,6 +111,12 @@ pub async fn play( send(window, Mouse::Move, last, cx)?; send(window, Mouse::Down, last, cx)?; } + Step::RightAt(key, x, y) => { + let at = target_bounds(window, key, cx).await?.origin + point(px(x), px(y)); + send(window, Mouse::Move, at, cx)?; + send(window, Mouse::RightDown, at, cx)?; + send(window, Mouse::RightUp, at, cx)?; + } Step::DragTo(key, x, y) => { let goal = target_bounds(window, key, cx).await?.origin + point(px(x), px(y)); for step in 1..=DRAG_STEPS { @@ -181,7 +191,7 @@ pub async fn play( Step::NativeKey(key) => { anyhow::ensure!(key == "escape", "native key {key} is not known"); let number = window.update(cx, |_, window, _| number(window))??; - post_escape(number)?; + post_key(number, true, "\u{1b}", 53)?; } Step::CancelPanel => cancel_panel()?, Step::ExpectClosed(key) => { @@ -202,11 +212,22 @@ pub async fn play( Ok(()) } -/// Types a keystroke without holding the root view, so the window can redraw. +/// Presses a key without holding the root view; Enter and Space also release through AppKit, since components pick on release. fn press(window: gpui::AnyWindowHandle, stroke: Keystroke, cx: &mut AsyncApp) -> Result<()> { - window.update(cx, |_, window, cx| { + let release = match stroke.key.as_str() { + _ if stroke.modifiers.modified() => None, + "enter" => Some(("\r", 36)), + "space" => Some((" ", 49)), + _ => None, + }; + let number = window.update(cx, |_, window, cx| { window.dispatch_keystroke(stroke, cx); - }) + number(window) + })??; + match release { + Some((text, code)) => post_key(number, false, text, code), + None => Ok(()), + } } fn send( @@ -234,6 +255,8 @@ fn post(kind: Mouse, x: f64, y: f64, number: u32) -> Result<()> { Mouse::Down => NSEventType::NSLeftMouseDown, Mouse::Drag => NSEventType::NSLeftMouseDragged, Mouse::Up => NSEventType::NSLeftMouseUp, + Mouse::RightDown => NSEventType::NSRightMouseDown, + Mouse::RightUp => NSEventType::NSRightMouseUp, }; unsafe { let event = ::mouseEventWithType_location_modifierFlags_timestamp_windowNumber_context_eventNumber_clickCount_pressure_( @@ -249,33 +272,59 @@ fn post(kind: Mouse, x: f64, y: f64, number: u32) -> Result<()> { 1.0, ); anyhow::ensure!(event != nil, "AppKit refused a synthetic mouse event"); + let right = matches!( + kind, + NSEventType::NSRightMouseDown | NSEventType::NSRightMouseUp + ); + let event = if right { right_button(event) } else { event }; + anyhow::ensure!(event != nil, "AppKit refused a right-button event"); NSApp().postEvent_atStart_(event, NO); } Ok(()) } -/// Queues an Escape key press on this app, for AppKit popovers. +/// A built event reports button zero, and gpui reads the number, so a right click sets it. #[cfg(target_os = "macos")] -fn post_escape(number: u32) -> Result<()> { +unsafe fn right_button(event: cocoa::base::id) -> cocoa::base::id { + use objc::{class, msg_send, sel, sel_impl}; + unsafe extern "C" { + fn CGEventSetIntegerValueField(event: *mut std::ffi::c_void, field: u32, value: i64); + } + const BUTTON_NUMBER: u32 = 3; + unsafe { + let cg: *mut std::ffi::c_void = msg_send![event, CGEvent]; + CGEventSetIntegerValueField(cg, BUTTON_NUMBER, 1); + msg_send![class!(NSEvent), eventWithCGEvent: cg] + } +} + +/// Queues a real key event on a window of this app; AppKit popovers take Escape this way. +#[cfg(target_os = "macos")] +fn post_key(number: u32, down: bool, text: &str, code: u16) -> Result<()> { use cocoa::{ appkit::{NSApp, NSApplication, NSEvent, NSEventModifierFlags, NSEventType}, base::{NO, id, nil}, foundation::{NSPoint, NSString}, }; + let kind = if down { + NSEventType::NSKeyDown + } else { + NSEventType::NSKeyUp + }; unsafe { - let escape = NSString::alloc(nil).init_str("\u{1b}"); + let text = NSString::alloc(nil).init_str(text); let event = ::keyEventWithType_location_modifierFlags_timestamp_windowNumber_context_characters_charactersIgnoringModifiers_isARepeat_keyCode_( nil, - NSEventType::NSKeyDown, + kind, NSPoint::new(0.0, 0.0), NSEventModifierFlags::empty(), 0.0, i64::from(number), nil, - escape, - escape, + text, + text, NO, - 53, + code, ); anyhow::ensure!(event != nil, "AppKit refused a synthetic key event"); NSApp().postEvent_atStart_(event, NO); @@ -284,7 +333,7 @@ fn post_escape(number: u32) -> Result<()> { } #[cfg(not(target_os = "macos"))] -fn post_escape(_: u32) -> Result<()> { +fn post_key(_: u32, _: bool, _: &str, _: u16) -> Result<()> { anyhow::bail!("native keys need macOS") } diff --git a/src/buttons/button.rs b/src/buttons/button.rs index 2908413..491bb40 100644 --- a/src/buttons/button.rs +++ b/src/buttons/button.rs @@ -225,7 +225,7 @@ impl RenderOnce for Button { .bg(tone.bg) .when(self.full_width, |el| el.w_full()) .when_some(self.icon, |el, name| el.child(icon(name))) - .child(self.label) + .when(!self.label.is_empty(), |el| el.child(self.label)) .when_some(self.trailing_icon, |el, name| el.child(icon(name))) .when_some(hint, |el, hint| { el.child( diff --git a/src/lib.rs b/src/lib.rs index c60e595..29d7cea 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ mod assets; pub mod buttons; pub mod forms; pub mod layout; +pub mod menus; pub mod motion; pub mod navigation; pub mod primitives; diff --git a/src/menus/hosts.rs b/src/menus/hosts.rs new file mode 100644 index 0000000..bd070af --- /dev/null +++ b/src/menus/hosts.rs @@ -0,0 +1,225 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, ClickEvent, Div, ElementId, Entity, InteractiveElement, IntoElement, + MouseButton, ParentElement, RenderOnce, SharedString, Stateful, Styled, Window, div, + prelude::*, +}; +use smallvec::SmallVec; + +use super::{ + menu::{Open, Spot, hang, measure_host}, + model::Menu, +}; +use crate::{ + buttons::{Button, ButtonGroup, ButtonVariant, IconButton}, + primitives::IconName, +}; + +type Click = Rc; + +fn state(id: &ElementId, window: &mut Window, cx: &mut App) -> Entity { + window.use_keyed_state((id.clone(), "menu"), cx, |_, _| Open::default()) +} + +/// Toggles the menu under the host; a keyboard press marks its first row. +fn toggle(state: &Entity, menu: &Menu, cx: &App) -> Click { + let (state, menu, open) = (state.clone(), menu.clone(), state.read(cx).is_open()); + Rc::new(move |event, window, cx| { + if open { + Open::close(&state, window, cx); + } else { + Open::show(&state, &menu, Spot::Under, event.is_keyboard(), cx); + } + }) +} + +/// A trigger, the canvas that measures it, and its menu while open. +fn host( + id: ElementId, + trigger: impl IntoElement, + menu: &Menu, + state: &Entity, + window: &mut Window, + cx: &mut App, +) -> Stateful
{ + div() + .id(id.clone()) + .relative() + .flex_none() + .child(trigger) + .child(measure_host(state.clone())) + .children(hang(&id, menu, state, window, cx)) +} + +/// A button that opens a menu under it. +#[derive(IntoElement)] +pub struct DropdownMenu { + id: ElementId, + label: SharedString, + icon: Option, + variant: ButtonVariant, + menu: Menu, +} + +impl DropdownMenu { + pub fn new(id: impl Into, label: impl Into, menu: Menu) -> Self { + Self { + id: id.into(), + label: label.into(), + icon: None, + variant: ButtonVariant::Secondary, + menu, + } + } + + pub fn icon(mut self, icon: IconName) -> Self { + self.icon = Some(icon); + self + } + + pub fn variant(mut self, variant: ButtonVariant) -> Self { + self.variant = variant; + self + } +} + +impl RenderOnce for DropdownMenu { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let state = state(&self.id, window, cx); + let click = toggle(&state, &self.menu, cx); + let button = Button::new((self.id.clone(), "button"), self.label) + .variant(self.variant) + .trailing_icon(IconName::ChevronDown) + .when_some(self.icon, |button, icon| button.icon(icon)) + .on_click(move |event, window, cx| click(event, window, cx)); + host(self.id, button, &self.menu, &state, window, cx) + } +} + +/// An icon button that opens a menu of what did not fit. +#[derive(IntoElement)] +pub struct OverflowMenu { + id: ElementId, + menu: Menu, +} + +impl OverflowMenu { + pub fn new(id: impl Into, menu: Menu) -> Self { + Self { + id: id.into(), + menu, + } + } +} + +impl RenderOnce for OverflowMenu { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let state = state(&self.id, window, cx); + let click = toggle(&state, &self.menu, cx); + let button = IconButton::new((self.id.clone(), "button"), IconName::Ellipsis) + .variant(ButtonVariant::Ghost) + .tooltip("More") + .on_click(move |event, window, cx| click(event, window, cx)); + host(self.id, button, &self.menu, &state, window, cx) + } +} + +/// A button's main action, and related ones in a menu under its arrow. +#[derive(IntoElement)] +pub struct SplitButton { + id: ElementId, + label: SharedString, + variant: ButtonVariant, + menu: Menu, + on_click: Option, +} + +impl SplitButton { + pub fn new(id: impl Into, label: impl Into, menu: Menu) -> Self { + Self { + id: id.into(), + label: label.into(), + variant: ButtonVariant::Secondary, + menu, + on_click: None, + } + } + + pub fn variant(mut self, variant: ButtonVariant) -> Self { + self.variant = variant; + self + } + + /// The main action. + pub fn on_click( + mut self, + handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_click = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for SplitButton { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let state = state(&self.id, window, cx); + let click = toggle(&state, &self.menu, cx); + let main = Button::new((self.id.clone(), "main"), self.label) + .variant(self.variant) + .when_some(self.on_click, |button, run| { + button.on_click(move |event, window, cx| run(event, window, cx)) + }); + let more = Button::new((self.id.clone(), "more"), "") + .variant(self.variant) + .icon(IconName::ChevronDown) + .on_click(move |event, window, cx| click(event, window, cx)); + host( + self.id, + ButtonGroup::new().button(main).button(more), + &self.menu, + &state, + window, + cx, + ) + } +} + +/// Its children, and a menu that opens at the pointer on a right click. +#[derive(IntoElement)] +pub struct ContextMenu { + id: ElementId, + menu: Menu, + children: SmallVec<[AnyElement; 2]>, +} + +impl ContextMenu { + pub fn new(id: impl Into, menu: Menu) -> Self { + Self { + id: id.into(), + menu, + children: SmallVec::new(), + } + } +} + +impl ParentElement for ContextMenu { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl RenderOnce for ContextMenu { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let state = state(&self.id, window, cx); + let (open, menu) = (state.clone(), self.menu.clone()); + div() + .id(self.id.clone()) + .on_mouse_down(MouseButton::Right, move |event, _, cx| { + log::info!("context menu: at {:?}", event.position); + Open::show(&open, &menu, Spot::At(event.position), false, cx); + }) + .children(self.children) + .children(hang(&self.id, &self.menu, &state, window, cx)) + } +} diff --git a/src/menus/menu.rs b/src/menus/menu.rs new file mode 100644 index 0000000..48402f8 --- /dev/null +++ b/src/menus/menu.rs @@ -0,0 +1,484 @@ +use std::rc::Rc; + +use gpui::{ + Animation, AnimationExt, AnyElement, App, Bounds, Div, ElementId, Entity, FontWeight, + InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, Point, Stateful, Styled, + Window, anchored, canvas, deferred, div, prelude::*, +}; + +use super::model::{Entry, Kind, Menu, MenuItem}; +use crate::{ + forms::{Run, float, surface}, + motion, + primitives::{Icon, IconName, Takeover, give_back, take_focus}, + theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize}, + typography::KbdCombo, +}; + +/// The menu open at `depth`, following the marked rows above it; `None` once those rows changed. +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)) + .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 at = levels[depth] + .at + .filter(|&ix| here.item_at(ix).is_some_and(|item| !item.disabled)); + levels[depth].at = at; + match at.and_then(|ix| here.item_at(ix)).map(|item| &item.kind) { + Some(Kind::Sub(sub)) if depth + 1 < levels.len() => here = sub, + _ => { + levels.truncate(depth + 1); + return; + } + } + } +} + +/// One open level: its marked row, that row's box and its panel's box. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +struct Level { + at: Option, + row: Bounds, + panel: Bounds, +} + +/// Where an open menu hangs: under its host's 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, and the focus to hand back. +#[derive(Default)] +pub(crate) struct Open { + spot: Option, + levels: Vec, + anchor: Bounds, + takeover: Option>, +} + +impl Open { + pub(crate) fn is_open(&self) -> bool { + self.spot.is_some() + } + + /// 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![Level { + at: first, + ..Level::default() + }]; + 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); + } + } +} + +/// Marks row `at` of level `depth` and drops deeper levels; a submenu row opens when `open_sub`. +fn mark(state: &Entity, menu: &Menu, depth: usize, at: 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).map(|item| &item.kind), Some(Kind::Sub(_))); + open.levels.truncate(depth + 1); + if open.levels[depth].at != Some(at) { + open.levels[depth].at = Some(at); + open.levels[depth].row = Bounds::default(); + } + if open_sub && sub { + open.levels.push(Level::default()); + } + cx.notify(); + }); +} + +/// Runs a row. A submenu opens with its first row marked; anything else closes the menu first. +fn choose( + state: &Entity, + menu: &Menu, + depth: usize, + at: 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)) + .cloned() + else { + log::debug!("menu: a press from an older frame found row {at} of level {depth} gone"); + return; + }; + if item.disabled { + return; + } + if let Kind::Sub(sub) = &item.kind { + let first = sub.step(None, 1); + mark(state, menu, depth, at, false, cx); + state.update(cx, |open, cx| { + open.levels.push(Level { + at: first, + ..Level::default() + }); + cx.notify(); + }); + return; + } + log::info!("menu: {}", item.label); + close(window, cx); + if let Some(on_click) = &item.on_click { + on_click(window, cx); + } +} + +/// Arrows walk the deepest level; Right opens a submenu; Enter and Space choose; Left and Escape back out. +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 = levels[depth].at; + let sub = at + .and_then(|at| here.item_at(at)) + .is_some_and(|item| matches!(item.kind, Kind::Sub(_))); + match (key, at) { + ("down" | "up", _) => { + let by = if key == "down" { 1 } else { -1 }; + if let Some(to) = here.step(at, by) { + mark(state, menu, depth, to, false, cx); + } + } + ("right", Some(at)) if sub => choose(state, menu, depth, at, close, window, cx), + ("enter" | "space", Some(at)) => choose(state, menu, depth, at, 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. +fn measure( + state: Entity, + write: impl Fn(&mut Open, Bounds) -> bool + 'static, +) -> impl IntoElement { + canvas( + move |bounds, _, cx| { + if state.update(cx, |open, _| write(open, bounds)) { + state.update(cx, |_, cx| cx.notify()); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .top_0() + .left_0() + .size_full() +} + +/// Measures the host's box, which a dropdown hangs under. +pub(crate) fn measure_host(state: Entity) -> impl IntoElement { + measure(state, |open, bounds| { + let changed = open.anchor != bounds; + open.anchor = bounds; + changed + }) +} + +fn row(item: &MenuItem, ix: usize, marked: bool, cx: &App) -> Stateful
{ + let theme = cx.theme(); + let colors = &theme.colors; + let fg = if item.disabled { + colors.fg_disabled + } else { + colors.fg + }; + let (lead, lead_color) = match &item.kind { + Kind::Check(true) => (Some(IconName::Check), fg), + Kind::Radio(true) => (Some(IconName::Dot), fg), + _ => (item.icon, if item.disabled { fg } else { colors.fg_muted }), + }; + div() + .id(("row", ix)) + .relative() + .flex() + .items_center() + .gap_2() + .h(theme.control_height(ControlSize::Md)) + .px_2() + .rounded(theme.radius(Radius::Md)) + .text_color(fg) + .when(marked && !item.disabled, |row| row.bg(colors.hover)) + .when(!item.disabled, |row| row.cursor_pointer()) + .child( + div() + .flex() + .flex_none() + .items_center() + .justify_center() + .size(theme.icon_size(IconSize::Sm)) + .when_some(lead, |slot, icon| { + slot.child(Icon::new(icon).size(IconSize::Sm).color(lead_color)) + }), + ) + .child( + div() + .flex_1() + .min_w_0() + .overflow_hidden() + .text_ellipsis() + .whitespace_nowrap() + .child(item.label.clone()), + ) + .when_some(item.keys.clone(), |row, keys| { + row.child( + div() + .flex_none() + .pl_4() + .text_color(colors.fg_subtle) + .child(KbdCombo::new(&keys)), + ) + }) + .when(matches!(item.kind, Kind::Sub(_)), |row| { + row.child( + Icon::new(IconName::ChevronRight) + .size(IconSize::Xs) + .color(colors.fg_subtle), + ) + }) +} + +fn panel( + id: &ElementId, + menu: &Menu, + state: &Entity, + depth: usize, + close: &Run, + cx: &App, +) -> Stateful
{ + let open = state.read(cx); + let here = at_depth(menu, &open.levels, depth).expect("settled levels follow submenu rows"); + let at = open.levels[depth].at; + let theme = cx.theme(); + let colors = &theme.colors; + let rows = here + .entries + .iter() + .enumerate() + .map(|(ix, entry)| match entry { + Entry::Separator => div().h_px().my_1().bg(colors.border).into_any_element(), + Entry::Heading(title) => div() + .px_2() + .pt_1p5() + .pb_1() + .text_size(theme.text_size(TextSize::Xs)) + .font_weight(FontWeight::MEDIUM) + .text_color(colors.fg_subtle) + .child(title.clone()) + .into_any_element(), + Entry::Item(item) => { + let marked = at == Some(ix); + let sub = matches!(item.kind, Kind::Sub(_)); + let (hover, click, close, menu) = + (state.clone(), state.clone(), close.clone(), menu.clone()); + let hover_menu = menu.clone(); + row(item, ix, marked, cx) + .when(!item.disabled, |row| { + row.on_mouse_move(move |_, _, cx| { + let open = hover.read(cx); + let Some(level) = open.levels.get(depth) else { + return; + }; + let shown = open.levels.len() > depth + 1; + if level.at != Some(ix) || (sub && !shown) { + mark(&hover, &hover_menu, depth, ix, true, cx); + } + }) + .on_mouse_down( + MouseButton::Left, + move |_, window, cx| { + window.prevent_default(); + cx.stop_propagation(); + choose(&click, &menu, depth, ix, &close, window, cx); + }, + ) + }) + .when(marked && sub, |row| { + row.child(measure(state.clone(), move |open, bounds| { + let level = &mut open.levels[depth]; + let changed = level.row != bounds; + level.row = bounds; + changed + })) + }) + .into_any_element() + } + }); + surface((id.clone(), format!("panel-{depth}")), cx) + .relative() + .min_w(theme.menu_width()) + .p_1() + .flex() + .flex_col() + .children(rows) + .child(measure(state.clone(), move |open, bounds| { + let level = &mut open.levels[depth]; + let changed = level.panel != bounds; + level.panel = 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, + 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.is_focused(window) { + log::info!("menu {id:?}: focus left"); + close(window, cx); + return None; + } + 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 root = panel(id, menu, state, 0, &close, cx) + .track_focus(&focus) + .on_key_down(move |event, window, cx| { + let stroke = &event.keystroke; + let key = stroke.key.as_str(); + if matches!(key, "enter" | "space") { + if !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 matches!(key, "enter" | "space") + && !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.spot == Some(Spot::Under) && open.anchor.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(state.read(cx).anchor, menu.entries.len(), root, window, cx), + Spot::At(point) => deferred(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(|| { + deferred( + anchored().position(row.top_right()).snap_to_window().child( + panel(id, menu, state, level, &close, 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(), + ) +} diff --git a/src/menus/mod.rs b/src/menus/mod.rs new file mode 100644 index 0000000..e5e4839 --- /dev/null +++ b/src/menus/mod.rs @@ -0,0 +1,8 @@ +mod hosts; +mod menu; +mod model; +#[cfg(all(test, feature = "test-support"))] +mod tests; + +pub use hosts::{ContextMenu, DropdownMenu, OverflowMenu, SplitButton}; +pub use model::{Menu, MenuItem}; diff --git a/src/menus/model.rs b/src/menus/model.rs new file mode 100644 index 0000000..7bb3b4a --- /dev/null +++ b/src/menus/model.rs @@ -0,0 +1,141 @@ +use std::rc::Rc; + +use gpui::{App, SharedString, Window}; + +use crate::{forms::Run, primitives::IconName}; + +/// What a row does when chosen. +#[derive(Clone)] +pub(super) enum Kind { + Run, + Check(bool), + Radio(bool), + Sub(Menu), +} + +/// One row of a menu. Its keys are shown, not bound. +#[derive(Clone)] +pub struct MenuItem { + pub(super) label: SharedString, + pub(super) icon: Option, + pub(super) keys: Option, + pub(super) kind: Kind, + pub(super) disabled: bool, + pub(super) on_click: Option, +} + +impl MenuItem { + fn with(label: impl Into, kind: Kind) -> Self { + Self { + label: label.into(), + icon: None, + keys: None, + kind, + disabled: false, + on_click: None, + } + } + + pub fn new(label: impl Into) -> Self { + Self::with(label, Kind::Run) + } + + /// A row with a check; the owner flips `checked` in `on_click`. + pub fn check(label: impl Into, checked: bool) -> Self { + Self::with(label, Kind::Check(checked)) + } + + /// One of a set; the owner moves `selected` in `on_click`. + pub fn radio(label: impl Into, selected: bool) -> Self { + Self::with(label, Kind::Radio(selected)) + } + + /// A row that opens `menu` beside it. + pub fn submenu(label: impl Into, menu: Menu) -> Self { + Self::with(label, Kind::Sub(menu)) + } + + pub fn icon(mut self, icon: IconName) -> Self { + self.icon = Some(icon); + self + } + + /// Its chord at the row's end, in gpui key syntax. + pub fn keys(mut self, keys: impl Into) -> Self { + self.keys = Some(keys.into()); + self + } + + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + pub fn on_click(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_click = Some(Rc::new(handler)); + self + } +} + +#[derive(Clone)] +pub(super) enum Entry { + Item(MenuItem), + Separator, + Heading(SharedString), +} + +/// Rows, separators and titled groups. A row can open a submenu. +#[derive(Clone, Default)] +pub struct Menu { + pub(super) entries: Vec, +} + +impl Menu { + pub fn new() -> Self { + Self::default() + } + + pub fn item(mut self, item: MenuItem) -> Self { + self.entries.push(Entry::Item(item)); + self + } + + pub fn separator(mut self) -> Self { + self.entries.push(Entry::Separator); + self + } + + /// A titled run of rows, set off from the rows before it. + pub fn group( + mut self, + title: impl Into, + items: impl IntoIterator, + ) -> Self { + if !self.entries.is_empty() { + self.entries.push(Entry::Separator); + } + self.entries.push(Entry::Heading(title.into())); + self.entries.extend(items.into_iter().map(Entry::Item)); + self + } + + pub(super) fn item_at(&self, ix: usize) -> Option<&MenuItem> { + match self.entries.get(ix) { + Some(Entry::Item(item)) => Some(item), + _ => None, + } + } + + /// The next enabled row from `from`, stepping by `by` and wrapping. + pub(super) fn step(&self, from: Option, by: isize) -> Option { + let count = self.entries.len() as isize; + let mut at = from.map_or(if by > 0 { -1 } else { count }, |at| at as isize); + for _ in 0..count { + at = (at + by).rem_euclid(count); + if self.item_at(at as usize).is_some_and(|item| !item.disabled) { + return Some(at as usize); + } + } + None + } +} diff --git a/src/menus/tests.rs b/src/menus/tests.rs new file mode 100644 index 0000000..e5df539 --- /dev/null +++ b/src/menus/tests.rs @@ -0,0 +1,267 @@ +use gpui::{ + App, Context, FocusHandle, InteractiveElement, IntoElement, KeyUpEvent, Keystroke, Modifiers, + MouseButton, ParentElement, Render, SharedString, Styled, TestAppContext, VisualTestContext, + Window, div, point, px, +}; + +use super::{ContextMenu, DropdownMenu, Menu, MenuItem}; +use crate::{primitives::FocusScope, theme::Theme}; + +/// What the rows did, and the state the check and radio rows show. +struct Desk { + root: FocusHandle, + ran: Vec, + wrap: bool, + theme: SharedString, + view_rows: bool, + enters_above: usize, +} + +impl Desk { + fn menu(&self, cx: &mut Context) -> Menu { + let run = |name: &'static str| { + let view = cx.entity(); + move |_: &mut Window, cx: &mut App| { + view.update(cx, |desk, cx| { + desk.ran.push(name.into()); + cx.notify(); + }) + } + }; + let (wrap, light, dark) = (cx.entity(), cx.entity(), cx.entity()); + let theme = Menu::new() + .item( + MenuItem::radio("Light", self.theme == "light") + .on_click(move |_, cx| light.update(cx, |desk, _| desk.theme = "light".into())), + ) + .item( + MenuItem::radio("Dark", self.theme == "dark") + .on_click(move |_, cx| dark.update(cx, |desk, _| desk.theme = "dark".into())), + ); + let edit = Menu::new() + .item( + MenuItem::new("Cut") + .keys("secondary-x") + .on_click(run("cut")), + ) + .item(MenuItem::new("Copy").disabled(true).on_click(run("copy"))) + .item(MenuItem::new("Paste").on_click(run("paste"))); + if !self.view_rows { + return edit; + } + edit.group( + "View", + [ + MenuItem::check("Word wrap", self.wrap) + .on_click(move |_, cx| wrap.update(cx, |desk, _| desk.wrap = !desk.wrap)), + MenuItem::submenu("Theme", theme), + ], + ) + } +} + +impl Render for Desk { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let menu = self.menu(cx); + let above = cx.entity(); + div() + .size_full() + .on_key_down(move |event, _, cx| { + if event.keystroke.key == "enter" { + above.update(cx, |desk, _| desk.enters_above += 1); + } + }) + .child( + FocusScope::new(&self.root) + .size_full() + .child(DropdownMenu::new("edit", "Edit", menu.clone())) + .child(ContextMenu::new("area", menu).child(div().w(px(200.0)).h(px(120.0)))), + ) + } +} + +fn settle(cx: &mut VisualTestContext) { + cx.run_until_parked(); + cx.update(|window, _| window.refresh()); + cx.run_until_parked(); +} + +/// Presses and releases `key`, with a frame in between as on a real keyboard. +fn press(key: &str, cx: &mut VisualTestContext) { + cx.simulate_keystrokes(key); + settle(cx); + cx.simulate_event(KeyUpEvent { + keystroke: Keystroke::parse(key).expect("a key"), + }); + settle(cx); +} + +fn desk(cx: &mut TestAppContext) -> (gpui::Entity, &mut VisualTestContext) { + cx.update(Theme::init); + let (view, cx) = cx.add_window_view(|_, cx| Desk { + root: cx.focus_handle(), + ran: Vec::new(), + wrap: false, + theme: "light".into(), + view_rows: true, + enters_above: 0, + }); + settle(cx); + (view, cx) +} + +fn ran(view: &gpui::Entity, cx: &mut VisualTestContext) -> Vec { + view.read_with(cx, |desk, _| desk.ran.clone()) +} + +fn click(x: f32, y: f32, cx: &mut VisualTestContext) { + let at = point(px(x), px(y)); + cx.simulate_mouse_move(at, None, Modifiers::none()); + cx.simulate_click(at, Modifiers::none()); + settle(cx); +} + +#[gpui::test] +fn the_keyboard_opens_walks_past_disabled_rows_and_runs(cx: &mut TestAppContext) { + let (view, cx) = desk(cx); + cx.update(|window, _| window.focus_next()); + press("enter", cx); + press("down", cx); + press("enter", cx); + assert_eq!(ran(&view, cx), ["paste"]); + press("down", cx); + press("enter", cx); + assert_eq!(ran(&view, cx), ["paste"], "the release left the menu shut"); +} + +#[gpui::test] +fn submenus_open_right_and_close_left(cx: &mut TestAppContext) { + let (view, cx) = desk(cx); + cx.update(|window, _| window.focus_next()); + press("enter", cx); + press("up", cx); + press("right", cx); + press("down", cx); + press("left", cx); + press("right", cx); + press("down", cx); + press("enter", cx); + assert_eq!(view.read_with(cx, |desk, _| desk.theme.clone()), "dark"); +} + +#[gpui::test] +fn a_check_row_reports_to_its_owner(cx: &mut TestAppContext) { + let (view, cx) = desk(cx); + cx.update(|window, _| window.focus_next()); + press("enter", cx); + press("up", cx); + press("up", cx); + press("enter", cx); + assert!(view.read_with(cx, |desk, _| desk.wrap)); +} + +#[gpui::test] +fn escape_backs_out_one_level_then_closes(cx: &mut TestAppContext) { + let (view, cx) = desk(cx); + cx.update(|window, _| window.focus_next()); + press("enter", cx); + press("up", cx); + press("right", cx); + press("escape", cx); + press("up", cx); + press("enter", cx); + assert!( + view.read_with(cx, |desk, _| desk.wrap), + "one escape left the root open" + ); + press("enter", cx); + press("escape", cx); + press("down", cx); + press("enter", cx); + assert!( + ran(&view, cx).is_empty(), + "the second escape closed the menu" + ); +} + +#[gpui::test] +fn a_right_click_opens_the_context_menu_where_it_lands(cx: &mut TestAppContext) { + let (view, cx) = desk(cx); + let at = point(px(60.0), px(80.0)); + cx.simulate_mouse_move(at, None, Modifiers::none()); + cx.simulate_mouse_down(at, MouseButton::Right, Modifiers::none()); + cx.simulate_mouse_up(at, MouseButton::Right, Modifiers::none()); + settle(cx); + press("down", cx); + press("enter", cx); + assert_eq!(ran(&view, cx), ["cut"]); +} + +#[gpui::test] +fn the_trigger_toggles_and_a_press_outside_closes(cx: &mut TestAppContext) { + let (view, cx) = desk(cx); + click(12.0, 12.0, cx); + click(12.0, 12.0, cx); + press("down", cx); + press("enter", cx); + assert!( + ran(&view, cx).is_empty(), + "the second press on the trigger closed it" + ); + click(12.0, 12.0, cx); + click(600.0, 400.0, cx); + press("down", cx); + press("enter", cx); + assert!(ran(&view, cx).is_empty(), "the press outside closed it"); +} + +#[gpui::test] +fn rows_the_owner_removes_leave_the_menu_usable(cx: &mut TestAppContext) { + let (view, cx) = desk(cx); + cx.update(|window, _| window.focus_next()); + press("enter", cx); + press("up", cx); + view.update(cx, |desk, cx| { + desk.view_rows = false; + cx.notify(); + }); + settle(cx); + press("enter", cx); + assert!(ran(&view, cx).is_empty(), "the removed row ran"); + press("down", cx); + press("enter", cx); + assert_eq!(ran(&view, cx), ["cut"]); +} + +#[gpui::test] +fn closing_from_the_trigger_hands_focus_back(cx: &mut TestAppContext) { + let (view, cx) = desk(cx); + cx.update(|window, _| window.focus_next()); + press("enter", cx); + click(12.0, 12.0, cx); + press("enter", cx); + press("enter", cx); + assert_eq!(ran(&view, cx), ["cut"], "focus came back to the trigger"); +} + +#[gpui::test] +fn confirm_keys_stay_inside_the_menu(cx: &mut TestAppContext) { + let (view, cx) = desk(cx); + click(12.0, 12.0, cx); + press("down", cx); + press("enter", cx); + assert_eq!(ran(&view, cx), ["cut"]); + assert_eq!(view.read_with(cx, |desk, _| desk.enters_above), 0); +} + +#[gpui::test] +fn modified_confirm_keys_do_not_pick(cx: &mut TestAppContext) { + let (view, cx) = desk(cx); + cx.update(|window, _| window.focus_next()); + press("enter", cx); + press("ctrl-enter", cx); + press("shift-space", cx); + assert!(ran(&view, cx).is_empty(), "a modified key picked"); + press("enter", cx); + assert_eq!(ran(&view, cx), ["cut"]); +} diff --git a/src/navigation/palette/mod.rs b/src/navigation/palette/mod.rs index e8cc84f..67b8296 100644 --- a/src/navigation/palette/mod.rs +++ b/src/navigation/palette/mod.rs @@ -327,9 +327,11 @@ impl Palette { cx.stop_propagation(); down(1, cx); }) - .capture_action(move |_: &Enter, window, cx| { - cx.stop_propagation(); - if count > 0 { + .capture_action(|_: &Enter, _, cx| cx.stop_propagation()) + .on_key_up(move |event, window, cx| { + let stroke = &event.keystroke; + if stroke.key == "enter" && !stroke.modifiers.modified() && count > 0 { + cx.stop_propagation(); enter(chosen.read(cx).at.min(count - 1), window, cx); } }) diff --git a/src/navigation/palette/tests.rs b/src/navigation/palette/tests.rs index 6263840..2f4ecd7 100644 --- a/src/navigation/palette/tests.rs +++ b/src/navigation/palette/tests.rs @@ -1,12 +1,13 @@ use std::ops::Range; use gpui::{ - Context, FocusHandle, InteractiveElement, IntoElement, KeyBinding, ParentElement, Render, - SharedString, TestAppContext, VisualTestContext, Window, div, prelude::*, + Context, FocusHandle, IntoElement, KeyBinding, KeyUpEvent, Keystroke, ParentElement, Render, + SharedString, TestAppContext, VisualTestContext, Window, prelude::*, }; use super::{Command, CommandPalette, Fit, QuickOpen, QuickSwitcher, SearchPalette, fuzzy}; use crate::{ + buttons::Button, forms::Choice, primitives::{FocusNext, FocusScope}, theme::Theme, @@ -26,6 +27,16 @@ fn settle(cx: &mut VisualTestContext) { cx.run_until_parked(); } +/// Presses and releases `key`, with a frame in between as on a real keyboard. +fn press(key: &str, cx: &mut VisualTestContext) { + cx.simulate_keystrokes(key); + settle(cx); + cx.simulate_event(KeyUpEvent { + keystroke: Keystroke::parse(key).expect("a key"), + }); + settle(cx); +} + #[test] fn fuzzy_finds_letters_in_order_and_ranks_word_starts() { let found = fuzzy("tsb", "Toggle sidebar").expect("a fit"); @@ -54,7 +65,6 @@ fn fuzzy_finds_letters_in_order_and_ranks_word_starts() { /// Opens one palette at a time and records what it handed back. struct Host { root: FocusHandle, - outside: FocusHandle, open: bool, got: Option, kind: Kind, @@ -83,7 +93,7 @@ fn results(query: &str) -> Vec<(SharedString, Vec)> { impl Render for Host { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let (view, close) = (cx.entity(), cx.entity()); + let (view, close, opener) = (cx.entity(), cx.entity(), cx.entity()); let close = move |_: &mut Window, cx: &mut gpui::App| { close.update(cx, |host, cx| { host.open = false; @@ -139,7 +149,12 @@ impl Render for Host { }; FocusScope::new(&self.root) .size_full() - .child(div().id("outside").track_focus(&self.outside)) + .child(Button::new("opener", "Open").on_click(move |_, _, cx| { + opener.update(cx, |host, cx| { + host.open = true; + cx.notify(); + }) + })) .when(self.open, |host| host.child(palette)) } } @@ -148,7 +163,6 @@ fn host(kind: Kind, cx: &mut TestAppContext) -> (gpui::Entity, &mut Visual setup(cx); let (view, cx) = cx.add_window_view(move |_, cx| Host { root: cx.focus_handle(), - outside: cx.focus_handle().tab_stop(true), open: true, got: None, kind, @@ -173,15 +187,16 @@ fn reopen(view: &gpui::Entity, cx: &mut VisualTestContext) { #[gpui::test] fn the_command_palette_leads_with_recent_and_runs_the_best_fit(cx: &mut TestAppContext) { let (view, cx) = host(Kind::Commands, cx); - cx.simulate_keystrokes("enter"); + press("enter", cx); assert_eq!(got(&view, cx), (false, Some("dark".into()))); reopen(&view, cx); cx.simulate_input("zi"); settle(cx); - cx.simulate_keystrokes("enter"); + press("enter", cx); assert_eq!(got(&view, cx), (false, Some("zoom".into()))); reopen(&view, cx); - cx.simulate_keystrokes("up enter"); + cx.simulate_keystrokes("up"); + press("enter", cx); assert_eq!(got(&view, cx), (false, Some("light".into()))); reopen(&view, cx); cx.simulate_keystrokes("escape"); @@ -193,7 +208,7 @@ fn quick_open_ranks_the_whole_path(cx: &mut TestAppContext) { let (view, cx) = host(Kind::Files, cx); cx.simulate_input("navmenu"); settle(cx); - cx.simulate_keystrokes("enter"); + press("enter", cx); assert_eq!( got(&view, cx), (false, Some("src/navigation/menu.rs".into())) @@ -203,24 +218,26 @@ fn quick_open_ranks_the_whole_path(cx: &mut TestAppContext) { #[gpui::test] fn the_switcher_opens_on_the_one_before(cx: &mut TestAppContext) { let (view, cx) = host(Kind::Switcher, cx); - cx.simulate_keystrokes("enter"); + press("enter", cx); assert_eq!(got(&view, cx), (false, Some("before".into()))); reopen(&view, cx); cx.simulate_input("old"); settle(cx); - cx.simulate_keystrokes("enter"); + press("enter", cx); assert_eq!(got(&view, cx), (false, Some("older".into()))); } #[gpui::test] fn search_walks_the_owners_results(cx: &mut TestAppContext) { let (view, cx) = host(Kind::Search, cx); - cx.simulate_keystrokes("down enter"); + cx.simulate_keystrokes("down"); + press("enter", cx); assert_eq!(got(&view, cx), (false, Some("motion".into()))); reopen(&view, cx); cx.simulate_input("theme"); settle(cx); - cx.simulate_keystrokes("down enter"); + cx.simulate_keystrokes("down"); + press("enter", cx); assert_eq!(got(&view, cx), (false, Some("tokens".into()))); } @@ -230,7 +247,7 @@ fn tab_stays_inside_an_open_palette(cx: &mut TestAppContext) { cx.simulate_keystrokes("tab"); cx.simulate_input("zi"); settle(cx); - cx.simulate_keystrokes("enter"); + press("enter", cx); assert_eq!(got(&view, cx), (false, Some("zoom".into()))); } @@ -239,3 +256,16 @@ fn tab_stays_inside_an_open_palette(cx: &mut TestAppContext) { fn search_refuses_disabled_results(cx: &mut TestAppContext) { host(Kind::Disabled, cx); } + +#[gpui::test] +fn enter_picks_on_release_so_the_opener_stays_shut(cx: &mut TestAppContext) { + let (view, cx) = host(Kind::Commands, cx); + press("escape", cx); + cx.update(|window, _| window.focus_next()); + press("enter", cx); + assert_eq!(got(&view, cx), (true, None)); + cx.simulate_input("zi"); + settle(cx); + press("enter", cx); + assert_eq!(got(&view, cx), (false, Some("zoom".into()))); +} diff --git a/src/primitives/mod.rs b/src/primitives/mod.rs index a1a5bb2..f535c12 100644 --- a/src/primitives/mod.rs +++ b/src/primitives/mod.rs @@ -11,7 +11,7 @@ mod tooltip; pub use backdrop::{Backdrop, Place}; pub use divider::Divider; pub use focus::{FocusNext, FocusPrev, FocusRing, FocusScope}; -pub(crate) use focus::{give_back, tab_stop, take_focus}; +pub(crate) use focus::{Takeover, give_back, tab_stop, take_focus}; pub use ghost::DragGhost; pub use icon::{Icon, IconName}; pub use image::Image; diff --git a/src/shell/mod.rs b/src/shell/mod.rs index 75c3d95..28e7c38 100644 --- a/src/shell/mod.rs +++ b/src/shell/mod.rs @@ -8,6 +8,8 @@ mod native; mod status; mod switcher; mod tabs; +#[cfg(all(test, feature = "test-support"))] +mod tests; mod update; mod windows; diff --git a/src/shell/switcher.rs b/src/shell/switcher.rs index bf41f12..2a6e8b4 100644 --- a/src/shell/switcher.rs +++ b/src/shell/switcher.rs @@ -95,8 +95,9 @@ impl RenderOnce for WindowSwitcher { }) .collect(); let handles: Vec = windows.iter().map(|(handle, _)| *handle).collect(); - let (next, prev, keys) = (state.clone(), state.clone(), state.clone()); - let (close_keys, close_scrim) = (close.clone(), close); + let (next, prev, keys, chosen) = + (state.clone(), state.clone(), state.clone(), state.clone()); + let (close_keys, close_enter, close_scrim) = (close.clone(), close.clone(), close); let focus = takeover.read(cx).focus.clone(); let panel = div() .id("window-switcher") @@ -117,16 +118,21 @@ impl RenderOnce for WindowSwitcher { match event.keystroke.key.as_str() { "down" => step(&keys, 1, count, cx), "up" => step(&keys, -1, count, cx), - "enter" => { - if let Some(handle) = handles.get(keys.read(cx).selected) { - switch_to(*handle, &close_keys, window, cx); - } - } "escape" => close_keys(window, cx), + "enter" if !event.keystroke.modifiers.modified() => {} _ => return, } cx.stop_propagation(); }) + .on_key_up(move |event, window, cx| { + let stroke = &event.keystroke; + if stroke.key == "enter" && !stroke.modifiers.modified() { + cx.stop_propagation(); + if let Some(handle) = handles.get(chosen.read(cx).selected) { + switch_to(*handle, &close_enter, window, cx); + } + } + }) .children(rows) .when(count == 0, |panel| { panel.child( diff --git a/src/shell/tests.rs b/src/shell/tests.rs new file mode 100644 index 0000000..215d0d2 --- /dev/null +++ b/src/shell/tests.rs @@ -0,0 +1,90 @@ +use gpui::{ + AppContext as _, Context, FocusHandle, InteractiveElement, IntoElement, KeyUpEvent, Keystroke, + ParentElement, Render, TestAppContext, VisualTestContext, Window, div, prelude::*, px, size, +}; + +use super::{WindowManager, WindowSwitcher}; +use crate::{buttons::Button, primitives::FocusScope, theme::Theme}; + +struct Blank; + +impl Render for Blank { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + } +} + +struct Desk { + root: FocusHandle, + open: bool, + enters_above: usize, +} + +impl Render for Desk { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let (opener, closer, above) = (cx.entity(), cx.entity(), cx.entity()); + let scope = FocusScope::new(&self.root) + .size_full() + .child(Button::new("opener", "Windows").on_click(move |_, _, cx| { + opener.update(cx, |desk, cx| { + desk.open = true; + cx.notify(); + }) + })) + .when(self.open, |desk| { + desk.child(WindowSwitcher::new("switcher", move |_, cx| { + closer.update(cx, |desk, cx| { + desk.open = false; + cx.notify(); + }) + })) + }); + div() + .size_full() + .on_key_down(move |event, _, cx| { + if event.keystroke.key == "enter" { + above.update(cx, |desk, _| desk.enters_above += 1); + } + }) + .child(scope) + } +} + +fn settle(cx: &mut VisualTestContext) { + cx.run_until_parked(); + cx.update(|window, _| window.refresh()); + cx.run_until_parked(); +} + +fn press(key: &str, cx: &mut VisualTestContext) { + cx.simulate_keystrokes(key); + settle(cx); + cx.simulate_event(KeyUpEvent { + keystroke: Keystroke::parse(key).expect("a key"), + }); + settle(cx); +} + +#[gpui::test] +fn the_switcher_switches_on_release_so_its_opener_stays_shut(cx: &mut TestAppContext) { + cx.update(Theme::init); + let (desk, cx) = cx.add_window_view(|_, cx| Desk { + root: cx.focus_handle(), + open: false, + enters_above: 0, + }); + cx.update(|_, cx| { + WindowManager::open("Notes", size(px(320.0), px(240.0)), cx, |_, cx| { + cx.new(|_| Blank) + }) + .expect("a managed window"); + }); + settle(cx); + cx.update(|window, _| window.focus_next()); + press("enter", cx); + assert!(desk.read_with(cx, |desk, _| desk.open)); + let above = desk.read_with(cx, |desk, _| desk.enters_above); + press("enter", cx); + assert!(!desk.read_with(cx, |desk, _| desk.open)); + assert_eq!(desk.read_with(cx, |desk, _| desk.enters_above), above); +} diff --git a/src/theme/tokens.rs b/src/theme/tokens.rs index ab4484e..b8e212f 100644 --- a/src/theme/tokens.rs +++ b/src/theme/tokens.rs @@ -228,6 +228,11 @@ impl Theme { px_to_rems(480.0) } + /// Narrowest a menu's panel gets. + pub fn menu_width(&self) -> Rems { + px_to_rems(200.0) + } + /// A palette's card; its list scrolls inside. pub fn palette_size(&self) -> Size { size(px_to_rems(600.0), px_to_rems(400.0)) diff --git a/tasks/ch01-10.md b/tasks/ch01-10.md index 87ae64f..c7c297f 100644 --- a/tasks/ch01-10.md +++ b/tasks/ch01-10.md @@ -146,8 +146,8 @@ - [x] Button (T00) - [x] IconButton (T00) - [x] ButtonGroup -- [x] SplitButton → lands with Menus (T08): its dropdown is a Menu -- [x] DropdownButton / MenuButton → `menus::DropdownMenu` (T08) +- [x] SplitButton → `menus::SplitButton` (T08a): its dropdown is a Menu +- [x] DropdownButton / MenuButton → `menus::DropdownMenu` (T08a) - [x] ToggleButton - [x] ToggleGroup - [x] SegmentedControl @@ -307,18 +307,21 @@ - [x] SpotlightSearch → `navigation::SearchPalette`; T04 QuickLauncher opens it always on top - [x] Tour / Onboarding → lands with Overlays (T09): its steps are Coachmarks -## T08 Menus — `src/menus` +## T08a Menus · Menu & hosts — `src/menus` + +- [x] Menu / MenuItem / MenuGroup / MenuSeparator → `menus::Menu` (`group`, `separator`), `menus::MenuItem` +- [x] SubMenu → `menus::MenuItem::submenu` +- [x] CheckboxMenuItem → `menus::MenuItem::check` +- [x] RadioMenuItem → `menus::MenuItem::radio` +- [x] MenuItemShortcut → `menus::MenuItem::keys` +- [x] MenuItemIcon → `menus::MenuItem::icon` +- [x] ContextMenu → `menus::ContextMenu` +- [x] DropdownMenu → `menus::DropdownMenu`; also T05 DropdownButton / MenuButton, and SplitButton as `menus::SplitButton` +- [x] OverflowMenu → `menus::OverflowMenu` + +## T08b Menus · Bar, search & pie -- [ ] Menu / MenuItem / MenuGroup / MenuSeparator -- [ ] SubMenu -- [ ] CheckboxMenuItem -- [ ] RadioMenuItem -- [ ] MenuItemShortcut -- [ ] MenuItemIcon -- [ ] ContextMenu -- [ ] DropdownMenu — also T05 DropdownButton / MenuButton and SplitButton - [ ] MenuBar Menu — `menus::MenuBar`, also T04 MenuBar: the native menus, drawn in the window -- [ ] OverflowMenu - [ ] MegaMenu - [ ] SearchableMenu - [ ] PieMenu / RadialMenu