T08a menus: menu, items, groups, separators, submenus, check and radio rows, key hints, icons; dropdown, split, overflow and context hosts; overlays pick on key release
This commit is contained in:
20 files changed
+1695
-52
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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 | |
|
||||
@@ -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<Option<SharedString>>, String) {
|
||||
let last = keep(key, || None::<SharedString>, 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<Option<SharedString>>,
|
||||
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()
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
+61
-12
@@ -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 = <id as NSEvent>::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 = <id as NSEvent>::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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
|
||||
|
||||
fn state(id: &ElementId, window: &mut Window, cx: &mut App) -> Entity<Open> {
|
||||
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<Open>, 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<Open>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Stateful<Div> {
|
||||
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<IconName>,
|
||||
variant: ButtonVariant,
|
||||
menu: Menu,
|
||||
}
|
||||
|
||||
impl DropdownMenu {
|
||||
pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>, 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<ElementId>, 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<Click>,
|
||||
}
|
||||
|
||||
impl SplitButton {
|
||||
pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>, 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<ElementId>, menu: Menu) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
menu,
|
||||
children: SmallVec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ParentElement for ContextMenu {
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -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<Level>) {
|
||||
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<usize>,
|
||||
row: Bounds<Pixels>,
|
||||
panel: Bounds<Pixels>,
|
||||
}
|
||||
|
||||
/// 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<Pixels>),
|
||||
}
|
||||
|
||||
/// 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<Spot>,
|
||||
levels: Vec<Level>,
|
||||
anchor: Bounds<Pixels>,
|
||||
takeover: Option<Entity<Takeover>>,
|
||||
}
|
||||
|
||||
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<Open>, menu: &Menu, spot: Spot, marked: bool, cx: &mut App) {
|
||||
let first = menu.step(None, 1).filter(|_| marked);
|
||||
state.update(cx, |open, cx| {
|
||||
open.spot = Some(spot);
|
||||
open.levels = vec![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<Open>, window: &mut Window, cx: &mut App) {
|
||||
let takeover = state.update(cx, |open, cx| {
|
||||
open.spot = None;
|
||||
open.levels.clear();
|
||||
cx.notify();
|
||||
open.takeover.take()
|
||||
});
|
||||
if let Some(takeover) = takeover {
|
||||
give_back(&takeover, window, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks row `at` of level `depth` and drops deeper levels; a submenu row opens when `open_sub`.
|
||||
fn mark(state: &Entity<Open>, 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<Open>,
|
||||
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<Open>,
|
||||
key: &str,
|
||||
close: &Run,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> bool {
|
||||
let levels = state.read(cx).levels.clone();
|
||||
let Some(depth) = levels.len().checked_sub(1) else {
|
||||
return false;
|
||||
};
|
||||
let Some(here) = at_depth(menu, &levels, depth) else {
|
||||
return false;
|
||||
};
|
||||
let at = 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<Open>,
|
||||
write: impl Fn(&mut Open, Bounds<Pixels>) -> 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<Open>) -> 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<Div> {
|
||||
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<Open>,
|
||||
depth: usize,
|
||||
close: &Run,
|
||||
cx: &App,
|
||||
) -> Stateful<Div> {
|
||||
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<Open>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Option<AnyElement> {
|
||||
let spot = state.read(cx).spot?;
|
||||
let takeover = take_focus((id.clone(), "takeover"), window, cx);
|
||||
let focus = takeover.read(cx).focus.clone();
|
||||
if state.read(cx).takeover.is_none() {
|
||||
state.update(cx, |open, _| open.takeover = Some(takeover));
|
||||
}
|
||||
let close: Run = {
|
||||
let (state, id) = (state.clone(), id.clone());
|
||||
Rc::new(move |window, cx| {
|
||||
log::info!("menu {id:?}: closed");
|
||||
Open::close(&state, window, cx);
|
||||
})
|
||||
};
|
||||
if !focus.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::<Vec<_>>())
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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<IconName>,
|
||||
pub(super) keys: Option<SharedString>,
|
||||
pub(super) kind: Kind,
|
||||
pub(super) disabled: bool,
|
||||
pub(super) on_click: Option<Run>,
|
||||
}
|
||||
|
||||
impl MenuItem {
|
||||
fn with(label: impl Into<SharedString>, kind: Kind) -> Self {
|
||||
Self {
|
||||
label: label.into(),
|
||||
icon: None,
|
||||
keys: None,
|
||||
kind,
|
||||
disabled: false,
|
||||
on_click: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new(label: impl Into<SharedString>) -> Self {
|
||||
Self::with(label, Kind::Run)
|
||||
}
|
||||
|
||||
/// A row with a check; the owner flips `checked` in `on_click`.
|
||||
pub fn check(label: impl Into<SharedString>, 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<SharedString>, selected: bool) -> Self {
|
||||
Self::with(label, Kind::Radio(selected))
|
||||
}
|
||||
|
||||
/// A row that opens `menu` beside it.
|
||||
pub fn submenu(label: impl Into<SharedString>, 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<SharedString>) -> 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<Entry>,
|
||||
}
|
||||
|
||||
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<SharedString>,
|
||||
items: impl IntoIterator<Item = MenuItem>,
|
||||
) -> 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<usize>, by: isize) -> Option<usize> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<SharedString>,
|
||||
wrap: bool,
|
||||
theme: SharedString,
|
||||
view_rows: bool,
|
||||
enters_above: usize,
|
||||
}
|
||||
|
||||
impl Desk {
|
||||
fn menu(&self, cx: &mut Context<Self>) -> 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<Self>) -> 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<Desk>, &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<Desk>, cx: &mut VisualTestContext) -> Vec<SharedString> {
|
||||
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"]);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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<SharedString>,
|
||||
kind: Kind,
|
||||
@@ -83,7 +93,7 @@ fn results(query: &str) -> Vec<(SharedString, Vec<Choice>)> {
|
||||
|
||||
impl Render for Host {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> 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<Host>, &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<Host>, 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())));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -8,6 +8,8 @@ mod native;
|
||||
mod status;
|
||||
mod switcher;
|
||||
mod tabs;
|
||||
#[cfg(all(test, feature = "test-support"))]
|
||||
mod tests;
|
||||
mod update;
|
||||
mod windows;
|
||||
|
||||
|
||||
+13
-7
@@ -95,8 +95,9 @@ impl RenderOnce for WindowSwitcher {
|
||||
})
|
||||
.collect();
|
||||
let handles: Vec<AnyWindowHandle> = 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(
|
||||
|
||||
@@ -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<Self>) -> 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<Self>) -> 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);
|
||||
}
|
||||
@@ -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<Rems> {
|
||||
size(px_to_rems(600.0), px_to_rems(400.0))
|
||||
|
||||
+15
-12
@@ -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
|
||||
|
||||
Reference in new issue
Block a user