T05 buttons and actions: groups, toggles, segmented, confirm, copy, share, bars, sheet, quick actions
This commit is contained in:
29 files changed
+2659
-151
No files matched your search
@@ -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
@@ -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"))'] }
|
||||
|
||||
@@ -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 | |
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
)),
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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(&[]));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"),
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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`
|
||||
|
||||
Reference in new issue
Block a user