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