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
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user