diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 1ec4a4e..294ccc8 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -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"] diff --git a/crates/core/src/clipboard.rs b/crates/core/src/clipboard.rs new file mode 100644 index 0000000..988ed98 --- /dev/null +++ b/crates/core/src/clipboard.rs @@ -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 AnyView>; + +/// Runs after the value lands on the clipboard. +type CopiedHandler = Box; + +/// 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, + tooltip: Option, + on_copied: Option, +} + +impl ClipboardButton { + /// Creates a copy button for this value. + pub fn new(id: I, value: V) -> Self + where + I: Into, + V: Into, + { + 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(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(mut self, label: L) -> Self + where + L: Into, + { + self.a11y_label = Some(label.into()); + self + } + + /// Runs after the value lands on the clipboard. + pub fn on_copied(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> = + 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); + } + } + }) + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 80a860f..2adb058 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -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; diff --git a/crates/core/tests/clipboard.rs b/crates/core/tests/clipboard.rs new file mode 100644 index 0000000..38b996d --- /dev/null +++ b/crates/core/tests/clipboard.rs @@ -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>, + disabled: bool, +} + +impl Render for Harness { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> 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>, +) { + 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()); +} diff --git a/crates/icons/src/lib.rs b/crates/icons/src/lib.rs index 490a744..b1f9cf2 100644 --- a/crates/icons/src/lib.rs +++ b/crates/icons/src/lib.rs @@ -27,6 +27,7 @@ pub enum IconRole { Folder, Warning, Info, + Copy, Eye, EyeOff, PanelLeft, @@ -52,6 +53,7 @@ impl IconRole { Self::Folder, Self::Warning, Self::Info, + Self::Copy, Self::Eye, Self::EyeOff, Self::PanelLeft, @@ -77,6 +79,7 @@ impl IconRole { Self::Folder => "icons/folder.svg", Self::Warning => "icons/triangle-alert.svg", Self::Info => "icons/info.svg", + Self::Copy => "icons/copy.svg", Self::Eye => "icons/eye.svg", Self::EyeOff => "icons/eye-off.svg", Self::PanelLeft => "icons/panel-left.svg", @@ -102,6 +105,7 @@ impl IconRole { Self::Folder => "folder", Self::Warning => "warning", Self::Info => "info", + Self::Copy => "copy", Self::Eye => "eye", Self::EyeOff => "eye-off", Self::PanelLeft => "panel-left", @@ -160,6 +164,7 @@ impl IconRole { Self::Folder => &["folder-symbolic", "folder"], Self::Warning => &["dialog-warning-symbolic", "dialog-warning", "warning"], Self::Info => &["dialog-information-symbolic", "dialog-information", "info"], + Self::Copy => &["edit-copy-symbolic", "edit-copy"], Self::Eye => &["view-visible-symbolic", "view-visible", "visibility", "eye"], Self::EyeOff => &[ "view-conceal-symbolic", diff --git a/crates/icons/src/segoe.rs b/crates/icons/src/segoe.rs index 736eaac..c569593 100644 --- a/crates/icons/src/segoe.rs +++ b/crates/icons/src/segoe.rs @@ -21,6 +21,7 @@ pub(crate) fn glyph(role: IconRole) -> Option { IconRole::Folder => 0xE8B7, // Folder IconRole::Warning => 0xE7BA, // Warning IconRole::Info => 0xE946, // Info + IconRole::Copy => 0xE8C8, // Copy IconRole::Eye => 0xE7B3, // RedEye IconRole::EyeOff => 0xED1A, // Hide IconRole::PanelLeft => 0xE90C, // DockLeft diff --git a/docs/CHECKLIST.md b/docs/CHECKLIST.md index 02dd340..740c192 100644 --- a/docs/CHECKLIST.md +++ b/docs/CHECKLIST.md @@ -11,6 +11,26 @@ is the first downstream consumer; its screens drive the order. ## Queue — to follow +Gaps surfaced by the signed comparison; ordered by signed's usage. + +- [ ] **Virtual list** — generic virtualized variable-height rows with a + scroll handle, promoted from the editor views' pattern over gpui's + `list`; signed's PR lists and commit diff drive it. +- [ ] **Editable code field** — syntax-highlighted editable multiline field, + the code view's highlighter over the input's editing model; signed's + repo file browser. +- [ ] **Separator** — standalone horizontal/vertical rule; menu-item + separators stay menu-scoped. +- [ ] **Resizable group** — stacked arbitrary panes with drag dividers + outside the dock; signed's commit-diff split. +- [ ] **Searchable list filter** — searchable collection behind combobox and + list option sources; the combobox's pluggable filter covers part of it + (signed's `SearchableVec`). +- [ ] **Dropdown menu** — trigger button + popover + menu composite, three + signed call sites; must stay a real component, not a wrapper. +- [ ] **Motion easing helpers** — cubic ease-out and lerp beside + `theme::Motion`; low priority, gpui's animation primitives cover most + cases. - [ ] **Markdown view follow-ups** — streaming `append` with a coalesced background parse, text selection with copy, per-block a11y roles, GFM alerts + table column alignment, per-fence highlight reuse, @@ -59,6 +79,8 @@ is the first downstream consumer; its screens drive the order. - [x] Command palette — filtered actions; the matcher stays caller-owned - [x] Scrollbar — overlay thumbs for gpui's `list` and `uniform_list`, platform autohide with a `visibility` override +- [x] Clipboard button — copies a value, confirms with a brief check mark + that reverts itself ### data (`crates/data`) diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index 983488d..17c3a9b 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -12,6 +12,7 @@ use gpui_native_core::avatar::Avatar; use gpui_native_core::badge::{Badge, BadgeVariant}; use gpui_native_core::button::{Button, ButtonVariant}; use gpui_native_core::checkbox::Checkbox; +use gpui_native_core::clipboard::ClipboardButton; use gpui_native_core::combobox::{Combobox, ComboboxOption, ComboboxState}; use gpui_native_core::command_palette::{Command, CommandPalette, CommandPaletteState}; use gpui_native_core::common::{Sizable as _, Size, h_flex, v_flex}; @@ -72,6 +73,7 @@ const CATALOG: &[(&str, &[(&str, &str)])] = &[ ("segmented", "Segmented"), ("select", "Select"), ("link", "Link"), + ("clipboard", "Clipboard"), ], ), ( @@ -496,6 +498,12 @@ impl Gallery { }), cx, ); + dock.register_panel( + PanelDescriptor::new("clipboard", "Clipboard", |_, cx| { + cx.new(|_| ClipboardDemo).into() + }), + cx, + ); dock.register_panel( PanelDescriptor::new("badge", "Badge", |_, cx| cx.new(|_| BadgeDemo).into()), cx, @@ -1624,6 +1632,41 @@ impl Render for LinkDemo { } } +struct ClipboardDemo; + +impl Render for ClipboardDemo { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.theme().colors; + + ScrollArea::new("clipboard-demo") + .size_full() + .p_4() + .child(demo_section( + "sizes", + colors, + h_flex() + .flex_wrap() + .gap_3() + .child(ClipboardButton::new("clipboard-small", "gpui-native-kit").small()) + .child(ClipboardButton::new("clipboard-medium", "gpui-native-kit")) + .child(ClipboardButton::new("clipboard-large", "gpui-native-kit").large()), + )) + .child(demo_section( + "states", + colors, + h_flex() + .flex_wrap() + .gap_3() + .child( + ClipboardButton::new("clipboard-url", "https://example.com/repo.git") + .tooltip(Tooltip::text("Copy repository URL")) + .accessibility_label("Copy repository URL"), + ) + .child(ClipboardButton::new("clipboard-disabled", "drift").disabled(true)), + )) + } +} + struct BadgeDemo; impl Render for BadgeDemo {