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> {
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 #[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 #[must_use]
56 pub fn masked(mut self, masked: bool) -> Self {
57 self.masked = masked;
58 self
59 }
60
61 #[must_use]
63 pub fn disabled(mut self, disabled: bool) -> Self {
64 self.disabled = disabled;
65 self
66 }
67
68 #[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
80fn 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 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}