1use 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
52pub const DEFAULT_CONFIRMATION: Duration = Duration::from_millis(1600);
54
55#[derive(Debug, Clone, PartialEq, Eq, Default)]
57pub enum CopyState {
58 #[default]
60 Idle,
61 Copied,
63 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#[derive(Debug, Clone, PartialEq, Eq)]
79pub enum CopyEvent {
80 Copied,
81 Failed(SharedString),
83}
84
85impl EventEmitter<CopyEvent> for CopyButton {}
86
87type Copier = Rc<dyn Fn(&str, &mut App) -> Result<(), SharedString>>;
89
90pub 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
101pub struct CopyButton {
103 ident: Ident,
104 focus_handle: FocusHandle,
105 text: SharedString,
106 label: Option<SharedString>,
107 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 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 pub fn text(mut self, text: impl Into<SharedString>) -> Self {
157 self.text = text.into();
158 self
159 }
160
161 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 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
169 self.label = Some(label.into());
170 self
171 }
172
173 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 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 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 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 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 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 _ => 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 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}