use anyhow::{Context, Result};
use sefy_core::{Error, ItemSummary};
pub fn to_clipboard(value: &str, seconds: u64) -> Result<ClipboardHold> {
Clipboard::open()?.hold(value, seconds)
}
pub struct Clipboard {
inner: arboard::Clipboard,
}
impl Clipboard {
pub fn open() -> Result<Self> {
let inner = arboard::Clipboard::new().context(
"cannot reach the clipboard\n\
use --stdout to print the value instead",
)?;
Ok(Self { inner })
}
pub fn put(&mut self, value: &str) -> Result<()> {
self.inner
.set_text(value.to_owned())
.context("cannot write to the clipboard")
}
pub fn take_back(&mut self, value: &str) {
if matches!(self.inner.get_text(), Ok(current) if current == value) {
let _ = self.inner.clear();
}
}
pub fn hold(self, value: &str, seconds: u64) -> Result<ClipboardHold> {
hold(self.inner, value, seconds)
}
}
fn hold(mut clipboard: arboard::Clipboard, value: &str, seconds: u64) -> Result<ClipboardHold> {
#[cfg(target_os = "linux")]
{
use arboard::SetExtLinux;
let deadline = std::time::Instant::now() + clipboard_lifetime(seconds);
clipboard
.set()
.wait_until(deadline)
.text(value.to_owned())
.context("cannot write to the clipboard")?;
Ok(ClipboardHold { cleared: true })
}
#[cfg(not(target_os = "linux"))]
{
clipboard
.set_text(value.to_owned())
.context("cannot write to the clipboard")?;
if seconds == 0 {
return Ok(ClipboardHold { cleared: false });
}
std::thread::sleep(std::time::Duration::from_secs(seconds));
match clipboard.get_text() {
Ok(current) if current == value => {
let _ = clipboard.clear();
Ok(ClipboardHold { cleared: true })
}
_ => Ok(ClipboardHold { cleared: false }),
}
}
}
#[cfg(target_os = "linux")]
fn clipboard_lifetime(seconds: u64) -> std::time::Duration {
const FOREVER_IN_PRACTICE: u64 = 8 * 60 * 60;
std::time::Duration::from_secs(if seconds == 0 {
FOREVER_IN_PRACTICE
} else {
seconds
})
}
pub struct ClipboardHold {
pub cleared: bool,
}
pub fn table(items: &[ItemSummary]) {
if items.is_empty() {
println!("no items");
return;
}
let id_width = items
.iter()
.map(|item| item.id.to_string().len())
.max()
.unwrap_or(2);
let title_width = items
.iter()
.map(|item| item.title.chars().count())
.max()
.unwrap_or(5)
.min(40);
let kind_width = items
.iter()
.map(|item| item.kind.as_str().chars().count())
.max()
.unwrap_or(10)
.max(10);
for item in items {
let tags = if item.tags.is_empty() {
String::new()
} else {
format!(" [{}]", item.tags.join(", "))
};
let unknown = if item.kind.is_known() {
""
} else {
" (needs a newer sefy)"
};
println!(
"{:>id_width$} {:<title_width$} {:<kind_width$}{}{}",
item.id,
truncate(&item.title, title_width),
item.kind.as_str(),
tags,
unknown,
);
}
}
fn truncate(text: &str, width: usize) -> String {
if text.chars().count() <= width {
return text.to_owned();
}
let kept: String = text.chars().take(width.saturating_sub(1)).collect();
format!("{kept}…")
}
pub fn count(n: usize, noun: &str) -> String {
if n == 1 {
format!("{n} {noun}")
} else {
format!("{n} {noun}s")
}
}
pub fn explain(error: Error) -> anyhow::Error {
match error {
Error::Ambiguous {
reference,
candidates,
} => {
let mut message = format!("{} items match {reference:?}:\n", candidates.len());
for item in &candidates {
message.push_str(&format!(
" {:>4} {:<30} {}\n",
item.id,
truncate(&item.title, 30),
item.kind.as_str()
));
}
message.push_str("narrow the text, or use an id");
anyhow::anyhow!(message)
}
Error::NotFound(reference) => {
anyhow::anyhow!("nothing matches {reference:?}")
}
other => other.into(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn truncate_keeps_short_text_and_marks_cuts() {
assert_eq!(truncate("short", 10), "short");
assert_eq!(truncate("exactly-ten", 11), "exactly-ten");
assert_eq!(truncate("much too long to fit", 8), "much to…");
}
#[test]
fn truncate_counts_characters_not_bytes() {
assert_eq!(truncate("паспорт", 7), "паспорт");
assert_eq!(truncate("паспорт", 4), "пас…");
}
}