From 817da41c22000525e0f788c54e7f402b17f92274 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 25 Sep 2026 03:51:34 -0400 Subject: [PATCH] T05 buttons and actions: groups, toggles, segmented, confirm, copy, share, bars, sheet, quick actions --- AGENTS.md | 5 +- Cargo.toml | 2 +- TASKS.md | 5 +- examples/gallery/capture.rs | 39 +++ examples/gallery/pages/buttons/actions.rs | 301 +++++++++++++++++ .../pages/{buttons.rs => buttons/basics.rs} | 60 +++- examples/gallery/pages/buttons/choices.rs | 167 ++++++++++ examples/gallery/pages/buttons/mod.rs | 89 +++++ examples/gallery/script.rs | 50 ++- src/buttons/bars.rs | 162 +++++++++ src/buttons/button.rs | 52 ++- src/buttons/confirm.rs | 287 ++++++++++++++++ src/buttons/copy.rs | 94 ++++++ src/buttons/fab.rs | 100 ++++++ src/buttons/group.rs | 311 ++++++++++++++++++ src/buttons/icon_button.rs | 14 +- src/buttons/mod.rs | 20 ++ src/buttons/presets.rs | 19 ++ src/buttons/quick.rs | 205 ++++++++++++ src/buttons/segmented.rs | 246 ++++++++++++++ src/buttons/share.rs | 166 ++++++++++ src/buttons/sheet.rs | 202 ++++++++++++ src/primitives/focus.rs | 44 ++- src/primitives/mod.rs | 1 + src/shell/switcher.rs | 40 +-- src/theme/tokens.rs | 5 + src/typography/copy.rs | 80 +---- tasks/ch01-10.md | 42 +-- tasks/ch11-20.md | 2 +- 29 files changed, 2659 insertions(+), 151 deletions(-) create mode 100644 examples/gallery/pages/buttons/actions.rs rename examples/gallery/pages/{buttons.rs => buttons/basics.rs} (69%) create mode 100644 examples/gallery/pages/buttons/choices.rs create mode 100644 examples/gallery/pages/buttons/mod.rs create mode 100644 src/buttons/bars.rs create mode 100644 src/buttons/confirm.rs create mode 100644 src/buttons/copy.rs create mode 100644 src/buttons/fab.rs create mode 100644 src/buttons/group.rs create mode 100644 src/buttons/presets.rs create mode 100644 src/buttons/quick.rs create mode 100644 src/buttons/segmented.rs create mode 100644 src/buttons/share.rs create mode 100644 src/buttons/sheet.rs diff --git a/AGENTS.md b/AGENTS.md index e4808ef..4c8ca39 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,8 +42,11 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - `hover()` needs an element id. - gpui sends a drag's moves to every listener of its type. Each drag payload carries its owner's `EntityId`, and handlers check it. - A component that takes a starting value from its owner keeps it in `layout::seeded`: a new value from the owner replaces local drags. -- The capture harness finds windows by gpui handle through `raw-window-handle`, never by scanning the process's windows. +- The capture harness finds gpui windows by handle through `raw-window-handle`. AppKit popups, which gpui does not own, come from the window list by process and level. - Posted events cannot move a macOS window, so window drags are not scripted. `drag_region` follows Zed's title bar. +- An overlay takes focus with `primitives::take_focus` and returns it with `give_back` on every close path. +- A state change made outside render calls `cx.notify()`, or nothing redraws. +- The macOS share picker draws its content in another process; a capture shows only its frame. - Set an explicit line height on any box that clips text. gpui's default leading is taller than a tight box, and the clip eats descenders. - `img()` keeps loading state only with an id. Content masks are rectangles, so rounded corners survive only when the image fills its box without cropping. diff --git a/Cargo.toml b/Cargo.toml index 015d5f9..459b562 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ unicode-segmentation = "1" [target.'cfg(target_os = "macos")'.dependencies] cocoa = "0.26" objc = "0.2" +raw-window-handle = "0.6" [dev-dependencies] env_logger = "0.11" @@ -36,7 +37,6 @@ env_logger = "0.11" core-foundation = "0.10" core-graphics = "0.24" image = { version = "0.25", default-features = false, features = ["png"] } -raw-window-handle = "0.6" [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(feature, values("cargo-clippy"))'] } diff --git a/TASKS.md b/TASKS.md index b45f7cf..41db440 100644 --- a/TASKS.md +++ b/TASKS.md @@ -21,7 +21,7 @@ Per-component lines live in `tasks/`. Tags there: - [x] T02 Typography - [x] T03 Layout - [x] T04 Window & Shell -- [ ] T05 Buttons & Actions +- [x] T05 Buttons & Actions - [ ] T06a Forms · Text - [ ] T06b Forms · Selection - [ ] T06c Forms · Date & Time @@ -116,3 +116,6 @@ Per-component lines live in `tasks/`. Tags there: | T04 | 1 | FAIL | system close skipped TitleBar::on_close; hosted windows had no Tab scope; switcher lost Tab to an ancestor, dropped focus on close, kept a stale index | | T04 | 2 | FAIL | a removed on_close kept blocking the system close; switcher keys bubbled past it | | T04 | 3 | PASS | | +| T05 | 1 | FAIL | hold task kept its state alive; a third click wedged the done state; border_1 undid the group seam | +| T05 | 2 | FAIL | AGENTS.md said no window is found by scanning; popups are | +| T05 | 3 | PASS | | diff --git a/examples/gallery/capture.rs b/examples/gallery/capture.rs index 194ac71..59d2654 100644 --- a/examples/gallery/capture.rs +++ b/examples/gallery/capture.rs @@ -100,6 +100,45 @@ pub fn number(window: &Window) -> Result { 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 { + use core_foundation::{ + base::{CFType, TCFType}, + dictionary::CFDictionary, + number::CFNumber, + string::CFString, + }; + use core_graphics::window; + + let read = |dict: &CFDictionary, key: &'static str| { + dict.find(CFString::from_static_string(key)) + .and_then(|value| value.downcast::()) + .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::::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 { + bail!("capture needs macOS window APIs") +} + #[cfg(not(target_os = "macos"))] pub fn number(_: &Window) -> Result { bail!("capture needs macOS window APIs") diff --git a/examples/gallery/pages/buttons/actions.rs b/examples/gallery/pages/buttons/actions.rs new file mode 100644 index 0000000..2dedeb9 --- /dev/null +++ b/examples/gallery/pages/buttons/actions.rs @@ -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.", + ))) +} diff --git a/examples/gallery/pages/buttons.rs b/examples/gallery/pages/buttons/basics.rs similarity index 69% rename from examples/gallery/pages/buttons.rs rename to examples/gallery/pages/buttons/basics.rs index da9ddcf..9bb3f7e 100644 --- a/examples/gallery/pages/buttons.rs +++ b/examples/gallery/pages/buttons/basics.rs @@ -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() } diff --git a/examples/gallery/pages/buttons/choices.rs b/examples/gallery/pages/buttons/choices.rs new file mode 100644 index 0000000..dbef1ed --- /dev/null +++ b/examples/gallery/pages/buttons/choices.rs @@ -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> { + 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>| { + 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| { + 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, + )), + ) +} diff --git a/examples/gallery/pages/buttons/mod.rs b/examples/gallery/pages/buttons/mod.rs new file mode 100644 index 0000000..cea3f98 --- /dev/null +++ b/examples/gallery/pages/buttons/mod.rs @@ -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() +} diff --git a/examples/gallery/script.rs b/examples/gallery/script.rs index 8760a92..7152aa3 100644 --- a/examples/gallery/script.rs +++ b/examples/gallery/script.rs @@ -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 = ::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<()> { diff --git a/src/buttons/bars.rs b/src/buttons/bars.rs new file mode 100644 index 0000000..8045120 --- /dev/null +++ b/src/buttons/bars.rs @@ -0,0 +1,162 @@ +use std::rc::Rc; + +use gpui::{ + Animation, AnimationExt, AnyElement, App, Div, ElementId, IntoElement, ParentElement, + RenderOnce, StyleRefinement, Styled, Window, div, prelude::*, +}; +use smallvec::SmallVec; + +use super::IconButton; +use crate::{ + motion, + primitives::IconName, + theme::{ActiveTheme, ControlSize, Elevation, TextSize}, + typography::AnimatedNumber, +}; + +/// Actions in a row at a region's foot: docked, or floating as a pill. +#[derive(IntoElement)] +pub struct ActionBar { + base: Div, + floating: bool, + children: SmallVec<[AnyElement; 4]>, +} + +impl ActionBar { + pub fn new() -> Self { + Self { + base: div(), + floating: false, + children: SmallVec::new(), + } + } + + /// A raised pill instead of a full-width strip. + pub fn floating(mut self) -> Self { + self.floating = true; + self + } +} + +impl Default for ActionBar { + fn default() -> Self { + Self::new() + } +} + +impl Styled for ActionBar { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } +} + +impl ParentElement for ActionBar { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl RenderOnce for ActionBar { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = &theme.colors; + let bar = self.base.flex().items_center().gap_2(); + let bar = if self.floating { + bar.p_1p5() + .rounded_full() + .bg(colors.overlay) + .border_1() + .border_color(colors.border) + .shadow(theme.elevation(Elevation::Floating)) + } else { + bar.w_full() + .justify_end() + .px_4() + .py_3() + .bg(colors.surface) + .border_t_1() + .border_color(colors.border) + }; + bar.children(self.children).with_animation( + "action-bar-in", + Animation::new(motion::duration(motion::BASE, cx)).with_easing(motion::ease_out_cubic), + |bar, t| bar.opacity(t).mt(motion::NUDGE * 2.0 * (1.0 - t)), + ) + } +} + +type OnClear = Rc; + +/// Rises when items are selected: the count, bulk actions, and a clear button. +#[derive(IntoElement)] +pub struct BulkActionBar { + id: ElementId, + count: usize, + actions: SmallVec<[AnyElement; 4]>, + on_clear: Option, +} + +impl BulkActionBar { + pub fn new(id: impl Into, count: usize) -> Self { + Self { + id: id.into(), + count, + actions: SmallVec::new(), + on_clear: None, + } + } + + pub fn action(mut self, action: impl IntoElement) -> Self { + self.actions.push(action.into_any_element()); + self + } + + pub fn on_clear(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_clear = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for BulkActionBar { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + if self.count == 0 { + return div().into_any_element(); + } + let theme = cx.theme(); + let colors = &theme.colors; + ActionBar::new() + .floating() + .child( + div() + .flex() + .items_center() + .gap_1() + .pl_2() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(colors.fg) + .child( + AnimatedNumber::new(self.id.clone(), self.count as f64).size(TextSize::Sm), + ) + .child("selected"), + ) + .child( + div() + .h(theme.control_height(ControlSize::Sm)) + .border_l_1() + .border_color(colors.border), + ) + .children(self.actions) + .when_some(self.on_clear, |bar, clear| { + bar.child( + IconButton::new("bulk-clear", IconName::X) + .size(ControlSize::Sm) + .tooltip("Clear selection") + .on_click(move |_, window, cx| { + log::info!("bulk action bar: cleared"); + clear(window, cx) + }), + ) + }) + .into_any_element() + } +} diff --git a/src/buttons/button.rs b/src/buttons/button.rs index 925f405..2908413 100644 --- a/src/buttons/button.rs +++ b/src/buttons/button.rs @@ -1,11 +1,12 @@ use gpui::{ - App, ClickEvent, ElementId, FontWeight, Hsla, IntoElement, MouseButton, RenderOnce, + App, ClickEvent, ElementId, FontWeight, Hsla, IntoElement, Keystroke, MouseButton, RenderOnce, SharedString, Window, div, prelude::*, transparent_black, }; use crate::{ primitives::{FocusRing, Icon, IconName}, theme::{ActiveTheme, ControlSize, IconSize, Mix, Palette, Radius, TextSize}, + typography::keys::{Platform, keystroke_labels}, }; pub(crate) type ClickHandler = Box; @@ -93,16 +94,33 @@ pub(crate) fn label_size(size: ControlSize) -> (TextSize, IconSize) { } } +/// Where a button sits inside a `ButtonGroup`. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum Slot { + First, + Middle, + Last, +} + +/// A keystroke as the platform spells it: `⌘S`, or `Ctrl+S`. +pub(crate) fn shortcut_text(stroke: &Keystroke) -> String { + let platform = Platform::current(); + let glue = if platform == Platform::Mac { "" } else { "+" }; + keystroke_labels(stroke, platform).join(glue) +} + #[derive(IntoElement)] pub struct Button { id: ElementId, label: SharedString, icon: Option, trailing_icon: Option, + shortcut: Option, variant: ButtonVariant, size: ControlSize, disabled: bool, full_width: bool, + pub(crate) slot: Option, on_click: Option, } @@ -113,10 +131,12 @@ impl Button { label: label.into(), icon: None, trailing_icon: None, + shortcut: None, variant: ButtonVariant::default(), size: ControlSize::default(), disabled: false, full_width: false, + slot: None, on_click: None, } } @@ -145,6 +165,14 @@ impl Button { self } + /// Shows the keystroke that also runs it, e.g. `"cmd-s"`. Panics on a bad keystroke. + pub fn shortcut(mut self, keystroke: &str) -> Self { + let parsed = Keystroke::parse(keystroke) + .unwrap_or_else(|error| panic!("button shortcut {keystroke:?}: {error}")); + self.shortcut = Some(parsed); + self + } + pub fn disabled(mut self, disabled: bool) -> Self { self.disabled = disabled; self @@ -171,6 +199,8 @@ impl RenderOnce for Button { let (text, icon_size) = label_size(self.size); let link = self.variant == ButtonVariant::Link; let icon = |name| Icon::new(name).size(icon_size).color(tone.fg); + let radius = theme.radius(Radius::Md); + let hint = self.shortcut.as_ref().map(shortcut_text); div() .id(self.id) @@ -181,17 +211,31 @@ impl RenderOnce for Button { .gap_1p5() .h(theme.control_height(self.size)) .when(!link, |el| el.px(theme.control_padding(self.size))) - .rounded(theme.radius(Radius::Md)) + .border_1() + .border_color(tone.border) + .map(|el| match self.slot { + None => el.rounded(radius), + Some(Slot::First) => el.rounded_l(radius), + Some(Slot::Middle) => el.border_l_0(), + Some(Slot::Last) => el.rounded_r(radius).border_l_0(), + }) .text_size(theme.text_size(text)) .font_weight(FontWeight::MEDIUM) .text_color(tone.fg) .bg(tone.bg) - .border_1() - .border_color(tone.border) .when(self.full_width, |el| el.w_full()) .when_some(self.icon, |el, name| el.child(icon(name))) .child(self.label) .when_some(self.trailing_icon, |el, name| el.child(icon(name))) + .when_some(hint, |el, hint| { + el.child( + div() + .pl_1() + .font_weight(FontWeight::NORMAL) + .text_color(tone.fg.opacity(0.55)) + .child(hint), + ) + }) .map(|el| { if self.disabled { return el.opacity(0.45).cursor_not_allowed(); diff --git a/src/buttons/confirm.rs b/src/buttons/confirm.rs new file mode 100644 index 0000000..8bfaee3 --- /dev/null +++ b/src/buttons/confirm.rs @@ -0,0 +1,287 @@ +use std::{rc::Rc, time::Duration}; + +use gpui::{ + Animation, AnimationExt, App, ElementId, FontWeight, InteractiveElement, IntoElement, + MouseButton, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Task, + Window, div, prelude::*, relative, +}; + +use super::button::{ButtonVariant, label_size, tone}; +use crate::{ + motion, + primitives::{FocusRing, Icon, IconName}, + theme::{ActiveTheme, ControlSize, Radius}, +}; + +/// How long a hold takes to confirm. +const HOLD: Duration = Duration::from_millis(1200); +/// How long a first click stays armed. +const ARMED: Duration = Duration::from_millis(3000); +/// How long the done state shows. +const DONE: Duration = Duration::from_millis(1400); + +/// Hold the button down, or click it twice. Keys always press twice. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ConfirmMode { + Hold, + Twice, +} + +#[derive(Default)] +struct Confirm { + holding: bool, + armed: bool, + done: bool, + generation: u64, + _timer: Option>, +} + +type OnConfirm = Rc; + +/// A destructive button that asks twice: by holding, or by a second click. +#[derive(IntoElement)] +pub struct ConfirmButton { + id: ElementId, + label: SharedString, + armed_label: SharedString, + mode: ConfirmMode, + size: ControlSize, + on_confirm: Option, +} + +impl ConfirmButton { + pub fn new( + id: impl Into, + label: impl Into, + mode: ConfirmMode, + ) -> Self { + let label = label.into(); + Self { + id: id.into(), + armed_label: match mode { + ConfirmMode::Hold => "Press again to confirm".into(), + ConfirmMode::Twice => "Click again to confirm".into(), + }, + label, + mode, + size: ControlSize::default(), + on_confirm: None, + } + } + + /// The label while waiting for the second click. + pub fn armed_label(mut self, label: impl Into) -> Self { + self.armed_label = label.into(); + self + } + + pub fn size(mut self, size: ControlSize) -> Self { + self.size = size; + self + } + + pub fn on_confirm(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_confirm = Some(Rc::new(handler)); + self + } +} + +/// Clears `field` after `after`, unless a newer change came first. +fn expire( + state: &gpui::Entity, + after: Duration, + clear: fn(&mut Confirm), + window: &mut Window, + cx: &mut App, +) -> Task<()> { + let (weak, generation) = (state.downgrade(), state.read(cx).generation); + window.spawn(cx, async move |cx| { + cx.background_executor().timer(after).await; + let cleared = weak.update(cx, |confirm, cx| { + if confirm.generation == generation { + clear(confirm); + cx.notify(); + } + }); + if let Err(error) = cleared { + log::error!("confirm button: state gone before it expired: {error:#}"); + } + }) +} + +fn confirm_now( + state: &gpui::Entity, + handler: &Option, + window: &mut Window, + cx: &mut App, +) { + log::info!("confirm button: confirmed"); + state.update(cx, |confirm, _| { + confirm.holding = false; + confirm.armed = false; + confirm.done = true; + confirm.generation += 1; + }); + let timer = expire(state, DONE, |confirm| confirm.done = false, window, cx); + state.update(cx, |confirm, cx| { + confirm._timer = Some(timer); + cx.notify(); + }); + if let Some(handler) = handler { + handler(window, cx); + } +} + +impl RenderOnce for ConfirmButton { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let state = window.use_keyed_state(self.id.clone(), cx, |_, _| Confirm::default()); + let (holding, armed, done, generation) = { + let confirm = state.read(cx); + ( + confirm.holding, + confirm.armed, + confirm.done, + confirm.generation, + ) + }; + let theme = cx.theme(); + let colors = &theme.colors; + let variant = if armed { + ButtonVariant::Danger + } else { + ButtonVariant::Outline + }; + let tone = tone(variant, colors); + let (text, icon_size) = label_size(self.size); + let label = if armed { + self.armed_label.clone() + } else { + self.label.clone() + }; + let fill = colors.danger.opacity(0.16); + let handler = self.on_confirm.clone(); + let (press, release, out, click) = + (state.clone(), state.clone(), state.clone(), state.clone()); + let hold = self.mode == ConfirmMode::Hold; + let (hold_handler, click_handler) = (handler.clone(), handler); + div() + .id(self.id) + .relative() + .overflow_hidden() + .flex() + .flex_none() + .items_center() + .justify_center() + .gap_1p5() + .h(theme.control_height(self.size)) + .px(theme.control_padding(self.size)) + .rounded(theme.radius(Radius::Md)) + .border_1() + .border_color(if holding { colors.danger } else { tone.border }) + .bg(tone.bg) + .text_size(theme.text_size(text)) + .font_weight(FontWeight::MEDIUM) + .text_color(if holding { colors.danger } else { tone.fg }) + .cursor_pointer() + .tab_index(0) + .focus_ring(cx) + .hover(|style| style.bg(tone.hover)) + .on_mouse_down(MouseButton::Left, move |_, window, cx| { + window.prevent_default(); + if !hold || press.read(cx).done { + return; + } + press.update(cx, |confirm, _| { + confirm.holding = true; + confirm.generation += 1; + }); + let (weak, handler) = (press.downgrade(), hold_handler.clone()); + let generation = press.read(cx).generation; + let timer = window.spawn(cx, async move |cx| { + cx.background_executor().timer(HOLD).await; + let held = cx.update(|window, cx| { + let Some(state) = weak.upgrade() else { + return; + }; + if state.read(cx).holding && state.read(cx).generation == generation { + confirm_now(&state, &handler, window, cx); + } + }); + if let Err(error) = held { + log::error!("confirm button: window gone during a hold: {error:#}"); + } + }); + press.update(cx, |confirm, cx| { + confirm._timer = Some(timer); + cx.notify(); + }); + }) + .on_mouse_up(MouseButton::Left, move |_, _, cx| let_go(&release, cx)) + .on_mouse_up_out(MouseButton::Left, move |_, _, cx| let_go(&out, cx)) + .on_click(move |event, window, cx| { + if (hold && !event.is_keyboard()) || click.read(cx).done { + return; + } + if click.read(cx).armed { + confirm_now(&click, &click_handler, window, cx); + return; + } + log::info!("confirm button: armed"); + click.update(cx, |confirm, _| { + confirm.armed = true; + confirm.generation += 1; + }); + let timer = expire(&click, ARMED, |confirm| confirm.armed = false, window, cx); + click.update(cx, |confirm, cx| { + confirm._timer = Some(timer); + cx.notify(); + }); + }) + .when(holding, |button| { + button.child( + div() + .absolute() + .top_0() + .bottom_0() + .left_0() + .bg(fill) + .with_animation( + ("confirm-hold", generation), + Animation::new(HOLD), + |bar, t| bar.w(relative(t)), + ), + ) + }) + .map(|button| { + if done { + button.child( + Icon::new(IconName::Check) + .size(icon_size) + .color(colors.success) + .with_animation( + ("confirm-done", generation), + Animation::new(motion::duration(motion::FAST, cx)) + .with_easing(motion::ease_out_cubic), + |icon, t| icon.rotate(gpui::radians((1.0 - t) * -0.6)), + ), + ) + } else { + button + } + }) + .child(label) + } +} + +fn let_go(state: &gpui::Entity, cx: &mut App) { + if !state.read(cx).holding { + return; + } + log::info!("confirm button: released early"); + state.update(cx, |confirm, cx| { + confirm.holding = false; + confirm.generation += 1; + confirm._timer = None; + cx.notify(); + }); +} diff --git a/src/buttons/copy.rs b/src/buttons/copy.rs new file mode 100644 index 0000000..48da63a --- /dev/null +++ b/src/buttons/copy.rs @@ -0,0 +1,94 @@ +use std::time::Duration; + +use gpui::{ + Animation, AnimationExt, App, ClipboardItem, ElementId, InteractiveElement, IntoElement, + ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Task, Window, div, + prelude::*, +}; + +use super::IconButton; +use crate::{ + motion, + primitives::{Icon, IconName, Tooltip}, + theme::{ActiveTheme, ControlSize, IconSize}, +}; + +const CONFIRM: Duration = Duration::from_millis(1400); + +#[derive(Default)] +struct Copied { + count: u32, + _reset: Option>, +} + +/// Copies text, then shows a check for a moment. +#[derive(IntoElement)] +pub struct CopyButton { + id: ElementId, + text: SharedString, +} + +impl CopyButton { + pub fn new(id: impl Into, text: impl Into) -> Self { + Self { + id: id.into(), + text: text.into(), + } + } +} + +impl RenderOnce for CopyButton { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let state = window.use_keyed_state(self.id.clone(), cx, |_, _| Copied::default()); + let count = state.read(cx).count; + let copied = state.read(cx)._reset.is_some(); + let theme = cx.theme(); + let success = theme.colors.success; + let text = self.text; + let button = IconButton::new("copy", IconName::Copy) + .size(ControlSize::Sm) + .on_click(move |_, window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(text.to_string())); + log::info!("copy button: {} characters copied", text.chars().count()); + let weak = state.downgrade(); + let reset = window.spawn(cx, async move |cx| { + cx.background_executor().timer(CONFIRM).await; + if let Err(error) = weak.update(cx, |copied, cx| { + copied._reset = None; + cx.notify(); + }) { + log::error!("copy button: reset lost its state: {error:#}"); + } + }); + state.update(cx, |copied, cx| { + copied.count += 1; + copied._reset = Some(reset); + cx.notify(); + }); + }); + let check = Icon::new(IconName::Check) + .size(IconSize::Sm) + .color(success) + .with_animation( + ("copied", count), + Animation::new(motion::duration(motion::FAST, cx)) + .with_easing(motion::ease_out_cubic), + |icon, t| icon.rotate(gpui::radians((1.0 - t) * -0.6)), + ); + div() + .id(self.id) + .flex_none() + .size(theme.control_height(ControlSize::Sm)) + .flex() + .items_center() + .justify_center() + .tooltip(Tooltip::text(if copied { "Copied" } else { "Copy" })) + .map(|slot| { + if copied { + slot.child(check) + } else { + slot.child(button) + } + }) + } +} diff --git a/src/buttons/fab.rs b/src/buttons/fab.rs new file mode 100644 index 0000000..3bdf64a --- /dev/null +++ b/src/buttons/fab.rs @@ -0,0 +1,100 @@ +use gpui::{ + Animation, AnimationExt, App, ClickEvent, ElementId, FontWeight, InteractiveElement, + IntoElement, MouseButton, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, + Styled, Window, div, prelude::*, +}; + +use super::button::ClickHandler; +use crate::{ + motion, + primitives::{FocusRing, Icon, IconName, Tooltip}, + theme::{ActiveTheme, Elevation, IconSize, TextSize}, +}; + +/// A raised round button for a screen's main action; a label makes it a pill. +#[derive(IntoElement)] +pub struct FloatingActionButton { + id: ElementId, + icon: IconName, + label: Option, + tooltip: Option, + on_click: Option, +} + +impl FloatingActionButton { + pub fn new(id: impl Into, icon: IconName) -> Self { + Self { + id: id.into(), + icon, + label: None, + tooltip: None, + on_click: None, + } + } + + pub fn label(mut self, label: impl Into) -> Self { + self.label = Some(label.into()); + self + } + + pub fn tooltip(mut self, text: impl Into) -> Self { + self.tooltip = Some(text.into()); + self + } + + pub fn on_click( + mut self, + handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_click = Some(Box::new(handler)); + self + } +} + +impl RenderOnce for FloatingActionButton { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = &theme.colors; + let side = theme.fab_size(); + let (hover, lifted) = (colors.accent_hover, theme.elevation(Elevation::Modal)); + div() + .id(self.id) + .flex() + .flex_none() + .items_center() + .justify_center() + .gap_2() + .h(side) + .map(|fab| match &self.label { + Some(_) => fab.pl_5().pr_6(), + None => fab.w(side), + }) + .rounded_full() + .bg(colors.accent) + .text_color(colors.on_accent) + .text_size(theme.text_size(TextSize::Md)) + .font_weight(FontWeight::MEDIUM) + .border_1() + .border_color(gpui::transparent_black()) + .shadow(theme.elevation(Elevation::Floating)) + .cursor_pointer() + .tab_index(0) + .focus_ring(cx) + .hover(move |style| style.bg(hover).shadow(lifted)) + .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) + .when_some(self.on_click, |fab, handler| fab.on_click(handler)) + .when_some(self.tooltip, |fab, text| fab.tooltip(Tooltip::text(text))) + .child( + Icon::new(self.icon) + .size(IconSize::Lg) + .color(colors.on_accent), + ) + .when_some(self.label, |fab, label| fab.child(label)) + .with_animation( + "fab-in", + Animation::new(motion::duration(motion::SLOW, cx)) + .with_easing(motion::ease_out_cubic), + |fab, t| fab.opacity(t).mt(motion::NUDGE * 2.0 * (1.0 - t)), + ) + } +} diff --git a/src/buttons/group.rs b/src/buttons/group.rs new file mode 100644 index 0000000..079a6f6 --- /dev/null +++ b/src/buttons/group.rs @@ -0,0 +1,311 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, FontWeight, InteractiveElement, IntoElement, MouseButton, ParentElement, + RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*, +}; + +use super::{ + Button, + button::{Slot, label_size}, +}; +use crate::{ + primitives::{FocusRing, Icon, IconName, Tooltip}, + theme::{ActiveTheme, ControlSize, Radius}, +}; + +/// Buttons joined edge to edge. +#[derive(IntoElement, Default)] +pub struct ButtonGroup { + buttons: Vec