1use std::{
4 env,
5 io::{self, Read, Write},
6 sync::mpsc::Sender,
7};
8
9use crate::{
10 FontSize, Resize, Result,
11 errors::Errors,
12 protocol::{
13 Protocol, StatefulProtocol, StatefulProtocolType,
14 halfblocks::Halfblocks,
15 iterm2::Iterm2,
16 kitty::{Kitty, StatefulKitty},
17 sixel::Sixel,
18 },
19};
20use cap_parser::{Parser, QueryStdioOptions, Response};
21use image::{DynamicImage, Rgba};
22use rand::random;
23use ratatui::layout::Size;
24#[cfg(feature = "serde")]
25use serde::{Deserialize, Serialize};
26
27pub mod cap_parser;
28
29#[derive(Debug, PartialEq, Clone)]
30pub enum Capability {
31 Kitty,
33 Sixel,
35 RectangularOps,
37 CellSize(Option<(u16, u16)>),
39 TextSizingProtocol,
41 Background(u8, u8, u8),
43}
44
45const STDIN_READ_TIMEOUT_MILLIS: u64 = 2000;
46
47#[derive(Clone, Debug)]
48pub struct Picker {
49 font_size: FontSize,
50 protocol_type: ProtocolType,
51 background_color: Option<Rgba<u8>>,
52 pub(crate) is_tmux: bool,
53 capabilities: Vec<Capability>,
54}
55
56#[derive(PartialEq, Clone, Debug, Copy)]
58#[cfg_attr(
59 feature = "serde",
60 derive(Deserialize, Serialize),
61 serde(rename_all = "lowercase")
62)]
63pub enum ProtocolType {
64 Halfblocks,
65 Sixel,
66 Kitty,
67 Iterm2,
68}
69
70impl ProtocolType {
71 pub fn next(&self) -> ProtocolType {
72 match self {
73 ProtocolType::Halfblocks => ProtocolType::Sixel,
74 ProtocolType::Sixel => ProtocolType::Kitty,
75 ProtocolType::Kitty => ProtocolType::Iterm2,
76 ProtocolType::Iterm2 => ProtocolType::Halfblocks,
77 }
78 }
79}
80
81impl Picker {
83 pub fn from_query_stdio() -> Result<Self> {
95 Picker::from_query_stdio_with_options(QueryStdioOptions::default())
96 }
97
98 pub fn from_query_stdio_with_options(options: QueryStdioOptions) -> Result<Self> {
107 let (is_tmux, tmux_proto) = detect_tmux_and_outer_protocol_from_env();
109
110 static DEFAULT_PICKER: Picker = Picker {
111 font_size: FontSize::new(10, 20),
115 background_color: None,
116 protocol_type: ProtocolType::Halfblocks,
117 is_tmux: false,
118 capabilities: Vec::new(),
119 };
120
121 let mut options_with_blacklist = options;
122 let is_wezterm = env::var("WEZTERM_EXECUTABLE").is_ok_and(|s| !s.is_empty());
123 let is_konsole = env::var("KONSOLE_VERSION").is_ok_and(|s| !s.is_empty());
124 if is_wezterm || is_konsole {
125 options_with_blacklist.blacklist_protocols =
129 vec![ProtocolType::Kitty, ProtocolType::Sixel];
130 }
131
132 match query_with_timeout(is_tmux, options_with_blacklist) {
134 Ok((capability_proto, font_size, caps)) => {
135 let iterm2_proto = iterm2_from_env();
136
137 let protocol_type = capability_proto
140 .or(tmux_proto)
141 .or(iterm2_proto)
142 .unwrap_or(ProtocolType::Halfblocks);
143
144 if let Some(font_size) = font_size {
145 Ok(Self {
146 font_size,
147 background_color: None,
148 protocol_type,
149 is_tmux,
150 capabilities: caps,
151 })
152 } else {
153 let mut p = DEFAULT_PICKER.clone();
154 p.is_tmux = is_tmux;
155 Ok(p)
156 }
157 }
158 Err(Errors::NoCap | Errors::NoStdinResponse | Errors::NoFontSize) => {
159 let mut p = DEFAULT_PICKER.clone();
160 p.is_tmux = is_tmux;
161 Ok(p)
162 }
163 Err(err) => Err(err),
164 }
165 }
166
167 pub fn halfblocks() -> Self {
176 let (is_tmux, _tmux_proto) = detect_tmux_and_outer_protocol_from_env();
178
179 Self {
180 font_size: FontSize::new(10, 20),
181 background_color: None,
182 protocol_type: ProtocolType::Halfblocks,
183 is_tmux,
184 capabilities: Vec::new(),
185 }
186 }
187
188 #[deprecated(
190 since = "9.0.0",
191 note = "use `from_query_stdio` or `halfblocks` instead"
192 )]
193 pub fn from_fontsize(font_size: FontSize) -> Self {
194 let (is_tmux, tmux_proto) = detect_tmux_and_outer_protocol_from_env();
196
197 let iterm2_proto = iterm2_from_env();
199
200 let protocol_type = tmux_proto
201 .or(iterm2_proto)
202 .unwrap_or(ProtocolType::Halfblocks);
203
204 Self {
205 font_size,
206 background_color: None,
207 protocol_type,
208 is_tmux,
209 capabilities: Vec::new(),
210 }
211 }
212
213 pub fn protocol_type(&self) -> ProtocolType {
215 self.protocol_type
216 }
217
218 pub fn set_protocol_type(&mut self, protocol_type: ProtocolType) {
220 self.protocol_type = protocol_type;
221 }
222
223 pub fn font_size(&self) -> FontSize {
225 self.font_size
226 }
227
228 pub fn set_background_color<T: Into<Rgba<u8>>>(&mut self, background_color: Option<T>) {
230 self.background_color = background_color.map(Into::into);
231 }
232
233 pub fn capabilities(&self) -> &Vec<Capability> {
235 &self.capabilities
236 }
237
238 pub(crate) fn new_protocol_raw(&self, image: DynamicImage, size: Size) -> Result<Protocol> {
242 match self.protocol_type {
243 ProtocolType::Halfblocks => Ok(Protocol::Halfblocks(Halfblocks::new(image, size)?)),
244 ProtocolType::Sixel => Ok(Protocol::Sixel(Sixel::new(image, size, self.is_tmux)?)),
245 ProtocolType::Kitty => Ok(Protocol::Kitty(Kitty::new(
246 image,
247 size,
248 rand::random(),
249 self.is_tmux,
250 )?)),
251 ProtocolType::Iterm2 => Ok(Protocol::ITerm2(Iterm2::new(image, size, self.is_tmux)?)),
252 }
253 }
254
255 pub fn new_protocol(
257 &self,
258 image: DynamicImage,
259 size: Size,
260 resize: Resize,
261 ) -> Result<Protocol> {
262 let desired =
263 Resize::round_pixel_size_to_cells(image.width(), image.height(), self.font_size);
264 let (image, area) =
265 match resize.needs_resize(&image, Some(desired), self.font_size, None, size, false) {
266 Some(area) => {
267 let image = resize.resize(&image, self.font_size, area, self.background_color);
268 (image, area)
269 }
270 None => (image, desired),
271 };
272
273 self.new_protocol_raw(image, area)
274 }
275
276 pub fn new_resize_protocol(&self, image: DynamicImage) -> StatefulProtocol {
278 let protocol_type = match self.protocol_type {
279 ProtocolType::Halfblocks => StatefulProtocolType::Halfblocks(Halfblocks::default()),
280 ProtocolType::Sixel => StatefulProtocolType::Sixel(Sixel {
281 is_tmux: self.is_tmux,
282 ..Sixel::default()
283 }),
284 ProtocolType::Kitty => {
285 StatefulProtocolType::Kitty(StatefulKitty::new(random(), self.is_tmux))
286 }
287 ProtocolType::Iterm2 => StatefulProtocolType::ITerm2(Iterm2 {
288 is_tmux: self.is_tmux,
289 ..Iterm2::default()
290 }),
291 };
292 StatefulProtocol::new(image, self.font_size, self.background_color, protocol_type)
293 }
294}
295
296fn detect_tmux_and_outer_protocol_from_env() -> (bool, Option<ProtocolType>) {
297 if !env::var("TERM").is_ok_and(|term| term.starts_with("tmux"))
299 && !env::var("TERM_PROGRAM").is_ok_and(|term_program| term_program == "tmux")
300 {
301 return (false, None);
302 }
303
304 let _ = std::process::Command::new("tmux")
305 .args(["set", "-p", "allow-passthrough", "on"])
306 .stdin(std::process::Stdio::null())
307 .stdout(std::process::Stdio::null())
308 .stderr(std::process::Stdio::null())
309 .spawn()
310 .and_then(|mut child| child.wait()); const OUTER_TERM_HINTS: [(&str, ProtocolType); 2] = [
316 ("ITERM_SESSION_ID", ProtocolType::Iterm2),
317 ("WEZTERM_EXECUTABLE", ProtocolType::Iterm2),
318 ];
319 for (hint, proto) in OUTER_TERM_HINTS {
320 if env::var(hint).is_ok_and(|s| !s.is_empty()) {
321 return (true, Some(proto));
322 }
323 }
324 (true, None)
325}
326
327fn iterm2_from_env() -> Option<ProtocolType> {
328 if env::var("TERM_PROGRAM").is_ok_and(|term_program| {
329 term_program.contains("iTerm")
330 || term_program.contains("WezTerm")
331 || term_program.contains("mintty")
332 || term_program.contains("vscode")
333 || term_program.contains("Tabby")
334 || term_program.contains("Hyper")
335 || term_program.contains("rio")
336 || term_program.contains("Bobcat")
337 || term_program.contains("WarpTerminal")
338 }) {
339 return Some(ProtocolType::Iterm2);
340 }
341 if env::var("LC_TERMINAL").is_ok_and(|lc_term| lc_term.contains("iTerm")) {
342 return Some(ProtocolType::Iterm2);
343 }
344 None
345}
346
347#[cfg(not(windows))]
348fn enable_raw_mode() -> Result<impl FnOnce() -> Result<()>> {
349 use rustix::termios::{self, LocalModes, OptionalActions};
350
351 let stdin = io::stdin();
352 let mut termios = termios::tcgetattr(&stdin)?;
353 let termios_original = termios.clone();
354
355 termios.local_modes &= !LocalModes::ICANON;
357 termios.local_modes &= !LocalModes::ECHO;
358 termios::tcsetattr(&stdin, OptionalActions::Drain, &termios)?;
359
360 Ok(move || {
361 Ok(termios::tcsetattr(
362 io::stdin(),
363 OptionalActions::Now,
364 &termios_original,
365 )?)
366 })
367}
368
369#[cfg(windows)]
370fn enable_raw_mode() -> Result<impl FnOnce() -> Result<()>> {
371 use windows::{
372 Win32::{
373 Foundation::{GENERIC_READ, GENERIC_WRITE, HANDLE},
374 Storage::FileSystem::{
375 self, FILE_FLAGS_AND_ATTRIBUTES, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING,
376 },
377 System::Console::{
378 self, CONSOLE_MODE, ENABLE_ECHO_INPUT, ENABLE_LINE_INPUT, ENABLE_PROCESSED_INPUT,
379 },
380 },
381 core::PCWSTR,
382 };
383
384 let utf16: Vec<u16> = "CONIN$\0".encode_utf16().collect();
385 let utf16_ptr: *const u16 = utf16.as_ptr();
386
387 let in_handle = unsafe {
388 FileSystem::CreateFileW(
389 PCWSTR(utf16_ptr),
390 (GENERIC_READ | GENERIC_WRITE).0,
391 FILE_SHARE_READ | FILE_SHARE_WRITE,
392 None,
393 OPEN_EXISTING,
394 FILE_FLAGS_AND_ATTRIBUTES(0),
395 HANDLE::default(),
396 )
397 }?;
398
399 let mut original_in_mode = CONSOLE_MODE::default();
400 unsafe { Console::GetConsoleMode(in_handle, &mut original_in_mode) }?;
401
402 let requested_in_modes = !ENABLE_ECHO_INPUT & !ENABLE_LINE_INPUT & !ENABLE_PROCESSED_INPUT;
403 let in_mode = original_in_mode & requested_in_modes;
404 unsafe { Console::SetConsoleMode(in_handle, in_mode) }?;
405
406 Ok(move || {
407 unsafe { Console::SetConsoleMode(in_handle, original_in_mode) }?;
408 Ok(())
409 })
410}
411
412#[cfg(not(windows))]
413fn font_size_fallback() -> Option<FontSize> {
414 use rustix::termios::{self, Winsize};
415
416 let winsize = termios::tcgetwinsize(io::stdout()).ok()?;
417 let Winsize {
418 ws_xpixel: x,
419 ws_ypixel: y,
420 ws_col: cols,
421 ws_row: rows,
422 } = winsize;
423 if x == 0 || y == 0 || cols == 0 || rows == 0 {
424 return None;
425 }
426
427 Some(FontSize::new(x / cols, y / rows))
428}
429
430#[cfg(windows)]
431fn font_size_fallback() -> Option<FontSize> {
432 None
433}
434
435fn query_stdio_capabilities(
442 is_tmux: bool,
443 options: QueryStdioOptions,
444 tx: &Sender<QueryResult>,
445) -> Result<()> {
446 let query = Parser::query(is_tmux, options);
454 io::stdout().write_all(query.as_bytes())?;
455 io::stdout().flush()?;
456
457 let mut parser = Parser::new();
458 let mut responses = vec![];
459 'out: loop {
460 let mut charbuf: [u8; 50] = [0; 50];
461
462 let read = io::stdin().read(&mut charbuf)?;
463 tx.send(QueryResult::Busy)
465 .map_err(|_senderr| Errors::NoStdinResponse)?;
466
467 for ch in charbuf.iter().take(read) {
468 let mut more_caps = parser.push(char::from(*ch));
469 match more_caps[..] {
470 [Response::Status] => {
471 break 'out;
472 }
473 _ => responses.append(&mut more_caps),
474 }
475 }
476 }
477
478 let result = interpret_parser_responses(responses)?;
479 tx.send(QueryResult::Done(result))
480 .map_err(|_senderr| Errors::NoStdinResponse)?;
481 Ok(())
482}
483
484fn interpret_parser_responses(
485 responses: Vec<Response>,
486) -> Result<(Option<ProtocolType>, Option<FontSize>, Vec<Capability>)> {
487 if responses.is_empty() {
488 return Err(Errors::NoCap);
489 }
490
491 let mut capabilities = Vec::new();
492
493 let mut proto = None;
494 let mut font_size = None;
495
496 let mut cursor_position_reports = vec![];
497 for response in &responses {
498 if let Some(capability) = match response {
499 Response::Kitty => {
500 proto = Some(ProtocolType::Kitty);
501 Some(Capability::Kitty)
502 }
503 Response::Sixel => {
504 if proto.is_none() {
505 proto = Some(ProtocolType::Sixel);
507 }
508 Some(Capability::Sixel)
509 }
510 Response::RectangularOps => Some(Capability::RectangularOps),
511 Response::CellSize(cell_size) => {
512 if let Some((w, h)) = cell_size {
513 font_size = Some((*w, *h).into());
514 }
515 Some(Capability::CellSize(*cell_size))
516 }
517 Response::CursorPositionReport(x, y) => {
518 cursor_position_reports.push((x, y));
519 None
520 }
521 Response::Background(r, g, b) => Some(Capability::Background(*r, *g, *b)),
522 Response::Status => None,
523 } {
524 capabilities.push(capability);
525 }
526 }
527
528 font_size = font_size.or_else(font_size_fallback);
530
531 if let [(x1, _y1), (x2, _y2), (x3, _y3)] = cursor_position_reports[..] {
532 if *x2 == x1 + 2 && *x3 == x2 + 2 {
548 capabilities.push(Capability::TextSizingProtocol);
549 }
550 }
551
552 Ok((proto, font_size, capabilities))
553}
554
555enum QueryResult {
556 Done((Option<ProtocolType>, Option<FontSize>, Vec<Capability>)),
557 Err(Errors),
558 Busy,
559}
560fn query_with_timeout(
561 is_tmux: bool,
562 options: QueryStdioOptions,
563) -> Result<(Option<ProtocolType>, Option<FontSize>, Vec<Capability>)> {
564 use std::{sync::mpsc, thread};
565 let (tx, rx) = mpsc::channel();
566
567 let timeout = options.timeout;
568 thread::spawn(move || {
569 if let Err(err) = tx
570 .send(QueryResult::Busy)
571 .map_err(|_senderr| Errors::NoStdinResponse)
572 .and_then(|_| enable_raw_mode())
573 .and_then(|disable_raw_mode| {
574 tx.send(QueryResult::Busy)
575 .map_err(|_senderr| Errors::NoStdinResponse)?;
576 let result = query_stdio_capabilities(is_tmux, options, &tx);
577 disable_raw_mode()?;
578 result
579 })
580 {
581 let _ = tx.send(QueryResult::Err(err));
583 }
584 });
585
586 loop {
587 match rx.recv_timeout(timeout) {
588 Ok(qresult) => match qresult {
589 QueryResult::Done(result) => return Ok(result),
590 QueryResult::Err(err) => return Err(err),
591 QueryResult::Busy => continue, },
593 Err(_recverr) => {
594 return Err(Errors::NoStdinResponse);
595 }
596 }
597 }
598}
599
600#[cfg(test)]
601mod tests {
602 use std::assert_eq;
603
604 use crate::picker::{Capability, Picker, ProtocolType};
605
606 use super::{cap_parser::Response, interpret_parser_responses};
607
608 #[test]
609 fn test_cycle_protocol() {
610 let mut proto = ProtocolType::Halfblocks;
611 proto = proto.next();
612 assert_eq!(proto, ProtocolType::Sixel);
613 proto = proto.next();
614 assert_eq!(proto, ProtocolType::Kitty);
615 proto = proto.next();
616 assert_eq!(proto, ProtocolType::Iterm2);
617 proto = proto.next();
618 assert_eq!(proto, ProtocolType::Halfblocks);
619 }
620
621 #[test]
622 fn test_from_query_stdio_no_hang() {
623 let _ = Picker::from_query_stdio();
624 }
625
626 #[test]
627 fn test_interpret_parser_responses_text_sizing_protocol() {
628 let (_, _, caps) = interpret_parser_responses(vec![
629 Response::CursorPositionReport(1, 1),
631 Response::CursorPositionReport(3, 1),
632 Response::CursorPositionReport(5, 1),
633 ])
634 .unwrap();
635 assert!(caps.contains(&Capability::TextSizingProtocol));
636 }
637
638 #[test]
639 fn test_interpret_parser_responses_text_sizing_protocol_incomplete() {
640 let (_, _, caps) = interpret_parser_responses(vec![
641 Response::CursorPositionReport(1, 22),
644 Response::CursorPositionReport(3, 22),
645 Response::CursorPositionReport(4, 22),
646 ])
647 .unwrap();
648 assert!(!caps.contains(&Capability::TextSizingProtocol));
649 }
650}