use std::rc::Rc;
use std::time::Duration;
use gpui::{
App, ClipboardItem, Context, EventEmitter, FocusHandle, Focusable, IntoElement, ParentElement,
Render, SharedString, Styled, Window, div, prelude::FluentBuilder, px,
};
use gpui_kit_assets::Icon;
use gpui_kit_semantics::{NodeSpec, Role, Semantic};
use gpui_kit_theme::{ActiveTheme, ControlSize, Space, TypeScale};
use web_time::Instant;
use crate::controls::button::{Button, ButtonVariant};
use crate::foundation::{Disableable, Ident, Sizable, StyledExt, text as foundation_text};
use crate::strings::{ActiveStrings, StringKey};
pub const DEFAULT_CONFIRMATION: Duration = Duration::from_millis(1600);
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum CopyState {
#[default]
Idle,
Copied,
Failed(SharedString),
}
impl CopyState {
pub fn is_copied(&self) -> bool {
matches!(self, Self::Copied)
}
pub fn is_failed(&self) -> bool {
matches!(self, Self::Failed(_))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CopyEvent {
Copied,
Failed(SharedString),
}
impl EventEmitter<CopyEvent> for CopyButton {}
type Copier = Rc<dyn Fn(&str, &mut App) -> Result<(), SharedString>>;
pub fn verified_clipboard_copy(text: &str, cx: &mut App) -> Result<(), SharedString> {
cx.write_to_clipboard(ClipboardItem::new_string(text.to_string()));
match cx.read_from_clipboard().and_then(|item| item.text()) {
Some(read) if read == text => Ok(()),
_ => Err(cx.strings().text(StringKey::CopyFailedDetail)),
}
}
pub struct CopyButton {
ident: Ident,
focus_handle: FocusHandle,
text: SharedString,
label: Option<SharedString>,
name: Option<SharedString>,
glyph_only: bool,
variant: ButtonVariant,
size: ControlSize,
disabled: bool,
copier: Option<Copier>,
confirmation: Duration,
state: CopyState,
remaining: Option<Duration>,
last_tick: Option<Instant>,
}
impl std::fmt::Debug for CopyButton {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("CopyButton")
.field("ident", &self.ident)
.field("state", &self.state)
.field("disabled", &self.disabled)
.field("has_copier", &self.copier.is_some())
.finish()
}
}
impl CopyButton {
pub fn new(ident: impl Into<Ident>, _window: &mut Window, cx: &mut Context<Self>) -> Self {
Self {
ident: ident.into(),
focus_handle: cx.focus_handle(),
text: SharedString::default(),
label: None,
name: None,
glyph_only: false,
variant: ButtonVariant::Secondary,
size: ControlSize::Md,
disabled: false,
copier: None,
confirmation: DEFAULT_CONFIRMATION,
state: CopyState::Idle,
remaining: None,
last_tick: None,
}
}
pub fn text(mut self, text: impl Into<SharedString>) -> Self {
self.text = text.into();
self
}
pub fn set_text(&mut self, text: impl Into<SharedString>, cx: &mut Context<Self>) {
self.text = text.into();
cx.notify();
}
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
self.label = Some(label.into());
self
}
pub fn glyph_only(mut self, name: impl Into<SharedString>) -> Self {
self.glyph_only = true;
self.name = Some(name.into());
self
}
pub fn variant(mut self, variant: ButtonVariant) -> Self {
self.variant = variant;
self
}
pub fn copier(
mut self,
copier: impl Fn(&str, &mut App) -> Result<(), SharedString> + 'static,
) -> Self {
self.copier = Some(Rc::new(copier));
self
}
pub fn confirmation(mut self, confirmation: Duration) -> Self {
self.confirmation = confirmation;
self
}
pub fn state(&self) -> &CopyState {
&self.state
}
pub fn set_disabled(&mut self, disabled: bool, cx: &mut Context<Self>) {
self.disabled = disabled;
cx.notify();
}
pub fn copy(&mut self, cx: &mut Context<Self>) {
if self.disabled {
return;
}
let copier = self.copier.clone();
let text = self.text.clone();
let outcome = match copier {
Some(copier) => copier(text.as_ref(), cx),
None => verified_clipboard_copy(text.as_ref(), cx),
};
match outcome {
Ok(()) => {
self.state = CopyState::Copied;
self.remaining = Some(self.confirmation);
self.last_tick = None;
cx.emit(CopyEvent::Copied);
}
Err(reason) => {
self.state = CopyState::Failed(reason.clone());
self.remaining = None;
self.last_tick = None;
cx.emit(CopyEvent::Failed(reason));
}
}
cx.notify();
}
fn tick(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(remaining) = self.remaining else {
self.last_tick = None;
return;
};
let now = cx.background_executor().now();
let spent = self
.last_tick
.map(|last| now.saturating_duration_since(last))
.unwrap_or_default();
let left = remaining.saturating_sub(spent);
if left.is_zero() {
self.state = CopyState::Idle;
self.remaining = None;
self.last_tick = None;
cx.notify();
return;
}
self.remaining = Some(left);
self.last_tick = Some(now);
window.request_animation_frame();
}
fn glyph(&self) -> Icon {
match self.state {
CopyState::Copied => Icon::Check,
_ => Icon::Copy,
}
}
fn button_label(&self, cx: &App) -> SharedString {
match &self.state {
CopyState::Copied => cx.strings().text(StringKey::CopyDone),
CopyState::Failed(_) => cx.strings().text(StringKey::CopyFailed),
CopyState::Idle => self
.label
.clone()
.unwrap_or_else(|| cx.strings().text(StringKey::Copy)),
}
}
}
impl Disableable for CopyButton {
fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
impl Sizable for CopyButton {
fn control_size(mut self, size: ControlSize) -> Self {
self.size = size;
self
}
}
impl Focusable for CopyButton {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for CopyButton {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
self.tick(window, cx);
let theme = cx.theme().clone();
let label = self.button_label(cx);
let glyph = self.glyph();
let parent = self.ident.semantic_id();
let button = Button::new(self.ident.child("action"))
.semantic_parent(parent.clone())
.variant(self.variant)
.control_size(self.size)
.disabled(self.disabled)
.track_focus(&self.focus_handle)
.map(|button| {
match (self.glyph_only, self.name.clone()) {
(true, Some(name)) => button.icon_only(glyph, name),
_ => button.icon(glyph).label(label.clone()),
}
})
.when(!self.disabled, |button| {
let copy = cx.entity().downgrade();
button.on_click(move |_, cx| {
copy.update(cx, |copy, cx| copy.copy(cx)).ok();
})
});
let status = match &self.state {
CopyState::Idle => None,
CopyState::Copied => Some((cx.strings().text(StringKey::CopyDone), false)),
CopyState::Failed(reason) => Some((reason.clone(), true)),
};
let status_ident = self.ident.child("status");
let status = status.map(|(text, failed)| {
foundation_text(&theme, TypeScale::Caption, text.clone())
.text_color(if failed {
theme.colors.danger
} else {
theme.colors.text_muted
})
.semantic_in(
cx,
NodeSpec::new(status_ident.semantic_id(), Role::Status)
.parent(parent.clone())
.text(text)
.invalid(failed),
)
});
div()
.row()
.flex_none()
.gap_token(&theme, Space::Xs)
.child(button)
.children(status)
.semantic_in(
cx,
NodeSpec::new(parent, Role::Group)
.disabled(self.disabled)
.invalid(self.state.is_failed()),
)
.min_h(px(0.0))
}
}