use std::time::Duration;
use unicode_segmentation::UnicodeSegmentation;
use super::cells;
use super::press::{self, Press};
use crate::event::Event;
use crate::geometry::{Rect, Size};
use crate::keymap::Key;
use crate::motion::Easing;
use crate::text;
use crate::theme::State;
use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};
const CONFIRMATION: Duration = Duration::from_millis(1400);
const MARKER_GAP: u16 = 2;
pub struct CopyValue<Msg> {
value: String,
masked: bool,
disabled: bool,
on_copy: Option<Msg>,
}
#[derive(Debug, Default)]
struct CopyMemory {
copied_at: Option<Duration>,
}
impl<Msg> CopyValue<Msg> {
#[must_use]
pub fn new(value: impl Into<String>) -> Self {
Self { value: value.into(), masked: false, disabled: false, on_copy: None }
}
#[must_use]
pub fn masked(mut self, masked: bool) -> Self {
self.masked = masked;
self
}
#[must_use]
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
#[must_use]
pub fn on_copy(mut self, message: Msg) -> Self {
self.on_copy = Some(message);
self
}
fn shown(&self, mask: &str) -> String {
if self.masked { self.value.graphemes(true).map(|_| mask).collect() } else { self.value.clone() }
}
}
fn markers(env: &crate::env::Env) -> (String, String) {
let i18n = env.i18n();
let check = env.icons().glyph("check");
(
i18n.translate("quvyta.copy-value.copy", &[]),
format!("{check} {}", i18n.translate("quvyta.copy-value.copied", &[])),
)
}
impl<Msg: Clone + 'static> Widget<Msg> for CopyValue<Msg> {
fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
let style = cx.env().theme().style("copy-value", None, &[]);
let (vertical, horizontal) = style.pair("padding").unwrap_or((0, 1));
let (idle, copied) = markers(cx.env());
let marker = text::width(&idle).max(text::width(&copied));
let value = text::width(&self.shown(&cx.env().icons().glyph("mask")));
Size::new(
cells::sum([value, MARKER_GAP, marker, horizontal.saturating_mul(2)]),
vertical.saturating_mul(2).saturating_add(1),
)
.min(available)
}
fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
let states = if self.disabled { vec![State::Disabled] } else { cx.states() };
let style = cx.style("copy-value", None, &states);
let surface = style.text();
cx.clear(area, surface.bg.unwrap_or_else(|| cx.color("raised")));
if !self.disabled {
cx.register_hit(area);
}
let padding = style.padding();
if let Some(color) = style.color("pillar").filter(|_| padding.left >= 1) {
cx.pillar(area.x, area.y + i32::from(padding.top), color);
}
let inner = area.inset(padding);
let (idle, copied) = markers(cx.env());
let copied_at = cx.memory::<CopyMemory>().copied_at;
let now = cx.now();
let confirmed = copied_at.filter(|at| now < *at + CONFIRMATION);
let idle_color = cx.style("copy-value-marker", None, &states).text().fg.unwrap_or_else(|| cx.color("muted"));
let (marker, color) = match confirmed {
Some(at) => {
let success = cx.style("copy-value-marker", Some("copied"), &[]).text().fg;
let success = success.unwrap_or_else(|| cx.color("success"));
let enter = cx.env().theme().motion().enter;
let fade_start = at + CONFIRMATION.saturating_sub(enter);
let fade = if now < fade_start {
cx.request_frame_in(fade_start - now);
0.0
} else {
cx.progress_since(fade_start, enter, Easing::EaseIn)
};
cx.request_frame_in((at + CONFIRMATION).saturating_sub(now));
(copied, success.mix(idle_color, fade))
}
None => (idle, idle_color),
};
let marker_width = text::width(&marker).min(inner.width);
let marker_x = inner.right() - i32::from(marker_width);
let mut marker_style = surface;
marker_style.fg = Some(color);
marker_style.bold = confirmed.is_some();
cx.text(marker_x, inner.y, &marker, marker_style, marker_width);
let budget = inner.width.saturating_sub(marker_width + MARKER_GAP);
let shown = self.shown(&cx.env().icons().glyph("mask"));
let value = text::truncate(&shown, budget).into_owned();
cx.text(inner.x, inner.y, &value, surface, budget);
}
fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
if self.disabled {
return false;
}
let copy = match event {
Event::Key(key) if key.is_plain(Key::Char('c')) => true,
_ => match press::read(cx, event) {
Press::Ignored => return false,
Press::Used => false,
Press::Key | Press::Click(..) => true,
},
};
if copy {
cx.copy(self.value.clone());
cx.flash();
let now = cx.now();
cx.memory::<CopyMemory>().copied_at = Some(now);
if let Some(message) = &self.on_copy {
cx.emit(message.clone());
}
}
true
}
fn focusable(&self) -> bool {
!self.disabled
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::icons::GlyphMode;
use crate::runtime::{App, ClipboardEvent, Command, Harness};
use crate::widget::View;
#[derive(Default)]
struct Demo {
copies: u32,
masked: bool,
disabled: bool,
heard: Vec<ClipboardEvent>,
pasted: Option<Option<String>>,
}
#[derive(Clone)]
enum Msg {
Copied,
Heard(ClipboardEvent),
ReadBack,
Read(Option<String>),
}
impl App for Demo {
type Msg = Msg;
fn update(&mut self, msg: Msg) -> Command<Msg> {
match msg {
Msg::Copied => self.copies += 1,
Msg::Heard(event) => self.heard.push(event),
Msg::ReadBack => return Command::read_clipboard(Msg::Read),
Msg::Read(text) => self.pasted = Some(text),
}
Command::none()
}
fn view(&self, ui: &mut View<'_, Msg>) {
ui.add(
CopyValue::new("docker pull ghcr.io/quvyta/api")
.masked(self.masked)
.disabled(self.disabled)
.on_copy(Msg::Copied),
)
.fill_width()
.id("install");
}
fn clipboard(&self, event: &ClipboardEvent) -> Option<Msg> {
Some(Msg::Heard(event.clone()))
}
}
#[test]
fn copies_with_keys_and_clicks_and_confirms_in_place() {
let mut h = Harness::new(Demo::default(), 44, 1);
assert_eq!(h.screen(), " docker pull ghcr.io/quvyta/api copy\n");
h.press("tab").press("c");
assert_eq!(h.app().copies, 1);
assert_eq!(h.copied(), &["docker pull ghcr.io/quvyta/api".to_owned()]);
assert_eq!(h.clipboard(), Some("docker pull ghcr.io/quvyta/api"));
assert!(h.screen().ends_with("✓ copied\n"), "{}", h.screen());
let (x, y) = h.find("copied").expect("marker shown");
let success = h.env().theme().color("success");
assert_eq!(h.fg(u16::try_from(x).unwrap_or(0), u16::try_from(y).unwrap_or(0)), success);
h.advance(CONFIRMATION + Duration::from_millis(10));
assert!(h.screen().ends_with(" copy\n"), "{}", h.screen());
h.click_text("docker");
h.press("enter");
assert_eq!(h.app().copies, 3);
assert_eq!(h.app().heard.len(), 3);
assert_eq!(h.app().heard[0], ClipboardEvent::Copied("docker pull ghcr.io/quvyta/api".into()));
}
#[test]
fn masks_secrets_but_copies_the_real_value() {
let mut h = Harness::new(Demo { masked: true, ..Demo::default() }, 44, 1);
assert!(!h.screen().contains("docker"), "{}", h.screen());
assert!(h.screen().contains("•••"));
h.press("tab").press("enter");
assert_eq!(h.clipboard(), Some("docker pull ghcr.io/quvyta/api"));
h.set_glyph_mode(GlyphMode::Ascii);
assert!(h.screen().contains("v copied"), "{}", h.screen());
}
#[test]
fn narrow_keeps_the_marker_and_disabled_ignores_input() {
let h = Harness::new(Demo::default(), 20, 1);
assert_eq!(h.screen(), " docker pu… copy\n");
let mut disabled = Harness::new(Demo { disabled: true, ..Demo::default() }, 44, 1);
disabled.press("tab").press("enter").click_text("docker");
assert_eq!(disabled.app().copies, 0);
assert!(disabled.copied().is_empty());
}
#[test]
fn paste_key_and_read_clipboard_use_the_in_process_copy() {
let mut h = Harness::new(Demo::default(), 44, 1);
h.send(Msg::ReadBack);
assert_eq!(h.app().pasted, Some(None));
h.press("ctrl+v");
assert!(h.app().heard.is_empty(), "nothing copied yet, so nothing pasted");
h.press("tab").press("c").press("ctrl+v");
assert_eq!(h.app().heard.last(), Some(&ClipboardEvent::Pasted("docker pull ghcr.io/quvyta/api".into())));
h.paste("from the terminal");
assert_eq!(h.app().heard.last(), Some(&ClipboardEvent::Pasted("from the terminal".into())));
h.send(Msg::ReadBack);
assert_eq!(h.app().pasted, Some(Some("docker pull ghcr.io/quvyta/api".into())));
}
struct Form {
value: String,
}
impl App for Form {
type Msg = String;
fn update(&mut self, value: String) -> Command<String> {
self.value = value;
Command::none()
}
fn view(&self, ui: &mut View<'_, String>) {
ui.add(CopyValue::new("eu-west-3")).id("region");
ui.add(crate::widgets::TextInput::new(&self.value).on_change(|v| v)).fill_width().id("field");
}
}
#[test]
fn paste_key_fills_the_focused_text_field() {
let mut h = Harness::new(Form { value: String::new() }, 30, 2);
h.press("tab").press("c");
h.press("tab").press("ctrl+v");
assert_eq!(h.app().value, "eu-west-3");
}
}