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
}