use std::io::{self, BufWriter, Stdout, Write};
use crossterm::{
cursor, execute, queue,
style::{Color, Print, ResetColor, SetBackgroundColor, SetForegroundColor},
terminal,
};
use unicode_width::UnicodeWidthChar;
use crate::theme::Rgb;
pub const WIDE_TAIL: char = '\0';
const COLOR_TOLERANCE: u8 = 3;
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct Cell {
pub ch: char,
pub fg: Rgb,
}
impl Cell {
fn looks_like(self, other: Cell) -> bool {
let near = |a: u8, b: u8| a.abs_diff(b) <= COLOR_TOLERANCE;
self.ch == other.ch && near(self.fg.0, other.fg.0) && near(self.fg.1, other.fg.1) && near(self.fg.2, other.fg.2)
}
}
pub fn str_width(s: &str) -> i32 {
s.chars().map(|c| c.width().unwrap_or(0) as i32).sum()
}
#[derive(Clone)]
pub struct Frame {
pub w: i32,
pub h: i32,
pub bg: Option<Rgb>,
cells: Vec<Option<Cell>>,
}
impl Frame {
pub fn new(w: u16, h: u16) -> Self {
Frame {
w: i32::from(w),
h: i32::from(h),
bg: None,
cells: vec![None; w as usize * h as usize],
}
}
pub fn clear(&mut self) {
self.bg = None;
self.cells.fill(None);
}
fn set(&mut self, x: i32, y: i32, cell: Option<Cell>) {
if x >= 0 && y >= 0 && x < self.w && y < self.h {
self.cells[(y * self.w + x) as usize] = cell;
}
}
pub fn put(&mut self, x: i32, y: i32, ch: char, fg: Rgb) {
match ch {
WIDE_TAIL => {}
' ' => self.set(x, y, None),
_ if ch.width() == Some(2) => {
if x + 1 < self.w {
self.set(x, y, Some(Cell { ch, fg }));
self.set(x + 1, y, Some(Cell { ch: WIDE_TAIL, fg }));
}
}
_ => self.set(x, y, Some(Cell { ch, fg })),
}
}
pub fn text(&mut self, x: i32, y: i32, s: &str, fg: Rgb) {
let mut x = x;
for ch in s.chars() {
self.put(x, y, ch, fg);
x += ch.width().unwrap_or(0) as i32;
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Input {
None,
Ready,
HungUp,
}
pub struct Term {
out: BufWriter<Stdout>,
#[cfg(unix)]
tty: std::fs::File,
shown: Option<Frame>,
background: Option<Rgb>,
}
impl Term {
pub fn enter() -> io::Result<Self> {
let mut out = BufWriter::with_capacity(1 << 16, io::stdout());
terminal::enable_raw_mode()?;
execute!(
out,
terminal::EnterAlternateScreen,
cursor::Hide,
terminal::Clear(terminal::ClearType::All)
)?;
#[cfg(unix)]
let tty = std::fs::File::open("/dev/tty")?;
#[cfg(unix)]
let background = if ["NVIM", "VIM_TERMINAL"].iter().any(|v| std::env::var_os(v).is_some()) {
None
} else {
query_background(&mut out, &tty)
};
#[cfg(not(unix))]
let background = None;
Ok(Term {
out,
shown: None,
#[cfg(unix)]
tty,
background,
})
}
pub fn background(&self) -> Option<Rgb> {
self.background
}
pub fn invalidate(&mut self) {
self.shown = None;
}
#[cfg(unix)]
pub fn wait_input(&self, timeout: std::time::Duration) -> io::Result<Input> {
use std::os::fd::AsRawFd;
let mut pfd = libc::pollfd {
fd: self.tty.as_raw_fd(),
events: libc::POLLIN,
revents: 0,
};
let ms = timeout.as_millis().min(i32::MAX as u128) as i32;
let n = unsafe { libc::poll(&raw mut pfd, 1, ms) };
if n < 0 {
let e = io::Error::last_os_error();
return if e.kind() == io::ErrorKind::Interrupted {
Ok(Input::None)
} else {
Err(e)
};
}
Ok(if pfd.revents & (libc::POLLHUP | libc::POLLERR | libc::POLLNVAL) != 0 {
Input::HungUp
} else if pfd.revents & libc::POLLIN != 0 {
Input::Ready
} else {
Input::None
})
}
#[cfg(not(unix))]
pub fn wait_input(&self, timeout: std::time::Duration) -> io::Result<Input> {
Ok(if crossterm::event::poll(timeout)? {
Input::Ready
} else {
Input::None
})
}
pub fn bell(&mut self) -> io::Result<()> {
self.out.write_all(b"\x07")?;
self.out.flush()
}
pub fn present(&mut self, f: &Frame) -> io::Result<()> {
let full = self
.shown
.as_ref()
.is_none_or(|s| s.w != f.w || s.h != f.h || s.bg != f.bg);
queue!(self.out, terminal::BeginSynchronizedUpdate)?;
if full {
queue!(self.out, ResetColor)?;
match f.bg {
Some(Rgb(r, g, b)) => queue!(self.out, SetBackgroundColor(Color::Rgb { r, g, b }))?,
None => queue!(self.out, terminal::Clear(terminal::ClearType::All))?,
}
match &mut self.shown {
Some(s) => s.clone_from(f),
None => self.shown = Some(f.clone()),
}
}
let shown = self.shown.as_mut().expect("set above when missing");
let mut cur: Option<(i32, i32)> = None;
let mut color: Option<Rgb> = None;
for y in 0..f.h {
for x in 0..f.w {
let i = (y * f.w + x) as usize;
let new = f.cells[i];
if full {
if new.is_none() && f.bg.is_none() {
continue;
}
} else {
let same = match (new, shown.cells[i]) {
(None, None) => true,
(Some(a), Some(b)) => a.looks_like(b),
_ => false,
};
if same {
continue;
}
shown.cells[i] = new;
}
if new.is_some_and(|c| c.ch == WIDE_TAIL) {
continue;
}
if cur != Some((x, y)) {
queue!(self.out, cursor::MoveTo(x as u16, y as u16))?;
}
let mut advance = 1;
match new {
Some(c) => {
if color != Some(c.fg) {
let Rgb(r, g, b) = c.fg;
queue!(self.out, SetForegroundColor(Color::Rgb { r, g, b }))?;
color = Some(c.fg);
}
queue!(self.out, Print(c.ch))?;
advance = c.ch.width().unwrap_or(1) as i32;
}
None => queue!(self.out, Print(' '))?,
}
cur = Some((x + advance, y));
}
}
queue!(self.out, terminal::EndSynchronizedUpdate)?;
self.out.flush()
}
}
#[cfg(unix)]
fn query_background(out: &mut impl Write, tty: &std::fs::File) -> Option<Rgb> {
use std::io::Read;
use std::os::fd::AsRawFd;
const WAIT: std::time::Duration = std::time::Duration::from_millis(300);
out.write_all(b"\x1b]11;?\x1b\\\x1b[c").ok()?;
out.flush().ok()?;
let deadline = std::time::Instant::now() + WAIT;
let mut reply = Vec::new();
let mut buf = [0u8; 256];
while !has_da1_reply(&reply) {
let left = deadline.saturating_duration_since(std::time::Instant::now());
if left.is_zero() {
break;
}
let mut pfd = libc::pollfd {
fd: tty.as_raw_fd(),
events: libc::POLLIN,
revents: 0,
};
let n = unsafe { libc::poll(&raw mut pfd, 1, left.as_millis().max(1) as i32) };
if n <= 0 || pfd.revents & libc::POLLIN == 0 {
break;
}
match (&*tty).read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => reply.extend_from_slice(&buf[..n]),
}
}
parse_osc11(&reply)
}
fn has_da1_reply(bytes: &[u8]) -> bool {
bytes.windows(3).enumerate().any(|(i, w)| {
w == b"\x1b[?"
&& bytes[i + 3..]
.iter()
.find(|b| !(b.is_ascii_digit() || **b == b';'))
.is_some_and(|&b| b == b'c')
})
}
fn parse_osc11(bytes: &[u8]) -> Option<Rgb> {
let text = String::from_utf8_lossy(bytes);
let start = text.find("\x1b]11;")? + 5;
let rest = &text[start..];
let end = rest.find(['\x07', '\x1b']).unwrap_or(rest.len());
let spec = &rest[..end];
let spec = spec.strip_prefix("rgb:").or_else(|| spec.strip_prefix("rgba:"))?;
let mut channels = spec.split('/').map(|h| {
if h.is_empty() || h.len() > 4 {
return None;
}
let v = u32::from_str_radix(h, 16).ok()?;
let max = (1u32 << (4 * h.len())) - 1;
Some(((v * 255 + max / 2) / max) as u8)
});
Some(Rgb(channels.next()??, channels.next()??, channels.next()??))
}
pub fn restore() {
let mut out = io::stdout();
let _ = execute!(out, ResetColor, cursor::Show, terminal::LeaveAlternateScreen);
let _ = terminal::disable_raw_mode();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_background_replies() {
let da1 = "\x1b[?62;22c";
assert_eq!(
parse_osc11(format!("\x1b]11;rgb:1d1d/1616/2626\x1b\\{da1}").as_bytes()),
Some(Rgb(0x1d, 0x16, 0x26))
);
assert_eq!(parse_osc11(b"\x1b]11;rgb:0c/0c/0c\x07"), Some(Rgb(12, 12, 12)));
assert_eq!(parse_osc11(b"\x1b]11;rgb:f/8/0\x07"), Some(Rgb(255, 136, 0)));
assert_eq!(
parse_osc11(b"\x1b]11;rgba:ffff/0000/0000/ffff\x07"),
Some(Rgb(255, 0, 0))
);
assert_eq!(parse_osc11(da1.as_bytes()), None);
assert_eq!(parse_osc11(b"\x1b]11;rgb:zz/00/00\x07"), None);
assert!(has_da1_reply(da1.as_bytes()));
assert!(has_da1_reply(b"junk\x1b]11;rgb:0/0/0\x07\x1b[?1;2c"));
assert!(!has_da1_reply(b"\x1b[?62;22"));
assert!(!has_da1_reply(b"\x1b]11;rgb:0/0/0\x07"));
}
}