feat: add clipboard button component

This commit is contained in:
2026-11-06 18:47:20 +07:00
parent e524c1a305
commit ad30792231
8 changed files with 375 additions and 0 deletions
+2
View File
@@ -29,6 +29,7 @@ full = [
"avatar",
"badge",
"input",
"clipboard",
"popover",
"scroll_area",
"scrollbar",
@@ -58,6 +59,7 @@ progress = []
avatar = []
badge = []
input = ["button"]
clipboard = ["button"]
popover = []
scroll_area = []
scrollbar = ["scroll_area"]
+161
View File
@@ -0,0 +1,161 @@
//! The clipboard button: copies a value and confirms with a brief check mark.
use std::rc::Rc;
use std::time::Duration;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyView, App, ClipboardItem, ElementId, Entity, IntoElement, RenderOnce, SharedString, Window,
};
use gpui_native_icons::IconRole;
use gpui_native_theme::Size;
use crate::button::{Button, ButtonVariant};
use crate::common::Sizable;
/// How long the check mark shows before reverting to the copy icon.
pub const COPIED_DURATION: Duration = Duration::from_secs(1);
/// The tooltip view builder gpui's `.tooltip(...)` expects.
type TooltipBuilder = Rc<dyn Fn(&mut Window, &mut App) -> AnyView>;
/// Runs after the value lands on the clipboard.
type CopiedHandler = Box<dyn Fn(&mut App)>;
/// A quiet icon button that copies a value to the system clipboard.
#[derive(IntoElement)]
pub struct ClipboardButton {
id: ElementId,
value: SharedString,
size: Size,
disabled: bool,
a11y_label: Option<SharedString>,
tooltip: Option<TooltipBuilder>,
on_copied: Option<CopiedHandler>,
}
impl ClipboardButton {
/// Creates a copy button for this value.
pub fn new<I, V>(id: I, value: V) -> Self
where
I: Into<ElementId>,
V: Into<SharedString>,
{
Self {
id: id.into(),
value: value.into(),
size: Size::default(),
disabled: false,
a11y_label: None,
tooltip: None,
on_copied: None,
}
}
/// Shows a tooltip view next to the button after it is hovered.
pub fn tooltip<F>(mut self, tooltip: F) -> Self
where
F: Fn(&mut Window, &mut App) -> AnyView + 'static,
{
self.tooltip = Some(Rc::new(tooltip));
self
}
/// Renders the button inert and dimmed.
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
/// Overrides the accessibility label, which defaults to `Copy`.
pub fn accessibility_label<L>(mut self, label: L) -> Self
where
L: Into<SharedString>,
{
self.a11y_label = Some(label.into());
self
}
/// Runs after the value lands on the clipboard.
pub fn on_copied<F>(mut self, handler: F) -> Self
where
F: Fn(&mut App) + 'static,
{
self.on_copied = Some(Box::new(handler));
self
}
}
impl Sizable for ClipboardButton {
fn apply_size(&mut self, size: Size) {
self.size = size;
}
}
impl RenderOnce for ClipboardButton {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let copied: Entity<Option<u64>> =
window.use_keyed_state((self.id.clone(), "clipboard-copied"), cx, |_, _| None);
let is_copied = copied.read(cx).is_some();
let selector = if is_copied {
"clipboard-copied"
} else {
"clipboard"
};
Button::new((self.id.clone(), "clipboard-button"))
.icon(if is_copied {
IconRole::Check
} else {
IconRole::Copy
})
.variant(ButtonVariant::Ghost)
.with_size(self.size)
.disabled(self.disabled)
.debug_selector(move || selector.to_string())
.accessibility_label(self.a11y_label.unwrap_or_else(|| "Copy".into()))
.when_some(self.tooltip, |button, tooltip| {
button.tooltip(move |window, cx| tooltip(window, cx))
})
.on_click({
let value = self.value;
let copied = copied.clone();
let on_copied = self.on_copied;
move |_, window, cx| {
cx.write_to_clipboard(ClipboardItem::new_string(value.to_string()));
let generation = copied.update(cx, |state, cx| {
let generation = state.map_or(1, |generation| generation.wrapping_add(1));
*state = Some(generation);
cx.notify();
generation
});
// Clear the copied state after the duration expires
window
.spawn(cx, {
let copied = copied.clone();
async move |cx| {
cx.background_executor().timer(COPIED_DURATION).await;
cx.update(|_, cx| {
copied.update(cx, |state, cx| {
if *state == Some(generation) {
*state = None;
cx.notify();
}
})
})
.ok();
}
})
.detach();
// Invoke the callback after copying
if let Some(on_copied) = &on_copied {
on_copied(cx);
}
}
})
}
}
+3
View File
@@ -45,6 +45,9 @@ pub mod badge;
#[cfg(feature = "input")]
pub mod input;
#[cfg(feature = "clipboard")]
pub mod clipboard;
#[cfg(feature = "dialog")]
pub mod dialog;
+138
View File
@@ -0,0 +1,138 @@
use std::{cell::RefCell, rc::Rc};
use gpui::{
AnyWindowHandle, AppContext as _, Context, IntoElement, Modifiers, ParentElement as _, Render,
Styled as _, TestAppContext, VisualTestContext, Window, div,
};
use gpui_native_core::clipboard::{COPIED_DURATION, ClipboardButton};
use gpui_native_theme::Theme;
const VALUE: &str = "https://example.com/repo.git";
struct Harness {
copies: Rc<RefCell<usize>>,
disabled: bool,
}
impl Render for Harness {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
let copies = self.copies.clone();
div().size_full().flex().flex_col().child(
ClipboardButton::new("test-clipboard", VALUE)
.disabled(self.disabled)
.on_copied(move |_| *copies.borrow_mut() += 1),
)
}
}
/// A maximized window whose button stretches across the top control row, so
/// `point(px(60.), px(14.))` always lands on it.
fn clipboard_window(
disabled: bool,
) -> (
TestAppContext,
VisualTestContext,
AnyWindowHandle,
Rc<RefCell<usize>>,
) {
let mut cx = TestAppContext::single();
cx.update(Theme::init);
let copies = Rc::new(RefCell::new(0));
let harness_copies = copies.clone();
let window = cx.add_window(move |_, _| Harness {
copies: harness_copies,
disabled,
});
let window = window.into();
cx.update_window(window, |_, window, cx| {
window.draw(cx).clear(cx);
})
.unwrap();
let visual = VisualTestContext::from_window(window, &cx);
(cx, visual, window, copies)
}
fn draw(visual: &mut VisualTestContext, window: AnyWindowHandle) {
visual
.update_window(window, |_, window, cx| {
window.draw(cx).clear(cx);
})
.unwrap();
}
fn clipboard_text(visual: &mut VisualTestContext) -> String {
visual.update(|_window, cx| {
cx.read_from_clipboard()
.and_then(|item| item.text())
.unwrap_or_default()
})
}
fn click(visual: &mut VisualTestContext) {
let selector = if visual.debug_bounds("clipboard").is_some() {
"clipboard"
} else {
"clipboard-copied"
};
let bounds = visual
.debug_bounds(selector)
.expect("clipboard button should be rendered");
visual.simulate_click(bounds.center(), Modifiers::default());
}
#[test]
fn click_copies_the_value_and_fires_on_copied() {
let (_cx, mut visual, _window, copies) = clipboard_window(false);
click(&mut visual);
assert_eq!(clipboard_text(&mut visual), VALUE);
assert_eq!(*copies.borrow(), 1);
}
#[test]
fn disabled_button_does_not_copy() {
let (_cx, mut visual, _window, copies) = clipboard_window(true);
click(&mut visual);
assert_eq!(clipboard_text(&mut visual), "");
assert_eq!(*copies.borrow(), 0);
}
#[test]
fn copied_confirmation_shows_then_reverts() {
let (cx, mut visual, window, _copies) = clipboard_window(false);
click(&mut visual);
draw(&mut visual, window);
assert!(
visual.debug_bounds("clipboard-copied").is_some(),
"the check mark state should render right after the click"
);
assert!(visual.debug_bounds("clipboard").is_none());
cx.executor().advance_clock(COPIED_DURATION);
visual.run_until_parked();
draw(&mut visual, window);
assert!(
visual.debug_bounds("clipboard").is_some(),
"the copy icon should return once the confirmation window passes"
);
assert!(visual.debug_bounds("clipboard-copied").is_none());
}
#[test]
fn a_second_click_within_the_window_keeps_the_confirmation_up() {
let (cx, mut visual, window, _copies) = clipboard_window(false);
click(&mut visual);
cx.executor().advance_clock(COPIED_DURATION / 2);
click(&mut visual);
cx.executor().advance_clock(COPIED_DURATION / 2);
visual.run_until_parked();
draw(&mut visual, window);
assert!(
visual.debug_bounds("clipboard-copied").is_some(),
"the first click's stale revert must not clear the second click's confirmation"
);
cx.executor().advance_clock(COPIED_DURATION);
visual.run_until_parked();
draw(&mut visual, window);
assert!(visual.debug_bounds("clipboard").is_some());
}