Skip to main content

qframe/widgets/
copy_value.rs

1//! A value the user copies with one key or click, with quiet confirmation in place.
2
3use std::time::Duration;
4
5use unicode_segmentation::UnicodeSegmentation;
6
7use super::cells;
8use super::press::{self, Press};
9use crate::event::Event;
10use crate::geometry::{Rect, Size};
11use crate::keymap::Key;
12use crate::motion::Easing;
13use crate::text;
14use crate::theme::State;
15use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};
16
17/// How long the "copied" confirmation stays before it fades back.
18const CONFIRMATION: Duration = Duration::from_millis(1400);
19
20/// Cells between the value and the marker.
21const MARKER_GAP: u16 = 2;
22
23/// A value on a raised surface that copies itself to the clipboard: an install command, a
24/// token, a URL.
25///
26/// Enter, Space or `c` while focused, or a click, copies the value, flashes the surface and
27/// turns the trailing `copy` word into a success mark with `copied` that fades back after a
28/// moment, so no toast is needed. The value is truncated with `…` when narrow; the marker always
29/// stays visible. The copy goes to the terminal clipboard (OSC 52) and to the application's
30/// in-process clipboard.
31///
32/// Style keys: `copy-value` (`bg`, `fg`, `padding`) with `hover`, `focus`, `pressed`,
33/// `disabled`; `copy-value-marker` (`fg`) with variant `copied`. Words come from
34/// `quvyta.copy-value.copy` and `quvyta.copy-value.copied`.
35pub struct CopyValue<Msg> {
36    value: String,
37    masked: bool,
38    disabled: bool,
39    on_copy: Option<Msg>,
40}
41
42#[derive(Debug, Default)]
43struct CopyMemory {
44    copied_at: Option<Duration>,
45}
46
47impl<Msg> CopyValue<Msg> {
48    /// Shows `value` and copies it.
49    #[must_use]
50    pub fn new(value: impl Into<String>) -> Self {
51        Self { value: value.into(), masked: false, disabled: false, on_copy: None }
52    }
53
54    /// Draws the value as mask dots while still copying the real text, for secrets.
55    #[must_use]
56    pub fn masked(mut self, masked: bool) -> Self {
57        self.masked = masked;
58        self
59    }
60
61    /// Greys the value out; it cannot be focused or copied.
62    #[must_use]
63    pub fn disabled(mut self, disabled: bool) -> Self {
64        self.disabled = disabled;
65        self
66    }
67
68    /// Message sent after every copy.
69    #[must_use]
70    pub fn on_copy(mut self, message: Msg) -> Self {
71        self.on_copy = Some(message);
72        self
73    }
74
75    fn shown(&self, mask: &str) -> String {
76        if self.masked { self.value.graphemes(true).map(|_| mask).collect() } else { self.value.clone() }
77    }
78}
79
80/// The idle and confirmed marker texts.
81fn markers(env: &crate::env::Env) -> (String, String) {
82    let i18n = env.i18n();
83    let check = env.icons().glyph("check");
84    (
85        i18n.translate("quvyta.copy-value.copy", &[]),
86        format!("{check} {}", i18n.translate("quvyta.copy-value.copied", &[])),
87    )
88}
89
90impl<Msg: Clone + 'static> Widget<Msg> for CopyValue<Msg> {
91    fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
92        let style = cx.env().theme().style("copy-value", None, &[]);
93        let (vertical, horizontal) = style.pair("padding").unwrap_or((0, 1));
94        let (idle, copied) = markers(cx.env());
95        let marker = text::width(&idle).max(text::width(&copied));
96        let value = text::width(&self.shown(&cx.env().icons().glyph("mask")));
97        Size::new(
98            cells::sum([value, MARKER_GAP, marker, horizontal.saturating_mul(2)]),
99            vertical.saturating_mul(2).saturating_add(1),
100        )
101        .min(available)
102    }
103
104    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
105        let states = if self.disabled { vec![State::Disabled] } else { cx.states() };
106        let style = cx.style("copy-value", None, &states);
107        let surface = style.text();
108        cx.clear(area, surface.bg.unwrap_or_else(|| cx.color("raised")));
109        if !self.disabled {
110            cx.register_hit(area);
111        }
112        let padding = style.padding();
113        if let Some(color) = style.color("pillar").filter(|_| padding.left >= 1) {
114            cx.pillar(area.x, area.y + i32::from(padding.top), color);
115        }
116        let inner = area.inset(padding);
117        let (idle, copied) = markers(cx.env());
118
119        // The confirmation holds, then its colour blends back to the idle marker over `enter`.
120        let copied_at = cx.memory::<CopyMemory>().copied_at;
121        let now = cx.now();
122        let confirmed = copied_at.filter(|at| now < *at + CONFIRMATION);
123        let idle_color = cx.style("copy-value-marker", None, &states).text().fg.unwrap_or_else(|| cx.color("muted"));
124        let (marker, color) = match confirmed {
125            Some(at) => {
126                let success = cx.style("copy-value-marker", Some("copied"), &[]).text().fg;
127                let success = success.unwrap_or_else(|| cx.color("success"));
128                let enter = cx.env().theme().motion().enter;
129                let fade_start = at + CONFIRMATION.saturating_sub(enter);
130                let fade = if now < fade_start {
131                    cx.request_frame_in(fade_start - now);
132                    0.0
133                } else {
134                    cx.progress_since(fade_start, enter, Easing::EaseIn)
135                };
136                cx.request_frame_in((at + CONFIRMATION).saturating_sub(now));
137                (copied, success.mix(idle_color, fade))
138            }
139            None => (idle, idle_color),
140        };
141        let marker_width = text::width(&marker).min(inner.width);
142        let marker_x = inner.right() - i32::from(marker_width);
143        let mut marker_style = surface;
144        marker_style.fg = Some(color);
145        marker_style.bold = confirmed.is_some();
146        cx.text(marker_x, inner.y, &marker, marker_style, marker_width);
147
148        let budget = inner.width.saturating_sub(marker_width + MARKER_GAP);
149        let shown = self.shown(&cx.env().icons().glyph("mask"));
150        let value = text::truncate(&shown, budget).into_owned();
151        cx.text(inner.x, inner.y, &value, surface, budget);
152    }
153
154    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
155        if self.disabled {
156            return false;
157        }
158        let copy = match event {
159            Event::Key(key) if key.is_plain(Key::Char('c')) => true,
160            _ => match press::read(cx, event) {
161                Press::Ignored => return false,
162                Press::Used => false,
163                Press::Key | Press::Click(..) => true,
164            },
165        };
166        if copy {
167            cx.copy(self.value.clone());
168            cx.flash();
169            let now = cx.now();
170            cx.memory::<CopyMemory>().copied_at = Some(now);
171            if let Some(message) = &self.on_copy {
172                cx.emit(message.clone());
173            }
174        }
175        true
176    }
177
178    fn focusable(&self) -> bool {
179        !self.disabled
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use crate::icons::GlyphMode;
187    use crate::runtime::{App, ClipboardEvent, Command, Harness};
188    use crate::widget::View;
189
190    #[derive(Default)]
191    struct Demo {
192        copies: u32,
193        masked: bool,
194        disabled: bool,
195        heard: Vec<ClipboardEvent>,
196        pasted: Option<Option<String>>,
197    }
198
199    #[derive(Clone)]
200    enum Msg {
201        Copied,
202        Heard(ClipboardEvent),
203        ReadBack,
204        Read(Option<String>),
205    }
206
207    impl App for Demo {
208        type Msg = Msg;
209        fn update(&mut self, msg: Msg) -> Command<Msg> {
210            match msg {
211                Msg::Copied => self.copies += 1,
212                Msg::Heard(event) => self.heard.push(event),
213                Msg::ReadBack => return Command::read_clipboard(Msg::Read),
214                Msg::Read(text) => self.pasted = Some(text),
215            }
216            Command::none()
217        }
218        fn view(&self, ui: &mut View<'_, Msg>) {
219            ui.add(
220                CopyValue::new("docker pull ghcr.io/quvyta/api")
221                    .masked(self.masked)
222                    .disabled(self.disabled)
223                    .on_copy(Msg::Copied),
224            )
225            .fill_width()
226            .id("install");
227        }
228        fn clipboard(&self, event: &ClipboardEvent) -> Option<Msg> {
229            Some(Msg::Heard(event.clone()))
230        }
231    }
232
233    #[test]
234    fn copies_with_keys_and_clicks_and_confirms_in_place() {
235        let mut h = Harness::new(Demo::default(), 44, 1);
236        assert_eq!(h.screen(), "  docker pull ghcr.io/quvyta/api      copy\n");
237        h.press("tab").press("c");
238        assert_eq!(h.app().copies, 1);
239        assert_eq!(h.copied(), &["docker pull ghcr.io/quvyta/api".to_owned()]);
240        assert_eq!(h.clipboard(), Some("docker pull ghcr.io/quvyta/api"));
241        assert!(h.screen().ends_with("✓ copied\n"), "{}", h.screen());
242        let (x, y) = h.find("copied").expect("marker shown");
243        let success = h.env().theme().color("success");
244        assert_eq!(h.fg(u16::try_from(x).unwrap_or(0), u16::try_from(y).unwrap_or(0)), success);
245        h.advance(CONFIRMATION + Duration::from_millis(10));
246        assert!(h.screen().ends_with(" copy\n"), "{}", h.screen());
247        h.click_text("docker");
248        h.press("enter");
249        assert_eq!(h.app().copies, 3);
250        assert_eq!(h.app().heard.len(), 3);
251        assert_eq!(h.app().heard[0], ClipboardEvent::Copied("docker pull ghcr.io/quvyta/api".into()));
252    }
253
254    #[test]
255    fn masks_secrets_but_copies_the_real_value() {
256        let mut h = Harness::new(Demo { masked: true, ..Demo::default() }, 44, 1);
257        assert!(!h.screen().contains("docker"), "{}", h.screen());
258        assert!(h.screen().contains("•••"));
259        h.press("tab").press("enter");
260        assert_eq!(h.clipboard(), Some("docker pull ghcr.io/quvyta/api"));
261        h.set_glyph_mode(GlyphMode::Ascii);
262        assert!(h.screen().contains("v copied"), "{}", h.screen());
263    }
264
265    #[test]
266    fn narrow_keeps_the_marker_and_disabled_ignores_input() {
267        let h = Harness::new(Demo::default(), 20, 1);
268        assert_eq!(h.screen(), "  docker pu…  copy\n");
269        let mut disabled = Harness::new(Demo { disabled: true, ..Demo::default() }, 44, 1);
270        disabled.press("tab").press("enter").click_text("docker");
271        assert_eq!(disabled.app().copies, 0);
272        assert!(disabled.copied().is_empty());
273    }
274
275    #[test]
276    fn paste_key_and_read_clipboard_use_the_in_process_copy() {
277        let mut h = Harness::new(Demo::default(), 44, 1);
278        h.send(Msg::ReadBack);
279        assert_eq!(h.app().pasted, Some(None));
280        h.press("ctrl+v");
281        assert!(h.app().heard.is_empty(), "nothing copied yet, so nothing pasted");
282        h.press("tab").press("c").press("ctrl+v");
283        assert_eq!(h.app().heard.last(), Some(&ClipboardEvent::Pasted("docker pull ghcr.io/quvyta/api".into())));
284        h.paste("from the terminal");
285        assert_eq!(h.app().heard.last(), Some(&ClipboardEvent::Pasted("from the terminal".into())));
286        h.send(Msg::ReadBack);
287        assert_eq!(h.app().pasted, Some(Some("docker pull ghcr.io/quvyta/api".into())));
288    }
289
290    struct Form {
291        value: String,
292    }
293
294    impl App for Form {
295        type Msg = String;
296        fn update(&mut self, value: String) -> Command<String> {
297            self.value = value;
298            Command::none()
299        }
300        fn view(&self, ui: &mut View<'_, String>) {
301            ui.add(CopyValue::new("eu-west-3")).id("region");
302            ui.add(crate::widgets::TextInput::new(&self.value).on_change(|v| v)).fill_width().id("field");
303        }
304    }
305
306    #[test]
307    fn paste_key_fills_the_focused_text_field() {
308        let mut h = Harness::new(Form { value: String::new() }, 30, 2);
309        h.press("tab").press("c");
310        h.press("tab").press("ctrl+v");
311        assert_eq!(h.app().value, "eu-west-3");
312    }
313}