use std::fmt;
use std::io::{self, Write};
use std::time::Duration;
use crossterm::QueueableCommand;
use crossterm::clipboard::CopyToClipboard;
use crossterm::event::{
DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
MouseButton, MouseEvent, MouseEventKind,
};
use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};
use ratatui::layout::{Position, Rect};
pub const TICK: Duration = Duration::from_millis(33);
pub const RETYPE_BUDGET: usize = 64;
pub fn enter<W: Write>(out: &mut W) -> io::Result<()> {
out.queue(EnterAlternateScreen)?;
out.queue(EnableMouseCapture)?;
out.queue(EnableBracketedPaste)?;
out.flush()
}
pub fn exit<W: Write>(out: &mut W) -> io::Result<()> {
out.queue(DisableBracketedPaste)?;
out.queue(DisableMouseCapture)?;
out.queue(LeaveAlternateScreen)?;
out.flush()
}
#[derive(Debug)]
pub struct HitMap<T> {
regions: Vec<(Rect, T)>,
}
impl<T> Default for HitMap<T> {
fn default() -> Self {
Self::new()
}
}
impl<T> HitMap<T> {
pub fn new() -> Self {
HitMap {
regions: Vec::new(),
}
}
pub fn clear(&mut self) {
self.regions.clear();
}
pub fn register(&mut self, area: Rect, target: T) {
self.regions.push((area, target));
}
pub fn targets(&self) -> impl Iterator<Item = &T> {
self.regions.iter().map(|(_, target)| target)
}
pub fn hit(&self, column: u16, row: u16) -> Option<&T> {
let position = Position::new(column, row);
self.regions
.iter()
.rev()
.find(|(area, _)| area.contains(position))
.map(|(_, target)| target)
}
pub fn click(&self, event: &MouseEvent) -> Option<&T> {
match event.kind {
MouseEventKind::Down(MouseButton::Left) => self.hit(event.column, event.row),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RetypableId(String);
impl RetypableId {
pub fn new(id: &str) -> Result<Self, IdError> {
if id.is_empty() {
return Err(IdError::Empty);
}
if let Some(c) = id.chars().find(|c| !c.is_ascii_graphic()) {
return Err(IdError::Unretypable(c));
}
if id.len() > RETYPE_BUDGET {
return Err(IdError::TooLong(id.len()));
}
Ok(RetypableId(id.to_owned()))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for RetypableId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IdError {
Empty,
TooLong(usize),
Unretypable(char),
}
impl fmt::Display for IdError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
IdError::Empty => write!(f, "an empty string is not an ID"),
IdError::TooLong(len) => write!(
f,
"{len} characters cannot be retyped from another screen (budget {RETYPE_BUDGET})"
),
IdError::Unretypable(c) => write!(f, "{c:?} cannot be reliably retyped"),
}
}
}
impl std::error::Error for IdError {}
pub fn copy_id<W: Write>(out: &mut W, id: &RetypableId) -> io::Result<()> {
out.queue(CopyToClipboard::to_clipboard_from(id.as_str()))?;
out.flush()
}
#[cfg(test)]
mod tests {
use super::*;
fn bytes_of<F: FnOnce(&mut Vec<u8>) -> io::Result<()>>(f: F) -> String {
let mut out = Vec::new();
f(&mut out).expect("writing to a Vec cannot fail");
String::from_utf8(out).expect("crossterm emits valid utf8")
}
#[test]
fn input_the_session_bracket_sets_and_clears_the_same_modes() {
let entered = bytes_of(enter);
let exited = bytes_of(exit);
for mode in ["?1049", "?1000", "?1002", "?1003", "?1015", "?1006"] {
assert!(
entered.contains(&format!("{mode}h")),
"enter must set {mode}"
);
assert!(
exited.contains(&format!("{mode}l")),
"exit must clear {mode}"
);
}
}
#[test]
fn input_the_bracket_unwinds_in_reverse_order() {
let entered = bytes_of(enter);
let exited = bytes_of(exit);
assert!(
entered.find("?1049h").expect("alt screen") < entered.find("?1000h").expect("capture")
);
assert!(
exited.find("?1000l").expect("capture") < exited.find("?1049l").expect("alt screen")
);
}
#[test]
fn input_a_click_inside_a_row_selects_it() {
let mut map = HitMap::new();
map.register(Rect::new(2, 5, 10, 1), "row-a");
assert_eq!(map.hit(2, 5), Some(&"row-a"), "the left edge is inside");
assert_eq!(map.hit(11, 5), Some(&"row-a"), "the last cell is inside");
}
#[test]
fn input_a_click_one_cell_off_a_row_must_not_select_it() {
let mut map = HitMap::new();
map.register(Rect::new(2, 5, 10, 1), "row-a");
assert_eq!(map.hit(1, 5), None, "one cell left");
assert_eq!(map.hit(12, 5), None, "one cell past the right edge");
assert_eq!(map.hit(5, 4), None, "one row above");
assert_eq!(map.hit(5, 6), None, "one row below");
}
#[test]
fn input_overlapping_regions_resolve_to_the_last_registered() {
let mut map = HitMap::new();
map.register(Rect::new(0, 0, 20, 3), "under");
map.register(Rect::new(5, 1, 5, 1), "over");
assert_eq!(
map.hit(6, 1),
Some(&"over"),
"later draws paint on top, so later registrations win"
);
assert_eq!(map.hit(0, 0), Some(&"under"));
}
#[test]
fn input_a_two_cell_pair_and_a_one_cell_floor_are_both_hittable() {
let mut map = HitMap::new();
map.register(Rect::new(4, 2, 2, 1), "pair");
map.register(Rect::new(9, 2, 1, 1), "floor");
assert_eq!(map.hit(4, 2), Some(&"pair"));
assert_eq!(map.hit(5, 2), Some(&"pair"));
assert_eq!(map.hit(9, 2), Some(&"floor"));
assert_eq!(map.hit(10, 2), None, "the floor is one cell, not two");
}
#[test]
fn input_only_a_left_button_press_is_a_click() {
use crossterm::event::KeyModifiers;
let mut map = HitMap::new();
map.register(Rect::new(0, 0, 10, 1), "row");
let at = |kind| MouseEvent {
kind,
column: 3,
row: 0,
modifiers: KeyModifiers::NONE,
};
assert_eq!(
map.click(&at(MouseEventKind::Down(MouseButton::Left))),
Some(&"row")
);
for kind in [
MouseEventKind::Down(MouseButton::Right),
MouseEventKind::Down(MouseButton::Middle),
MouseEventKind::Up(MouseButton::Left),
MouseEventKind::Drag(MouseButton::Left),
MouseEventKind::Moved,
MouseEventKind::ScrollUp,
MouseEventKind::ScrollDown,
] {
assert_eq!(
map.click(&at(kind)),
None,
"{kind:?} is not a selection gesture"
);
}
}
#[test]
fn input_clear_forgets_the_frame() {
let mut map = HitMap::new();
map.register(Rect::new(0, 0, 10, 1), "row");
map.clear();
assert_eq!(
map.hit(3, 0),
None,
"a cleared map must not deliver clicks to rows that are gone"
);
}
#[test]
fn input_copy_rides_osc52_with_the_id_encoded() {
let id = RetypableId::new("t-42").expect("a plain short ID");
let written = bytes_of(|out| copy_id(out, &id));
assert_eq!(written, "\x1b]52;c;dC00Mg==\x1b\\");
}
#[test]
fn input_an_id_that_cannot_be_retyped_is_refused() {
assert_eq!(RetypableId::new(""), Err(IdError::Empty));
assert_eq!(
RetypableId::new(&"x".repeat(RETYPE_BUDGET + 1)),
Err(IdError::TooLong(RETYPE_BUDGET + 1))
);
assert_eq!(RetypableId::new("a b"), Err(IdError::Unretypable(' ')));
assert_eq!(
RetypableId::new("a\x07b"),
Err(IdError::Unretypable('\x07'))
);
assert_eq!(
RetypableId::new("a\x1b[31mred"),
Err(IdError::Unretypable('\x1b'))
);
assert_eq!(RetypableId::new("café"), Err(IdError::Unretypable('é')));
}
#[test]
fn input_the_fallback_is_the_id_itself() {
let id = RetypableId::new("task-0042").expect("a plain short ID");
assert_eq!(id.as_str(), "task-0042");
assert_eq!(id.to_string(), "task-0042", "display IS the fallback path");
}
#[test]
fn input_a_budget_length_id_is_accepted() {
let at_budget = "x".repeat(RETYPE_BUDGET);
assert!(RetypableId::new(&at_budget).is_ok());
}
}