snappers 0.1.0

Standalone Wayland screenshot tool with a niri-style selection UI
use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use chrono::Local;
use directories::{ProjectDirs, UserDirs};
use serde::Deserialize;
use smithay_client_toolkit::seat::keyboard::{Keysym, Modifiers};

use crate::theme::Theme;

#[derive(Debug, Clone)]
pub struct AppConfig {
    pub screenshot_path: Option<String>,
    pub keymap: Keymap,
    pub theme: Theme,
}

#[derive(Debug, Clone)]
pub struct Keymap {
    pub confirm: Vec<KeyBinding>,
    pub copy_only: Vec<KeyBinding>,
    pub cancel: Vec<KeyBinding>,
    pub toggle_pointer: Vec<KeyBinding>,
    pub move_left: Vec<KeyBinding>,
    pub move_right: Vec<KeyBinding>,
    pub move_up: Vec<KeyBinding>,
    pub move_down: Vec<KeyBinding>,
    pub resize_left: Vec<KeyBinding>,
    pub resize_right: Vec<KeyBinding>,
    pub resize_up: Vec<KeyBinding>,
    pub resize_down: Vec<KeyBinding>,
    pub next_output: Vec<KeyBinding>,
    pub previous_output: Vec<KeyBinding>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KeyBinding {
    pub ctrl: bool,
    pub alt: bool,
    pub shift: bool,
    pub logo: bool,
    pub key: Keysym,
}

#[derive(Debug, Default, Deserialize)]
struct FileConfig {
    screenshot_path: Option<Option<String>>,
    keymap: Option<FileKeymap>,
    theme: Option<FileThemeConfig>,
}

#[derive(Debug, Default, Deserialize)]
struct FileThemeConfig {
    name: Option<String>,
    flavor: Option<String>,
}

#[derive(Debug, Default, Deserialize)]
struct FileKeymap {
    confirm: Option<Vec<String>>,
    copy_only: Option<Vec<String>>,
    cancel: Option<Vec<String>>,
    toggle_pointer: Option<Vec<String>>,
    move_left: Option<Vec<String>>,
    move_right: Option<Vec<String>>,
    move_up: Option<Vec<String>>,
    move_down: Option<Vec<String>>,
    resize_left: Option<Vec<String>>,
    resize_right: Option<Vec<String>>,
    resize_up: Option<Vec<String>>,
    resize_down: Option<Vec<String>>,
    next_output: Option<Vec<String>>,
    previous_output: Option<Vec<String>>,
}

impl AppConfig {
    pub fn load() -> Result<Self> {
        let path = config_path()?;
        if !path.exists() {
            return Ok(Self::default());
        }

        let raw = fs::read_to_string(&path)
            .with_context(|| format!("failed to read config at {}", path.display()))?;
        let file: FileConfig = toml::from_str(&raw)
            .with_context(|| format!("failed to parse config at {}", path.display()))?;
        Self::from_file(file)
    }

    fn from_file(file: FileConfig) -> Result<Self> {
        let defaults = Keymap::default();
        let file_keymap = file.keymap.unwrap_or_default();
        let file_theme = file.theme.unwrap_or_default();
        let theme = crate::theme::resolve_theme(
            file_theme.name.as_deref(),
            file_theme.flavor.as_deref(),
        )?;

        Ok(Self {
            screenshot_path: file
                .screenshot_path
                .unwrap_or_else(|| Some(default_screenshot_pattern())),
            theme,
            keymap: Keymap {
                confirm: parse_bindings(file_keymap.confirm, defaults.confirm)?,
                copy_only: parse_bindings(file_keymap.copy_only, defaults.copy_only)?,
                cancel: parse_bindings(file_keymap.cancel, defaults.cancel)?,
                toggle_pointer: parse_bindings(
                    file_keymap.toggle_pointer,
                    defaults.toggle_pointer,
                )?,
                move_left: parse_bindings(file_keymap.move_left, defaults.move_left)?,
                move_right: parse_bindings(file_keymap.move_right, defaults.move_right)?,
                move_up: parse_bindings(file_keymap.move_up, defaults.move_up)?,
                move_down: parse_bindings(file_keymap.move_down, defaults.move_down)?,
                resize_left: parse_bindings(file_keymap.resize_left, defaults.resize_left)?,
                resize_right: parse_bindings(file_keymap.resize_right, defaults.resize_right)?,
                resize_up: parse_bindings(file_keymap.resize_up, defaults.resize_up)?,
                resize_down: parse_bindings(file_keymap.resize_down, defaults.resize_down)?,
                next_output: parse_bindings(file_keymap.next_output, defaults.next_output)?,
                previous_output: parse_bindings(
                    file_keymap.previous_output,
                    defaults.previous_output,
                )?,
            },
        })
    }

