Skip to main content

gpui_kit/controls/
copy_button.rs

1//! Copying caller-supplied text, and saying truthfully whether it worked.
2//!
3//! # What GPUI offers, and what it does not
4//!
5//! [`gpui::App::write_to_clipboard`] takes a [`gpui::ClipboardItem`] and
6//! returns `()`. There is no `Result`, no error, and no callback: the platform
7//! layer takes the item and the call is over. So a button that showed a tick
8//! because `write_to_clipboard` returned would be showing a tick because a
9//! function with no failure mode did not fail, which is not evidence of
10//! anything.
11//!
12//! The one thing GPUI does offer is [`gpui::App::read_from_clipboard`], which
13//! returns `Option<ClipboardItem>`. That is real evidence, so it is what this
14//! component uses: it writes, reads back, and compares. A read that comes back
15//! empty, or comes back holding something else, is reported as a failure.
16//!
17//! That check is honest but not complete, and the gap is stated rather than
18//! papered over: a platform where a write silently succeeds into a clipboard
19//! that a read then reports correctly, while some other application never sees
20//! it, would be indistinguishable from success here. Nothing in GPUI's surface
21//! can tell that apart. What the check does catch is the common case — a
22//! clipboard that refused the write — and it never reports success on the
23//! strength of a call that cannot fail.
24//!
25//! A host that knows better supplies its own [`CopyButton::copier`], which
26//! returns a `Result` and whose failure text is shown verbatim.
27//!
28//! # Why the confirmation does not time the failure out
29//!
30//! A confirmation is transient: it says "that went through" and there is no
31//! reason for it to stay. A failure is not, for the reason
32//! `docs/components.md` gives for notifications — a failure nobody saw is a
33//! failure that was never reported. So the tick fades on a timer and the
34//! refusal stays until the next attempt replaces it.
35
36use std::rc::Rc;
37use std::time::Duration;
38
39use gpui::{
40    App, ClipboardItem, Context, EventEmitter, FocusHandle, Focusable, IntoElement, ParentElement,
41    Render, SharedString, Styled, Window, div, prelude::FluentBuilder, px,
42};
43use gpui_kit_assets::Icon;
44use gpui_kit_semantics::{NodeSpec, Role, Semantic};
45use gpui_kit_theme::{ActiveTheme, ControlSize, Space, TypeScale};
46use web_time::Instant;
47
48use crate::controls::button::{Button, ButtonVariant};
49use crate::foundation::{Disableable, Ident, Sizable, StyledExt, text as foundation_text};
50use crate::strings::{ActiveStrings, StringKey};
51
52/// How long a confirmation stays before the button goes back to offering.
53pub const DEFAULT_CONFIRMATION: Duration = Duration::from_millis(1600);
54
55/// What the button is currently claiming.
56#[derive(Debug, Clone, PartialEq, Eq, Default)]
57pub enum CopyState {
58    /// Nothing has been tried, or the last confirmation has expired.
59    #[default]
60    Idle,
61    /// The clipboard took it, and reading it back agreed.
62    Copied,
63    /// It did not go through, for the reason carried here.
64    Failed(SharedString),
65}
66
67impl CopyState {
68    pub fn is_copied(&self) -> bool {
69        matches!(self, Self::Copied)
70    }
71
72    pub fn is_failed(&self) -> bool {
73        matches!(self, Self::Failed(_))
74    }
75}
76
77/// What a copy button reports. The owner decides what any of it means.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub enum CopyEvent {
80    Copied,
81    /// Carries the reason, which is the same text the button shows.
82    Failed(SharedString),
83}
84
85impl EventEmitter<CopyEvent> for CopyButton {}
86
87/// Puts text somewhere and says whether it got there.
88type Copier = Rc<dyn Fn(&str, &mut App) -> Result<(), SharedString>>;
89
90/// Writes to the platform clipboard and reads it back to check.
91///
92/// The readback is the whole point: the write itself cannot report anything.
93pub fn verified_clipboard_copy(text: &str, cx: &mut App) -> Result<(), SharedString> {
94    cx.write_to_clipboard(ClipboardItem::new_string(text.to_string()));
95    match cx.read_from_clipboard().and_then(|item| item.text()) {
96        Some(read) if read == text => Ok(()),
97        _ => Err(cx.strings().text(StringKey::CopyFailedDetail)),
98    }
99}
100
101/// A button that copies caller-supplied text and confirms what happened.
102pub struct CopyButton {
103    ident: Ident,
104    focus_handle: FocusHandle,
105    text: SharedString,
106    label: Option<SharedString>,
107    /// What the button is called when it carries only a glyph.
108    name: Option<SharedString>,
109    glyph_only: bool,
110    variant: ButtonVariant,
111    size: ControlSize,
112    disabled: bool,
113    copier: Option<Copier>,
114    confirmation: Duration,
115    state: CopyState,
116    /// How much of the confirmation is left, and when it was last spent.
117    remaining: Option<Duration>,
118    last_tick: Option<Instant>,
119}
120
121impl std::fmt::Debug for CopyButton {
122    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123        formatter
124            .debug_struct("CopyButton")
125            .field("ident", &self.ident)
126            .field("state", &self.state)
127            .field("disabled", &self.disabled)
128            .field("has_copier", &self.copier.is_some())
129            .finish()
130    }
131}
132
133impl CopyButton {
134    pub fn new(ident: impl Into<Ident>, _window: &mut Window, cx: &mut Context<Self>) -> Self {
135        Self {
136            ident: ident.into(),
137            focus_handle: cx.focus_handle(),
138            text: SharedString::default(),
139            label: None,
140            name: None,
141            glyph_only: false,
142            variant: ButtonVariant::Secondary,
143            size: ControlSize::Md,
144            disabled: false,
145            copier: None,
146            confirmation: DEFAULT_CONFIRMATION,
147            state: CopyState::Idle,
148            remaining: None,
149            last_tick: None,
150        }
151    }
152
153    /// The text this button copies. Never published: a copy button is a
154    /// plausible carrier of a credential, so the tree gets the button, not its
155    /// payload.
156    pub fn text(mut self, text: impl Into<SharedString>) -> Self {
157        self.text = text.into();
158        self
159    }
160
161    /// Replaces the payload from the host side, between frames.
162    pub fn set_text(&mut self, text: impl Into<SharedString>, cx: &mut Context<Self>) {
163        self.text = text.into();
164        cx.notify();
165    }
166
167    /// Words on the button instead of the catalogue's own.
168    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
169        self.label = Some(label.into());
170        self
171    }
172
173    /// Draws the button as a square carrying only its glyph, and names it.
174    pub fn glyph_only(mut self, name: impl Into<SharedString>) -> Self {
175        self.glyph_only = true;
176        self.name = Some(name.into());
177        self
178    }
179
180    pub fn variant(mut self, variant: ButtonVariant) -> Self {
181        self.variant = variant;
182        self
183    }
184
185    /// Supplies a host that knows whether the copy worked.
186    ///
187    /// The default writes to the platform clipboard and reads it back; a host
188    /// with a better answer replaces it, and the failure text it returns is
189    /// shown verbatim rather than reworded.
190    pub fn copier(
191        mut self,
192        copier: impl Fn(&str, &mut App) -> Result<(), SharedString> + 'static,
193    ) -> Self {
194        self.copier = Some(Rc::new(copier));
195        self
196    }
197
198    /// How long a confirmation stays. A refusal is not timed and this does not
199    /// touch it.
200    pub fn confirmation(mut self, confirmation: Duration) -> Self {
201        self.confirmation = confirmation;
202        self
203    }
204
205    pub fn state(&self) -> &CopyState {
206        &self.state
207    }
208
209    pub fn set_disabled(&mut self, disabled: bool, cx: &mut Context<Self>) {
210        self.disabled = disabled;
211        cx.notify();
212    }
213
214    /// Does the copy and records what actually happened.
215    pub fn copy(&mut self, cx: &mut Context<Self>) {
216        if self.disabled {
217            return;
218        }
219        let copier = self.copier.clone();
220        let text = self.text.clone();
221        let outcome = match copier {
222            Some(copier) => copier(text.as_ref(), cx),
223            None => verified_clipboard_copy(text.as_ref(), cx),
224        };
225        match outcome {
226            Ok(()) => {
227                self.state = CopyState::Copied;
228                self.remaining = Some(self.confirmation);
229                self.last_tick = None;
230                cx.emit(CopyEvent::Copied);
231            }
232            Err(reason) => {
233                // A refusal is not put on a timer: see the module note.
234                self.state = CopyState::Failed(reason.clone());
235                self.remaining = None;
236                self.last_tick = None;
237                cx.emit(CopyEvent::Failed(reason));
238            }
239        }
240        cx.notify();
241    }
242
243    /// Spends one frame of the confirmation, if one is standing.
244    fn tick(&mut self, window: &mut Window, cx: &mut Context<Self>) {
245        let Some(remaining) = self.remaining else {
246            self.last_tick = None;
247            return;
248        };
249        let now = cx.background_executor().now();
250        let spent = self
251            .last_tick
252            .map(|last| now.saturating_duration_since(last))
253            .unwrap_or_default();
254        let left = remaining.saturating_sub(spent);
255        if left.is_zero() {
256            self.state = CopyState::Idle;
257            self.remaining = None;
258            self.last_tick = None;
259            cx.notify();
260            return;
261        }
262        self.remaining = Some(left);
263        self.last_tick = Some(now);
264        window.request_animation_frame();
265    }
266
267    fn glyph(&self) -> Icon {
268        match self.state {
269            CopyState::Copied => Icon::Check,
270            _ => Icon::Copy,
271        }
272    }
273
274    fn button_label(&self, cx: &App) -> SharedString {
275        match &self.state {
276            CopyState::Copied => cx.strings().text(StringKey::CopyDone),
277            CopyState::Failed(_) => cx.strings().text(StringKey::CopyFailed),
278            CopyState::Idle => self
279                .label
280                .clone()
281                .unwrap_or_else(|| cx.strings().text(StringKey::Copy)),
282        }
283    }
284}
285
286impl Disableable for CopyButton {
287    fn disabled(mut self, disabled: bool) -> Self {
288        self.disabled = disabled;
289        self
290    }
291}
292
293impl Sizable for CopyButton {
294    fn control_size(mut self, size: ControlSize) -> Self {
295        self.size = size;
296        self
297    }
298}
299
300impl Focusable for CopyButton {
301    fn focus_handle(&self, _cx: &App) -> FocusHandle {
302        self.focus_handle.clone()
303    }
304}
305
306impl Render for CopyButton {
307    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
308        self.tick(window, cx);
309        let theme = cx.theme().clone();
310        let label = self.button_label(cx);
311        let glyph = self.glyph();
312        let parent = self.ident.semantic_id();
313
314        let button = Button::new(self.ident.child("action"))
315            .semantic_parent(parent.clone())
316            .variant(self.variant)
317            .control_size(self.size)
318            .disabled(self.disabled)
319            .track_focus(&self.focus_handle)
320            .map(|button| {
321                match (self.glyph_only, self.name.clone()) {
322                    (true, Some(name)) => button.icon_only(glyph, name),
323                    // The words change with the state, so the name a reader
324                    // hears changes with it too; there is no second name that
325                    // would go stale.
326                    _ => button.icon(glyph).label(label.clone()),
327                }
328            })
329            .when(!self.disabled, |button| {
330                let copy = cx.entity().downgrade();
331                button.on_click(move |_, cx| {
332                    copy.update(cx, |copy, cx| copy.copy(cx)).ok();
333                })
334            });
335
336        // The outcome is a separate node because it is a separate claim. A
337        // test asking whether the copy worked reads this, not the wording on
338        // the control, and a refusal publishes `invalid` so nothing has to
339        // match on prose to tell the two apart.
340        let status = match &self.state {
341            CopyState::Idle => None,
342            CopyState::Copied => Some((cx.strings().text(StringKey::CopyDone), false)),
343            CopyState::Failed(reason) => Some((reason.clone(), true)),
344        };
345        let status_ident = self.ident.child("status");
346        let status = status.map(|(text, failed)| {
347            foundation_text(&theme, TypeScale::Caption, text.clone())
348                .text_color(if failed {
349                    theme.colors.danger
350                } else {
351                    theme.colors.text_muted
352                })
353                .semantic_in(
354                    cx,
355                    NodeSpec::new(status_ident.semantic_id(), Role::Status)
356                        .parent(parent.clone())
357                        .text(text)
358                        .invalid(failed),
359                )
360        });
361
362        div()
363            .row()
364            .flex_none()
365            .gap_token(&theme, Space::Xs)
366            .child(button)
367            .children(status)
368            .semantic_in(
369                cx,
370                NodeSpec::new(parent, Role::Group)
371                    .disabled(self.disabled)
372                    .invalid(self.state.is_failed()),
373            )
374            .min_h(px(0.0))
375    }
376}