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`; `copy-value-label` (`fg`) for a
34/// [labelled](CopyValue::labelled) value's label and its whole value above a narrow box. Words come from
35/// `quvyta.copy-value.copy` and `quvyta.copy-value.copied`.
36pub struct CopyValue<Msg> {
37    value: String,
38    label: Option<String>,
39    masked: bool,
40    disabled: bool,
41    on_copy: Option<Msg>,
42}
43
44#[derive(Debug, Default)]
45struct CopyMemory {
46    copied_at: Option<Duration>,
47}
48
49impl<Msg> CopyValue<Msg> {
50    /// Shows `value` and copies it.
51    #[must_use]
52    pub fn new(value: impl Into<String>) -> Self {
53        Self { value: value.into(), label: None, masked: false, disabled: false, on_copy: None }
54    }
55
56    /// A faint `label` above the value, such as "Install command". When the value does not fit
57    /// its box it is also written out whole above the box, wrapped, so it can be read while the
58    /// box keeps its one line; a masked value is never written out.
59    #[must_use]
60    pub fn labelled(mut self, label: impl Into<String>) -> Self {
61        self.label = Some(label.into());
62        self
63    }
64
65    /// Draws the value as mask dots while still copying the real text, for secrets.
66    #[must_use]
67    pub fn masked(mut self, masked: bool) -> Self {
68        self.masked = masked;
69        self
70    }
71
72    /// Greys the value out; it cannot be focused or copied.
73    #[must_use]
74    pub fn disabled(mut self, disabled: bool) -> Self {
75        self.disabled = disabled;
76        self
77    }
78
79    /// Message sent after every copy.
80    #[must_use]
81    pub fn on_copy(mut self, message: Msg) -> Self {
82        self.on_copy = Some(message);
83        self
84    }
85
86    fn shown(&self, mask: &str) -> String {
87        if self.masked { self.value.graphemes(true).map(|_| mask).collect() } else { self.value.clone() }
88    }
89}
90
91/// The idle and confirmed marker texts.
92fn markers(env: &crate::env::Env) -> (String, String) {
93    let i18n = env.i18n();
94    let check = env.icons().glyph("check");
95    (
96        i18n.translate("quvyta.copy-value.copy", &[]),
97        format!("{check} {}", i18n.translate("quvyta.copy-value.copied", &[])),
98    )
99}
100
101impl<Msg> CopyValue<Msg> {
102    /// The size of the box alone: the value, the marker and the padding.
103    fn box_size(&self, env: &crate::env::Env) -> Size {
104        let style = env.theme().style("copy-value", None, &[]);
105        let (vertical, horizontal) = style.pair("padding").unwrap_or((0, 1));
106        let (idle, copied) = markers(env);
107        let marker = text::width(&idle).max(text::width(&copied));
108        let value = text::width(&self.shown(&env.icons().glyph("mask")));
109        Size::new(
110            cells::sum([value, MARKER_GAP, marker, horizontal.saturating_mul(2)]),
111            vertical.saturating_mul(2).saturating_add(1),
112        )
113    }
114
115    /// The lines above the box in `width` cells: the label, then the whole value when the box
116    /// cannot show it.
117    fn head(&self, env: &crate::env::Env, width: u16) -> Vec<String> {
118        let Some(label) = &self.label else { return Vec::new() };
119        let mut lines = vec![label.clone()];
120        if !self.masked && self.box_size(env).width > width {
121            lines.extend(text::wrap(&self.value, width.max(1)));
122        }
123        lines
124    }
125}
126
127impl<Msg: Clone + 'static> Widget<Msg> for CopyValue<Msg> {
128    fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
129        let boxed = self.box_size(cx.env());
130        let head = self.head(cx.env(), available.width);
131        let widest = head.iter().map(|line| text::width(line)).max().unwrap_or(0);
132        let rows = u16::try_from(head.len()).unwrap_or(u16::MAX);
133        Size::new(boxed.width.max(widest), boxed.height.saturating_add(rows)).min(available)
134    }
135
136    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
137        let head = self.head(cx.env(), area.width);
138        let label_style = cx.style("copy-value-label", None, &[]).text();
139        for (y, line) in (area.y..area.bottom()).zip(&head) {
140            let shown = text::truncate(line, area.width).into_owned();
141            cx.text(area.x, y, &shown, label_style, area.width);
142        }
143        let rows = u16::try_from(head.len()).unwrap_or(u16::MAX).min(area.height);
144        let area = Rect::new(area.x, area.y + i32::from(rows), area.width, area.height - rows);
145        let states = if self.disabled { vec![State::Disabled] } else { cx.states() };
146        let style = cx.style("copy-value", None, &states);
147        let surface = style.text();
148        cx.clear(area, surface.bg.unwrap_or_else(|| cx.color("raised")));
149        if !self.disabled {
150            cx.register_hit(area);
151        }
152        let padding = style.padding();
153        if let Some(color) = style.color("pillar").filter(|_| padding.left >= 1) {
154            cx.pillar(area.x, area.y + i32::from(padding.top), color);
155        }
156        let inner = area.inset(padding);
157        let (idle, copied) = markers(cx.env());
158
159        // The confirmation holds, then its colour blends back to the idle marker over `enter`.
160        let copied_at = cx.memory::<CopyMemory>().copied_at;
161        let now = cx.now();
162        let confirmed = copied_at.filter(|at| now < *at + CONFIRMATION);
163        let idle_color = cx.style("copy-value-marker", None, &states).text().fg.unwrap_or_else(|| cx.color("muted"));
164        let (marker, color) = match confirmed {
165            Some(at) => {
166                let success = cx.style("copy-value-marker", Some("copied"), &[]).text().fg;
167                let success = success.unwrap_or_else(|| cx.color("success"));
168                let enter = cx.env().theme().motion().enter;
169                let fade_start = at + CONFIRMATION.saturating_sub(enter);
170                let fade = if now < fade_start {
171                    cx.request_frame_in(fade_start - now);
172                    0.0
173                } else {
174                    cx.progress_since(fade_start, enter, Easing::EaseIn)
175                };
176                cx.request_frame_in((at + CONFIRMATION).saturating_sub(now));
177                (copied, success.mix(idle_color, fade))
178            }
179            None => (idle, idle_color),
180        };
181        let marker_width = text::width(&marker).min(inner.width);
182        let marker_x = inner.right() - i32::from(marker_width);
183        let mut marker_style = surface;
184        marker_style.fg = Some(color);
185        marker_style.bold = confirmed.is_some();
186        cx.text(marker_x, inner.y, &marker, marker_style, marker_width);
187
188        let budget = inner.width.saturating_sub(marker_width + MARKER_GAP);
189        let shown = self.shown(&cx.env().icons().glyph("mask"));
190        let value = text::truncate(&shown, budget).into_owned();
191        cx.text(inner.x, inner.y, &value, surface, budget);
192    }
193
194    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
195        if self.disabled {
196            return false;
197        }
198        let copy = match event {
199            Event::Key(key) if key.is_plain(Key::Char('c')) => true,
200            _ => match press::read(cx, event) {
201                Press::Ignored => return false,
202                Press::Used => false,
203                Press::Key | Press::Click(..) => true,
204            },
205        };
206        if copy {
207            cx.copy(self.value.clone());
208            cx.flash();
209            let now = cx.now();
210            cx.memory::<CopyMemory>().copied_at = Some(now);
211            if let Some(message) = &self.on_copy {
212                cx.emit(message.clone());
213            }
214        }
215        true
216    }
217
218    fn focusable(&self) -> bool {
219        !self.disabled
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use crate::icons::GlyphMode;
227    use crate::runtime::{App, ClipboardEvent, Command, Harness};
228    use crate::widget::View;
229
230    #[derive(Default)]
231    struct Demo {
232        copies: u32,
233        masked: bool,
234        disabled: bool,
235        heard: Vec<ClipboardEvent>,
236        pasted: Option<Option<String>>,
237    }
238
239    #[derive(Clone)]
240    enum Msg {
241        Copied,
242        Heard(ClipboardEvent),
243        ReadBack,
244        Read(Option<String>),
245    }
246
247    impl App for Demo {
248        type Msg = Msg;
249        fn update(&mut self, msg: Msg) -> Command<Msg> {
250            match msg {
251                Msg::Copied => self.copies += 1,
252                Msg::Heard(event) => self.heard.push(event),
253                Msg::ReadBack => return Command::read_clipboard(Msg::Read),
254                Msg::Read(text) => self.pasted = Some(text),
255            }
256            Command::none()
257        }
258        fn view(&self, ui: &mut View<'_, Msg>) {
259            ui.add(
260                CopyValue::new("docker pull ghcr.io/quvyta/api")
261                    .masked(self.masked)
262                    .disabled(self.disabled)
263                    .on_copy(Msg::Copied),
264            )
265            .fill_width()
266            .id("install");
267        }
268        fn clipboard(&self, event: &ClipboardEvent) -> Option<Msg> {
269            Some(Msg::Heard(event.clone()))
270        }
271    }
272
273    #[test]
274    fn copies_with_keys_and_clicks_and_confirms_in_place() {
275        let mut h = Harness::new(Demo::default(), 44, 1);
276        assert_eq!(h.screen(), "  docker pull ghcr.io/quvyta/api      copy\n");
277        h.press("tab").press("c");
278        assert_eq!(h.app().copies, 1);
279        assert_eq!(h.copied(), &["docker pull ghcr.io/quvyta/api".to_owned()]);
280        assert_eq!(h.clipboard(), Some("docker pull ghcr.io/quvyta/api"));
281        assert!(h.screen().ends_with("✓ copied\n"), "{}", h.screen());
282        let (x, y) = h.find("copied").expect("marker shown");
283        let success = h.env().theme().color("success");
284        assert_eq!(h.fg(u16::try_from(x).unwrap_or(0), u16::try_from(y).unwrap_or(0)), success);
285        h.advance(CONFIRMATION + Duration::from_millis(10));
286        assert!(h.screen().ends_with(" copy\n"), "{}", h.screen());
287        h.click_text("docker");
288        h.press("enter");
289        assert_eq!(h.app().copies, 3);
290        assert_eq!(h.app().heard.len(), 3);
291        assert_eq!(h.app().heard[0], ClipboardEvent::Copied("docker pull ghcr.io/quvyta/api".into()));
292    }
293
294    #[test]
295    fn masks_secrets_but_copies_the_real_value() {
296        let mut h = Harness::new(Demo { masked: true, ..Demo::default() }, 44, 1);
297        assert!(!h.screen().contains("docker"), "{}", h.screen());
298        assert!(h.screen().contains("•••"));
299        h.press("tab").press("enter");
300        assert_eq!(h.clipboard(), Some("docker pull ghcr.io/quvyta/api"));
301        h.set_glyph_mode(GlyphMode::Ascii);
302        assert!(h.screen().contains("v copied"), "{}", h.screen());
303    }
304
305    #[test]
306    fn narrow_keeps_the_marker_and_disabled_ignores_input() {
307        let h = Harness::new(Demo::default(), 20, 1);
308        assert_eq!(h.screen(), "  docker pu…  copy\n");
309        let mut disabled = Harness::new(Demo { disabled: true, ..Demo::default() }, 44, 1);
310        disabled.press("tab").press("enter").click_text("docker");
311        assert_eq!(disabled.app().copies, 0);
312        assert!(disabled.copied().is_empty());
313    }
314
315    #[test]
316    fn paste_key_and_read_clipboard_use_the_in_process_copy() {
317        let mut h = Harness::new(Demo::default(), 44, 1);
318        h.send(Msg::ReadBack);
319        assert_eq!(h.app().pasted, Some(None));
320        h.press("ctrl+v");
321        assert!(h.app().heard.is_empty(), "nothing copied yet, so nothing pasted");
322        h.press("tab").press("c").press("ctrl+v");
323        assert_eq!(h.app().heard.last(), Some(&ClipboardEvent::Pasted("docker pull ghcr.io/quvyta/api".into())));
324        h.paste("from the terminal");
325        assert_eq!(h.app().heard.last(), Some(&ClipboardEvent::Pasted("from the terminal".into())));
326        h.send(Msg::ReadBack);
327        assert_eq!(h.app().pasted, Some(Some("docker pull ghcr.io/quvyta/api".into())));
328    }
329
330    struct Form {
331        value: String,
332    }
333
334    impl App for Form {
335        type Msg = String;
336        fn update(&mut self, value: String) -> Command<String> {
337            self.value = value;
338            Command::none()
339        }
340        fn view(&self, ui: &mut View<'_, String>) {
341            ui.add(CopyValue::new("eu-west-3")).id("region");
342            ui.add(crate::widgets::TextInput::new(&self.value).on_change(|v| v)).fill_width().id("field");
343        }
344    }
345
346    #[test]
347    fn paste_key_fills_the_focused_text_field() {
348        let mut h = Harness::new(Form { value: String::new() }, 30, 2);
349        h.press("tab").press("c");
350        h.press("tab").press("ctrl+v");
351        assert_eq!(h.app().value, "eu-west-3");
352    }
353
354    /// A labelled install command.
355    struct Labelled;
356
357    impl App for Labelled {
358        type Msg = ();
359        fn update(&mut self, (): ()) -> Command<()> {
360            Command::none()
361        }
362        fn view(&self, ui: &mut View<'_, ()>) {
363            ui.add(CopyValue::new("cargo install quvyta-notes --locked").labelled("Install command")).fill_width();
364        }
365    }
366
367    #[test]
368    fn a_label_sits_above_the_value_and_a_narrow_box_shows_the_value_whole_above_it() {
369        let wide = Harness::new(Labelled, 60, 4);
370        let screen = wide.screen();
371        let lines: Vec<&str> = screen.lines().collect();
372        assert!(lines[0].starts_with("Install command"), "{screen}");
373        assert!(lines[1].contains("cargo install quvyta-notes --locked"), "the value in its box: {screen}");
374        let muted = wide.env().theme().color("muted");
375        assert_eq!(wide.fg(0, 0), muted, "the label is faint");
376
377        let mut narrow = Harness::new(Labelled, 24, 6);
378        let screen = narrow.screen();
379        let words: Vec<&str> = screen.lines().skip(1).take(2).flat_map(str::split_whitespace).collect();
380        assert_eq!(words, ["cargo", "install", "quvyta-notes", "--locked"], "written out whole: {screen}");
381        assert!(screen.contains('…'), "while the box cuts it: {screen}");
382        let (x, y) = narrow.find("…").expect("the box");
383        narrow.click(x, y);
384        assert_eq!(narrow.copied(), &["cargo install quvyta-notes --locked".to_owned()], "the whole value is copied");
385    }
386}