    pub fn resolve_output_path(&self, override_path: Option<&Path>) -> Result<Option<PathBuf>> {
        if let Some(path) = override_path {
            return Ok(Some(path.to_path_buf()));
        }

        let Some(pattern) = &self.screenshot_path else {
            return Ok(None);
        };

        let expanded = expand_tilde(pattern);
        let formatted = Local::now().format(&expanded).to_string();
        Ok(Some(PathBuf::from(formatted)))
    }
}

impl Default for AppConfig {
    fn default() -> Self {
        Self {
            screenshot_path: Some(default_screenshot_pattern()),
            keymap: Keymap::default(),
            theme: Theme::default(),
        }
    }
}

impl Default for Keymap {
    fn default() -> Self {
        Self {
            confirm: vec![binding("Return"), binding("space")],
            copy_only: vec![binding("Ctrl+C")],
            cancel: vec![binding("Escape")],
            toggle_pointer: vec![binding("p")],
            move_left: vec![binding("Left")],
            move_right: vec![binding("Right")],
            move_up: vec![binding("Up")],
            move_down: vec![binding("Down")],
            resize_left: vec![binding("Shift+Left")],
            resize_right: vec![binding("Shift+Right")],
            resize_up: vec![binding("Shift+Up")],
            resize_down: vec![binding("Shift+Down")],
            next_output: vec![binding("Tab")],
            previous_output: vec![binding("Shift+Tab")],
        }
    }
}

impl KeyBinding {
    pub fn matches(&self, keysym: Keysym, modifiers: Modifiers) -> bool {
        self.key == keysym
            && self.ctrl == modifiers.ctrl
            && self.alt == modifiers.alt
            && self.shift == modifiers.shift
            && self.logo == modifiers.logo
    }
}

pub fn config_path() -> Result<PathBuf> {
    let dirs = ProjectDirs::from("io", "github", "snappers")
        .context("could not resolve XDG project directories")?;
    Ok(dirs.config_dir().join("config.toml"))
}

pub fn default_screenshot_pattern() -> String {
    let base = UserDirs::new()
        .and_then(|dirs| dirs.picture_dir().map(Path::to_path_buf))
        .unwrap_or_else(|| PathBuf::from("~/Pictures"));
    base.join("Screenshots")
        .join("Screenshot from %Y-%m-%d %H-%M-%S.png")
        .to_string_lossy()
        .into_owned()
}

fn parse_bindings(raw: Option<Vec<String>>, default: Vec<KeyBinding>) -> Result<Vec<KeyBinding>> {
    let Some(raw) = raw else {
        return Ok(default);
    };

    raw.into_iter()
        .map(|binding| parse_binding(&binding))
        .collect()
}

fn binding(raw: &str) -> KeyBinding {
    parse_binding(raw).expect("default binding must parse")
}

fn parse_binding(raw: &str) -> Result<KeyBinding> {
    let mut ctrl = false;
    let mut alt = false;
    let mut shift = false;
    let mut logo = false;
    let mut key = None;

    for part in raw.split('+') {
        let token = part.trim();
        match token.to_ascii_lowercase().as_str() {
            "ctrl" | "control" => ctrl = true,
            "alt" => alt = true,
            "shift" => shift = true,
            "super" | "logo" | "meta" => logo = true,
            _ => key = Some(parse_keysym(token)?),
        }
    }

    Ok(KeyBinding {
        ctrl,
        alt,
        shift,
        logo,
        key: key.context("binding is missing a key")?,
    })
}

fn parse_keysym(raw: &str) -> Result<Keysym> {
    let lower = raw.trim().to_ascii_lowercase();
    let key = match lower.as_str() {
        "escape" | "esc" => Keysym::Escape,
        "return" | "enter" => Keysym::Return,
        "space" => Keysym::space,
        "tab" => Keysym::Tab,
        "left" => Keysym::Left,
        "right" => Keysym::Right,
        "up" => Keysym::Up,
        "down" => Keysym::Down,
        "c" => Keysym::c,
        "p" => Keysym::p,
        _ => anyhow::bail!("unsupported keysym `{raw}`"),
    };
    Ok(key)
}

fn expand_tilde(path: &str) -> String {
    if !path.starts_with("~/") {
        return path.to_owned();
    }

    let home = std::env::var("HOME").unwrap_or_else(|_| "~".to_owned());
    format!("{home}/{}", &path[2..])
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_default_binding() {
        let binding = parse_binding("Ctrl+Shift+Left").expect("binding");
        assert!(binding.ctrl);
        assert!(binding.shift);
        assert_eq!(binding.key, Keysym::Left);
    }

    #[test]
    fn expands_tilde_paths() {
        let expanded = expand_tilde("~/Pictures/example.png");
        assert!(expanded.contains("Pictures/example.png"));
    }

    #[test]
    fn default_path_uses_screenshots_directory() {
        let path = default_screenshot_pattern();
        assert!(path.contains("Screenshots"));
        assert!(path.contains("%Y-%m-%d"));
    }
}