use std::{
env,
io::{self, Read, Write},
sync::mpsc::Sender,
};
use crate::{
FontSize, ImageSource, Resize, Result,
errors::Errors,
protocol::{
Protocol, StatefulProtocol, StatefulProtocolType,
halfblocks::Halfblocks,
iterm2::Iterm2,
kitty::{Kitty, StatefulKitty},
sixel::Sixel,
},
};
use cap_parser::{Parser, QueryStdioOptions, Response};
use image::{DynamicImage, Rgba};
use rand::random;
use ratatui::layout::Rect;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
pub mod cap_parser;
#[derive(Debug, PartialEq, Clone)]
pub enum Capability {
Kitty,
Sixel,
RectangularOps,
CellSize(Option<(u16, u16)>),
TextSizingProtocol,
}
const DEFAULT_BACKGROUND: Rgba<u8> = Rgba([0, 0, 0, 0]);
const STDIN_READ_TIMEOUT_MILLIS: u64 = 2000;
#[derive(Clone, Debug)]
pub struct Picker {
font_size: FontSize,
protocol_type: ProtocolType,
background_color: Rgba<u8>,
is_tmux: bool,
capabilities: Vec<Capability>,
}
#[derive(PartialEq, Clone, Debug, Copy)]
#[cfg_attr(
feature = "serde",
derive(Deserialize, Serialize),
serde(rename_all = "lowercase")
)]
pub enum ProtocolType {
Halfblocks,
Sixel,
Kitty,
Iterm2,
}
impl ProtocolType {
pub fn next(&self) -> ProtocolType {
match self {
ProtocolType::Halfblocks => ProtocolType::Sixel,
ProtocolType::Sixel => ProtocolType::Kitty,
ProtocolType::Kitty => ProtocolType::Iterm2,
ProtocolType::Iterm2 => ProtocolType::Halfblocks,
}
}
}
impl Picker {
pub fn from_query_stdio() -> Result<Self> {
Picker::from_query_stdio_with_options(QueryStdioOptions::default())
}
pub fn from_query_stdio_with_options(options: QueryStdioOptions) -> Result<Self> {
let (is_tmux, tmux_proto) = detect_tmux_and_outer_protocol_from_env();
static DEFAULT_PICKER: Picker = Picker {
font_size: (10, 20),
background_color: DEFAULT_BACKGROUND,
protocol_type: ProtocolType::Halfblocks,
is_tmux: false,
capabilities: Vec::new(),
};
let mut options_with_blacklist = options;
let is_wezterm = env::var("WEZTERM_EXECUTABLE").is_ok_and(|s| !s.is_empty());
let is_konsole = env::var("KONSOLE_VERSION").is_ok_and(|s| !s.is_empty());
if is_wezterm || is_konsole {
options_with_blacklist
.blacklist_protocols(vec![ProtocolType::Kitty, ProtocolType::Sixel]);
}
match query_with_timeout(is_tmux, options_with_blacklist) {
Ok((capability_proto, font_size, caps)) => {
let iterm2_proto = iterm2_from_env();
let protocol_type = capability_proto
.or(tmux_proto)
.or(iterm2_proto)
.unwrap_or(ProtocolType::Halfblocks);
if let Some(font_size) = font_size {
Ok(Self {
font_size,
background_color: DEFAULT_BACKGROUND,
protocol_type,
is_tmux,
capabilities: caps,
})
} else {
let mut p = DEFAULT_PICKER.clone();
p.is_tmux = is_tmux;
Ok(p)
}
}
Err(Errors::NoCap | Errors::NoStdinResponse | Errors::NoFontSize) => {
let mut p = DEFAULT_PICKER.clone();
p.is_tmux = is_tmux;
Ok(p)
}
Err(err) => Err(err),
}
}
pub fn halfblocks() -> Self {
let (is_tmux, _tmux_proto) = detect_tmux_and_outer_protocol_from_env();
Self {
font_size: (10, 20),
background_color: DEFAULT_BACKGROUND,
protocol_type: ProtocolType::Halfblocks,
is_tmux,
capabilities: Vec::new(),
}
}
#[deprecated(
since = "9.0.0",
note = "use `from_query_stdio` or `halfblocks` instead"
)]
pub fn from_fontsize(font_size: FontSize) -> Self {
let (is_tmux, tmux_proto) = detect_tmux_and_outer_protocol_from_env();
let iterm2_proto = iterm2_from_env();
let protocol_type = tmux_proto
.or(iterm2_proto)
.unwrap_or(ProtocolType::Halfblocks);
Self {
font_size,
background_color: DEFAULT_BACKGROUND,
protocol_type,
is_tmux,
capabilities: Vec::new(),
}
}
pub fn protocol_type(&self) -> ProtocolType {
self.protocol_type
}
pub fn set_protocol_type(&mut self, protocol_type: ProtocolType) {
self.protocol_type = protocol_type;
}
pub fn font_size(&self) -> FontSize {
self.font_size
}
pub fn set_background_color<T: Into<Rgba<u8>>>(&mut self, background_color: T) {
self.background_color = background_color.into();
}
pub fn capabilities(&self) -> &Vec<Capability> {
&self.capabilities
}
pub fn new_protocol(
&self,
image: DynamicImage,
size: Rect,
resize: Resize,
) -> Result<Protocol> {
let source = ImageSource::new(image, self.font_size, self.background_color);
let (image, area) =
match resize.needs_resize(&source, self.font_size, source.desired, size, false) {
Some(area) => {
let image = resize.resize(&source, self.font_size, area, self.background_color);
(image, area)
}
None => (source.image, source.desired),
};
match self.protocol_type {
ProtocolType::Halfblocks => Ok(Protocol::Halfblocks(Halfblocks::new(image, area)?)),
ProtocolType::Sixel => Ok(Protocol::Sixel(Sixel::new(image, area, self.is_tmux)?)),
ProtocolType::Kitty => Ok(Protocol::Kitty(Kitty::new(
image,
area,
rand::random(),
self.is_tmux,
)?)),
ProtocolType::Iterm2 => Ok(Protocol::ITerm2(Iterm2::new(image, area, self.is_tmux)?)),
}
}
pub fn new_resize_protocol(&self, image: DynamicImage) -> StatefulProtocol {
let source = ImageSource::new(image, self.font_size, self.background_color);
let protocol_type = match self.protocol_type {
ProtocolType::Halfblocks => StatefulProtocolType::Halfblocks(Halfblocks::default()),
ProtocolType::Sixel => StatefulProtocolType::Sixel(Sixel {
is_tmux: self.is_tmux,
..Sixel::default()
}),
ProtocolType::Kitty => {
StatefulProtocolType::Kitty(StatefulKitty::new(random(), self.is_tmux))
}
ProtocolType::Iterm2 => StatefulProtocolType::ITerm2(Iterm2 {
is_tmux: self.is_tmux,
..Iterm2::default()
}),
};
StatefulProtocol::new(source, self.font_size, protocol_type)
}
}
fn detect_tmux_and_outer_protocol_from_env() -> (bool, Option<ProtocolType>) {
if !env::var("TERM").is_ok_and(|term| term.starts_with("tmux"))
&& !env::var("TERM_PROGRAM").is_ok_and(|term_program| term_program == "tmux")
{
return (false, None);
}
let _ = std::process::Command::new("tmux")
.args(["set", "-p", "allow-passthrough", "on"])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.and_then(|mut child| child.wait());
const OUTER_TERM_HINTS: [(&str, ProtocolType); 2] = [
("ITERM_SESSION_ID", ProtocolType::Iterm2),
("WEZTERM_EXECUTABLE", ProtocolType::Iterm2),
];
for (hint, proto) in OUTER_TERM_HINTS {
if env::var(hint).is_ok_and(|s| !s.is_empty()) {
return (true, Some(proto));
}
}
(true, None)
}
fn iterm2_from_env() -> Option<ProtocolType> {
if env::var("TERM_PROGRAM").is_ok_and(|term_program| {
term_program.contains("iTerm")
|| term_program.contains("WezTerm")
|| term_program.contains("mintty")
|| term_program.contains("vscode")
|| term_program.contains("Tabby")
|| term_program.contains("Hyper")
|| term_program.contains("rio")
|| term_program.contains("Bobcat")
|| term_program.contains("WarpTerminal")
}) {
return Some(ProtocolType::Iterm2);
}
if env::var("LC_TERMINAL").is_ok_and(|lc_term| lc_term.contains("iTerm")) {
return Some(ProtocolType::Iterm2);
}
None
}
#[cfg(not(windows))]
fn enable_raw_mode() -> Result<impl FnOnce() -> Result<()>> {
use rustix::termios::{self, LocalModes, OptionalActions};
let stdin = io::stdin();
let mut termios = termios::tcgetattr(&stdin)?;
let termios_original = termios.clone();
termios.local_modes &= !LocalModes::ICANON;
termios.local_modes &= !LocalModes::ECHO;
termios::tcsetattr(&stdin, OptionalActions::Drain, &termios)?;
Ok(move || {
Ok(termios::tcsetattr(
io::stdin(),
OptionalActions::Now,
&termios_original,
)?)
})
}
#[cfg(windows)]
fn enable_raw_mode() -> Result<impl FnOnce() -> Result<()>> {
use windows::{
Win32::{
Foundation::{GENERIC_READ, GENERIC_WRITE, HANDLE},
Storage::FileSystem::{
self, FILE_FLAGS_AND_ATTRIBUTES, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING,
},
System::Console::{
self, CONSOLE_MODE, ENABLE_ECHO_INPUT, ENABLE_LINE_INPUT, ENABLE_PROCESSED_INPUT,
},
},
core::PCWSTR,
};
let utf16: Vec<u16> = "CONIN$\0".encode_utf16().collect();
let utf16_ptr: *const u16 = utf16.as_ptr();
let in_handle = unsafe {
FileSystem::CreateFileW(
PCWSTR(utf16_ptr),
(GENERIC_READ | GENERIC_WRITE).0,
FILE_SHARE_READ | FILE_SHARE_WRITE,
None,
OPEN_EXISTING,
FILE_FLAGS_AND_ATTRIBUTES(0),
HANDLE::default(),
)
}?;
let mut original_in_mode = CONSOLE_MODE::default();
unsafe { Console::GetConsoleMode(in_handle, &mut original_in_mode) }?;
let requested_in_modes = !ENABLE_ECHO_INPUT & !ENABLE_LINE_INPUT & !ENABLE_PROCESSED_INPUT;
let in_mode = original_in_mode & requested_in_modes;
unsafe { Console::SetConsoleMode(in_handle, in_mode) }?;
Ok(move || {
unsafe { Console::SetConsoleMode(in_handle, original_in_mode) }?;
Ok(())
})
}
#[cfg(not(windows))]
fn font_size_fallback() -> Option<FontSize> {
use rustix::termios::{self, Winsize};
let winsize = termios::tcgetwinsize(io::stdout()).ok()?;
let Winsize {
ws_xpixel: x,
ws_ypixel: y,
ws_col: cols,
ws_row: rows,
} = winsize;
if x == 0 || y == 0 || cols == 0 || rows == 0 {
return None;
}
Some((x / cols, y / rows))
}
#[cfg(windows)]
fn font_size_fallback() -> Option<FontSize> {
None
}
fn query_stdio_capabilities(
is_tmux: bool,
options: QueryStdioOptions,
tx: &Sender<QueryResult>,
) -> Result<()> {
let query = Parser::query(is_tmux, options);
io::stdout().write_all(query.as_bytes())?;
io::stdout().flush()?;
let mut parser = Parser::new();
let mut responses = vec![];
'out: loop {
let mut charbuf: [u8; 50] = [0; 50];
let read = io::stdin().read(&mut charbuf)?;
tx.send(QueryResult::Busy)
.map_err(|_senderr| Errors::NoStdinResponse)?;
for ch in charbuf.iter().take(read) {
let mut more_caps = parser.push(char::from(*ch));
match more_caps[..] {
[Response::Status] => {
break 'out;
}
_ => responses.append(&mut more_caps),
}
}
}
let result = interpret_parser_responses(responses)?;
tx.send(QueryResult::Done(result))
.map_err(|_senderr| Errors::NoStdinResponse)?;
Ok(())
}
fn interpret_parser_responses(
responses: Vec<Response>,
) -> Result<(Option<ProtocolType>, Option<FontSize>, Vec<Capability>)> {
if responses.is_empty() {
return Err(Errors::NoCap);
}
let mut capabilities = Vec::new();
let mut proto = None;
let mut font_size = None;
let mut cursor_position_reports = vec![];
for response in &responses {
if let Some(capability) = match response {
Response::Kitty => {
proto = Some(ProtocolType::Kitty);
Some(Capability::Kitty)
}
Response::Sixel => {
if proto.is_none() {
proto = Some(ProtocolType::Sixel);
}
Some(Capability::Sixel)
}
Response::RectangularOps => Some(Capability::RectangularOps),
Response::CellSize(cell_size) => {
if let Some((w, h)) = cell_size {
font_size = Some((*w, *h));
}
Some(Capability::CellSize(*cell_size))
}
Response::CursorPositionReport(x, y) => {
cursor_position_reports.push((x, y));
None
}
Response::Status => None,
} {
capabilities.push(capability);
}
}
font_size = font_size.or_else(font_size_fallback);
if let [(x1, _y1), (x2, _y2), (x3, _y3)] = cursor_position_reports[..] {
if *x2 == x1 + 2 && *x3 == x2 + 2 {
capabilities.push(Capability::TextSizingProtocol);
}
}
Ok((proto, font_size, capabilities))
}
enum QueryResult {
Done((Option<ProtocolType>, Option<FontSize>, Vec<Capability>)),
Err(Errors),
Busy,
}
fn query_with_timeout(
is_tmux: bool,
options: QueryStdioOptions,
) -> Result<(Option<ProtocolType>, Option<FontSize>, Vec<Capability>)> {
use std::{sync::mpsc, thread};
let (tx, rx) = mpsc::channel();
let timeout = options.timeout;
thread::spawn(move || {
if let Err(err) = tx
.send(QueryResult::Busy)
.map_err(|_senderr| Errors::NoStdinResponse)
.and_then(|_| enable_raw_mode())
.and_then(|disable_raw_mode| {
tx.send(QueryResult::Busy)
.map_err(|_senderr| Errors::NoStdinResponse)?;
let result = query_stdio_capabilities(is_tmux, options, &tx);
disable_raw_mode()?;
result
})
{
let _ = tx.send(QueryResult::Err(err));
}
});
loop {
match rx.recv_timeout(timeout) {
Ok(qresult) => match qresult {
QueryResult::Done(result) => return Ok(result),
QueryResult::Err(err) => return Err(err),
QueryResult::Busy => continue, },
Err(_recverr) => {
return Err(Errors::NoStdinResponse);
}
}
}
}
#[cfg(test)]
mod tests {
use std::assert_eq;
use crate::picker::{Capability, Picker, ProtocolType};
use super::{cap_parser::Response, interpret_parser_responses};
#[test]
fn test_cycle_protocol() {
let mut proto = ProtocolType::Halfblocks;
proto = proto.next();
assert_eq!(proto, ProtocolType::Sixel);
proto = proto.next();
assert_eq!(proto, ProtocolType::Kitty);
proto = proto.next();
assert_eq!(proto, ProtocolType::Iterm2);
proto = proto.next();
assert_eq!(proto, ProtocolType::Halfblocks);
}
#[test]
fn test_from_query_stdio_no_hang() {
let _ = Picker::from_query_stdio();
}
#[test]
fn test_interpret_parser_responses_text_sizing_protocol() {
let (_, _, caps) = interpret_parser_responses(vec![
Response::CursorPositionReport(1, 1),
Response::CursorPositionReport(3, 1),
Response::CursorPositionReport(5, 1),
])
.unwrap();
assert!(caps.contains(&Capability::TextSizingProtocol));
}
#[test]
fn test_interpret_parser_responses_text_sizing_protocol_incomplete() {
let (_, _, caps) = interpret_parser_responses(vec![
Response::CursorPositionReport(1, 22),
Response::CursorPositionReport(3, 22),
Response::CursorPositionReport(4, 22),
])
.unwrap();
assert!(!caps.contains(&Capability::TextSizingProtocol));
}
}