use clap::builder::{StringValueParser, TypedValueParser, ValueParserFactory};
#[derive(Debug, Clone, Copy)]
pub enum CoordValue {
Pixels(f64),
Percent(f64),
}
#[derive(Debug)]
pub struct CoordValueParseError(pub String);
impl std::fmt::Display for CoordValueParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::error::Error for CoordValueParseError {}
impl CoordValue {
pub fn parse(s: &str) -> Result<Self, CoordValueParseError> {
if s.is_empty() {
return Err(CoordValueParseError(
"coordinate value must not be empty".to_string(),
));
}
if let Some(pct_str) = s.strip_suffix('%') {
if pct_str.contains('%') {
return Err(CoordValueParseError(format!(
"invalid coordinate '{s}': malformed percentage (extra '%')"
)));
}
if pct_str.is_empty() {
return Err(CoordValueParseError(
"invalid coordinate '%': percentage value must have a number before '%'"
.to_string(),
));
}
let value: f64 = pct_str.parse().map_err(|_| {
CoordValueParseError(format!(
"invalid coordinate '{s}': percentage value is not a valid number"
))
})?;
if !(0.0..=100.0).contains(&value) {
return Err(CoordValueParseError(format!(
"invalid coordinate '{s}': percentage must be in range 0%–100% (got {value}%)"
)));
}
Ok(Self::Percent(value))
} else {
if s.contains('%') {
return Err(CoordValueParseError(format!(
"invalid coordinate '{s}': malformed value"
)));
}
let value: f64 = s.parse().map_err(|_| {
CoordValueParseError(format!(
"invalid coordinate '{s}': not a valid number or percentage"
))
})?;
Ok(Self::Pixels(value))
}
}
}
#[derive(Clone)]
pub struct CoordValueParser;
impl TypedValueParser for CoordValueParser {
type Value = CoordValue;
fn parse_ref(
&self,
cmd: &clap::Command,
arg: Option<&clap::Arg>,
value: &std::ffi::OsStr,
) -> Result<Self::Value, clap::Error> {
let inner = StringValueParser::new();
let s = inner.parse_ref(cmd, arg, value)?;
CoordValue::parse(&s).map_err(|e| {
let mut err = clap::Error::new(clap::error::ErrorKind::ValueValidation);
err.insert(
clap::error::ContextKind::InvalidValue,
clap::error::ContextValue::String(e.to_string()),
);
err
})
}
}
impl ValueParserFactory for CoordValue {
type Parser = CoordValueParser;
fn value_parser() -> Self::Parser {
CoordValueParser
}
}
#[derive(Debug, Clone, Copy)]
pub struct BoundingBox {
pub x: f64,
pub y: f64,
pub width: f64,
pub height: f64,
}
#[must_use]
pub fn resolve_relative_coords(
x: CoordValue,
y: CoordValue,
element_box: BoundingBox,
frame_offset: (f64, f64),
) -> (f64, f64) {
let x_frame_local = match x {
CoordValue::Pixels(px) => element_box.x + px,
CoordValue::Percent(p) => element_box.x + percent_to_offset(p, element_box.width),
};
let y_frame_local = match y {
CoordValue::Pixels(py) => element_box.y + py,
CoordValue::Percent(p) => element_box.y + percent_to_offset(p, element_box.height),
};
(
x_frame_local + frame_offset.0,
y_frame_local + frame_offset.1,
)
}
fn percent_to_offset(p: f64, dim: f64) -> f64 {
if (p - 100.0).abs() < f64::EPSILON {
(dim - 1.0).max(0.0)
} else {
(p / 100.0) * dim
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_integer_pixels() {
let v = CoordValue::parse("10").unwrap();
assert!(matches!(v, CoordValue::Pixels(x) if (x - 10.0).abs() < f64::EPSILON));
}
#[test]
fn parse_negative_integer_pixels() {
let v = CoordValue::parse("-10").unwrap();
assert!(matches!(v, CoordValue::Pixels(x) if (x - (-10.0)).abs() < f64::EPSILON));
}
#[test]
fn parse_decimal_pixels() {
let v = CoordValue::parse("10.5").unwrap();
assert!(matches!(v, CoordValue::Pixels(x) if (x - 10.5).abs() < f64::EPSILON));
}
#[test]
fn parse_negative_decimal_pixels() {
let v = CoordValue::parse("-10.5").unwrap();
assert!(matches!(v, CoordValue::Pixels(x) if (x - (-10.5)).abs() < f64::EPSILON));
}
#[test]
fn parse_zero_pixels() {
let v = CoordValue::parse("0").unwrap();
assert!(matches!(v, CoordValue::Pixels(x) if x == 0.0));
}
#[test]
fn parse_zero_percent() {
let v = CoordValue::parse("0%").unwrap();
assert!(matches!(v, CoordValue::Percent(p) if p == 0.0));
}
#[test]
fn parse_fifty_percent() {
let v = CoordValue::parse("50%").unwrap();
assert!(matches!(v, CoordValue::Percent(p) if (p - 50.0).abs() < f64::EPSILON));
}
#[test]
fn parse_hundred_percent() {
let v = CoordValue::parse("100%").unwrap();
assert!(matches!(v, CoordValue::Percent(p) if (p - 100.0).abs() < f64::EPSILON));
}
#[test]
fn parse_fractional_percent() {
let v = CoordValue::parse("33.33%").unwrap();
assert!(matches!(v, CoordValue::Percent(p) if (p - 33.33).abs() < 0.001));
}
#[test]
fn parse_empty_string_rejected() {
assert!(CoordValue::parse("").is_err());
}
#[test]
fn parse_non_numeric_rejected() {
assert!(CoordValue::parse("abc").is_err());
}
#[test]
fn parse_double_percent_rejected() {
assert!(CoordValue::parse("5%%").is_err());
}
#[test]
fn parse_bare_percent_rejected() {
assert!(CoordValue::parse("%").is_err());
}
#[test]
fn parse_negative_percent_rejected() {
assert!(CoordValue::parse("-5%").is_err());
}
#[test]
fn parse_over_100_percent_rejected() {
assert!(CoordValue::parse("150%").is_err());
}
#[test]
fn parse_100_01_percent_rejected() {
assert!(CoordValue::parse("100.01%").is_err());
}
#[test]
fn parse_negative_pixel_regression() {
let v = CoordValue::parse("-10").unwrap();
assert!(
matches!(v, CoordValue::Pixels(x) if (x - (-10.0)).abs() < f64::EPSILON),
"negative pixel value must parse as Pixels, not be rejected"
);
}
fn bbox(x: f64, y: f64, w: f64, h: f64) -> BoundingBox {
BoundingBox {
x,
y,
width: w,
height: h,
}
}
#[test]
fn resolve_both_pixels_main_frame() {
let r = resolve_relative_coords(
CoordValue::Pixels(10.0),
CoordValue::Pixels(5.0),
bbox(100.0, 200.0, 80.0, 32.0),
(0.0, 0.0),
);
assert_eq!(r, (110.0, 205.0));
}
#[test]
fn resolve_both_50_percent_is_center_main_frame() {
let r = resolve_relative_coords(
CoordValue::Percent(50.0),
CoordValue::Percent(50.0),
bbox(100.0, 200.0, 200.0, 100.0),
(0.0, 0.0),
);
assert!((r.0 - 200.0).abs() < f64::EPSILON, "x={}", r.0);
assert!((r.1 - 250.0).abs() < f64::EPSILON, "y={}", r.1);
}
#[test]
fn resolve_0_percent_top_left() {
let r = resolve_relative_coords(
CoordValue::Percent(0.0),
CoordValue::Percent(0.0),
bbox(100.0, 200.0, 200.0, 100.0),
(0.0, 0.0),
);
assert_eq!(r, (100.0, 200.0));
}
#[test]
fn resolve_100_percent_bottom_right_inclusive() {
let r = resolve_relative_coords(
CoordValue::Percent(100.0),
CoordValue::Percent(100.0),
bbox(100.0, 200.0, 200.0, 100.0),
(0.0, 0.0),
);
assert_eq!(r, (299.0, 299.0));
}
#[test]
fn resolve_mixed_percent_pixels() {
let r = resolve_relative_coords(
CoordValue::Percent(50.0),
CoordValue::Pixels(10.0),
bbox(100.0, 200.0, 200.0, 100.0),
(0.0, 0.0),
);
assert!((r.0 - 200.0).abs() < f64::EPSILON, "x={}", r.0);
assert!((r.1 - 210.0).abs() < f64::EPSILON, "y={}", r.1);
}
#[test]
fn resolve_with_iframe_offset() {
let r = resolve_relative_coords(
CoordValue::Percent(50.0),
CoordValue::Percent(50.0),
bbox(10.0, 20.0, 80.0, 32.0),
(50.0, 100.0),
);
assert!((r.0 - 100.0).abs() < f64::EPSILON, "x={}", r.0);
assert!((r.1 - 136.0).abs() < f64::EPSILON, "y={}", r.1);
}
#[test]
fn resolve_zero_width_element_does_not_panic() {
let r = resolve_relative_coords(
CoordValue::Percent(100.0),
CoordValue::Percent(100.0),
bbox(50.0, 50.0, 0.0, 0.0),
(0.0, 0.0),
);
assert_eq!(r, (50.0, 50.0));
}
#[test]
fn clap_routes_50_percent_to_coord_value() {
use clap::Parser;
#[derive(Parser)]
struct MinimalArgs {
#[arg(value_parser = clap::value_parser!(CoordValue))]
x: CoordValue,
}
let args = MinimalArgs::try_parse_from(["test", "50%"]).expect("clap rejected '50%'");
assert!(
matches!(args.x, CoordValue::Percent(p) if (p - 50.0).abs() < f64::EPSILON),
"expected Percent(50.0), got {:?}",
args.x
);
}
#[test]
fn clap_routes_negative_pixels_to_coord_value() {
use clap::Parser;
#[derive(Parser)]
struct MinimalArgs {
#[arg(value_parser = clap::value_parser!(CoordValue), allow_hyphen_values = true)]
x: CoordValue,
}
let args =
MinimalArgs::try_parse_from(["test", "-10"]).expect("clap rejected negative pixel");
assert!(
matches!(args.x, CoordValue::Pixels(px) if (px - (-10.0)).abs() < f64::EPSILON),
"expected Pixels(-10.0), got {:?}",
args.x
);
}
}