sip 0.3.0

Interactive Wayland screen-region selector with slurp-compatible output
Documentation
use std::io::{IsTerminal, Read};
use std::process::ExitCode;

use color_eyre::eyre::{Result, WrapErr, bail};
use pound::Parse;
use sip::{ChoiceBox, Color, Rect, SelectOptions, Selection};

#[derive(Parse)]
#[pound(name = "sip", version = "0.1.0")]
struct Cli {
    #[pound(short = 'd', help = "display the dimensions of the selection")]
    dimensions: bool,

    #[pound(short = 'b', value_name = "COLOR", help = "background color")]
    background: Option<ParsedColor>,

    #[pound(short = 'c', value_name = "COLOR", help = "border color")]
    border: Option<ParsedColor>,

    #[pound(short = 's', value_name = "COLOR", help = "selection fill color")]
    selection: Option<ParsedColor>,

    #[pound(short = 'B', value_name = "COLOR", help = "choice-box color")]
    choice: Option<ParsedColor>,

    #[pound(
        short = 'F',
        value_name = "FAMILY",
        help = "font family for the dimensions readout"
    )]
    font: Option<String>,

    #[pound(short = 'w', value_name = "N", help = "border weight in pixels")]
    weight: Option<f32>,

    #[pound(
        short = 'f',
        value_name = "FORMAT",
        default = "%x,%y %wx%h\\n",
        help = "output format string"
    )]
    format: String,

    #[pound(short = 'p', help = "select a single point instead of a region")]
    point: bool,

    #[pound(short = 'o', help = "add a choice box for every output")]
    outputs: bool,

    #[pound(short = 'r', help = "restrict the selection to the predefined boxes")]
    restrict: bool,

    #[pound(
        short = 'a',
        value_name = "W:H",
        help = "force a WIDTH:HEIGHT aspect ratio"
    )]
    aspect: Option<Aspect>,

    #[pound(
        short = 'x',
        help = "draw fullscreen crosshairs until a selection begins"
    )]
    crosshairs: bool,

    #[pound(long, help = "freeze the screen contents while selecting")]
    freeze: bool,
}

struct Aspect(u32, u32);

struct ParsedColor(Color);

pound::from_str!(Aspect, ParsedColor);

impl std::str::FromStr for Aspect {
    type Err = String;

    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
        let (w, h) = s.split_once(':').ok_or("expected WIDTH:HEIGHT")?;
        let w = w.parse().map_err(|_| "invalid width")?;
        let h = h.parse().map_err(|_| "invalid height")?;
        if w == 0 || h == 0 {
            return Err("aspect ratio components must be positive".into());
        }
        Ok(Self(w, h))
    }
}

impl std::str::FromStr for ParsedColor {
    type Err = sip::ParseColorError;

    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
        s.parse().map(Self)
    }
}

fn main() -> ExitCode {
    if let Err(err) = color_eyre::install() {
        eprintln!("sip: failed to initialize error reporting: {err}");
        return ExitCode::FAILURE;
    }

    match run() {
        Ok(code) => code,
        Err(err) => {
            eprintln!("{err:?}");
            ExitCode::FAILURE
        }
    }
}

fn run() -> Result<ExitCode> {
    let cli = Cli::parse();

    if cli.point && cli.restrict {
        bail!("-p and -r cannot be used together");
    }

    let defaults = SelectOptions::default();
    let options = SelectOptions {
        background: cli
            .background
            .map(|ParsedColor(c)| c)
            .unwrap_or(defaults.background),
        border: cli
            .border
            .map(|ParsedColor(c)| c)
            .unwrap_or(defaults.border),
        selection: cli
            .selection
            .map(|ParsedColor(c)| c)
            .unwrap_or(defaults.selection),
        choice: cli
            .choice
            .map(|ParsedColor(c)| c)
            .unwrap_or(defaults.choice),
        border_weight: cli.weight.unwrap_or(defaults.border_weight),
        font_family: cli.font,
        display_dimensions: cli.dimensions,
        single_point: cli.point,
        restrict: cli.restrict,
        crosshairs: cli.crosshairs,
        aspect_ratio: cli.aspect.map(|Aspect(w, h)| (w, h)),
        all_outputs: cli.outputs,
        freeze: cli.freeze,
    };

    let boxes = if !cli.point && !std::io::stdin().is_terminal() {
        read_boxes()?
    } else {
        Vec::new()
    };

    let result = if boxes.is_empty() {
        sip::select_region(options)
    } else {
        sip::select_from_boxes(boxes, options)
    };

    let selection = result?;
    print!("{}", format_selection(&unescape(&cli.format), &selection));
    Ok(ExitCode::SUCCESS)
}

fn read_boxes() -> Result<Vec<ChoiceBox>> {
    let mut input = String::new();
    std::io::stdin()
        .read_to_string(&mut input)
        .wrap_err("read predefined boxes from standard input")?;
    let mut boxes = Vec::new();
    for line in input.lines() {
        if line.trim().is_empty() {
            continue;
        }
        boxes.push(
            parse_box(line).ok_or_else(|| color_eyre::eyre::eyre!("invalid box format: {line}"))?,
        );
    }
    Ok(boxes)
}

fn parse_box(line: &str) -> Option<ChoiceBox> {
    let mut parts = line.splitn(3, ' ');
    let position = parts.next()?;
    let size = parts.next()?;
    let label = parts.next().map(str::to_string);
    let (x, y) = position.split_once(',')?;
    let (w, h) = size.split_once('x')?;
    Some(ChoiceBox {
        rect: Rect::new(
            x.parse().ok()?,
            y.parse().ok()?,
            w.parse().ok()?,
            h.parse().ok()?,
        ),
        label,
        id: None,
    })
}

fn unescape(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    let mut chars = s.chars();
    while let Some(c) = chars.next() {
        if c == '\\' {
            match chars.next() {
                Some('n') => out.push('\n'),
                Some('t') => out.push('\t'),
                Some('\\') => out.push('\\'),
                Some(other) => {
                    out.push('\\');
                    out.push(other);
                }
                None => out.push('\\'),
            }
        } else {
            out.push(c);
        }
    }
    out
}

fn format_selection(format: &str, sel: &Selection) -> String {
    let r = sel.rect;
    let geom = sel.output_geometry;
    let mut out = String::new();
    let mut chars = format.chars().peekable();
    while let Some(c) = chars.next() {
        if c != '%' {
            out.push(c);
            continue;
        }
        let Some(&next) = chars.peek() else {
            out.push('%');
            break;
        };
        let mut consumed = true;
        match next {
            'x' => out.push_str(&r.x.to_string()),
            'y' => out.push_str(&r.y.to_string()),
            'w' => out.push_str(&r.width.to_string()),
            'h' => out.push_str(&r.height.to_string()),
            'X' => out.push_str(&(r.x - geom.map_or(0, |g| g.x)).to_string()),
            'Y' => out.push_str(&(r.y - geom.map_or(0, |g| g.y)).to_string()),
            'W' => {
                let w = geom.map_or(r.width, |g| r.width.min(g.x + g.width - r.x));
                out.push_str(&w.to_string());
            }
            'H' => {
                let h = geom.map_or(r.height, |g| r.height.min(g.y + g.height - r.y));
                out.push_str(&h.to_string());
            }
            'l' => out.push_str(sel.label.as_deref().unwrap_or("")),
            'o' => out.push_str(sel.output.as_deref().unwrap_or("<unknown>")),
            _ => consumed = false,
        }
        if consumed {
            chars.next();
        } else {
            out.push('%');
        }
    }
    out
}