From d6b982016d903ad1928b5f1dc54498512951997f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 25 Sep 2026 13:43:12 -0400 Subject: [PATCH] T08b menus: menu bar over the native menus, searchable menu, pie menu; megamenu points to the navigation menu; menus follow their hosts --- AGENTS.md | 4 +- TASKS.md | 5 +- examples/gallery/main.rs | 11 +- examples/gallery/pages/menus/mod.rs | 81 +++++ examples/gallery/pages/menus/more.rs | 111 ++++++ .../gallery/pages/{menus.rs => menus/rows.rs} | 67 +--- src/menus/bar.rs | 206 +++++++++++ src/menus/draw.rs | 166 +++++++++ src/menus/hosts.rs | 105 +++++- src/menus/menu.rs | 278 ++++++--------- src/menus/mod.rs | 7 +- src/menus/model.rs | 34 +- src/menus/pie.rs | 319 ++++++++++++++++++ src/menus/{tests.rs => tests/mod.rs} | 124 ++++++- src/menus/tests/more.rs | 243 +++++++++++++ src/navigation/mod.rs | 1 + src/theme/tokens.rs | 5 + tasks/ch01-10.md | 10 +- 18 files changed, 1516 insertions(+), 261 deletions(-) create mode 100644 examples/gallery/pages/menus/mod.rs create mode 100644 examples/gallery/pages/menus/more.rs rename examples/gallery/pages/{menus.rs => menus/rows.rs} (84%) create mode 100644 src/menus/bar.rs create mode 100644 src/menus/draw.rs create mode 100644 src/menus/pie.rs rename src/menus/{tests.rs => tests/mod.rs} (68%) create mode 100644 src/menus/tests/more.rs diff --git a/AGENTS.md b/AGENTS.md index 9093039..7ba0701 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,7 +58,9 @@ 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. +- A menu goes through `menus::menu`: rows, submenus, keys, focus and presses outside live there, for DropdownMenu, OverflowMenu, SplitButton, ContextMenu, SearchableMenu and MenuBar. A press on the host's own box is the host's to handle, so a trigger toggles and a right click moves a context menu without a close and reopen in one event. +- gpui focuses the nearest focusable ancestor on mouse down, and apps wrap everything in a `FocusScope`. A press that must leave focus alone calls `prevent_default`, as buttons and context regions do. +- gpui draws a window's first frame inside `open_window`. State the first frame reads, such as `cx.set_menus`, is set before it opens. - 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`). diff --git a/TASKS.md b/TASKS.md index 339832d..d824b79 100644 --- a/TASKS.md +++ b/TASKS.md @@ -33,7 +33,7 @@ Per-component lines live in `tasks/`. Tags there: - [x] T07b Navigation · Places - [x] T07c Navigation · Palettes & tour - [x] T08a Menus · Menu & hosts -- [ ] T08b Menus · Bar, search & pie +- [x] T08b Menus · Bar, search & pie - [ ] T09 Overlays - [ ] T10 Feedback - [ ] T11a Loading — `tasks/ch11-20.md` @@ -149,3 +149,6 @@ Per-component lines live in `tasks/`. Tags there: | 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 | | +| T08b | 1 | FAIL | a shrinking pie kept a mark past its end, a hub press lost focus, pie confirm keys bubbled and modified releases picked, an open dropdown stayed put when its host moved, clearing the filter kept the old mark, and the ring sat half a slice off its hub | +| T08b | 2 | FAIL | the pie's opening right click let a root FocusScope take focus before take_focus recorded it | +| T08b | 3 | PASS | | diff --git a/examples/gallery/main.rs b/examples/gallery/main.rs index 922fda7..3f23e7a 100644 --- a/examples/gallery/main.rs +++ b/examples/gallery/main.rs @@ -16,8 +16,8 @@ use gpui::{ actions!(gallery, [Quit, About, LightMode, DarkMode, NewWindow]); -/// The gallery's menu bar and Dock menu: NativeMenu and JumpList at work. -fn menus(window: WindowHandle, cx: &mut App) { +/// The gallery's menu bar and Dock menu: NativeMenu and JumpList at work. Set before the window opens, whose first frame may draw them. +fn menus(cx: &mut App) { cx.set_menus(vec![ Menu { name: "Ely".into(), @@ -36,6 +36,10 @@ fn menus(window: WindowHandle, cx: &mut App) { }, ]); cx.set_dock_menu(vec![MenuItem::action("New Window", NewWindow)]); +} + +/// What the menus' actions do; the theme ones need the window. +fn menu_actions(window: WindowHandle, cx: &mut App) { cx.on_action(|_: &About, cx| pages::open_about(cx)); cx.on_action(|_: &NewWindow, cx| pages::open_managed(cx)); cx.on_action(move |_: &LightMode, cx| choose(window, shell::Choice::Light, cx)); @@ -84,6 +88,7 @@ fn main() -> Result<()> { ely_gpui_component::init(cx); cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]); cx.on_action(|_: &Quit, cx| cx.quit()); + menus(cx); let bounds = Bounds::centered(None, size(px(1280.0), px(820.0)), cx); let options = WindowOptions { window_bounds: Some(WindowBounds::Windowed(bounds)), @@ -100,7 +105,7 @@ fn main() -> Result<()> { cx.new(|cx| shell::Gallery::new(args.page.unwrap_or(0), window, cx)) }) .expect("gallery window failed to open"); - menus(window, cx); + menu_actions(window, cx); cx.activate(true); if let Some(dir) = args.capture { capture::run(window, dir, args.page, cx); diff --git a/examples/gallery/pages/menus/mod.rs b/examples/gallery/pages/menus/mod.rs new file mode 100644 index 0000000..42d0bb3 --- /dev/null +++ b/examples/gallery/pages/menus/mod.rs @@ -0,0 +1,81 @@ +mod more; +mod rows; + +use gpui::{AnyElement, App, IntoElement, ParentElement, Window, div}; + +use super::Page; +use crate::script::Step; + +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::DownAt("menubar", 16.0, 12.0), + Step::UpAt("menubar", 16.0, 12.0), + Step::Wait(400), + Step::Shot("menubar"), + Step::Key("right"), + Step::Wait(300), + Step::Shot("menubar-view"), + Step::Key("escape"), + Step::Click("move"), + Step::Wait(400), + Step::Type("re"), + Step::Wait(300), + Step::Shot("searchable"), + Step::Key("escape"), + Step::RightAt("pie-area", 210.0, 130.0), + Step::Wait(600), + Step::Key("right"), + Step::Key("right"), + Step::Wait(300), + Step::Shot("pie"), + Step::Key("escape"), + Step::Rest, +]; + +fn render(window: &mut Window, cx: &mut App) -> AnyElement { + div() + .child(rows::file_menu(window, cx)) + .child(rows::view_menu(window, cx)) + .child(rows::hosts(window, cx)) + .child(rows::context(window, cx)) + .child(more::menu_bar(cx)) + .child(more::mega_menu(cx)) + .child(more::searchable(window, cx)) + .child(more::pie(window, cx)) + .into_any_element() +} diff --git a/examples/gallery/pages/menus/more.rs b/examples/gallery/pages/menus/more.rs new file mode 100644 index 0000000..8c24f95 --- /dev/null +++ b/examples/gallery/pages/menus/more.rs @@ -0,0 +1,111 @@ +use ely_gpui_component::{ + menus::{Menu, MenuBar, MenuItem, PieItem, PieMenu, SearchableMenu}, + primitives::IconName, + theme::{ActiveTheme, Radius}, + typography::Caption, +}; +use gpui::{App, IntoElement, ParentElement, Styled, Window, div, px}; + +use super::rows::{ran, run}; +use crate::{ + probe::probe, + ui::{section, specimen}, +}; + +pub fn menu_bar(cx: &mut App) -> impl IntoElement + use<> { + let menus = cx.get_menus().expect("the gallery sets its menus in main"); + section( + "MenuBar Menu", + "The app's own menus, the ones cx.set_menus gave the system, drawn in the window. Picking a row dispatches its action.", + cx, + ) + .child(specimen( + "hover moves between menus while one is open", + probe("menubar", MenuBar::new("app-bar", menus)), + cx, + )) +} + +pub fn mega_menu(cx: &App) -> impl IntoElement + use<> { + section( + "MegaMenu", + "A menu of links wide enough for a line of detail each.", + cx, + ) + .child(Caption::new( + "It is navigation::NavigationMenu: each entry opens a panel of links. See Navigation, chapter 7.", + )) +} + +const PLACES: [(&str, IconName); 8] = [ + ("Inbox", IconName::Inbox), + ("Roadmap", IconName::Map), + ("Design", IconName::PenTool), + ("Engineering", IconName::Code), + ("Research", IconName::BookOpen), + ("Marketing", IconName::Globe), + ("Archive", IconName::Archive), + ("Trash", IconName::Trash2), +]; + +pub fn searchable(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let (last, caption) = ran("move-ran", window, cx); + let menu = PLACES.into_iter().fold(Menu::new(), |menu, (name, icon)| { + menu.item(MenuItem::new(name).icon(icon).on_click(run(&last, name))) + }); + section( + "SearchableMenu", + "A filter field heads the menu. Typing narrows the rows, best first, and marks the letters that matched.", + cx, + ) + .child(specimen( + caption, + probe( + "move", + SearchableMenu::new("move", "Move to", menu) + .icon(IconName::FolderOpen) + .placeholder("Find a place"), + ), + cx, + )) +} + +pub fn pie(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let (last, caption) = ran("pie-ran", window, cx); + let items = [ + (IconName::Copy, "Copy"), + (IconName::Scissors, "Cut"), + (IconName::Clipboard, "Paste"), + (IconName::Share2, "Share"), + (IconName::Pin, "Pin"), + (IconName::Trash2, "Delete"), + ] + .map(|(icon, name)| PieItem::new(icon, name).on_click(run(&last, name))); + let theme = cx.theme(); + section( + "PieMenu / RadialMenu", + "A right click opens a ring at the pointer. The pointer's direction marks a slice and a click runs it; arrows turn the ring, the hub cancels.", + cx, + ) + .child(specimen( + caption, + probe( + "pie-area", + PieMenu::new("pie", items).child( + div() + .flex() + .items_center() + .justify_center() + .w(px(420.0)) + .h(px(260.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 for the ring"), + ), + ), + cx, + )) +} diff --git a/examples/gallery/pages/menus.rs b/examples/gallery/pages/menus/rows.rs similarity index 84% rename from examples/gallery/pages/menus.rs rename to examples/gallery/pages/menus/rows.rs index 1d92902..567e271 100644 --- a/examples/gallery/pages/menus.rs +++ b/examples/gallery/pages/menus/rows.rs @@ -4,59 +4,15 @@ use ely_gpui_component::{ primitives::IconName, theme::{ActiveTheme, Radius}, }; -use gpui::{ - AnyElement, App, Entity, IntoElement, ParentElement, SharedString, Styled, Window, div, px, -}; +use gpui::{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( +pub(super) fn ran( key: &'static str, window: &mut Window, cx: &mut App, @@ -69,7 +25,7 @@ fn ran( (last, caption) } -fn run( +pub(super) fn run( last: &Entity>, name: &'static str, ) -> impl Fn(&mut Window, &mut App) + 'static { @@ -77,7 +33,7 @@ fn run( move |_, cx| set(&last, Some(name.into()), cx) } -fn file_menu(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { +pub 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( @@ -142,7 +98,7 @@ fn file_menu(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { )) } -fn view_menu(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { +pub 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)); @@ -192,7 +148,7 @@ fn view_menu(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { )) } -fn hosts(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { +pub 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"))) @@ -252,7 +208,7 @@ fn hosts(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { )) } -fn context(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { +pub fn context(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { let (last, caption) = ran("context-ran", window, cx); let menu = Menu::new() .item( @@ -311,12 +267,3 @@ fn context(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { 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/src/menus/bar.rs b/src/menus/bar.rs new file mode 100644 index 0000000..08b1b79 --- /dev/null +++ b/src/menus/bar.rs @@ -0,0 +1,206 @@ +use std::rc::Rc; + +use gpui::{ + Action, App, Bounds, ElementId, Entity, InteractiveElement, IntoElement, MouseButton, + OwnedMenu, OwnedMenuItem, ParentElement, Pixels, RenderOnce, StatefulInteractiveElement, + Styled, Window, canvas, div, prelude::*, +}; + +use super::{ + menu::{Open, Spot, hang, measure_host}, + model::{Menu, MenuItem}, +}; +use crate::{ + primitives::tab_stop, + theme::{ActiveTheme, ControlSize, Radius, TextSize}, +}; + +/// The title under the keyboard and where each title sits. +#[derive(Default)] +struct Titles { + at: usize, + boxes: Vec>, +} + +/// gpui's menu model as rows: actions run through dispatch and show the keys bound to them. +fn rows(items: &[OwnedMenuItem], window: &Window) -> Menu { + items.iter().fold(Menu::new(), |menu, item| match item { + OwnedMenuItem::Separator => menu.separator(), + OwnedMenuItem::Submenu(sub) => menu.item(MenuItem::submenu( + sub.name.clone(), + rows(&sub.items, window), + )), + OwnedMenuItem::SystemMenu(os) => menu.item(MenuItem::new(os.name.clone()).disabled(true)), + OwnedMenuItem::Action { name, action, .. } => { + let keys = window + .highest_precedence_binding_for_action(action.as_ref()) + .map(|binding| { + let strokes: Vec = binding + .keystrokes() + .iter() + .map(|stroke| stroke.unparse()) + .collect(); + strokes.join(" ") + }); + let action: Rc> = Rc::new(action.boxed_clone()); + let row = MenuItem::new(name.clone()) + .on_click(move |window, cx| window.dispatch_action(action.boxed_clone(), cx)); + menu.item(match keys { + Some(keys) => row.keys(keys), + None => row, + }) + } + }) +} + +/// Opens title `at` under its box, its first row marked when `marked`. +fn open_title( + state: &Entity, + titles: &Entity, + menus: &[Menu], + at: usize, + marked: bool, + cx: &mut App, +) { + let anchor = titles.read(cx).boxes[at]; + titles.update(cx, |titles, cx| { + titles.at = at; + cx.notify(); + }); + Open::set_anchor(state, anchor, cx); + Open::show(state, &menus[at], Spot::Under, marked, cx); +} + +/// The app's menus drawn in the window, for platforms and layouts without a native bar. +#[derive(IntoElement)] +pub struct MenuBar { + id: ElementId, + menus: Vec, +} + +impl MenuBar { + /// Pass `cx.get_menus()`: the menus `cx.set_menus` set. + pub fn new(id: impl Into, menus: Vec) -> Self { + assert!(!menus.is_empty(), "a menu bar needs menus"); + Self { + id: id.into(), + menus, + } + } +} + +impl RenderOnce for MenuBar { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let count = self.menus.len(); + let focus = tab_stop((self.id.clone(), "focus").into(), true, window, cx); + let bar_focused = focus.is_focused(window); + let state = window.use_keyed_state((self.id.clone(), "menu"), cx, |_, _| Open::default()); + let titles = + window.use_keyed_state((self.id.clone(), "titles"), cx, |_, _| Titles::default()); + if titles.read(cx).boxes.len() != count { + titles.update(cx, |titles, _| { + titles.boxes = vec![Bounds::default(); count] + }); + } + let menus: Rc> = Rc::new( + self.menus + .iter() + .map(|menu| rows(&menu.items, window)) + .collect(), + ); + let open = state.read(cx).is_open(); + let at = titles.read(cx).at.min(count - 1); + let theme = cx.theme(); + let colors = &theme.colors; + let heads = self.menus.iter().enumerate().map(|(ix, menu)| { + let (press, hover, measure, follow) = + (state.clone(), state.clone(), titles.clone(), state.clone()); + let (press_titles, hover_titles) = (titles.clone(), titles.clone()); + let (press_menus, hover_menus) = (menus.clone(), menus.clone()); + let here = ix == at; + div() + .id(("title", ix)) + .relative() + .flex() + .items_center() + .h(theme.control_height(ControlSize::Sm)) + .px_2() + .rounded(theme.radius(Radius::Md)) + .border_1() + .border_color(if bar_focused && here && !open { + colors.focus + } else { + gpui::transparent_black() + }) + .text_size(theme.text_size(TextSize::Sm)) + .when(open && here, |head| head.bg(colors.hover)) + .hover(|style| style.bg(colors.hover)) + .on_mouse_down(MouseButton::Left, move |_, window, cx| { + window.prevent_default(); + if open && here { + Open::close(&press, window, cx); + } else { + open_title(&press, &press_titles, &press_menus, ix, false, cx); + } + }) + .on_hover(move |hovered, _, cx| { + if *hovered && hover.read(cx).is_open() && hover_titles.read(cx).at != ix { + open_title(&hover, &hover_titles, &hover_menus, ix, false, cx); + } + }) + .child(menu.name.clone()) + .child( + canvas( + move |bounds, _, cx| { + if measure.read(cx).boxes.get(ix) != Some(&bounds) { + measure.update(cx, |titles, _| titles.boxes[ix] = bounds); + } + if here { + Open::set_anchor(&follow, bounds, cx); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .top_0() + .left_0() + .size_full(), + ) + }); + let (keys, key_titles, key_menus) = (state.clone(), titles.clone(), menus.clone()); + div() + .id(self.id.clone()) + .track_focus(&focus) + .flex() + .items_center() + .gap_0p5() + .on_key_down(move |event, _, cx| { + let key = event.keystroke.key.as_str(); + let open = keys.read(cx).is_open(); + let at = key_titles.read(cx).at.min(count - 1); + let next = |by: isize| (at as isize + by).rem_euclid(count as isize) as usize; + match key { + "left" | "right" => { + let to = next(if key == "right" { 1 } else { -1 }); + if open { + open_title(&keys, &key_titles, &key_menus, to, true, cx); + } else { + key_titles.update(cx, |titles, cx| { + titles.at = to; + cx.notify(); + }); + } + } + "down" | "enter" | "space" if !open => { + open_title(&keys, &key_titles, &key_menus, at, key == "down", cx) + } + _ => return, + } + cx.stop_propagation(); + }) + .relative() + .children(heads) + .child(measure_host(state.clone(), false)) + .children(hang(&self.id, &menus[at], &state, None, window, cx)) + } +} diff --git a/src/menus/draw.rs b/src/menus/draw.rs new file mode 100644 index 0000000..479114a --- /dev/null +++ b/src/menus/draw.rs @@ -0,0 +1,166 @@ +use gpui::{ + AnyElement, App, Div, ElementId, Entity, FontWeight, InteractiveElement, IntoElement, + MouseButton, ParentElement, Stateful, Styled, div, prelude::*, +}; + +use super::{ + menu::{Open, at_depth, choose, mark, measure}, + model::{Entry, Kind, Menu, MenuItem}, +}; +use crate::{ + forms::{Run, surface}, + navigation::marked, + primitives::{Icon, IconName}, + theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize}, + typography::KbdCombo, +}; + +fn row(item: &MenuItem, ix: usize, current: 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(current && !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(if item.hits.is_empty() { + item.label.clone().into_any_element() + } else { + marked(item.label.clone(), item.hits.clone(), cx) + }), + ) + .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), + ) + }) +} + +pub(super) fn panel( + id: &ElementId, + menu: &Menu, + state: &Entity, + depth: usize, + close: &Run, + head: Option, + 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(head) + .children(rows) + .child(measure(state.clone(), move |open, bounds| { + let level = &mut open.levels[depth]; + let changed = level.panel != bounds; + level.panel = bounds; + changed + })) +} diff --git a/src/menus/hosts.rs b/src/menus/hosts.rs index bd070af..86749b7 100644 --- a/src/menus/hosts.rs +++ b/src/menus/hosts.rs @@ -13,6 +13,7 @@ use super::{ }; use crate::{ buttons::{Button, ButtonGroup, ButtonVariant, IconButton}, + forms::TextInput, primitives::IconName, }; @@ -40,6 +41,7 @@ fn host( trigger: impl IntoElement, menu: &Menu, state: &Entity, + field: Option<&Entity>, window: &mut Window, cx: &mut App, ) -> Stateful
{ @@ -48,8 +50,8 @@ fn host( .relative() .flex_none() .child(trigger) - .child(measure_host(state.clone())) - .children(hang(&id, menu, state, window, cx)) + .child(measure_host(state.clone(), true)) + .children(hang(&id, menu, state, field, window, cx)) } /// A button that opens a menu under it. @@ -93,7 +95,7 @@ impl RenderOnce for DropdownMenu { .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) + host(self.id, button, &self.menu, &state, None, window, cx) } } @@ -121,7 +123,7 @@ impl RenderOnce for OverflowMenu { .variant(ButtonVariant::Ghost) .tooltip("More") .on_click(move |event, window, cx| click(event, window, cx)); - host(self.id, button, &self.menu, &state, window, cx) + host(self.id, button, &self.menu, &state, None, window, cx) } } @@ -165,10 +167,16 @@ 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 (shut, run) = (state.clone(), self.on_click); 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)) + .on_click(move |event, window, cx| { + if shut.read(cx).is_open() { + Open::close(&shut, window, cx); + } + if let Some(run) = &run { + run(event, window, cx); + } }); let more = Button::new((self.id.clone(), "more"), "") .variant(self.variant) @@ -179,6 +187,7 @@ impl RenderOnce for SplitButton { ButtonGroup::new().button(main).button(more), &self.menu, &state, + None, window, cx, ) @@ -212,14 +221,92 @@ impl ParentElement for ContextMenu { 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()); + let (open, shut, menu) = (state.clone(), state.clone(), self.menu.clone()); div() .id(self.id.clone()) - .on_mouse_down(MouseButton::Right, move |event, _, cx| { + .relative() + .on_mouse_down(MouseButton::Right, move |event, window, cx| { + window.prevent_default(); log::info!("context menu: at {:?}", event.position); Open::show(&open, &menu, Spot::At(event.position), false, cx); }) + .on_mouse_down(MouseButton::Left, move |_, window, cx| { + if shut.read(cx).is_open() { + window.prevent_default(); + Open::close(&shut, window, cx); + } + }) .children(self.children) - .children(hang(&self.id, &self.menu, &state, window, cx)) + .child(measure_host(state.clone(), false)) + .children(hang(&self.id, &self.menu, &state, None, window, cx)) + } +} + +/// A button that opens a menu headed by a filter field. Typing narrows the rows, best first, and marks the letters that matched. +#[derive(IntoElement)] +pub struct SearchableMenu { + id: ElementId, + label: SharedString, + icon: Option, + placeholder: SharedString, + menu: Menu, +} + +impl SearchableMenu { + pub fn new(id: impl Into, label: impl Into, menu: Menu) -> Self { + Self { + id: id.into(), + label: label.into(), + icon: None, + placeholder: "Filter".into(), + menu, + } + } + + pub fn icon(mut self, icon: IconName) -> Self { + self.icon = Some(icon); + self + } + + pub fn placeholder(mut self, text: impl Into) -> Self { + self.placeholder = text.into(); + self + } +} + +impl RenderOnce for SearchableMenu { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let state = state(&self.id, window, cx); + let field = state.read(cx).is_open().then(|| { + let placeholder = self.placeholder.clone(); + window.use_keyed_state((self.id.clone(), "filter"), cx, move |window, cx| { + TextInput::new(window, cx).placeholder(placeholder) + }) + }); + let query = field + .as_ref() + .map(|field| field.read(cx).text().trim().to_string()); + let shown = match &query { + Some(query) => self.menu.filtered(query), + None => self.menu.clone(), + }; + let last = window.use_keyed_state((self.id.clone(), "query"), cx, |_, _| None::); + if *last.read(cx) != query { + last.update(cx, |last, _| *last = query.clone()); + if query.is_some() { + log::info!( + "searchable menu {:?}: {} rows fit", + self.id, + shown.entries.len() + ); + Open::restart(&state, &shown, cx); + } + } + let click = toggle(&state, &shown, cx); + let button = Button::new((self.id.clone(), "button"), self.label) + .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, &shown, &state, field.as_ref(), window, cx) } } diff --git a/src/menus/menu.rs b/src/menus/menu.rs index 48402f8..196189f 100644 --- a/src/menus/menu.rs +++ b/src/menus/menu.rs @@ -1,22 +1,24 @@ 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::*, + Animation, AnimationExt, AnyElement, App, Bounds, ElementId, Entity, InteractiveElement, + IntoElement, ParentElement, Pixels, Point, Styled, Window, anchored, canvas, deferred, div, + prelude::*, }; -use super::model::{Entry, Kind, Menu, MenuItem}; +use super::{ + draw, + model::{Kind, Menu}, +}; use crate::{ - forms::{Run, float, surface}, + forms::{Down, Enter, Run, TextInput, Up, float}, motion, primitives::{Icon, IconName, Takeover, give_back, take_focus}, - theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize}, - typography::KbdCombo, + theme::{ActiveTheme, IconSize}, }; /// 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> { +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 @@ -49,24 +51,25 @@ fn settle(menu: &Menu, levels: &mut Vec) { /// 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, +pub(super) struct Level { + pub(super) at: Option, + pub(super) row: Bounds, + pub(super) panel: Bounds, } -/// Where an open menu hangs: under its host's box, or at a point. +/// 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, and the focus to hand back. +/// 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, - levels: Vec, + pub(super) levels: Vec, + host: Bounds, anchor: Bounds, takeover: Option>, } @@ -76,6 +79,16 @@ impl Open { 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); @@ -89,6 +102,18 @@ impl Open { }); } + /// 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![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| { @@ -104,7 +129,14 @@ impl Open { } /// 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) { +pub(super) 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; @@ -123,7 +155,7 @@ fn mark(state: &Entity, menu: &Menu, depth: usize, at: usize, open_sub: bo } /// Runs a row. A submenu opens with its first row marked; anything else closes the menu first. -fn choose( +pub(super) fn choose( state: &Entity, menu: &Menu, depth: usize, @@ -203,7 +235,7 @@ fn keys( } /// Records a box into the host's state: the host's own, a marked row's or a panel's. -fn measure( +pub(super) fn measure( state: Entity, write: impl Fn(&mut Open, Bounds) -> bool + 'static, ) -> impl IntoElement { @@ -221,164 +253,24 @@ fn measure( .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; +/// 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 }) } -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, + field: Option<&Entity>, window: &mut Window, cx: &mut App, ) -> Option { @@ -395,11 +287,17 @@ pub(crate) fn hang( Open::close(&state, window, cx); }) }; - if !focus.is_focused(window) { + 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); + } let mut levels = state.read(cx).levels.clone(); settle(menu, &mut levels); if levels != state.read(cx).levels { @@ -412,13 +310,47 @@ pub(crate) fn hang( 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) + 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 !stroke.modifiers.modified() { + if confirm(key) && !stroke.modifiers.modified() { cx.stop_propagation(); } return; @@ -430,7 +362,7 @@ pub(crate) fn hang( .on_key_up(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() && keys(&lift_menu, &lifted, key, &lift_close, window, cx) { @@ -439,7 +371,7 @@ pub(crate) fn hang( }) .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); + let on_host = open.host.contains(&event.position); if !on_host && !open .levels @@ -462,7 +394,7 @@ pub(crate) fn hang( (row != Bounds::default()).then(|| { deferred( anchored().position(row.top_right()).snap_to_window().child( - panel(id, menu, state, level, &close, cx) + draw::panel(id, menu, state, level, &close, None, cx) .mt_neg_1() .with_animation( (id.clone(), format!("sub-{level}")), diff --git a/src/menus/mod.rs b/src/menus/mod.rs index e5e4839..fafccc7 100644 --- a/src/menus/mod.rs +++ b/src/menus/mod.rs @@ -1,8 +1,13 @@ +mod bar; +mod draw; mod hosts; mod menu; mod model; +mod pie; #[cfg(all(test, feature = "test-support"))] mod tests; -pub use hosts::{ContextMenu, DropdownMenu, OverflowMenu, SplitButton}; +pub use bar::MenuBar; +pub use hosts::{ContextMenu, DropdownMenu, OverflowMenu, SearchableMenu, SplitButton}; pub use model::{Menu, MenuItem}; +pub use pie::{PieItem, PieMenu}; diff --git a/src/menus/model.rs b/src/menus/model.rs index 7bb3b4a..f658885 100644 --- a/src/menus/model.rs +++ b/src/menus/model.rs @@ -1,8 +1,8 @@ -use std::rc::Rc; +use std::{ops::Range, rc::Rc}; use gpui::{App, SharedString, Window}; -use crate::{forms::Run, primitives::IconName}; +use crate::{forms::Run, navigation::fuzzy, primitives::IconName}; /// What a row does when chosen. #[derive(Clone)] @@ -22,6 +22,7 @@ pub struct MenuItem { pub(super) kind: Kind, pub(super) disabled: bool, pub(super) on_click: Option, + pub(super) hits: Vec>, } impl MenuItem { @@ -33,6 +34,7 @@ impl MenuItem { kind, disabled: false, on_click: None, + hits: Vec::new(), } } @@ -138,4 +140,32 @@ impl Menu { } None } + + /// The rows whose labels fit `query`, best first, with the letters that matched. All of it for an empty query. + pub(super) fn filtered(&self, query: &str) -> Menu { + if query.is_empty() { + return self.clone(); + } + let mut found: Vec<(i32, MenuItem)> = self + .entries + .iter() + .filter_map(|entry| match entry { + Entry::Item(item) => fuzzy(query, &item.label).map(|fit| { + let item = MenuItem { + hits: fit.hits, + ..item.clone() + }; + (fit.score, item) + }), + _ => None, + }) + .collect(); + found.sort_by(|(a, _), (b, _)| b.cmp(a)); + Menu { + entries: found + .into_iter() + .map(|(_, item)| Entry::Item(item)) + .collect(), + } + } } diff --git a/src/menus/pie.rs b/src/menus/pie.rs new file mode 100644 index 0000000..6b06032 --- /dev/null +++ b/src/menus/pie.rs @@ -0,0 +1,319 @@ +use std::{f32::consts::TAU, rc::Rc}; + +use gpui::{ + Animation, AnimationExt, AnyElement, App, Bounds, ElementId, Entity, FontWeight, + InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, Point, RenderOnce, + SharedString, Styled, Window, anchored, canvas, deferred, div, point, +}; +use smallvec::SmallVec; + +use crate::{ + forms::{Pick, Run}, + motion, + primitives::{Icon, IconName, give_back, take_focus}, + theme::{ActiveTheme, Elevation, IconSize, TextSize}, +}; + +/// One slice of a pie menu. +#[derive(Clone)] +pub struct PieItem { + icon: IconName, + label: SharedString, + on_click: Option, +} + +impl PieItem { + pub fn new(icon: IconName, label: impl Into) -> Self { + Self { + icon, + label: label.into(), + on_click: None, + } + } + + pub fn on_click(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_click = Some(Rc::new(handler)); + self + } +} + +/// Where the ring opened, where its center landed, and the slice the pointer points at. +#[derive(Default)] +struct Pie { + at: Option>, + center: Point, + marked: Option, +} + +/// The slice at `pointer`: item 0 sits at the top and the rest follow clockwise; the hub picks none. +fn slice( + center: Point, + pointer: Point, + hub: Pixels, + count: usize, +) -> Option { + let (dx, dy) = ( + f32::from(pointer.x - center.x), + f32::from(pointer.y - center.y), + ); + if dx.hypot(dy) < f32::from(hub) / 2.0 { + return None; + } + let wedge = TAU / count as f32; + let turn = (dy.atan2(dx) + TAU / 4.0 + wedge / 2.0).rem_euclid(TAU); + Some((turn / wedge) as usize % count) +} + +/// Its children, and a ring of choices that opens at the pointer on a right click. The pointer's direction marks one; a click runs it. +#[derive(IntoElement)] +pub struct PieMenu { + id: ElementId, + items: Vec, + children: SmallVec<[AnyElement; 2]>, +} + +impl PieMenu { + pub fn new(id: impl Into, items: impl IntoIterator) -> Self { + let items: Vec = items.into_iter().collect(); + assert!(items.len() >= 2, "a pie menu needs two slices or more"); + Self { + id: id.into(), + items, + children: SmallVec::new(), + } + } +} + +impl ParentElement for PieMenu { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +fn show(state: &Entity, at: Option>, cx: &mut App) { + state.update(cx, |pie, cx| { + *pie = Pie { + at, + ..Pie::default() + }; + cx.notify(); + }); +} + +impl RenderOnce for PieMenu { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let state = window.use_keyed_state((self.id.clone(), "pie"), cx, |_, _| Pie::default()); + let opener = state.clone(); + let region = div() + .id(self.id.clone()) + .on_mouse_down(MouseButton::Right, move |event, window, cx| { + window.prevent_default(); + log::info!("pie menu: at {:?}", event.position); + show(&opener, Some(event.position), cx); + }) + .children(self.children); + let Some(at) = state.read(cx).at else { + return region; + }; + let takeover = take_focus((self.id.clone(), "takeover"), window, cx); + let focus = takeover.read(cx).focus.clone(); + let close: Run = { + let (state, id) = (state.clone(), self.id.clone()); + Rc::new(move |window, cx| { + log::info!("pie menu {id:?}: closed"); + show(&state, None, cx); + give_back(&takeover, window, cx); + }) + }; + if !focus.is_focused(window) { + close(window, cx); + return region; + } + let items = Rc::new(self.items); + let count = items.len(); + let run: Pick = { + let (items, close) = (items.clone(), close.clone()); + Rc::new(move |ix, window, cx| { + let item = &items[ix]; + log::info!("pie menu: {}", item.label); + close(window, cx); + if let Some(on_click) = &item.on_click { + on_click(window, cx); + } + }) + }; + let theme = cx.theme(); + let colors = &theme.colors; + let rem = window.rem_size(); + let (radius, bubble) = theme.pie(); + let (radius, bubble) = (radius.to_pixels(rem), bubble.to_pixels(rem)); + let hub = bubble * 1.75; + let span = (radius + bubble) * 2.0; + let marked = state.read(cx).marked.filter(|&ix| ix < count); + let bloom = + Animation::new(motion::duration(motion::BASE, cx)).with_easing(motion::ease_out_cubic); + let bubbles = items.iter().enumerate().map(|(ix, item)| { + let angle = ix as f32 * TAU / count as f32 - TAU / 4.0; + let on = marked == Some(ix); + let (fg, bg) = if on { + (colors.on_accent, colors.accent) + } else { + (colors.fg_muted, colors.overlay) + }; + div() + .absolute() + .flex() + .items_center() + .justify_center() + .size(bubble) + .rounded_full() + .bg(bg) + .border_1() + .border_color(if on { colors.accent } else { colors.border }) + .shadow(theme.elevation(Elevation::Floating)) + .child(Icon::new(item.icon).size(IconSize::Md).color(fg)) + .with_animation( + (self.id.clone(), format!("bubble-{ix}")), + bloom.clone(), + move |bubble_el, t| { + let late = (ix as f32 * 0.06).min(0.4); + let t = ((t - late) / (1.0 - late)).clamp(0.0, 1.0); + let reach = radius * motion::spring(t); + let middle = span / 2.0; + bubble_el + .left(middle + reach * angle.cos() - bubble / 2.0) + .top(middle + reach * angle.sin() - bubble / 2.0) + .opacity(t) + }, + ) + }); + let label = marked.map_or(SharedString::from("Cancel"), |ix| items[ix].label.clone()); + let measure = state.clone(); + let ring = div() + .relative() + .size(span) + .child( + div() + .absolute() + .left(radius + bubble - hub / 2.0) + .top(radius + bubble - hub / 2.0) + .flex() + .items_center() + .justify_center() + .size(hub) + .rounded_full() + .bg(colors.overlay) + .border_1() + .border_color(colors.border) + .shadow(theme.elevation(Elevation::Modal)) + .text_size(theme.text_size(TextSize::Xs)) + .font_weight(FontWeight::MEDIUM) + .text_color(if marked.is_some() { + colors.fg + } else { + colors.fg_subtle + }) + .child(label), + ) + .children(bubbles) + .child( + canvas( + move |bounds: Bounds, _, cx| { + let center = bounds.center(); + if measure.read(cx).center != center { + measure.update(cx, |pie, cx| { + pie.center = center; + cx.notify(); + }); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .top_0() + .left_0() + .size_full(), + ); + let (aim, pick, keys, lift) = (state.clone(), state.clone(), state.clone(), state.clone()); + let (run_click, run_key) = (run.clone(), run); + let (close_click, close_right, escape) = (close.clone(), close.clone(), close); + let viewport = window.viewport_size(); + let catcher = div() + .id((self.id.clone(), "catcher")) + .track_focus(&focus) + .w(viewport.width) + .h(viewport.height) + .occlude() + .on_mouse_move(move |event, _, cx| { + let pie = aim.read(cx); + let to = slice(pie.center, event.position, hub, count); + if pie.marked != to { + aim.update(cx, |pie, cx| { + pie.marked = to; + cx.notify(); + }); + } + }) + .on_mouse_down(MouseButton::Left, move |event, window, cx| { + window.prevent_default(); + let pie = pick.read(cx); + match slice(pie.center, event.position, hub, count) { + Some(ix) => run_click(ix, window, cx), + None => close_click(window, cx), + } + }) + .on_mouse_down(MouseButton::Right, move |_, window, cx| { + window.prevent_default(); + close_right(window, cx) + }) + .on_key_down(move |event, window, cx| { + let stroke = &event.keystroke; + let by: isize = match stroke.key.as_str() { + "right" | "down" => 1, + "left" | "up" => -1, + "escape" => { + cx.stop_propagation(); + return escape(window, cx); + } + "enter" | "space" if !stroke.modifiers.modified() => { + return cx.stop_propagation(); + } + _ => return, + }; + cx.stop_propagation(); + keys.update(cx, |pie, cx| { + let from = pie + .marked + .filter(|&at| at < count) + .map_or(if by > 0 { -1 } else { 0 }, |at| at as isize); + pie.marked = Some((from + by).rem_euclid(count as isize) as usize); + cx.notify(); + }); + }) + .on_key_up(move |event, window, cx| { + let stroke = &event.keystroke; + if matches!(stroke.key.as_str(), "enter" | "space") && !stroke.modifiers.modified() + { + cx.stop_propagation(); + if let Some(ix) = lift.read(cx).marked.filter(|&ix| ix < count) { + run_key(ix, window, cx); + } + } + }); + region.child( + deferred( + anchored() + .position(point(Pixels::ZERO, Pixels::ZERO)) + .child( + catcher.child( + anchored() + .position(at - point(span / 2.0, span / 2.0)) + .snap_to_window() + .child(ring), + ), + ), + ) + .with_priority(1), + ) + } +} diff --git a/src/menus/tests.rs b/src/menus/tests/mod.rs similarity index 68% rename from src/menus/tests.rs rename to src/menus/tests/mod.rs index e5df539..e2dc763 100644 --- a/src/menus/tests.rs +++ b/src/menus/tests/mod.rs @@ -1,10 +1,12 @@ use gpui::{ App, Context, FocusHandle, InteractiveElement, IntoElement, KeyUpEvent, Keystroke, Modifiers, MouseButton, ParentElement, Render, SharedString, Styled, TestAppContext, VisualTestContext, - Window, div, point, px, + Window, div, point, prelude::*, px, }; -use super::{ContextMenu, DropdownMenu, Menu, MenuItem}; +mod more; + +use super::{ContextMenu, DropdownMenu, Menu, MenuItem, SearchableMenu}; use crate::{primitives::FocusScope, theme::Theme}; /// What the rows did, and the state the check and radio rows show. @@ -15,6 +17,7 @@ struct Desk { theme: SharedString, view_rows: bool, enters_above: usize, + shift: bool, } impl Desk { @@ -74,20 +77,25 @@ impl Render for Desk { .child( FocusScope::new(&self.root) .size_full() + .when(self.shift, |scope| scope.child(div().h(px(80.0)))) .child(DropdownMenu::new("edit", "Edit", menu.clone())) - .child(ContextMenu::new("area", menu).child(div().w(px(200.0)).h(px(120.0)))), + .child( + ContextMenu::new("area", menu.clone()) + .child(div().w(px(200.0)).h(px(120.0))), + ) + .child(SearchableMenu::new("find", "Find", menu)), ) } } -fn settle(cx: &mut VisualTestContext) { +pub(super) 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) { +pub(super) fn press(key: &str, cx: &mut VisualTestContext) { cx.simulate_keystrokes(key); settle(cx); cx.simulate_event(KeyUpEvent { @@ -97,7 +105,10 @@ fn press(key: &str, cx: &mut VisualTestContext) { } fn desk(cx: &mut TestAppContext) -> (gpui::Entity, &mut VisualTestContext) { - cx.update(Theme::init); + cx.update(|cx| { + Theme::init(cx); + crate::forms::bind_keys(cx); + }); let (view, cx) = cx.add_window_view(|_, cx| Desk { root: cx.focus_handle(), ran: Vec::new(), @@ -105,6 +116,7 @@ fn desk(cx: &mut TestAppContext) -> (gpui::Entity, &mut VisualTestContext) theme: "light".into(), view_rows: true, enters_above: 0, + shift: false, }); settle(cx); (view, cx) @@ -265,3 +277,103 @@ fn modified_confirm_keys_do_not_pick(cx: &mut TestAppContext) { press("enter", cx); assert_eq!(ran(&view, cx), ["cut"]); } + +#[gpui::test] +fn a_second_right_click_moves_the_context_menu(cx: &mut TestAppContext) { + let (view, cx) = desk(cx); + cx.update(|window, _| window.focus_next()); + for at in [point(px(150.0), px(120.0)), point(px(20.0), px(40.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"], + "the menu stayed open at the second click" + ); +} + +#[gpui::test] +fn a_left_press_in_the_region_closes_the_context_menu(cx: &mut TestAppContext) { + let (view, cx) = desk(cx); + let at = point(px(150.0), px(120.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); + click(20.0, 40.0, cx); + press("down", cx); + press("enter", cx); + assert!(ran(&view, cx).is_empty(), "the left press closed the menu"); +} + +fn open_find(cx: &mut VisualTestContext) { + cx.update(|window, _| { + window.focus_next(); + window.focus_next(); + }); + press("enter", cx); +} + +#[gpui::test] +fn the_filter_runs_the_best_fit(cx: &mut TestAppContext) { + let (view, cx) = desk(cx); + open_find(cx); + cx.simulate_input("pa"); + settle(cx); + press("enter", cx); + assert_eq!(ran(&view, cx), ["paste"]); +} + +#[gpui::test] +fn space_types_into_the_filter(cx: &mut TestAppContext) { + let (view, cx) = desk(cx); + open_find(cx); + cx.simulate_input("pa"); + settle(cx); + press("space", cx); + assert!(ran(&view, cx).is_empty(), "space picked a row"); + press("enter", cx); + assert_eq!(ran(&view, cx), ["paste"]); +} + +#[gpui::test] +fn arrows_walk_rows_from_the_filter(cx: &mut TestAppContext) { + let (view, cx) = desk(cx); + open_find(cx); + press("down", cx); + press("enter", cx); + assert_eq!(ran(&view, cx), ["paste"]); +} + +#[gpui::test] +fn an_open_dropdown_follows_its_host(cx: &mut TestAppContext) { + let (view, cx) = desk(cx); + click(12.0, 12.0, cx); + view.update(cx, |desk, cx| { + desk.shift = true; + cx.notify(); + }); + settle(cx); + settle(cx); + cx.simulate_mouse_move(point(px(40.0), px(131.0)), None, Modifiers::none()); + settle(cx); + press("enter", cx); + assert_eq!(ran(&view, cx), ["cut"], "the panel moved with its button"); +} + +#[gpui::test] +fn clearing_the_filter_marks_the_first_row_again(cx: &mut TestAppContext) { + let (view, cx) = desk(cx); + open_find(cx); + cx.simulate_input("t"); + settle(cx); + press("down", cx); + press("backspace", cx); + press("enter", cx); + assert_eq!(ran(&view, cx), ["cut"]); +} diff --git a/src/menus/tests/more.rs b/src/menus/tests/more.rs new file mode 100644 index 0000000..a898bad --- /dev/null +++ b/src/menus/tests/more.rs @@ -0,0 +1,243 @@ +use gpui::{ + Context, FocusHandle, InteractiveElement, IntoElement, KeyBinding, Modifiers, MouseButton, + OwnedMenu, OwnedMenuItem, ParentElement, Render, Styled, TestAppContext, VisualTestContext, + Window, actions, div, point, px, +}; + +use super::{press, settle}; +use crate::{ + menus::{MenuBar, PieItem, PieMenu}, + primitives::{FocusScope, IconName}, + theme::Theme, +}; + +actions!(menu_bar_test, [OpenFile, Save, Undo, Deep]); + +/// A bar over two menus, the second with a submenu, recording what they dispatch. +struct Bar { + root: FocusHandle, + ran: Vec<&'static str>, +} + +fn action(name: &str, action: impl gpui::Action) -> OwnedMenuItem { + OwnedMenuItem::Action { + name: name.into(), + action: Box::new(action), + os_action: None, + } +} + +impl Render for Bar { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let menus = vec![ + OwnedMenu { + name: "File".into(), + items: vec![action("Open", OpenFile), action("Save", Save)], + }, + OwnedMenu { + name: "Edit".into(), + items: vec![ + action("Undo", Undo), + OwnedMenuItem::Separator, + OwnedMenuItem::Submenu(OwnedMenu { + name: "More".into(), + items: vec![action("Deep", Deep)], + }), + ], + }, + ]; + div() + .size_full() + .on_action(cx.listener(|bar, _: &OpenFile, _, _| bar.ran.push("open"))) + .on_action(cx.listener(|bar, _: &Save, _, _| bar.ran.push("save"))) + .on_action(cx.listener(|bar, _: &Undo, _, _| bar.ran.push("undo"))) + .on_action(cx.listener(|bar, _: &Deep, _, _| bar.ran.push("deep"))) + .child( + FocusScope::new(&self.root) + .size_full() + .child(MenuBar::new("bar", menus)), + ) + } +} + +fn bar(cx: &mut TestAppContext) -> (gpui::Entity, &mut VisualTestContext) { + cx.update(|cx| { + Theme::init(cx); + cx.bind_keys([KeyBinding::new("secondary-s", Save, None)]); + }); + let (view, cx) = cx.add_window_view(|_, cx| Bar { + root: cx.focus_handle(), + ran: Vec::new(), + }); + settle(cx); + cx.update(|window, _| window.focus_next()); + (view, cx) +} + +#[gpui::test] +fn the_bar_walks_menus_and_dispatches_their_actions(cx: &mut TestAppContext) { + let (view, cx) = bar(cx); + press("down", cx); + press("enter", cx); + press("down", cx); + press("right", cx); + press("up", cx); + press("right", cx); + press("enter", cx); + assert_eq!( + view.read_with(cx, |bar, _| bar.ran.clone()), + ["open", "deep"] + ); +} + +#[gpui::test] +fn arrows_move_between_closed_titles(cx: &mut TestAppContext) { + let (view, cx) = bar(cx); + press("right", cx); + press("down", cx); + press("enter", cx); + assert_eq!(view.read_with(cx, |bar, _| bar.ran.clone()), ["undo"]); +} + +/// Four slices around a big area: Copy on top, then Cut, Paste and Share clockwise. +struct Wheel { + ran: Vec<&'static str>, + count: usize, + root: FocusHandle, + before: FocusHandle, + enters_above: usize, +} + +impl Render for Wheel { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let slice = |icon, name: &'static str| { + let view = cx.entity(); + PieItem::new(icon, name) + .on_click(move |_, cx| view.update(cx, |wheel, _| wheel.ran.push(name))) + }; + let mut items = vec![ + slice(IconName::Copy, "copy"), + slice(IconName::Scissors, "cut"), + slice(IconName::Clipboard, "paste"), + slice(IconName::Share2, "share"), + ]; + items.truncate(self.count); + let above = cx.entity(); + div() + .size_full() + .on_key_down(move |event, _, cx| { + let stroke = &event.keystroke; + if stroke.key == "enter" && !stroke.modifiers.modified() { + above.update(cx, |wheel, _| wheel.enters_above += 1); + } + }) + .child( + FocusScope::new(&self.root) + .size_full() + .child(div().id("before").track_focus(&self.before)) + .child(PieMenu::new("pie", items).child(div().w(px(600.0)).h(px(600.0)))), + ) + } +} + +fn wheel(cx: &mut TestAppContext) -> (gpui::Entity, &mut VisualTestContext) { + cx.update(Theme::init); + let (view, cx) = cx.add_window_view(|_, cx| Wheel { + ran: Vec::new(), + count: 4, + root: cx.focus_handle(), + before: cx.focus_handle(), + enters_above: 0, + }); + settle(cx); + cx.update(|window, cx| window.focus(&view.read(cx).before.clone())); + let at = point(px(300.0), px(300.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); + (view, cx) +} + +#[gpui::test] +fn the_pointer_direction_picks_a_slice(cx: &mut TestAppContext) { + let (view, cx) = wheel(cx); + let right = point(px(400.0), px(300.0)); + cx.simulate_mouse_move(right, None, Modifiers::none()); + cx.simulate_click(right, Modifiers::none()); + settle(cx); + assert_eq!(view.read_with(cx, |wheel, _| wheel.ran.clone()), ["cut"]); +} + +#[gpui::test] +fn arrows_turn_the_pie_and_enter_runs(cx: &mut TestAppContext) { + let (view, cx) = wheel(cx); + press("right", cx); + press("right", cx); + press("enter", cx); + assert_eq!(view.read_with(cx, |wheel, _| wheel.ran.clone()), ["cut"]); +} + +#[gpui::test] +fn a_press_on_the_hub_cancels(cx: &mut TestAppContext) { + let (view, cx) = wheel(cx); + let hub = point(px(300.0), px(300.0)); + cx.simulate_mouse_move(hub, None, Modifiers::none()); + cx.simulate_click(hub, Modifiers::none()); + settle(cx); + press("right", cx); + press("enter", cx); + assert!(view.read_with(cx, |wheel, _| wheel.ran.is_empty())); +} + +#[gpui::test] +fn right_moves_a_menu_opened_by_the_mouse(cx: &mut TestAppContext) { + let (view, cx) = bar(cx); + let title = point(px(10.0), px(10.0)); + cx.simulate_mouse_move(title, None, Modifiers::none()); + cx.simulate_click(title, Modifiers::none()); + settle(cx); + press("right", cx); + press("enter", cx); + assert_eq!(view.read_with(cx, |bar, _| bar.ran.clone()), ["undo"]); +} + +#[gpui::test] +fn a_pie_that_shrinks_forgets_a_mark_past_its_end(cx: &mut TestAppContext) { + let (view, cx) = wheel(cx); + for _ in 0..4 { + press("right", cx); + } + view.update(cx, |wheel, cx| { + wheel.count = 2; + cx.notify(); + }); + settle(cx); + press("enter", cx); + assert!(view.read_with(cx, |wheel, _| wheel.ran.is_empty())); +} + +#[gpui::test] +fn cancelling_on_the_hub_hands_focus_back(cx: &mut TestAppContext) { + let (view, cx) = wheel(cx); + let hub = point(px(300.0), px(300.0)); + cx.simulate_mouse_move(hub, None, Modifiers::none()); + cx.simulate_click(hub, Modifiers::none()); + settle(cx); + let before = view.read_with(cx, |wheel, _| wheel.before.clone()); + assert!(cx.update(|window, _| before.is_focused(window))); +} + +#[gpui::test] +fn pie_confirm_keys_stay_inside_and_ignore_modifiers(cx: &mut TestAppContext) { + let (view, cx) = wheel(cx); + press("right", cx); + press("ctrl-enter", cx); + assert!( + view.read_with(cx, |wheel, _| wheel.ran.is_empty()), + "a modified key ran" + ); + press("enter", cx); + assert_eq!(view.read_with(cx, |wheel, _| wheel.ran.clone()), ["copy"]); + assert_eq!(view.read_with(cx, |wheel, _| wheel.enters_above), 0); +} diff --git a/src/navigation/mod.rs b/src/navigation/mod.rs index 55959c4..2244bb1 100644 --- a/src/navigation/mod.rs +++ b/src/navigation/mod.rs @@ -7,6 +7,7 @@ mod nav; mod overflow; mod pages; mod palette; +pub(crate) use palette::{fuzzy, marked}; mod steps; mod tabs; #[cfg(all(test, feature = "test-support"))] diff --git a/src/theme/tokens.rs b/src/theme/tokens.rs index b8e212f..394ce20 100644 --- a/src/theme/tokens.rs +++ b/src/theme/tokens.rs @@ -228,6 +228,11 @@ impl Theme { px_to_rems(480.0) } + /// A pie menu's ring radius and the size of each slice's button. + pub fn pie(&self) -> (Rems, Rems) { + (px_to_rems(84.0), px_to_rems(40.0)) + } + /// Narrowest a menu's panel gets. pub fn menu_width(&self) -> Rems { px_to_rems(200.0) diff --git a/tasks/ch01-10.md b/tasks/ch01-10.md index c7c297f..5fe0f9e 100644 --- a/tasks/ch01-10.md +++ b/tasks/ch01-10.md @@ -117,7 +117,7 @@ - [x] TrafficLights → `shell::WindowControls` - [x] WindowDragRegion — `shell::drag_region`: native drag area, Linux move, double-click zoom - [x] WindowResizeBorder — `shell::ResizeBorder`: client decorations only; macOS and Windows resize natively -- [x] MenuBar → `menus::MenuBar` (T08): the NativeMenu definitions, drawn in the window +- [x] MenuBar → `menus::MenuBar` (T08b): the NativeMenu definitions, drawn in the window - [x] NativeMenu — native `cx.set_menus()` - [x] Toolbar / ToolbarGroup / ToolbarSeparator - [x] StatusBar / StatusBarItem @@ -321,10 +321,10 @@ ## T08b Menus · Bar, search & pie -- [ ] MenuBar Menu — `menus::MenuBar`, also T04 MenuBar: the native menus, drawn in the window -- [ ] MegaMenu -- [ ] SearchableMenu -- [ ] PieMenu / RadialMenu +- [x] MenuBar Menu → `menus::MenuBar`; also T04 MenuBar: the native menus, drawn in the window +- [x] MegaMenu → `navigation::NavigationMenu`: each entry opens a panel of links with a line of detail +- [x] SearchableMenu → `menus::SearchableMenu` +- [x] PieMenu / RadialMenu → `menus::PieMenu`, `menus::PieItem` ## T09 Overlays — `src/overlays`