T05 buttons and actions: groups, toggles, segmented, confirm, copy, share, bars, sheet, quick actions

This commit is contained in:
ZacharyZhang-NY committed 2026-09-25 03:51:34 -04:00
1 parent 77bcd5be07
commit 817da41c22
29 files changed
+2659 -151

No files matched your search

+39
View File
@@ -100,6 +100,45 @@ pub fn number(window: &Window) -> Result<u32> {
u32::try_from(number).context("window number out of range")
}
/// This process's frontmost window above the normal level: a popover or menu.
#[cfg(target_os = "macos")]
pub fn popup_number() -> Result<u32> {
use core_foundation::{
base::{CFType, TCFType},
dictionary::CFDictionary,
number::CFNumber,
string::CFString,
};
use core_graphics::window;
let read = |dict: &CFDictionary<CFString, CFType>, key: &'static str| {
dict.find(CFString::from_static_string(key))
.and_then(|value| value.downcast::<CFNumber>())
.and_then(|value| value.to_i64())
};
let pid = i64::from(std::process::id());
let list = window::copy_window_info(
window::kCGWindowListOptionOnScreenOnly,
window::kCGNullWindowID,
)
.context("window list unavailable")?;
let id = list
.iter()
.map(|item| unsafe { CFDictionary::<CFString, CFType>::wrap_under_get_rule(*item as _) })
.find(|dict| {
read(dict, "kCGWindowOwnerPID") == Some(pid)
&& read(dict, "kCGWindowLayer").is_some_and(|layer| layer > 0)
})
.and_then(|dict| read(&dict, "kCGWindowNumber"))
.context("no popup window of this process is on screen")?;
u32::try_from(id).context("window number out of range")
}
#[cfg(not(target_os = "macos"))]
pub fn popup_number() -> Result<u32> {
bail!("capture needs macOS window APIs")
}
#[cfg(not(target_os = "macos"))]
pub fn number(_: &Window) -> Result<u32> {
bail!("capture needs macOS window APIs")
+301
View File
@@ -0,0 +1,301 @@
use ely_gpui_component::{
buttons::{
ActionBar, ActionSheet, BulkActionBar, Button, ButtonVariant, ConfirmButton, ConfirmMode,
CopyButton, FloatingActionButton, QuickAction, QuickActions, ShareButton, back_button,
close_button, more_button,
},
primitives::IconName,
theme::{ActiveTheme, ControlSize, Radius},
typography::Caption,
};
use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, prelude::*, px};
use crate::{
probe::probe,
ui::{code, row, section, specimen, specimens},
};
pub fn later(cx: &App) -> impl IntoElement + use<> {
section(
"SplitButton / DropdownButton / MenuButton / LoadingButton",
"Built from parts in later chapters.",
cx,
)
.child(Caption::new(
"Split and dropdown buttons open a Menu: they land with Menus, chapter 8.",
))
.child(Caption::new(
"The loading state spins a Spinner: it lands with Loading, chapter 11.",
))
}
pub fn fab(cx: &App) -> impl IntoElement + use<> {
let theme = cx.theme();
section(
"FloatingActionButton",
"Round and raised for the screen's one main action. It rises in.",
cx,
)
.child(
div()
.relative()
.w(px(420.0))
.h(px(160.0))
.rounded(theme.radius(Radius::Lg))
.border_1()
.border_color(theme.colors.border)
.bg(theme.colors.sunken)
.child(
div()
.absolute()
.right_4()
.bottom_4()
.flex()
.items_center()
.gap_3()
.child(
FloatingActionButton::new("fab-compose", IconName::Pencil).label("Compose"),
)
.child(
FloatingActionButton::new("fab-add", IconName::Plus).tooltip("New item"),
),
),
)
}
pub fn confirm(cx: &App) -> impl IntoElement + use<> {
section(
"ConfirmButton / HoldToConfirm",
"Hold until the fill completes, or click twice within three seconds.",
cx,
)
.child(
specimens()
.child(specimen(
"hold",
probe(
"confirm-hold",
ConfirmButton::new("confirm-hold", "Hold to delete", ConfirmMode::Hold)
.on_confirm(|_, _| log::info!("gallery: held to delete")),
),
cx,
))
.child(specimen(
"twice",
probe(
"confirm-twice",
ConfirmButton::new("confirm-twice", "Discard draft", ConfirmMode::Twice)
.on_confirm(|_, _| log::info!("gallery: draft discarded")),
),
cx,
)),
)
}
pub fn small_buttons(cx: &App) -> impl IntoElement + use<> {
section(
"CopyButton / ShareButton / CloseButton / BackButton / MoreButton",
"Small, familiar verbs. Each names itself on hover.",
cx,
)
.child(
row()
.child(specimen(
"copy",
probe(
"copy-button",
CopyButton::new("copy-invite", "https://ely.dev/invite/7Q2"),
),
cx,
))
.child(specimen(
"share",
probe(
"share-button",
ShareButton::new("share-page")
.text("Ely, components for GPUI")
.url("https://github.com/ZacharyZhang-NY/Ely-GPUI-Components"),
),
cx,
))
.child(specimen("close", close_button("preset-close"), cx))
.child(specimen("back", back_button("preset-back"), cx))
.child(specimen("more", more_button("preset-more"), cx)),
)
.child(code(
"close_button(id), back_button(id), more_button(id)",
cx,
))
}
pub fn action_bar(cx: &App) -> impl IntoElement + use<> {
let theme = cx.theme();
section(
"ActionBar",
"Docked along a panel's foot, or floating as a pill.",
cx,
)
.child(
div()
.w(px(560.0))
.h(px(200.0))
.flex()
.flex_col()
.justify_end()
.items_center()
.overflow_hidden()
.rounded(theme.radius(Radius::Lg))
.border_1()
.border_color(theme.colors.border)
.bg(theme.colors.sunken)
.child(
div().pb_4().child(
ActionBar::new()
.floating()
.child(
Button::new("bar-reply", "Reply")
.variant(ButtonVariant::Ghost)
.icon(IconName::Reply),
)
.child(
Button::new("bar-forward", "Forward")
.variant(ButtonVariant::Ghost)
.icon(IconName::Forward),
)
.child(
Button::new("bar-archive", "Archive")
.primary()
.icon(IconName::Archive),
),
),
)
.child(
ActionBar::new()
.child(Button::new("bar-cancel", "Cancel").variant(ButtonVariant::Ghost))
.child(Button::new("bar-save", "Save changes").primary()),
),
)
}
pub fn bulk(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let picked = window.use_keyed_state("bulk-picked", cx, |_, _| 0usize);
let count = *picked.read(cx);
let (add, clear) = (picked.clone(), picked);
section(
"BulkActionBar",
"Rises once rows are selected. The count rolls as it changes.",
cx,
)
.child(
row()
.child(probe(
"bulk-select",
Button::new("bulk-select", "Select one more")
.variant(ButtonVariant::Outline)
.on_click(move |_, _, cx| {
add.update(cx, |count, cx| {
*count += 1;
cx.notify();
})
}),
))
.child(Caption::new(format!("{count} selected"))),
)
.child(
div()
.h(cx.theme().control_height(ControlSize::Lg) * 1.5)
.child(
BulkActionBar::new("bulk-bar", count)
.action(
Button::new("bulk-move", "Move")
.variant(ButtonVariant::Ghost)
.icon(IconName::Folder),
)
.action(
Button::new("bulk-delete", "Delete")
.variant(ButtonVariant::Danger)
.icon(IconName::Trash2),
)
.on_clear(move |_, cx| {
clear.update(cx, |count, cx| {
*count = 0;
cx.notify();
})
}),
),
)
}
pub fn action_sheet(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let open = window.use_keyed_state("sheet-shown", cx, |_, _| false);
let shown = *open.read(cx);
let (show, hide) = (open.clone(), open);
section(
"ActionSheet",
"A few choices from the bottom, and a separate way out.",
cx,
)
.child(probe(
"action-sheet-open",
Button::new("action-sheet-open", "Photo options")
.variant(ButtonVariant::Outline)
.on_click(move |_, _, cx| {
show.update(cx, |shown, cx| {
*shown = true;
cx.notify();
})
}),
))
.when(shown, |block| {
block.child(
ActionSheet::new("photo-sheet", move |_, cx| {
hide.update(cx, |shown, cx| {
*shown = false;
cx.notify();
})
})
.title("dunes.jpg")
.message("Taken at dusk. 4.2 MB.")
.action("Set as wallpaper", |_, _| log::info!("gallery: wallpaper"))
.action("Duplicate", |_, _| log::info!("gallery: duplicate"))
.destructive("Delete photo", |_, _| log::info!("gallery: delete photo")),
)
})
}
pub fn quick_actions(cx: &App) -> impl IntoElement + use<> {
let note = |label: &'static str| {
move |_: &mut Window, _: &mut App| log::info!("gallery: quick {label}")
};
section(
"QuickActions",
"Common moves with their keys. Arrows choose, Enter runs.",
cx,
)
.child(probe(
"quick",
div().w(px(420.0)).child(
QuickActions::new("quick-actions")
.action(
QuickAction::new(IconName::FilePlus, "New document", note("new"))
.note("Blank, or from a template")
.shortcut("cmd-n"),
)
.action(
QuickAction::new(IconName::FolderOpen, "Open…", note("open")).shortcut("cmd-o"),
)
.action(
QuickAction::new(IconName::Search, "Search everywhere", note("search"))
.note("Files, symbols, commands")
.shortcut("cmd-shift-p"),
)
.action(
QuickAction::new(IconName::Settings, "Settings", note("settings"))
.shortcut("cmd-,"),
),
),
))
.child(Caption::new(SharedString::from(
"Tab in, then use the arrows.",
)))
}
@@ -1,21 +1,11 @@
use ely_gpui_component::{
buttons::{Button, ButtonVariant, IconButton},
buttons::{Button, ButtonGroup, ButtonVariant, IconButton},
primitives::IconName,
theme::ControlSize,
};
use gpui::{AnyElement, App, ElementId, IntoElement, ParentElement, Styled, Window, div};
use gpui::{AnyElement, App, ElementId, IntoElement, ParentElement, Styled, div};
use super::Page;
use crate::ui::{row, section, specimen, specimens};
pub const PAGE: Page = Page {
number: 5,
slug: "buttons",
title: "Buttons & Actions",
summary: "Seven variants, three sizes, one focus ring.",
render,
script: &[],
};
use crate::ui::{code, row, section, specimen, specimens};
const VARIANTS: [(ButtonVariant, &str); 7] = [
(ButtonVariant::Primary, "Primary"),
@@ -27,7 +17,7 @@ const VARIANTS: [(ButtonVariant, &str); 7] = [
(ButtonVariant::Link, "Link"),
];
fn render(_: &mut Window, cx: &mut App) -> AnyElement {
pub fn basics(cx: &App) -> AnyElement {
let sizes = [
(ControlSize::Sm, "Small"),
(ControlSize::Md, "Medium"),
@@ -128,5 +118,47 @@ fn render(_: &mut Window, cx: &mut App) -> AnyElement {
}),
)),
)
.child(
section(
"ShortcutHint",
"The keystroke rides along, quiet, in the platform's spelling.",
cx,
)
.child(
row()
.child(Button::new("hint-save", "Save").shortcut("cmd-s"))
.child(
Button::new("hint-find", "Find")
.variant(ButtonVariant::Ghost)
.icon(IconName::Search)
.shortcut("cmd-shift-f"),
),
)
.child(code("Button::new(id, label).shortcut(\"cmd-s\")", cx)),
)
.child(
section("ButtonGroup", "Buttons joined edge to edge.", cx).child(
row()
.child(
ButtonGroup::new()
.button(Button::new("group-day", "Day"))
.button(Button::new("group-week", "Week"))
.button(Button::new("group-month", "Month")),
)
.child(
ButtonGroup::new()
.button(
Button::new("group-prev", "Previous")
.variant(ButtonVariant::Outline)
.icon(IconName::ChevronLeft),
)
.button(
Button::new("group-next", "Next")
.variant(ButtonVariant::Outline)
.trailing_icon(IconName::ChevronRight),
),
),
),
)
.into_any_element()
}
+167
View File
@@ -0,0 +1,167 @@
use ely_gpui_component::{
buttons::{SegmentedControl, ToggleButton, ToggleGroup, ToggleItem},
primitives::IconName,
};
use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
use crate::{
probe::probe,
ui::{row, section, specimen, specimens},
};
pub fn toggle_button(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let pinned = window.use_keyed_state("toggle-pinned", cx, |_, _| true);
let muted = window.use_keyed_state("toggle-muted", cx, |_, _| false);
let (pin_on, mute_on) = (*pinned.read(cx), *muted.read(cx));
section("ToggleButton", "Stays pressed until pressed again.", cx).child(
row()
.child(
ToggleButton::new(
"toggle-pin",
ToggleItem::new("pin").label("Pinned").icon(IconName::Pin),
pin_on,
)
.on_toggle(move |on, _, cx| {
pinned.update(cx, |pinned, cx| {
*pinned = on;
cx.notify();
})
}),
)
.child(
ToggleButton::new(
"toggle-mute",
ToggleItem::new("mute")
.icon(IconName::VolumeX)
.tooltip("Mute"),
mute_on,
)
.on_toggle(move |on, _, cx| {
muted.update(cx, |muted, cx| {
*muted = on;
cx.notify();
})
}),
),
)
}
fn choice(
key: &'static str,
initial: &'static [&'static str],
window: &mut Window,
cx: &mut App,
) -> gpui::Entity<Vec<SharedString>> {
window.use_keyed_state(key, cx, |_, _| {
initial.iter().map(|v| SharedString::from(*v)).collect()
})
}
pub fn toggle_group(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let marks = choice("toggle-marks", &["bold"], window, cx);
let align = choice("toggle-align", &["left"], window, cx);
let (marks_now, align_now) = (marks.read(cx).clone(), align.read(cx).clone());
let store = |state: gpui::Entity<Vec<SharedString>>| {
move |values: &[SharedString], _: &mut Window, cx: &mut App| {
let values = values.to_vec();
state.update(cx, |state, cx| {
*state = values;
cx.notify();
})
}
};
section(
"ToggleGroup",
"Several at once for marks; one at a time for alignment.",
cx,
)
.child(
specimens()
.child(specimen(
"multiple",
ToggleGroup::new("marks")
.multiple()
.item(ToggleItem::new("bold").icon(IconName::Bold).tooltip("Bold"))
.item(
ToggleItem::new("italic")
.icon(IconName::Italic)
.tooltip("Italic"),
)
.item(
ToggleItem::new("underline")
.icon(IconName::Underline)
.tooltip("Underline"),
)
.selected(marks_now)
.on_change(store(marks)),
cx,
))
.child(specimen(
"single",
ToggleGroup::new("align")
.item(
ToggleItem::new("left")
.label("Left")
.icon(IconName::AlignLeft),
)
.item(
ToggleItem::new("center")
.label("Center")
.icon(IconName::AlignCenter),
)
.item(
ToggleItem::new("right")
.label("Right")
.icon(IconName::AlignRight),
)
.selected(align_now)
.on_change(store(align)),
cx,
)),
)
}
pub fn segmented(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let range = window.use_keyed_state("segment-range", cx, |_, _| SharedString::from("day"));
let view = window.use_keyed_state("segment-view", cx, |_, _| SharedString::from("list"));
let (range_now, view_now) = (range.read(cx).clone(), view.read(cx).clone());
let store = |state: gpui::Entity<SharedString>| {
move |value: &SharedString, _: &mut Window, cx: &mut App| {
let value = value.clone();
state.update(cx, |state, cx| {
*state = value;
cx.notify();
})
}
};
section(
"SegmentedControl",
"One of a few. The thumb springs to the choice.",
cx,
)
.child(
specimens()
.child(specimen(
"text",
probe(
"segmented",
div().w(px(300.0)).child(
SegmentedControl::new("range", range_now)
.segment("day", "Day", None)
.segment("week", "Week", None)
.segment("month", "Month", None)
.on_change(store(range)),
),
),
cx,
))
.child(specimen(
"with icons",
SegmentedControl::new("view", view_now)
.segment("list", "List", Some(IconName::List))
.segment("grid", "Grid", Some(IconName::LayoutGrid))
.on_change(store(view)),
cx,
)),
)
}
+89
View File
@@ -0,0 +1,89 @@
mod actions;
mod basics;
mod choices;
use gpui::{AnyElement, App, IntoElement, ParentElement, Window, div};
use super::Page;
use crate::script::Step;
pub const PAGE: Page = Page {
number: 5,
slug: "buttons",
title: "Buttons & Actions",
summary: "Seven variants, three sizes, one focus ring, and the verbs around them.",
render,
script: SCRIPT,
};
const SCRIPT: &[Step] = &[
Step::Rest,
Step::DownAt("segmented", 150.0, 16.0),
Step::UpAt("segmented", 150.0, 16.0),
Step::Wait(500),
Step::Shot("segmented"),
Step::DownAt("confirm-hold", 40.0, 14.0),
Step::Wait(500),
Step::Shot("holding"),
Step::Wait(900),
Step::Shot("held"),
Step::UpAt("confirm-hold", 40.0, 14.0),
Step::Click("confirm-twice"),
Step::Wait(200),
Step::Shot("armed"),
Step::Click("confirm-twice"),
Step::Wait(200),
Step::Shot("confirmed"),
Step::Wait(3200),
Step::Click("confirm-twice"),
Step::Click("confirm-twice"),
Step::Click("confirm-twice"),
Step::Wait(1600),
Step::Shot("twice-reset"),
Step::Click("copy-button"),
Step::Wait(300),
Step::Shot("copied"),
Step::Click("share-button"),
Step::Wait(900),
Step::ShotPopup("share-picker"),
Step::NativeKey("escape"),
Step::Wait(400),
Step::Click("bulk-select"),
Step::Click("bulk-select"),
Step::Click("bulk-select"),
Step::Wait(500),
Step::Shot("bulk"),
Step::Click("action-sheet-open"),
Step::Wait(400),
Step::Key("tab"),
Step::Key("tab"),
Step::Key("tab"),
Step::Key("tab"),
Step::Key("tab"),
Step::Wait(200),
Step::Shot("action-sheet"),
Step::Key("escape"),
Step::Click("quick"),
Step::Key("down"),
Step::Key("down"),
Step::Wait(200),
Step::Shot("quick"),
Step::Rest,
];
fn render(window: &mut Window, cx: &mut App) -> AnyElement {
div()
.child(basics::basics(cx))
.child(actions::later(cx))
.child(choices::toggle_button(window, cx))
.child(choices::toggle_group(window, cx))
.child(choices::segmented(window, cx))
.child(actions::fab(cx))
.child(actions::confirm(cx))
.child(actions::small_buttons(cx))
.child(actions::action_bar(cx))
.child(actions::bulk(window, cx))
.child(actions::action_sheet(window, cx))
.child(actions::quick_actions(cx))
.into_any_element()
}
+49 -1
View File
@@ -4,7 +4,7 @@ use anyhow::{Context as _, Result};
use gpui::{AsyncApp, Bounds, Keystroke, Pixels, Point, WindowHandle, point, px};
use crate::{
capture::{number, snapshot},
capture::{number, popup_number, snapshot},
probe::{Opened, Probes},
shell::Gallery,
};
@@ -35,6 +35,10 @@ pub enum Step {
CloseNative(&'static str),
/// Fails unless the window is gone.
ExpectClosed(&'static str),
/// Photographs this app's frontmost popup, such as the share picker.
ShotPopup(&'static str),
/// Posts a key to AppKit itself; only `escape` is known.
NativeKey(&'static str),
}
const FRAME: Duration = Duration::from_millis(120);
@@ -152,6 +156,16 @@ pub async fn play(
let number = opened(key, cx)?.update(cx, |_, window, _| number(window))??;
perform_close(number)?;
}
Step::ShotPopup(name) => {
let file = path(name);
snapshot(popup_number()?, &file)?;
log::info!("script: wrote {}", file.display());
}
Step::NativeKey(key) => {
anyhow::ensure!(key == "escape", "native key {key} is not known");
let number = window.update(cx, |_, window, _| number(window))??;
post_escape(number)?;
}
Step::ExpectClosed(key) => {
let handle = opened(key, cx)?;
let open = cx.update(|cx| cx.windows().contains(&handle))?;
@@ -215,6 +229,40 @@ fn post(kind: Mouse, x: f64, y: f64, number: u32) -> Result<()> {
Ok(())
}
/// Queues an Escape key press on this app, for AppKit popovers.
#[cfg(target_os = "macos")]
fn post_escape(number: u32) -> Result<()> {
use cocoa::{
appkit::{NSApp, NSApplication, NSEvent, NSEventModifierFlags, NSEventType},
base::{NO, id, nil},
foundation::{NSPoint, NSString},
};
unsafe {
let escape = NSString::alloc(nil).init_str("\u{1b}");
let event = <id as NSEvent>::keyEventWithType_location_modifierFlags_timestamp_windowNumber_context_characters_charactersIgnoringModifiers_isARepeat_keyCode_(
nil,
NSEventType::NSKeyDown,
NSPoint::new(0.0, 0.0),
NSEventModifierFlags::empty(),
0.0,
i64::from(number),
nil,
escape,
escape,
NO,
53,
);
anyhow::ensure!(event != nil, "AppKit refused a synthetic key event");
NSApp().postEvent_atStart_(event, NO);
}
Ok(())
}
#[cfg(not(target_os = "macos"))]
fn post_escape(_: u32) -> Result<()> {
anyhow::bail!("native keys need macOS")
}
/// Runs `performClose:` on a window, which asks before closing.
#[cfg(target_os = "macos")]
fn perform_close(number: u32) -> Result<()> {