2026-09-09 13:00:52 -07:00
|
|
|
//! 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.
|
2026-09-09 13:08:42 -07:00
|
|
|
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\""
|
|
|
|
|
)
|
|
|
|
|
})?;
|
2026-09-09 13:00:52 -07:00
|
|
|
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();
|
2026-09-09 13:08:42 -07:00
|
|
|
assert!(
|
|
|
|
|
!n.contains(':') && !n.contains('.') && !n.contains('-'),
|
|
|
|
|
"{n}"
|
|
|
|
|
);
|
2026-09-09 13:00:52 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn unquoted_hex_coordinate_gets_a_useful_error() {
|
|
|
|
|
// This is what YAML actually hands us for `at: 0x0`.
|
2026-09-09 13:08:42 -07:00
|
|
|
let err = serde_yaml::from_str::<Coord>("0x0")
|
|
|
|
|
.unwrap_err()
|
|
|
|
|
.to_string();
|
2026-09-09 13:00:52 -07:00
|
|
|
assert!(err.contains("hexadecimal"), "unhelpful error: {err}");
|
2026-09-09 13:08:42 -07:00
|
|
|
assert!(
|
|
|
|
|
err.contains("at: \"0x0\""),
|
|
|
|
|
"error should show the fix: {err}"
|
|
|
|
|
);
|
2026-09-09 13:00:52 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn sorts_by_row_then_column() {
|
2026-09-09 13:08:42 -07:00
|
|
|
let mut v = vec![
|
|
|
|
|
Coord::new(1, 0),
|
|
|
|
|
Coord::new(-1, 5),
|
|
|
|
|
Coord::new(0, 2),
|
|
|
|
|
Coord::new(0, -1),
|
|
|
|
|
];
|
2026-09-09 13:00:52 -07:00
|
|
|
v.sort();
|
2026-09-09 13:08:42 -07:00
|
|
|
assert_eq!(
|
|
|
|
|
v,
|
|
|
|
|
vec![
|
|
|
|
|
Coord::new(-1, 5),
|
|
|
|
|
Coord::new(0, -1),
|
|
|
|
|
Coord::new(0, 2),
|
|
|
|
|
Coord::new(1, 0)
|
|
|
|
|
]
|
|
|
|
|
);
|
2026-09-09 13:00:52 -07:00
|
|
|
}
|
|
|
|
|
}
|