Files
socktop-swipe/src/config/coord.rs
T

167 lines
5.4 KiB
Rust
Raw Normal View History

//! Grid coordinates: `"<row>x<col>"`, e.g. `"0x0"`, `"-1x0"`, `"1x-2"`.
//!
//! Row increases *downward* (`-1x0` is above `0x0`); column increases rightward.
//! Coordinates are sparse ordinals -- only their ordering matters, so `0x1` and
//! `0x5` are interchangeable as long as they sort the same way. See
//! notes/PLAN-v2.md section 2.
use std::fmt;
use serde::de::{self, Deserializer, Visitor};
use serde::Deserialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Coord {
/// Sorts first, and ascending row means descending on screen.
pub row: i32,
pub col: i32,
}
impl Coord {
pub fn new(row: i32, col: i32) -> Self {
Self { row, col }
}
/// A tmux-safe window name. tmux treats `:` and `.` as target separators, so
/// negative coordinates use `m` ("minus") rather than a sign character.
pub fn window_name(&self) -> String {
fn part(prefix: char, v: i32) -> String {
if v < 0 {
format!("{prefix}m{}", v.unsigned_abs())
} else {
format!("{prefix}{v}")
}
}
format!("{}{}", part('r', self.row), part('c', self.col))
}
}
impl fmt::Display for Coord {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}x{}", self.row, self.col)
}
}
impl std::str::FromStr for Coord {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let t = s.trim();
// Split on the separator 'x', which cannot be part of either number.
let (row, col) = t.split_once('x').ok_or_else(|| {
format!(
"{t:?} is not a coordinate -- expected \"<row>x<col>\", e.g. \"0x0\" or \"-1x0\""
)
})?;
let parse = |part: &str, which: &str| -> Result<i32, String> {
part.trim().parse::<i32>().map_err(|_| {
format!("{t:?} is not a coordinate -- the {which} {part:?} is not a whole number")
})
};
Ok(Coord::new(parse(row, "row")?, parse(col, "column")?))
}
}
impl<'de> Deserialize<'de> for Coord {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
struct V;
impl<'de> Visitor<'de> for V {
type Value = Coord;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a quoted coordinate such as \"0x0\" or \"-1x0\"")
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<Coord, E> {
v.parse().map_err(de::Error::custom)
}
// YAML reads an unquoted `0x0` as the HEXADECIMAL number 0, so the
// coordinate never reaches us as a string at all. `1x0` is not valid
// hex and does arrive as a string, which makes the failure look
// arbitrary -- only the row-0 entries break. Say exactly that.
fn visit_i64<E: de::Error>(self, v: i64) -> Result<Coord, E> {
Err(de::Error::custom(format!(
"YAML read this coordinate as the hexadecimal number {v}, not as text. \
Unquoted `0x0` is hex for 0. Quote it: at: \"0x0\""
)))
}
fn visit_u64<E: de::Error>(self, v: u64) -> Result<Coord, E> {
self.visit_i64(v as i64)
}
}
d.deserialize_any(V)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_signed_coordinates() {
assert_eq!("0x0".parse::<Coord>().unwrap(), Coord::new(0, 0));
assert_eq!("-1x0".parse::<Coord>().unwrap(), Coord::new(-1, 0));
assert_eq!("1x-2".parse::<Coord>().unwrap(), Coord::new(1, -2));
assert_eq!("-3x-4".parse::<Coord>().unwrap(), Coord::new(-3, -4));
assert_eq!(" 2x7 ".parse::<Coord>().unwrap(), Coord::new(2, 7));
}
#[test]
fn rejects_malformed_coordinates() {
for bad in ["", "0", "0x", "x0", "0x0x0", "axb", "0.5x1"] {
assert!(bad.parse::<Coord>().is_err(), "{bad:?} should not parse");
}
}
#[test]
fn window_names_are_tmux_safe() {
assert_eq!(Coord::new(0, 0).window_name(), "r0c0");
assert_eq!(Coord::new(-1, 0).window_name(), "rm1c0");
assert_eq!(Coord::new(1, -2).window_name(), "r1cm2");
for c in [Coord::new(0, 0), Coord::new(-1, -1), Coord::new(9, 9)] {
let n = c.window_name();
assert!(
!n.contains(':') && !n.contains('.') && !n.contains('-'),
"{n}"
);
}
}
#[test]
fn unquoted_hex_coordinate_gets_a_useful_error() {
// This is what YAML actually hands us for `at: 0x0`.
let err = serde_yaml::from_str::<Coord>("0x0")
.unwrap_err()
.to_string();
assert!(err.contains("hexadecimal"), "unhelpful error: {err}");
assert!(
err.contains("at: \"0x0\""),
"error should show the fix: {err}"
);
}
#[test]
fn sorts_by_row_then_column() {
let mut v = vec![
Coord::new(1, 0),
Coord::new(-1, 5),
Coord::new(0, 2),
Coord::new(0, -1),
];
v.sort();
assert_eq!(
v,
vec![
Coord::new(-1, 5),
Coord::new(0, -1),
Coord::new(0, 2),
Coord::new(1, 0)
]
);
}
}