mod capture; mod config; mod detach; mod dnd; mod doctor; mod error; mod frame; mod host; mod ipc; mod lock; mod log; mod niri; mod output; mod region; mod render; mod selection; mod stack; mod thumbnail; mod wayland; use std::path::PathBuf; use std::process::ExitCode; use std::time::Duration; use anyhow::{Context, Result}; use chrono::Local; use clap::{Args, Parser, Subcommand}; use image::RgbaImage; use crate::capture::Capturer; use crate::config::Config; use crate::error::Cancelled; use crate::lock::Lock; use crate::output::Target; use crate::wayland::Wayland; #[derive(Parser)] #[command(version, about = "macOS-style screenshots for niri")] struct Cli { #[command(subcommand)] command: Command, } #[derive(Subcommand)] enum Command { /// Capture the focused output (Cmd+Shift+3). Screen { /// Capture every output, one file each. #[arg(long, conflicts_with_all = ["output", "clipboard_only"])] all: bool, #[command(flatten)] common: Common, }, /// Drag to select a region (Cmd+Shift+4). Region { #[command(flatten)] common: Common, }, /// Report what the compositor and system support. Doctor, /// Internal: the process that shows preview thumbnails. #[command(name = "__preview-host", hide = true)] PreviewHost, } #[derive(Args)] struct Common { /// Copy to the clipboard without saving a file. #[arg(long, conflicts_with = "output")] clipboard_only: bool, /// Write to this path instead of the configured directory; "-" for stdout. #[arg(short, long, value_name = "PATH")] output: Option, /// Wait this many seconds before capturing. #[arg(long, value_name = "SECS", value_parser = parse_delay)] delay: Option, /// Include the mouse cursor. #[arg(long)] cursor: bool, /// Don't show the preview thumbnail. #[arg(long)] no_preview: bool, } fn parse_delay(s: &str) -> Result { let secs: f64 = s.parse().map_err(|_| format!("not a number: {s}"))?; Duration::try_from_secs_f64(secs) .map_err(|_| format!("must be a non-negative number of seconds: {s}")) } enum Mode { Screen { all: bool }, Region, } fn main() -> ExitCode { let cli = Cli::parse(); let log = log::init(&log::default_dir(), Local::now()); log::install_panic_hook(log.clone()); tracing::info!( "valw {} {:?}", env!("CARGO_PKG_VERSION"), std::env::args().collect::>() ); match run(cli) { Ok(()) => ExitCode::SUCCESS, Err(e) if e.downcast_ref::().is_some() => { tracing::info!("cancelled by the user"); ExitCode::from(3) } Err(e) => { tracing::error!("{e:#}"); eprint!("{}", error::render(&e, log.as_deref())); ExitCode::FAILURE } } } fn run(cli: Cli) -> Result<()> { match cli.command { Command::Doctor => doctor::run(), Command::PreviewHost => host::run(), Command::Screen { all, common } => capture(Mode::Screen { all }, common), Command::Region { common } => capture(Mode::Region, common), } } fn capture(mode: Mode, common: Common) -> Result<()> { let config = config::load(&config::default_path())?; let lock = Lock::acquire(&lock::default_path())?; if let Some(delay) = common.delay { std::thread::sleep(delay); } let cursor = common.cursor || config.capture.show_cursor; let mut wl = Wayland::connect()?; if let Ok(version) = niri::version() { tracing::info!("niri {version}"); } let outputs = wl.outputs(); anyhow::ensure!(!outputs.is_empty(), "the compositor reported no outputs"); // Thumbnails from earlier captures must not end up in this one. let mut hide = ipc::HideGuard::new(&ipc::socket_path()); // (image, output name for the file name), which one goes to the // clipboard, and the output the preview appears on. let (shots, clip, source): (Vec<(RgbaImage, Option)>, usize, String) = match mode { Mode::Screen { all } => { let focused = focused_output(&outputs); let source = outputs[focused].geom.name.clone(); if all { let frames = wl.capture(&outputs, cursor)?; let shots = frames .into_iter() .map(|f| (f.to_rgba(f.full()), Some(f.output.name))) .collect(); (shots, focused, source) } else { let frame = wl.capture(&outputs[focused..=focused], cursor)?.remove(0); (vec![(frame.to_rgba(frame.full()), None)], 0, source) } } Mode::Region => { let frames = wl.capture(&outputs, cursor)?; let (i, r) = region::select(&mut wl, &outputs, &frames)?; let source = frames[i].output.name.clone(); (vec![(frames[i].to_rgba(r), None)], 0, source) } }; drop(wl); let target = Target::from_args(common.output, common.clipboard_only); let (pngs, saved) = save(&shots, clip, &target, &config)?; if config.preview.enabled && !common.no_preview && let Some(file) = saved && let Err(e) = hide.add(&ipc::socket_path(), &file, &source, ipc::start_host) { tracing::warn!("no preview: {e:#}"); } // Show the thumbnails again before forking the clipboard server; the // child would inherit this connection and keep them hidden. drop(hide); if target == Target::ClipboardOnly || config.save.copy_to_clipboard { let png = pngs .into_iter() .nth(clip) .context("no image for the clipboard")?; output::copy_to_clipboard(png, lock)?; } Ok(()) } /// Index of niri's focused output, or 0 if niri can't tell us. fn focused_output(outputs: &[wayland::Output]) -> usize { let name = match niri::focused_output() { Ok(Some(name)) => name, Ok(None) => return 0, Err(e) => { tracing::warn!("using the first output: {e:#}"); return 0; } }; outputs .iter() .position(|o| o.geom.name == name) .unwrap_or_else(|| { tracing::warn!("niri's focused output {name} is not among the Wayland outputs"); 0 }) } /// Encodes and writes the shots. Returns the PNGs and, when the clipboard /// shot went to a regular file, that file's absolute path for the preview. fn save( shots: &[(RgbaImage, Option)], clip: usize, target: &Target, config: &Config, ) -> Result<(Vec>, Option)> { let now = Local::now(); let pngs = shots .iter() .map(|(img, _)| output::encode_png(img)) .collect::>>()?; let saved = match target { Target::Default => { let dir = config.save_dir(); let mut paths = Vec::new(); for (png, (_, name)) in pngs.iter().zip(shots) { let file = output::file_name(&config.save.filename, &now, name.as_deref())?; let path = output::unique_path(&dir, &file); output::write_atomic(&path, png)?; println!("{}", path.display()); paths.push(path); } paths.into_iter().nth(clip) } Target::Path(path) => { output::write_atomic(path, &pngs[0])?; println!("{}", path.display()); // No preview for devices and pipes (`-o /dev/null`). std::fs::metadata(path) .is_ok_and(|m| m.is_file()) .then(|| path.clone()) } Target::Stdout => { output::write_stdout(&pngs[0])?; None } Target::ClipboardOnly => None, }; // The host may run in another directory than this capture. let saved = saved.map(|p| std::fs::canonicalize(&p).unwrap_or(p)); Ok((pngs, saved)) } #[cfg(test)] mod tests { use super::*; use clap::CommandFactory; fn parses(args: &[&str]) -> bool { Cli::try_parse_from(std::iter::once("valw").chain(args.iter().copied())).is_ok() } #[test] fn cli_definition_is_valid() { Cli::command().debug_assert(); } #[test] fn flag_conflicts() { assert!(parses(&["screen", "--all"])); assert!(parses(&["screen", "-o", "-"])); assert!(parses(&["region", "--clipboard-only", "--cursor"])); assert!(!parses(&["screen", "--all", "-o", "-"])); assert!(!parses(&["screen", "--all", "--clipboard-only"])); assert!(!parses(&["region", "--clipboard-only", "-o", "a.png"])); assert!(parses(&["region", "--no-preview"])); assert!(parses(&["screen", "--all", "--no-preview"])); } #[test] fn preview_host_is_hidden_from_help() { assert!(parses(&["__preview-host"])); let help = Cli::command().render_help().to_string(); assert!(!help.contains("preview-host"), "{help}"); } #[test] fn delay_values() { assert_eq!(parse_delay("1.5"), Ok(Duration::from_millis(1500))); assert_eq!(parse_delay("0"), Ok(Duration::ZERO)); assert!(parse_delay("-1").is_err()); assert!(parse_delay("soon").is_err()); assert!(parse_delay("NaN").is_err()); } }