feat: file names, atomic PNG writes and clipboard
This commit is contained in:
1 parent
ac8d4e1b72
commit
c6094307f5
2 files changed
+232
No files matched your search
@@ -7,6 +7,7 @@ mod error;
|
|||||||
mod frame;
|
mod frame;
|
||||||
mod lock;
|
mod lock;
|
||||||
mod log;
|
mod log;
|
||||||
|
mod output;
|
||||||
mod render;
|
mod render;
|
||||||
mod selection;
|
mod selection;
|
||||||
|
|
||||||
|
|||||||
+231
@@ -0,0 +1,231 @@
|
|||||||
|
use std::fmt::Write as _;
|
||||||
|
use std::fs;
|
||||||
|
use std::io::{Cursor, Write as _};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, anyhow};
|
||||||
|
use chrono::{DateTime, Local};
|
||||||
|
use image::{ImageFormat, RgbaImage};
|
||||||
|
use wl_clipboard_rs::copy::{MimeType, Options, Source};
|
||||||
|
|
||||||
|
use crate::error::HintExt;
|
||||||
|
use crate::lock::Lock;
|
||||||
|
|
||||||
|
/// Renders the strftime `template`. With `output`, appends ` (NAME)` before
|
||||||
|
/// the extension, for `valw screen --all`.
|
||||||
|
pub fn file_name(template: &str, now: &DateTime<Local>, output: Option<&str>) -> Result<String> {
|
||||||
|
let mut name = String::new();
|
||||||
|
write!(name, "{}", now.format(template))
|
||||||
|
.map_err(|_| anyhow!("invalid strftime pattern in save.filename: {template:?}"))
|
||||||
|
.hint("see https://docs.rs/chrono/latest/chrono/format/strftime for the syntax")?;
|
||||||
|
if name.is_empty() || name.contains('/') {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"save.filename must render to a plain file name, got {name:?}"
|
||||||
|
))
|
||||||
|
.hint("put folders in save.directory, not save.filename");
|
||||||
|
}
|
||||||
|
if let Some(output) = output {
|
||||||
|
name = with_suffix(&name, &format!(" ({output})"));
|
||||||
|
}
|
||||||
|
Ok(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inserts `suffix` before the extension: `a.png` + ` (2)` = `a (2).png`.
|
||||||
|
fn with_suffix(name: &str, suffix: &str) -> String {
|
||||||
|
match name.rsplit_once('.') {
|
||||||
|
Some((stem, ext)) if !stem.is_empty() => format!("{stem}{suffix}.{ext}"),
|
||||||
|
_ => format!("{name}{suffix}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `dir/name`, or `dir/name (2)`, `(3)`, … if taken.
|
||||||
|
pub fn unique_path(dir: &Path, name: &str) -> PathBuf {
|
||||||
|
let first = dir.join(name);
|
||||||
|
if !first.exists() {
|
||||||
|
return first;
|
||||||
|
}
|
||||||
|
(2..)
|
||||||
|
.map(|n| dir.join(with_suffix(name, &format!(" ({n})"))))
|
||||||
|
.find(|p| !p.exists())
|
||||||
|
.expect("some suffix is free")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encode_png(img: &RgbaImage) -> Result<Vec<u8>> {
|
||||||
|
let mut png = Vec::new();
|
||||||
|
img.write_to(&mut Cursor::new(&mut png), ImageFormat::Png)
|
||||||
|
.context("could not encode PNG")?;
|
||||||
|
Ok(png)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes via a temp file in the same directory and a rename, so a partial
|
||||||
|
/// screenshot never appears under the final name.
|
||||||
|
pub fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||||
|
// "a.png" has the parent "", which means the current directory.
|
||||||
|
let dir = match path.parent() {
|
||||||
|
Some(p) if !p.as_os_str().is_empty() => p,
|
||||||
|
_ => Path::new("."),
|
||||||
|
};
|
||||||
|
fs::create_dir_all(dir)
|
||||||
|
.with_context(|| format!("could not create {}", dir.display()))
|
||||||
|
.hint("check save.directory in the config")?;
|
||||||
|
let name = path.file_name().context("path has no file name")?;
|
||||||
|
let tmp = dir.join(format!(".{}.tmp", name.to_string_lossy()));
|
||||||
|
let result = fs::write(&tmp, bytes).and_then(|()| fs::rename(&tmp, path));
|
||||||
|
if result.is_err() {
|
||||||
|
let _ = fs::remove_file(&tmp);
|
||||||
|
}
|
||||||
|
result.with_context(|| format!("could not write {}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_stdout(bytes: &[u8]) -> Result<()> {
|
||||||
|
let mut out = std::io::stdout().lock();
|
||||||
|
out.write_all(bytes)
|
||||||
|
.and_then(|()| out.flush())
|
||||||
|
.context("could not write to stdout")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copies `png` to the clipboard. The data is served by a forked child that
|
||||||
|
/// lives until something else is copied, so this consumes the lock.
|
||||||
|
pub fn copy_to_clipboard(png: Vec<u8>, lock: Lock) -> Result<()> {
|
||||||
|
let mut options = Options::new();
|
||||||
|
options.foreground(true);
|
||||||
|
let prepared = options
|
||||||
|
.prepare_copy(
|
||||||
|
Source::Bytes(png.into()),
|
||||||
|
MimeType::Specific("image/png".into()),
|
||||||
|
)
|
||||||
|
.context("could not copy to the clipboard")
|
||||||
|
.hint("the compositor needs ext-data-control or wlr-data-control; see `valw doctor`")?;
|
||||||
|
crate::detach::spawn(lock, move || {
|
||||||
|
let _ = prepared.serve();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a capture goes.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Target {
|
||||||
|
/// Save under the config's directory and filename template.
|
||||||
|
Default,
|
||||||
|
/// `--output <path>`.
|
||||||
|
Path(PathBuf),
|
||||||
|
/// `--output -`.
|
||||||
|
Stdout,
|
||||||
|
/// `--clipboard-only`.
|
||||||
|
ClipboardOnly,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Target {
|
||||||
|
/// clap rejects `--output` together with `--clipboard-only`.
|
||||||
|
pub fn from_args(output: Option<PathBuf>, clipboard_only: bool) -> Target {
|
||||||
|
match output {
|
||||||
|
_ if clipboard_only => Target::ClipboardOnly,
|
||||||
|
Some(p) if p.as_os_str() == "-" => Target::Stdout,
|
||||||
|
Some(p) => Target::Path(p),
|
||||||
|
None => Target::Default,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use chrono::TimeZone;
|
||||||
|
|
||||||
|
fn now() -> DateTime<Local> {
|
||||||
|
Local.with_ymd_and_hms(2026, 9, 29, 19, 4, 5).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_template() {
|
||||||
|
let name = file_name("Screenshot %Y-%m-%d at %H.%M.%S.png", &now(), None).unwrap();
|
||||||
|
assert_eq!(name, "Screenshot 2026-09-29 at 19.04.05.png");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn output_suffix() {
|
||||||
|
let name = file_name("shot.png", &now(), Some("HDMI-A-1")).unwrap();
|
||||||
|
assert_eq!(name, "shot (HDMI-A-1).png");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_pattern_is_an_error_not_a_panic() {
|
||||||
|
let err = file_name("shot %Q.png", &now(), None).unwrap_err();
|
||||||
|
assert!(
|
||||||
|
format!("{err:#}").contains("invalid strftime pattern"),
|
||||||
|
"{err:#}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_slashes_and_empty_names() {
|
||||||
|
assert!(file_name("%Y/%m.png", &now(), None).is_err());
|
||||||
|
assert!(file_name("", &now(), None).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn suffix_placement() {
|
||||||
|
assert_eq!(with_suffix("a.png", " (2)"), "a (2).png");
|
||||||
|
assert_eq!(with_suffix("a.b.png", " (2)"), "a.b (2).png");
|
||||||
|
assert_eq!(with_suffix("noext", " (2)"), "noext (2)");
|
||||||
|
assert_eq!(with_suffix(".hidden", " (2)"), ".hidden (2)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unique_path_counts_up() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
assert_eq!(unique_path(dir.path(), "a.png"), dir.path().join("a.png"));
|
||||||
|
fs::write(dir.path().join("a.png"), b"").unwrap();
|
||||||
|
fs::write(dir.path().join("a (2).png"), b"").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
unique_path(dir.path(), "a.png"),
|
||||||
|
dir.path().join("a (3).png")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_atomic_creates_dirs_and_leaves_no_temp() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("new/sub/a.png");
|
||||||
|
write_atomic(&path, b"png").unwrap();
|
||||||
|
assert_eq!(fs::read(&path).unwrap(), b"png");
|
||||||
|
let names: Vec<_> = fs::read_dir(path.parent().unwrap()).unwrap().collect();
|
||||||
|
assert_eq!(names.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_atomic_reports_unwritable_dir() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let file = dir.path().join("file");
|
||||||
|
fs::write(&file, b"").unwrap();
|
||||||
|
let err = write_atomic(&file.join("a.png"), b"png").unwrap_err();
|
||||||
|
assert!(format!("{err:#}").contains("could not create"), "{err:#}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_atomic_relative_path() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
std::env::set_current_dir(dir.path()).unwrap();
|
||||||
|
write_atomic(Path::new("a.png"), b"png").unwrap();
|
||||||
|
assert_eq!(fs::read(dir.path().join("a.png")).unwrap(), b"png");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn png_round_trip() {
|
||||||
|
let img = RgbaImage::from_raw(1, 1, vec![1, 2, 3, 255]).unwrap();
|
||||||
|
let png = encode_png(&img).unwrap();
|
||||||
|
let back = image::load_from_memory(&png).unwrap().to_rgba8();
|
||||||
|
assert_eq!(back, img);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn targets() {
|
||||||
|
use Target::*;
|
||||||
|
assert_eq!(Target::from_args(None, false), Default);
|
||||||
|
assert_eq!(Target::from_args(None, true), ClipboardOnly);
|
||||||
|
assert_eq!(Target::from_args(Some("-".into()), false), Stdout);
|
||||||
|
assert_eq!(
|
||||||
|
Target::from_args(Some("a.png".into()), false),
|
||||||
|
Path("a.png".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user