1use 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
17const CONFIRMATION: Duration = Duration::from_millis(1400);
19
20const MARKER_GAP: u16 = 2;
22
23pub 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 #[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 #[must_use]
60 pub fn labelled(mut self, label: impl Into<String>) -> Self {
61 self.label = Some(label.into());
62 self
63 }
64
65 #[must_use]
67 pub fn masked(mut self, masked: bool) -> Self {
68 self.masked = masked;
69 self
70 }
71
72 #[must_use]
74 pub fn disabled(mut self, disabled: bool) -> Self {
75 self.disabled = disabled;
76 self
77 }
78
79 #[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
91fn 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 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 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 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 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}