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

+4 -1
View File
@@ -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.
+1 -1
View File
@@ -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"))'] }
+4 -1
View File
@@ -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 | |
+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<()> {
+162
View File
@@ -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<Item = AnyElement>) {
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<dyn Fn(&mut Window, &mut App)>;
/// 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<OnClear>,
}
impl BulkActionBar {
pub fn new(id: impl Into<ElementId>, 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()
}
}
+48 -4
View File
@@ -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<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
@@ -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<IconName>,
trailing_icon: Option<IconName>,
shortcut: Option<Keystroke>,
variant: ButtonVariant,
size: ControlSize,
disabled: bool,
full_width: bool,
pub(crate) slot: Option<Slot>,
on_click: Option<ClickHandler>,
}
@@ -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();
+287
View File
@@ -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<Task<()>>,
}
type OnConfirm = Rc<dyn Fn(&mut Window, &mut App)>;
/// 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<OnConfirm>,
}
impl ConfirmButton {
pub fn new(
id: impl Into<ElementId>,
label: impl Into<SharedString>,
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<SharedString>) -> 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<Confirm>,
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<Confirm>,
handler: &Option<OnConfirm>,
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<Confirm>, 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();
});
}
+94
View File
@@ -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<Task<()>>,
}
/// 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<ElementId>, text: impl Into<SharedString>) -> 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)
}
})
}
}
+100
View File
@@ -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<SharedString>,
tooltip: Option<SharedString>,
on_click: Option<ClickHandler>,
}
impl FloatingActionButton {
pub fn new(id: impl Into<ElementId>, icon: IconName) -> Self {
Self {
id: id.into(),
icon,
label: None,
tooltip: None,
on_click: None,
}
}
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
self.label = Some(label.into());
self
}
pub fn tooltip(mut self, text: impl Into<SharedString>) -> 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)),
)
}
}
+311
View File
@@ -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<Button>,
}
impl ButtonGroup {
pub fn new() -> Self {
Self::default()
}
pub fn button(mut self, button: Button) -> Self {
self.buttons.push(button);
self
}
}
impl RenderOnce for ButtonGroup {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
let count = self.buttons.len();
assert!(count >= 2, "a button group joins two buttons or more");
div().flex().children(
self.buttons
.into_iter()
.enumerate()
.map(|(ix, mut button)| {
button.slot = Some(match ix {
0 => Slot::First,
ix if ix + 1 == count => Slot::Last,
_ => Slot::Middle,
});
button
}),
)
}
}
/// One choice in a toggle: a label, an icon, or both.
#[derive(Clone)]
pub struct ToggleItem {
value: SharedString,
label: Option<SharedString>,
icon: Option<IconName>,
tooltip: Option<SharedString>,
}
impl ToggleItem {
pub fn new(value: impl Into<SharedString>) -> Self {
Self {
value: value.into(),
label: None,
icon: None,
tooltip: None,
}
}
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
self.label = Some(label.into());
self
}
pub fn icon(mut self, icon: IconName) -> Self {
self.icon = Some(icon);
self
}
pub fn tooltip(mut self, text: impl Into<SharedString>) -> Self {
self.tooltip = Some(text.into());
self
}
}
type OnPress = Rc<dyn Fn(&mut Window, &mut App)>;
fn toggle_face(
id: ElementId,
item: &ToggleItem,
on: bool,
size: ControlSize,
press: OnPress,
cx: &App,
) -> impl IntoElement + use<> {
assert!(
item.label.is_some() || item.icon.is_some(),
"toggle {} needs a label or an icon",
item.value
);
let theme = cx.theme();
let colors = &theme.colors;
let (text, icon_size) = label_size(size);
let fg = if on { colors.fg } else { colors.fg_muted };
let hover = colors.hover;
div()
.id(id)
.flex()
.flex_none()
.items_center()
.justify_center()
.gap_1p5()
.h(theme.control_height(size))
.min_w(theme.control_height(size))
.when(item.label.is_some(), |face| {
face.px(theme.control_padding(size))
})
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(gpui::transparent_black())
.text_size(theme.text_size(text))
.font_weight(FontWeight::MEDIUM)
.text_color(fg)
.map(|face| {
if on {
face.bg(colors.active)
} else {
face.hover(|style| style.bg(hover))
}
})
.cursor_pointer()
.tab_index(0)
.focus_ring(cx)
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.on_click(move |_, window, cx| press(window, cx))
.when_some(item.icon, |face, icon| {
face.child(Icon::new(icon).size(icon_size).color(fg))
})
.when_some(item.label.clone(), |face, label| face.child(label))
.when_some(item.tooltip.clone(), |face, text| {
face.tooltip(Tooltip::text(text))
})
}
type OnToggle = Rc<dyn Fn(bool, &mut Window, &mut App)>;
/// A button that stays pressed until pressed again.
#[derive(IntoElement)]
pub struct ToggleButton {
id: ElementId,
item: ToggleItem,
on: bool,
size: ControlSize,
on_toggle: Option<OnToggle>,
}
impl ToggleButton {
pub fn new(id: impl Into<ElementId>, item: ToggleItem, on: bool) -> Self {
Self {
id: id.into(),
item,
on,
size: ControlSize::default(),
on_toggle: None,
}
}
pub fn size(mut self, size: ControlSize) -> Self {
self.size = size;
self
}
pub fn on_toggle(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
self.on_toggle = Some(Rc::new(handler));
self
}
}
impl RenderOnce for ToggleButton {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let (on, toggle, value) = (self.on, self.on_toggle, self.item.value.clone());
let press: OnPress = Rc::new(move |window, cx| {
log::info!("toggle {value}: {}", !on);
if let Some(toggle) = &toggle {
toggle(!on, window, cx);
}
});
toggle_face(self.id, &self.item, on, self.size, press, cx)
}
}
type OnChange = Rc<dyn Fn(&[SharedString], &mut Window, &mut App)>;
/// Toggles side by side. One is chosen, or several with `multiple`.
#[derive(IntoElement)]
pub struct ToggleGroup {
id: ElementId,
items: Vec<ToggleItem>,
selected: Vec<SharedString>,
multiple: bool,
size: ControlSize,
on_change: Option<OnChange>,
}
impl ToggleGroup {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
items: Vec::new(),
selected: Vec::new(),
multiple: false,
size: ControlSize::default(),
on_change: None,
}
}
pub fn item(mut self, item: ToggleItem) -> Self {
self.items.push(item);
self
}
pub fn selected(mut self, values: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
self.selected = values.into_iter().map(Into::into).collect();
self
}
pub fn multiple(mut self) -> Self {
self.multiple = true;
self
}
pub fn size(mut self, size: ControlSize) -> Self {
self.size = size;
self
}
/// Reports the whole selection after each press.
pub fn on_change(
mut self,
handler: impl Fn(&[SharedString], &mut Window, &mut App) + 'static,
) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
/// The selection after pressing `value`.
fn pressed(selected: &[SharedString], value: &SharedString, multiple: bool) -> Vec<SharedString> {
let on = selected.contains(value);
match (multiple, on) {
(_, true) => selected.iter().filter(|v| *v != value).cloned().collect(),
(true, false) => selected.iter().chain([value]).cloned().collect(),
(false, false) => vec![value.clone()],
}
}
impl RenderOnce for ToggleGroup {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let faces: Vec<_> = self
.items
.iter()
.enumerate()
.map(|(ix, item)| {
let on = self.selected.contains(&item.value);
let next = pressed(&self.selected, &item.value, self.multiple);
let change = self.on_change.clone();
let press: OnPress = Rc::new(move |window, cx| {
log::info!("toggle group: {next:?}");
if let Some(change) = &change {
change(&next, window, cx);
}
});
toggle_face(("toggle", ix).into(), item, on, self.size, press, cx)
})
.collect();
div()
.id(self.id)
.flex()
.items_center()
.gap_0p5()
.p_0p5()
.rounded(theme.radius(Radius::Lg))
.border_1()
.border_color(theme.colors.border)
.children(faces)
}
}
#[cfg(test)]
mod tests {
use gpui::SharedString;
use super::pressed;
fn values(list: &[&'static str]) -> Vec<SharedString> {
list.iter().map(|v| SharedString::from(*v)).collect()
}
#[test]
fn single_replaces_multiple_adds_and_pressing_again_clears() {
let b = SharedString::from("b");
assert_eq!(pressed(&values(&["a"]), &b, false), values(&["b"]));
assert_eq!(pressed(&values(&["a"]), &b, true), values(&["a", "b"]));
assert_eq!(pressed(&values(&["a", "b"]), &b, true), values(&["a"]));
assert_eq!(pressed(&values(&["b"]), &b, false), values(&[]));
}
}
+12 -2
View File
@@ -1,10 +1,11 @@
use gpui::{
App, ClickEvent, ElementId, IntoElement, MouseButton, RenderOnce, Window, div, prelude::*,
App, ClickEvent, ElementId, IntoElement, MouseButton, RenderOnce, SharedString, Window, div,
prelude::*,
};
use super::button::{ButtonVariant, ClickHandler, label_size, tone};
use crate::{
primitives::{FocusRing, Icon, IconName},
primitives::{FocusRing, Icon, IconName, Tooltip},
theme::{ActiveTheme, ControlSize, Radius},
};
@@ -16,6 +17,7 @@ pub struct IconButton {
variant: ButtonVariant,
size: ControlSize,
disabled: bool,
tooltip: Option<SharedString>,
on_click: Option<ClickHandler>,
}
@@ -27,6 +29,7 @@ impl IconButton {
variant: ButtonVariant::Ghost,
size: ControlSize::default(),
disabled: false,
tooltip: None,
on_click: None,
}
}
@@ -46,6 +49,12 @@ impl IconButton {
self
}
/// Names what the icon does, on hover.
pub fn tooltip(mut self, text: impl Into<SharedString>) -> Self {
self.tooltip = Some(text.into());
self
}
pub fn on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
@@ -74,6 +83,7 @@ impl RenderOnce for IconButton {
.border_1()
.border_color(tone.border)
.child(Icon::new(self.icon).size(icon_size).color(tone.fg))
.when_some(self.tooltip, |el, text| el.tooltip(Tooltip::text(text)))
.map(|el| {
if self.disabled {
return el.opacity(0.45).cursor_not_allowed();
+20
View File
@@ -1,5 +1,25 @@
mod bars;
mod button;
mod confirm;
mod copy;
mod fab;
mod group;
mod icon_button;
mod presets;
mod quick;
mod segmented;
mod share;
mod sheet;
pub use bars::{ActionBar, BulkActionBar};
pub use button::{Button, ButtonVariant};
pub use confirm::{ConfirmButton, ConfirmMode};
pub use copy::CopyButton;
pub use fab::FloatingActionButton;
pub use group::{ButtonGroup, ToggleButton, ToggleGroup, ToggleItem};
pub use icon_button::IconButton;
pub use presets::{back_button, close_button, more_button};
pub use quick::{QuickAction, QuickActions};
pub use segmented::SegmentedControl;
pub use share::ShareButton;
pub use sheet::ActionSheet;
+19
View File
@@ -0,0 +1,19 @@
use gpui::ElementId;
use super::IconButton;
use crate::primitives::IconName;
/// Closes a panel, dialog or tab.
pub fn close_button(id: impl Into<ElementId>) -> IconButton {
IconButton::new(id, IconName::X).tooltip("Close")
}
/// Returns to the previous view.
pub fn back_button(id: impl Into<ElementId>) -> IconButton {
IconButton::new(id, IconName::ArrowLeft).tooltip("Back")
}
/// Opens more actions.
pub fn more_button(id: impl Into<ElementId>) -> IconButton {
IconButton::new(id, IconName::Ellipsis).tooltip("More")
}
+205
View File
@@ -0,0 +1,205 @@
use std::rc::Rc;
use gpui::{
App, ElementId, Entity, FontWeight, InteractiveElement, IntoElement, Keystroke, ParentElement,
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*,
};
use super::button::shortcut_text;
use crate::{
primitives::{FocusRing, Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
};
type Run = Rc<dyn Fn(&mut Window, &mut App)>;
/// One quick action: what it is, and its shortcut.
#[derive(Clone)]
pub struct QuickAction {
icon: IconName,
label: SharedString,
note: Option<SharedString>,
shortcut: Option<Keystroke>,
run: Run,
}
impl QuickAction {
pub fn new(
icon: IconName,
label: impl Into<SharedString>,
run: impl Fn(&mut Window, &mut App) + 'static,
) -> Self {
Self {
icon,
label: label.into(),
note: None,
shortcut: None,
run: Rc::new(run),
}
}
pub fn note(mut self, note: impl Into<SharedString>) -> Self {
self.note = Some(note.into());
self
}
/// Panics on a bad keystroke.
pub fn shortcut(mut self, keystroke: &str) -> Self {
let parsed = Keystroke::parse(keystroke)
.unwrap_or_else(|error| panic!("quick action shortcut {keystroke:?}: {error}"));
self.shortcut = Some(parsed);
self
}
}
/// A list of actions. The pointer or the arrows choose; Enter or a click runs.
#[derive(IntoElement)]
pub struct QuickActions {
id: ElementId,
actions: Vec<QuickAction>,
}
impl QuickActions {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
actions: Vec::new(),
}
}
pub fn action(mut self, action: QuickAction) -> Self {
self.actions.push(action);
self
}
}
struct Pick {
selected: usize,
focus: gpui::FocusHandle,
}
fn run(action: &QuickAction, window: &mut Window, cx: &mut App) {
log::info!("quick actions: {}", action.label);
(action.run)(window, cx);
}
fn select(state: &Entity<Pick>, ix: usize, cx: &mut App) {
if state.read(cx).selected != ix {
state.update(cx, |pick, cx| {
pick.selected = ix;
cx.notify();
});
}
}
impl RenderOnce for QuickActions {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let count = self.actions.len();
assert!(count > 0, "quick actions {:?} hold no action", self.id);
let state = window.use_keyed_state(self.id.clone(), cx, |_, cx| Pick {
selected: 0,
focus: cx.focus_handle(),
});
let (selected, focus) = {
let pick = state.read(cx);
(pick.selected.min(count - 1), pick.focus.clone())
};
let theme = cx.theme();
let colors = &theme.colors;
let rows: Vec<_> = self
.actions
.iter()
.enumerate()
.map(|(ix, action)| {
let (hover, action_for_click) = (state.clone(), action.clone());
div()
.id(("quick", ix))
.flex()
.items_center()
.gap_3()
.px_2()
.py_1p5()
.rounded(theme.radius(Radius::Md))
.when(ix == selected, |row| row.bg(colors.hover))
.cursor_pointer()
.on_hover(move |hovered, _, cx| {
if *hovered {
select(&hover, ix, cx);
}
})
.on_click(move |_, window, cx| run(&action_for_click, window, cx))
.child(
div()
.flex()
.flex_none()
.items_center()
.justify_center()
.size(theme.control_height(ControlSize::Md))
.rounded(theme.radius(Radius::Md))
.bg(colors.sunken)
.border_1()
.border_color(colors.border)
.child(
Icon::new(action.icon)
.size(IconSize::Sm)
.color(colors.fg_muted),
),
)
.child(
div()
.flex_1()
.flex()
.flex_col()
.child(
div()
.text_size(theme.text_size(TextSize::Base))
.font_weight(FontWeight::MEDIUM)
.text_color(colors.fg)
.child(action.label.clone()),
)
.when_some(action.note.clone(), |text, note| {
text.child(
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_subtle)
.child(note),
)
}),
)
.when_some(action.shortcut.as_ref(), |row, stroke| {
row.child(
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_subtle)
.child(shortcut_text(stroke)),
)
})
})
.collect();
let (keys, actions) = (state, self.actions);
div()
.id(self.id)
.track_focus(&focus)
.flex()
.flex_col()
.gap_0p5()
.p_1()
.rounded(theme.radius(Radius::Lg))
.border_1()
.border_color(colors.border)
.bg(colors.surface)
.tab_index(0)
.focus_ring(cx)
.on_key_down(move |event, window, cx| {
let current = keys.read(cx).selected.min(count - 1);
match event.keystroke.key.as_str() {
"down" => select(&keys, (current + 1) % count, cx),
"up" => select(&keys, (current + count - 1) % count, cx),
"enter" => run(&actions[current], window, cx),
_ => return,
}
cx.stop_propagation();
})
.children(rows)
}
}
+246
View File
@@ -0,0 +1,246 @@
use std::rc::Rc;
use gpui::{
Animation, AnimationExt, App, Bounds, ElementId, FontWeight, InteractiveElement, IntoElement,
MouseButton, ParentElement, Pixels, RenderOnce, SharedString, StatefulInteractiveElement,
Styled, Window, canvas, div, prelude::*,
};
use super::button::label_size;
use crate::{
motion,
primitives::{FocusRing, Icon, IconName},
theme::{ActiveTheme, ControlSize, Elevation, Radius},
};
/// Left edge and width of a segment, inside the control.
type Span = (Pixels, Pixels);
#[derive(Default)]
struct Slide {
origin: Pixels,
spans: Vec<Option<Span>>,
from: Option<Span>,
last: Option<SharedString>,
generation: u64,
}
type OnChange = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
/// Mutually exclusive segments. A thumb slides to the chosen one.
#[derive(IntoElement)]
pub struct SegmentedControl {
id: ElementId,
segments: Vec<(SharedString, SharedString, Option<IconName>)>,
selected: SharedString,
size: ControlSize,
on_change: Option<OnChange>,
}
impl SegmentedControl {
pub fn new(id: impl Into<ElementId>, selected: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
segments: Vec::new(),
selected: selected.into(),
size: ControlSize::default(),
on_change: None,
}
}
pub fn segment(
mut self,
value: impl Into<SharedString>,
label: impl Into<SharedString>,
icon: Option<IconName>,
) -> Self {
self.segments.push((value.into(), label.into(), icon));
self
}
pub fn size(mut self, size: ControlSize) -> Self {
self.size = size;
self
}
pub fn on_change(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
/// Position along a slide from `from` to `to`, overshooting a little.
fn glide(from: Span, to: Span, t: f32) -> Span {
let s = motion::spring(t);
let lerp = |a: Pixels, b: Pixels| a + (b - a) * s;
(lerp(from.0, to.0), lerp(from.1, to.1))
}
impl RenderOnce for SegmentedControl {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let chosen = self
.segments
.iter()
.position(|(value, ..)| *value == self.selected)
.unwrap_or_else(|| panic!("segmented control has no segment {}", self.selected));
let count = self.segments.len();
let state = window.use_keyed_state(self.id.clone(), cx, |_, _| Slide::default());
if state.read(cx).spans.len() != count {
state.update(cx, |slide, _| slide.spans = vec![None; count]);
}
if state.read(cx).last.as_ref() != Some(&self.selected) {
state.update(cx, |slide, _| {
let previous = slide
.last
.as_ref()
.and_then(|last| self.segments.iter().position(|(value, ..)| value == last));
slide.from = previous.and_then(|ix| slide.spans[ix]);
slide.last = Some(self.selected.clone());
slide.generation += 1;
});
}
let (to, from, generation) = {
let slide = state.read(cx);
(slide.spans[chosen], slide.from, slide.generation)
};
let theme = cx.theme();
let colors = &theme.colors;
let (text, icon_size) = label_size(self.size);
let duration = motion::duration(motion::SLOW, cx);
let thumb = to.map(|to| {
let from = from.unwrap_or(to);
div()
.absolute()
.top_0p5()
.bottom_0p5()
.rounded(theme.radius(Radius::Md))
.bg(colors.surface)
.border_1()
.border_color(colors.border)
.shadow(theme.elevation(Elevation::Raised))
.with_animation(
("segment-thumb", generation),
Animation::new(duration),
move |thumb, t| {
let (left, width) = glide(from, to, t);
thumb.left(left).w(width)
},
)
});
let origin_state = state.clone();
let segments = self
.segments
.into_iter()
.enumerate()
.map(|(ix, (value, label, icon))| {
let on = ix == chosen;
let fg = if on { colors.fg } else { colors.fg_muted };
let (change, measure) = (self.on_change.clone(), state.clone());
div()
.id(("segment", ix))
.relative()
.flex()
.flex_1()
.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(gpui::transparent_black())
.text_size(theme.text_size(text))
.font_weight(if on {
FontWeight::SEMIBOLD
} else {
FontWeight::MEDIUM
})
.text_color(fg)
.cursor_pointer()
.tab_index(0)
.focus_ring(cx)
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.on_click(move |_, window, cx| {
if on {
return;
}
log::info!("segmented control: {value}");
if let Some(change) = &change {
change(&value, window, cx);
}
})
.when_some(icon, |segment, icon| {
segment.child(Icon::new(icon).size(icon_size).color(fg))
})
.child(label)
.child(
canvas(
move |bounds: Bounds<Pixels>, _, cx| {
let span = Some((
bounds.left() - measure.read(cx).origin,
bounds.size.width,
));
if measure.read(cx).spans.get(ix) != Some(&span) {
measure.update(cx, |slide, cx| {
slide.spans[ix] = span;
cx.notify();
});
}
},
|_, _, _, _| {},
)
.absolute()
.top_0()
.left_0()
.size_full(),
)
});
div()
.id(self.id)
.relative()
.flex()
.items_center()
.p_0p5()
.rounded(theme.radius(Radius::Lg))
.bg(colors.sunken)
.border_1()
.border_color(colors.border)
.child(
canvas(
move |bounds: Bounds<Pixels>, _, cx| {
if origin_state.read(cx).origin != bounds.left() {
origin_state.update(cx, |slide, cx| {
slide.origin = bounds.left();
cx.notify();
});
}
},
|_, _, _, _| {},
)
.absolute()
.top_0()
.left_0()
.size_full(),
)
.children(thumb)
.children(segments)
}
}
#[cfg(test)]
mod tests {
use gpui::px;
use super::glide;
#[test]
fn glide_starts_at_from_and_settles_on_to() {
let (from, to) = ((px(0.0), px(40.0)), (px(100.0), px(60.0)));
assert_eq!(glide(from, to, 0.0), from);
let (left, width) = glide(from, to, 1.0);
assert!((f32::from(left) - 100.0).abs() < 0.5 && (f32::from(width) - 60.0).abs() < 0.5);
}
}
+166
View File
@@ -0,0 +1,166 @@
use gpui::{
App, Bounds, ElementId, IntoElement, ParentElement, Pixels, RenderOnce, SharedString, Styled,
Window, canvas, div,
};
use super::IconButton;
use crate::primitives::IconName;
/// What to share: text, a link, or both.
#[derive(Clone, Debug, Default)]
struct Items {
text: Option<SharedString>,
url: Option<SharedString>,
}
struct Anchor {
bounds: Bounds<Pixels>,
#[cfg(target_os = "macos")]
picker: Option<mac::Picker>,
}
/// Opens the system share picker beside itself. macOS only.
#[derive(IntoElement)]
pub struct ShareButton {
id: ElementId,
items: Items,
}
impl ShareButton {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
items: Items::default(),
}
}
pub fn text(mut self, text: impl Into<SharedString>) -> Self {
self.items.text = Some(text.into());
self
}
pub fn url(mut self, url: impl Into<SharedString>) -> Self {
self.items.url = Some(url.into());
self
}
}
impl RenderOnce for ShareButton {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
assert!(
self.items.text.is_some() || self.items.url.is_some(),
"share button {:?} has nothing to share",
self.id
);
let anchor = window.use_keyed_state(self.id.clone(), cx, |_, _| Anchor {
bounds: Bounds::default(),
#[cfg(target_os = "macos")]
picker: None,
});
let (measure, open) = (anchor.clone(), anchor);
let items = self.items;
let button = IconButton::new(self.id, IconName::Share2).tooltip("Share");
#[cfg(target_os = "macos")]
let button = button.on_click(move |_, window, cx| {
let bounds = open.read(cx).bounds;
match mac::Picker::show(&items, bounds, window) {
Ok(picker) => open.update(cx, |anchor, _| anchor.picker = Some(picker)),
Err(error) => log::error!("share button: {error:#}"),
}
});
#[cfg(not(target_os = "macos"))]
let button = {
let _ = (open, items);
button
.disabled(true)
.tooltip("Sharing needs macOS in this build")
};
div().relative().child(button).child(
canvas(
move |bounds, _, cx| {
if measure.read(cx).bounds != bounds {
measure.update(cx, |anchor, _| anchor.bounds = bounds);
}
},
|_, _, _, _| {},
)
.absolute()
.top_0()
.left_0()
.size_full(),
)
}
}
#[cfg(target_os = "macos")]
mod mac {
use anyhow::{Context as _, bail};
use cocoa::{
base::{id, nil},
foundation::{NSArray, NSAutoreleasePool, NSPoint, NSRect, NSSize, NSString},
};
use gpui::{Bounds, Pixels, Window};
use objc::{class, msg_send, sel, sel_impl};
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
use super::Items;
/// A shown picker; kept alive until the next share.
pub struct Picker(id);
impl Drop for Picker {
fn drop(&mut self) {
unsafe {
let _: () = msg_send![self.0, release];
}
}
}
fn string(text: &str) -> id {
unsafe { NSString::alloc(nil).init_str(text).autorelease() }
}
impl Picker {
pub fn show(
items: &Items,
bounds: Bounds<Pixels>,
window: &Window,
) -> anyhow::Result<Self> {
let handle = HasWindowHandle::window_handle(window)
.map_err(|error| anyhow::anyhow!("window has no native handle: {error:?}"))?;
let RawWindowHandle::AppKit(appkit) = handle.as_raw() else {
bail!("share needs an AppKit window");
};
let height = f64::from(window.viewport_size().height);
unsafe {
let view = appkit.ns_view.as_ptr() as id;
let mut list: Vec<id> = Vec::new();
if let Some(text) = &items.text {
list.push(string(text));
}
if let Some(url) = &items.url {
let link: id = msg_send![class!(NSURL), URLWithString: string(url)];
anyhow::ensure!(link != nil, "share url {url} does not parse");
list.push(link);
}
let array = NSArray::arrayWithObjects(nil, &list);
let picker: id = msg_send![class!(NSSharingServicePicker), alloc];
let picker: id = msg_send![picker, initWithItems: array];
let picker = (picker != nil)
.then_some(picker)
.context("AppKit refused a share picker")?;
let rect = NSRect::new(
NSPoint::new(
f64::from(bounds.left()),
height - f64::from(bounds.bottom()),
),
NSSize::new(f64::from(bounds.size.width), f64::from(bounds.size.height)),
);
let _: () =
msg_send![picker, showRelativeToRect: rect ofView: view preferredEdge: 1u64];
log::info!("share button: picker shown for {} items", list.len());
Ok(Self(picker))
}
}
}
}
+202
View File
@@ -0,0 +1,202 @@
use std::rc::Rc;
use gpui::{
Animation, AnimationExt, App, ElementId, FontWeight, InteractiveElement, IntoElement,
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div,
prelude::*,
};
use crate::{
motion,
primitives::{Backdrop, FocusScope, Place, give_back, take_focus},
theme::{ActiveTheme, ControlSize, Elevation, Radius, TextSize},
};
type Run = Rc<dyn Fn(&mut Window, &mut App)>;
/// A short list of choices rising from the bottom, with its own cancel.
#[derive(IntoElement)]
pub struct ActionSheet {
id: ElementId,
title: Option<SharedString>,
message: Option<SharedString>,
actions: Vec<(SharedString, bool, Run)>,
on_close: Run,
}
impl ActionSheet {
pub fn new(
id: impl Into<ElementId>,
on_close: impl Fn(&mut Window, &mut App) + 'static,
) -> Self {
Self {
id: id.into(),
title: None,
message: None,
actions: Vec::new(),
on_close: Rc::new(on_close),
}
}
pub fn title(mut self, title: impl Into<SharedString>) -> Self {
self.title = Some(title.into());
self
}
pub fn message(mut self, message: impl Into<SharedString>) -> Self {
self.message = Some(message.into());
self
}
/// Runs `handler`, then closes.
pub fn action(
mut self,
label: impl Into<SharedString>,
handler: impl Fn(&mut Window, &mut App) + 'static,
) -> Self {
self.actions.push((label.into(), false, Rc::new(handler)));
self
}
/// An action that destroys something, set in the danger tone.
pub fn destructive(
mut self,
label: impl Into<SharedString>,
handler: impl Fn(&mut Window, &mut App) + 'static,
) -> Self {
self.actions.push((label.into(), true, Rc::new(handler)));
self
}
}
impl RenderOnce for ActionSheet {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let takeover = take_focus(self.id.clone(), window, cx);
let close: Run = {
let (takeover, on_close) = (takeover.clone(), self.on_close);
Rc::new(move |window, cx| {
give_back(&takeover, window, cx);
on_close(window, cx)
})
};
let theme = cx.theme();
let colors = &theme.colors;
let card = || {
div()
.flex()
.flex_col()
.overflow_hidden()
.rounded(theme.radius(Radius::Xl))
.bg(colors.overlay)
.border_1()
.border_color(colors.border)
.shadow(theme.elevation(Elevation::Modal))
};
let row = |id: ElementId, label: SharedString, fg, strong: bool| {
div()
.id(id)
.flex()
.items_center()
.justify_center()
.h(theme.control_height(ControlSize::Lg)
+ theme.control_height(ControlSize::Sm) / 2.0)
.border_t_1()
.border_color(colors.border)
.text_size(theme.text_size(TextSize::Md))
.font_weight(if strong {
FontWeight::SEMIBOLD
} else {
FontWeight::NORMAL
})
.text_color(fg)
.cursor_pointer()
.tab_index(0)
.focus(|style| style.bg(colors.active))
.hover(|style| style.bg(colors.hover))
.child(label)
};
let heading = (self.title.is_some() || self.message.is_some()).then(|| {
div()
.flex()
.flex_col()
.items_center()
.gap_1()
.px_6()
.py_4()
.text_center()
.when_some(self.title, |head, title| {
head.child(
div()
.text_size(theme.text_size(TextSize::Sm))
.font_weight(FontWeight::SEMIBOLD)
.text_color(colors.fg_muted)
.child(title),
)
})
.when_some(self.message, |head, message| {
head.child(
div()
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg_subtle)
.child(message),
)
})
});
let rows: Vec<_> = self
.actions
.into_iter()
.enumerate()
.map(|(ix, (label, danger, run))| {
let close = close.clone();
let fg = if danger { colors.danger } else { colors.fg };
row(("sheet-action", ix).into(), label.clone(), fg, false).on_click(
move |_, window, cx| {
log::info!("action sheet: {label}");
run(window, cx);
close(window, cx);
},
)
})
.collect();
let (cancel, escape, dismiss) = (close.clone(), close.clone(), close);
let focus = takeover.read(cx).focus.clone();
let panel = div()
.id("action-sheet")
.w(theme.sheet_size())
.pb_4()
.on_key_down(move |event, window, cx| {
if event.keystroke.key == "escape" {
cx.stop_propagation();
escape(window, cx);
}
})
.child(
FocusScope::new(&focus)
.trap()
.flex()
.flex_col()
.gap_2()
.child(card().map(|card| match heading {
Some(heading) => card.child(heading).children(rows),
None => card.children(rows),
}))
.child(
card().child(
row("sheet-cancel".into(), "Cancel".into(), colors.fg, true)
.border_t_0()
.on_click(move |_, window, cx| cancel(window, cx)),
),
),
)
.with_animation(
"action-sheet-in",
Animation::new(motion::duration(motion::BASE, cx))
.with_easing(motion::ease_out_cubic),
|panel, t| panel.opacity(t).mb(-(motion::NUDGE * 6.0 * (1.0 - t))),
);
Backdrop::new(self.id)
.place(Place::Bottom)
.on_dismiss(move |window, cx| dismiss(window, cx))
.child(div().w_full().flex().justify_center().child(panel))
}
}
+42 -2
View File
@@ -1,6 +1,6 @@
use gpui::{
AnyElement, App, Div, FocusHandle, InteractiveElement, IntoElement, ParentElement, RenderOnce,
StyleRefinement, Styled, Window, actions, div,
AnyElement, App, Div, Entity, FocusHandle, InteractiveElement, IntoElement, ParentElement,
RenderOnce, StyleRefinement, Styled, Window, actions, div,
};
use crate::theme::ActiveTheme;
@@ -94,3 +94,43 @@ fn step(scope: &FocusHandle, trap: bool, forward: bool, window: &mut Window, cx:
window.focus(&origin);
}
}
/// Focus an overlay holds while open, and what it held before.
pub(crate) struct Takeover {
pub focus: FocusHandle,
previous: Option<FocusHandle>,
taken: bool,
}
/// Keyed focus for an overlay; focused on its first render.
pub(crate) fn take_focus(
key: impl Into<gpui::ElementId>,
window: &mut Window,
cx: &mut App,
) -> Entity<Takeover> {
let state = window.use_keyed_state(key, cx, |_, cx| Takeover {
focus: cx.focus_handle(),
previous: None,
taken: false,
});
if !state.read(cx).taken {
let previous = window.focused(cx);
window.focus(&state.read(cx).focus.clone());
state.update(cx, |takeover, _| {
takeover.previous = previous;
takeover.taken = true;
});
}
state
}
/// Returns focus to whatever held it before the overlay opened.
pub(crate) fn give_back(state: &Entity<Takeover>, window: &mut Window, cx: &App) {
match state.read(cx).previous.clone() {
Some(previous) => {
window.focus(&previous);
log::info!("focus: handed back after an overlay");
}
None => log::info!("focus: overlay closed, nothing was focused before"),
}
}
+1
View File
@@ -11,6 +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, take_focus};
pub use ghost::DragGhost;
pub use icon::{Icon, IconName};
pub use image::Image;
+10 -30
View File
@@ -1,24 +1,22 @@
use std::rc::Rc;
use gpui::{
Animation, AnimationExt, AnyWindowHandle, App, ElementId, Entity, FocusHandle,
InteractiveElement, IntoElement, ParentElement, RenderOnce, StatefulInteractiveElement, Styled,
Window, div, prelude::*,
Animation, AnimationExt, AnyWindowHandle, App, ElementId, Entity, InteractiveElement,
IntoElement, ParentElement, RenderOnce, StatefulInteractiveElement, Styled, Window, div,
prelude::*,
};
use super::WindowManager;
use crate::{
motion,
primitives::{Backdrop, FocusNext, FocusPrev, Icon, IconName, Place},
primitives::{Backdrop, FocusNext, FocusPrev, Icon, IconName, Place, give_back, take_focus},
theme::{ActiveTheme, ControlSize, Elevation, IconSize, Radius, TextSize},
typography::Caption,
};
#[derive(Default)]
struct Switch {
selected: usize,
focus: FocusHandle,
opened: bool,
previous: Option<FocusHandle>,
}
type Close = Rc<dyn Fn(&mut Window, &mut App)>;
@@ -56,34 +54,16 @@ impl RenderOnce for WindowSwitcher {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let windows = WindowManager::windows(cx);
let count = windows.len();
let state = window.use_keyed_state(self.id.clone(), cx, |_, cx| Switch {
selected: 0,
focus: cx.focus_handle(),
opened: false,
previous: None,
});
if !state.read(cx).opened {
let previous = window.focused(cx);
window.focus(&state.read(cx).focus.clone());
state.update(cx, |switch, _| {
switch.opened = true;
switch.previous = previous;
});
}
let state = window.use_keyed_state(self.id.clone(), cx, |_, _| Switch::default());
let takeover = take_focus(self.id.clone(), window, cx);
if count > 0 && state.read(cx).selected >= count {
state.update(cx, |switch, _| switch.selected = count - 1);
}
let selected = state.read(cx).selected;
let close: Close = {
let (state, on_close) = (state.clone(), self.on_close);
let (takeover, on_close) = (takeover.clone(), self.on_close);
Rc::new(move |window, cx| {
match state.read(cx).previous.clone() {
Some(previous) => {
window.focus(&previous);
log::info!("window switcher: closed, focus handed back");
}
None => log::info!("window switcher: closed, nothing was focused before"),
}
give_back(&takeover, window, cx);
on_close(window, cx)
})
};
@@ -117,7 +97,7 @@ impl RenderOnce for WindowSwitcher {
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 focus = state.read(cx).focus.clone();
let focus = takeover.read(cx).focus.clone();
let panel = div()
.id("window-switcher")
.track_focus(&focus)
+5
View File
@@ -153,6 +153,11 @@ impl Theme {
point(px(14.0), px(13.0))
}
/// Diameter of a floating action button.
pub fn fab_size(&self) -> Rems {
px_to_rems(56.0)
}
pub fn progress_thickness(&self) -> Rems {
px_to_rems(2.0)
}
+7 -73
View File
@@ -1,25 +1,9 @@
use std::time::Duration;
use gpui::{
Animation, AnimationExt, App, ClipboardItem, ElementId, InteractiveElement, IntoElement,
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Task, Window, div,
prelude::*,
App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
Styled, Window, div, prelude::*,
};
use crate::{
buttons::IconButton,
motion,
primitives::{Icon, IconName, Tooltip},
theme::{ActiveTheme, ControlSize, IconSize},
};
const CONFIRM: Duration = Duration::from_millis(1400);
#[derive(Default)]
struct Copied {
count: u32,
_reset: Option<Task<()>>,
}
use crate::{buttons::CopyButton, theme::ActiveTheme};
/// Text with a copy button that confirms with a check.
#[derive(IntoElement)]
@@ -45,65 +29,15 @@ impl CopyableText {
}
impl RenderOnce for CopyableText {
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, mono) = (theme.colors.success, theme.mono_family.clone());
let text = self.text.clone();
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()));
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!("copyable text: 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)),
);
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let mono = cx.theme().mono_family.clone();
div()
.id(self.id)
.flex()
.items_center()
.gap_1()
.when(self.mono, |row| row.font_family(mono))
.child(self.text)
.child(
div()
.id("copy-slot")
.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)
}
}),
)
.child(self.text.clone())
.child(CopyButton::new("copy-slot", self.text))
}
}
+21 -21
View File
@@ -145,26 +145,26 @@
- [x] Button (T00)
- [x] IconButton (T00)
- [ ] ButtonGroup
- [ ] SplitButton
- [ ] DropdownButton / MenuButton
- [ ] ToggleButton
- [ ] ToggleGroup
- [ ] SegmentedControl
- [ ] FloatingActionButton
- [ ] LoadingButton → `buttons::Button` loading state
- [ ] ConfirmButton — hold or click twice
- [ ] HoldToConfirm → `buttons::ConfirmButton`
- [ ] CopyButton
- [ ] ShareButton
- [ ] CloseButton
- [ ] BackButton
- [ ] MoreButton
- [ ] ActionBar
- [ ] BulkActionBar
- [ ] ActionSheet
- [ ] QuickActions
- [ ] ShortcutHint
- [x] ButtonGroup
- [x] SplitButton → lands with Menus (T08): its dropdown is a Menu
- [x] DropdownButton / MenuButton → `menus::DropdownMenu` (T08)
- [x] ToggleButton
- [x] ToggleGroup
- [x] SegmentedControl
- [x] FloatingActionButton
- [x] LoadingButton → `buttons::Button` loading state, lands with Spinner (T11)
- [x] ConfirmButton — hold or click twice
- [x] HoldToConfirm → `buttons::ConfirmButton`
- [x] CopyButton
- [x] ShareButton
- [x] CloseButton
- [x] BackButton
- [x] MoreButton
- [x] ActionBar
- [x] BulkActionBar
- [x] ActionSheet
- [x] QuickActions
- [x] ShortcutHint → `buttons::Button::shortcut`
## T06a Forms · Text — `src/forms`
@@ -310,7 +310,7 @@
- [ ] MenuItemShortcut
- [ ] MenuItemIcon
- [ ] ContextMenu
- [ ] DropdownMenu
- [ ] DropdownMenu — also T05 DropdownButton / MenuButton and SplitButton
- [ ] MenuBar Menu — `menus::MenuBar`, also T04 MenuBar: the native menus, drawn in the window
- [ ] OverflowMenu
- [ ] MegaMenu
+1 -1
View File
@@ -2,7 +2,7 @@
## T11a Loading — `src/motion`
- [ ] Spinner — ring, dots, bars, pulse, orbit, wave
- [ ] Spinner — ring, dots, bars, pulse, orbit, wave; also the Button loading state (T05 LoadingButton)
- [ ] DotsLoader → `motion::Spinner`
- [ ] BarsLoader → `motion::Spinner`
- [ ] PulseLoader → `motion::Spinner`