use std::io;
use crate::shared::i18n::Locale;
use crate::shared::osc52::{self, Osc52Mode};
#[cfg(windows)]
pub(super) fn read_clipboard_text(slot: &mut Option<arboard::Clipboard>) -> Option<String> {
if slot.is_none() {
*slot = arboard::Clipboard::new().ok();
}
slot.as_mut().and_then(|c| c.get_text().ok())
}
pub(super) fn clipboard_text(slot: &mut Option<arboard::Clipboard>) -> Option<String> {
if slot.is_none() {
*slot = arboard::Clipboard::new().ok();
}
slot.as_mut()
.and_then(|c| c.get_text().ok())
.filter(|t| !t.is_empty())
}
pub(super) fn clipboard_image(
slot: &mut Option<arboard::Clipboard>,
) -> Option<(u32, u32, Vec<u8>)> {
if slot.is_none() {
*slot = arboard::Clipboard::new().ok();
}
let data = slot.as_mut()?.get_image().ok()?;
let (width, height) = (
u32::try_from(data.width).ok()?,
u32::try_from(data.height).ok()?,
);
Some((width, height, data.bytes.into_owned()))
}
fn write_local(slot: &mut Option<arboard::Clipboard>, text: &str) -> Result<(), String> {
if slot.is_none() {
*slot = Some(arboard::Clipboard::new().map_err(|e| e.to_string())?);
}
slot.as_mut()
.unwrap()
.set_text(text.to_string())
.map_err(|e| e.to_string())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum TerminalCopy {
NotTried,
Sent,
TooLarge { bytes: usize },
}
#[derive(Debug, Clone, PartialEq)]
pub(super) struct CopyReport {
pub(super) local: Result<(), String>,
pub(super) terminal: TerminalCopy,
}
impl CopyReport {
pub(super) fn message(&self, loc: &'static Locale) -> (String, bool) {
match (&self.local, self.terminal) {
(Ok(()), TerminalCopy::NotTried) => (loc.t("ui.chat.copied").into(), false),
(_, TerminalCopy::Sent) => (loc.t("ui.chat.copied_terminal").into(), false),
(Ok(()), TerminalCopy::TooLarge { bytes }) => (
loc.tf(
"ui.chat.copied_local_too_large",
&[("bytes", &bytes.to_string())],
),
false,
),
(Err(err), TerminalCopy::TooLarge { bytes }) => (
loc.tf(
"ui.err.copy_too_large",
&[("bytes", &bytes.to_string()), ("err", err)],
),
true,
),
(Err(err), TerminalCopy::NotTried) => {
(loc.tf("ui.err.copy_failed", &[("err", err)]), true)
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TerminalPlan {
Skip,
TooLarge,
Send,
}
fn plan_terminal(mode: Osc52Mode, remote: bool, local_failed: bool, len: usize) -> TerminalPlan {
if !osc52::should_emit(mode, remote, local_failed) {
TerminalPlan::Skip
} else if len > osc52::MAX_TEXT_BYTES {
TerminalPlan::TooLarge
} else {
TerminalPlan::Send
}
}
pub(super) fn copy_text(
slot: &mut Option<arboard::Clipboard>,
text: &str,
mode: Osc52Mode,
) -> CopyReport {
let local = write_local(slot, text);
let plan = plan_terminal(
mode,
osc52::session_looks_remote(),
local.is_err(),
text.len(),
);
let terminal = match plan {
TerminalPlan::Skip => TerminalCopy::NotTried,
TerminalPlan::TooLarge => TerminalCopy::TooLarge { bytes: text.len() },
TerminalPlan::Send => match osc52::write_to(&mut io::stdout(), text, osc52::in_tmux()) {
Ok(true) => TerminalCopy::Sent,
Ok(false) | Err(_) => TerminalCopy::NotTried,
},
};
CopyReport { local, terminal }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_terminal_half_is_planned_before_it_is_done() {
use Osc52Mode::*;
use TerminalPlan::*;
let max = crate::shared::osc52::MAX_TEXT_BYTES;
for (mode, remote, failed, len, expected) in [
(Auto, false, false, 10, Skip),
(Auto, true, false, 10, Send),
(Auto, false, true, 10, Send),
(Always, false, false, 10, Send),
(Off, true, true, 10, Skip),
(Always, false, false, max, Send),
(Always, false, false, max + 1, TooLarge),
(Auto, false, false, max + 1, Skip),
(Off, true, true, max + 1, Skip),
] {
assert_eq!(
plan_terminal(mode, remote, failed, len),
expected,
"{mode:?} remote={remote} local_failed={failed} len={len}"
);
}
}
#[test]
fn each_outcome_gets_its_own_wording() {
let loc = crate::shared::i18n::locale(crate::shared::i18n::Lang::En);
let msg = |local: Result<(), String>, terminal| CopyReport { local, terminal }.message(loc);
let (plain, failed) = msg(Ok(()), TerminalCopy::NotTried);
assert!(!failed);
assert_eq!(
plain,
loc.t("ui.chat.copied"),
"the unchanged local wording"
);
let (sent, failed) = msg(Ok(()), TerminalCopy::Sent);
assert!(!failed);
assert_ne!(sent, plain, "sending to the terminal is not the same claim");
assert!(sent.contains("never confirms"), "{sent}");
let (big_local, failed) = msg(Ok(()), TerminalCopy::TooLarge { bytes: 100_000 });
assert!(!failed, "the text did reach this machine's clipboard");
let (big_none, failed) = msg(
Err("no clipboard".into()),
TerminalCopy::TooLarge { bytes: 100_000 },
);
assert!(failed, "nothing was copied anywhere");
assert_ne!(big_local, big_none);
for m in [&big_local, &big_none] {
assert!(m.contains("100000"), "the size is named: {m}");
}
assert!(big_none.contains("no clipboard"), "{big_none}");
let (err, failed) = msg(Err("no clipboard".into()), TerminalCopy::NotTried);
assert!(failed);
assert_ne!(err, big_none);
}
#[test]
fn every_clipboard_dead_end_names_the_export_route() {
for &lang in crate::shared::i18n::Lang::ALL {
let loc = crate::shared::i18n::locale(lang);
for (local, terminal) in [
(Ok(()), TerminalCopy::Sent),
(Ok(()), TerminalCopy::TooLarge { bytes: 90_000 }),
(
Err("x".to_string()),
TerminalCopy::TooLarge { bytes: 90_000 },
),
] {
let (msg, _) = CopyReport { local, terminal }.message(loc);
assert!(
msg.contains("/export"),
"{lang:?} leaves the user without a route: {msg}"
);
}
let (plain, _) = CopyReport {
local: Ok(()),
terminal: TerminalCopy::NotTried,
}
.message(loc);
assert!(!plain.contains("/export"), "{lang:?}: {plain}");
}
}
#[test]
fn the_wordings_are_localized_for_all_langs() {
for &lang in crate::shared::i18n::Lang::ALL {
let loc = crate::shared::i18n::locale(lang);
for (local, terminal) in [
(Ok(()), TerminalCopy::NotTried),
(Ok(()), TerminalCopy::Sent),
(Ok(()), TerminalCopy::TooLarge { bytes: 90_000 }),
(
Err("x".to_string()),
TerminalCopy::TooLarge { bytes: 90_000 },
),
(Err("x".to_string()), TerminalCopy::NotTried),
] {
let (msg, _) = CopyReport { local, terminal }.message(loc);
assert!(
!msg.contains('{') && !msg.contains('}'),
"unsubstituted placeholder in {lang:?}: {msg}"
);
assert!(!msg.trim().is_empty(), "an empty answer in {lang:?}");
if lang == crate::shared::i18n::Lang::En {
assert!(
!msg.chars().any(|c| ('\u{0400}'..='\u{04FF}').contains(&c)),
"Cyrillic leaked into the en message: {msg}"
);
}
}
let (msg, _) = CopyReport {
local: Ok(()),
terminal: TerminalCopy::TooLarge { bytes: 90_000 },
}
.message(loc);
assert!(
msg.contains(&crate::shared::osc52::MAX_TEXT_BYTES.to_string()),
"the limit is named in {lang:?}: {msg}"
);
}
}
#[test]
#[ignore = "uses the real system clipboard (clobbers what the user copied)"]
fn clipboard_image_round_trip() {
let (w, h) = (64u32, 32u32);
let source: Vec<u8> = (0..w as usize * h as usize)
.flat_map(|i| [(i % 251) as u8, (i % 253) as u8, (i % 257 % 256) as u8, 255])
.collect();
let mut slot: Option<arboard::Clipboard> = None;
if slot.is_none() {
match arboard::Clipboard::new() {
Ok(c) => slot = Some(c),
Err(e) => {
eprintln!("skip: no system clipboard here ({e})");
return;
}
}
}
slot.as_mut()
.unwrap()
.set_image(arboard::ImageData {
width: w as usize,
height: h as usize,
bytes: source.clone().into(),
})
.expect("putting an image on the clipboard");
let (rw, rh, rgba) = clipboard_image(&mut slot).expect("an image back off the clipboard");
assert_eq!((rw, rh), (w, h), "the size must survive the round trip");
assert_eq!(rgba.len(), source.len(), "RGBA8, four bytes per pixel");
let distinct = rgba
.chunks(4)
.map(|p| (p[0], p[1], p[2]))
.collect::<std::collections::HashSet<_>>();
assert!(
distinct.len() > 100,
"the round trip flattened the image into {} distinct colours",
distinct.len()
);
let prepared =
crate::features::image_prepare::prepare_rgba(rw, rh, &rgba, 1568).expect("encoding");
assert_eq!(prepared.mime, "image/png");
assert_eq!((prepared.width, prepared.height), (w, h));
eprintln!(
"clipboard round trip: {w}x{h} -> {} bytes png",
prepared.bytes.len()
);
}
}