quvyta-framework 0.1.33

A Rust framework for building terminal applications
//! A value the user copies with one key or click, with quiet confirmation in place.

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};

/// How long the "copied" confirmation stays before it fades back.
const CONFIRMATION: Duration = Duration::from_millis(1400);

/// Cells between the value and the marker.
const MARKER_GAP: u16 = 2;

/// A value on a raised surface that copies itself to the clipboard: an install command, a
/// token, a URL.
///
/// Enter, Space or `c` while focused, or a click, copies the value, flashes the surface and
/// turns the trailing `copy` word into a success mark with `copied` that fades back after a
/// moment, so no toast is needed. The value is truncated with `…` when narrow; the marker always
/// stays visible. The copy goes to the terminal clipboard (OSC 52) and to the application's
/// in-process clipboard.
///
/// Style keys: `copy-value` (`bg`, `fg`, `padding`) with `hover`, `focus`, `pressed`,
/// `disabled`; `copy-value-marker` (`fg`) with variant `copied`; `copy-value-label` (`fg`) for a
/// [labelled](CopyValue::labelled) value's label and its whole value above a narrow box. Words come from
/// `quvyta.copy-value.copy` and `quvyta.copy-value.copied`.
pub struct CopyValue<Msg> {
    value: String,
    label: Option<String>,
    masked: bool,
    disabled: bool,
    on_copy: Option<Msg>,
}

#[derive(Debug, Default)]
struct CopyMemory {
    copied_at: Option<Duration>,
}

impl<Msg> CopyValue<Msg> {
    /// Shows `value` and copies it.
    #[must_use]
    pub fn new(value: impl Into<String>) -> Self {
        Self { value: value.into(), label: None, masked: false, disabled: false, on_copy: None }
    }

    /// A faint `label` above the value, such as "Install command". When the value does not fit
    /// its box it is also written out whole above the box, wrapped, so it can be read while the
    /// box keeps its one line; a masked value is never written out.
    #[must_use]
    pub fn labelled(mut self, label: impl Into<String>) -> Self {
        self.label = Some(label.into());
        self
    }

    /// Draws the value as mask dots while still copying the real text, for secrets.
    #[must_use]
    pub fn masked(mut self, masked: bool) -> Self {
        self.masked = masked;
        self
    }

    /// Greys the value out; it cannot be focused or copied.
    #[must_use]
    pub fn disabled(mut self, disabled: bool) -> Self {
        self.disabled = disabled;
        self
    }

    /// Message sent after every copy.
    #[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() }
    }
}

/// The idle and confirmed marker texts.
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> CopyValue<Msg> {
    /// The size of the box alone: the value, the marker and the padding.
    fn box_size(&self, env: &crate::env::Env) -> Size {
        let style = env.theme().style("copy-value", None, &[]);
        let (vertical, horizontal) = style.pair("padding").unwrap_or((0, 1));
        let (idle, copied) = markers(env);
        let marker = text::width(&idle).max(text::width(&copied));
        let value = text::width(&self.shown(&env.icons().glyph("mask")));
        Size::new(
            cells::sum([value, MARKER_GAP, marker, horizontal.saturating_mul(2)]),
            vertical.saturating_mul(2).saturating_add(1),
        )
    }

    /// The lines above the box in `width` cells: the label, then the whole value when the box
    /// cannot show it.
    fn head(&self, env: &crate::env::Env, width: u16) -> Vec<String> {
        let Some(label) = &self.label else { return Vec::new() };
        let mut lines = vec![label.clone()];
        if !self.masked && self.box_size(env).width > width {
            lines.extend(text::wrap(&self.value, width.max(1)));
        }
        lines
    }
}

impl<Msg: Clone + 'static> Widget<Msg> for CopyValue<Msg> {
    fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
        let boxed = self.box_size(cx.env());
        let head = self.head(cx.env(), available.width);
        let widest = head.iter().map(|line| text::width(line)).max().unwrap_or(0);
        let rows = u16::try_from(head.len()).unwrap_or(u16::MAX);
        Size::new(boxed.width.max(widest), boxed.height.saturating_add(rows)).min(available)
    }

    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
        let head = self.head(cx.env(), area.width);
        let label_style = cx.style("copy-value-label", None, &[]).text();
        for (y, line) in (area.y..area.bottom()).zip(&head) {
            let shown = text::truncate(line, area.width).into_owned();
            cx.text(area.x, y, &shown, label_style, area.width);
        }
        let rows = u16::try_from(head.len()).unwrap_or(u16::MAX).min(area.height);
        let area = Rect::new(area.x, area.y + i32::from(rows), area.width, area.height - rows);
        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());

        // The confirmation holds, then its colour blends back to the idle marker over `enter`.
        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");
    }

    /// A labelled install command.
    struct Labelled;

    impl App for Labelled {
        type Msg = ();
        fn update(&mut self, (): ()) -> Command<()> {
            Command::none()
        }
        fn view(&self, ui: &mut View<'_, ()>) {
            ui.add(CopyValue::new("cargo install quvyta-notes --locked").labelled("Install command")).fill_width();
        }
    }

    #[test]
    fn a_label_sits_above_the_value_and_a_narrow_box_shows_the_value_whole_above_it() {
        let wide = Harness::new(Labelled, 60, 4);
        let screen = wide.screen();
        let lines: Vec<&str> = screen.lines().collect();
        assert!(lines[0].starts_with("Install command"), "{screen}");
        assert!(lines[1].contains("cargo install quvyta-notes --locked"), "the value in its box: {screen}");
        let muted = wide.env().theme().color("muted");
        assert_eq!(wide.fg(0, 0), muted, "the label is faint");

        let mut narrow = Harness::new(Labelled, 24, 6);
        let screen = narrow.screen();
        let words: Vec<&str> = screen.lines().skip(1).take(2).flat_map(str::split_whitespace).collect();
        assert_eq!(words, ["cargo", "install", "quvyta-notes", "--locked"], "written out whole: {screen}");
        assert!(screen.contains('…'), "while the box cuts it: {screen}");
        let (x, y) = narrow.find("…").expect("the box");
        narrow.click(x, y);
        assert_eq!(narrow.copied(), &["cargo install quvyta-notes --locked".to_owned()], "the whole value is copied");
    }
}