//! Grid coordinates: `"x"`, 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 { 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 \"x\", e.g. \"0x0\" or \"-1x0\"" ) })?; let parse = |part: &str, which: &str| -> Result { part.trim().parse::().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: D) -> Result { 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(self, v: &str) -> Result { 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(self, v: i64) -> Result { 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(self, v: u64) -> Result { 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::().unwrap(), Coord::new(0, 0)); assert_eq!("-1x0".parse::().unwrap(), Coord::new(-1, 0)); assert_eq!("1x-2".parse::().unwrap(), Coord::new(1, -2)); assert_eq!("-3x-4".parse::().unwrap(), Coord::new(-3, -4)); assert_eq!(" 2x7 ".parse::().unwrap(), Coord::new(2, 7)); } #[test] fn rejects_malformed_coordinates() { for bad in ["", "0", "0x", "x0", "0x0x0", "axb", "0.5x1"] { assert!(bad.parse::().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::("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) ] ); } }