1use std::{
4 env,
5 io::{self, Read, Write},
6 time::Duration,
7};
8
9use crate::{
10 errors::Errors,
11 protocol::{
12 halfblocks::Halfblocks,
13 iterm2::Iterm2,
14 kitty::{Kitty, StatefulKitty},
15 sixel::Sixel,
16 Protocol, StatefulProtocol, StatefulProtocolType,
17 },
18 FontSize, ImageSource, Resize, Result,
19};
20use cap_parser::{Capability, Parser};
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
29const DEFAULT_BACKGROUND: Rgba<u8> = Rgba([0, 0, 0, 0]);
30
31#[derive(Clone, Copy, Debug)]
32pub struct Picker {
33 font_size: FontSize,
34 protocol_type: ProtocolType,
35 background_color: Rgba<u8>,
36 is_tmux: bool,
37}
38
39#[derive(PartialEq, Clone, Debug, Copy)]
41#[cfg_attr(
42 feature = "serde",
43 derive(Deserialize, Serialize),
44 serde(rename_all = "lowercase")
45)]
46pub enum ProtocolType {
47 Halfblocks,
48 Sixel,
49 Kitty,
50 Iterm2,
51}
52
53impl ProtocolType {
54 pub fn next(&self) -> ProtocolType {
55 match self {
56 ProtocolType::Halfblocks => ProtocolType::Sixel,
57 ProtocolType::Sixel => ProtocolType::Kitty,
58 ProtocolType::Kitty => ProtocolType::Iterm2,
59 ProtocolType::Iterm2 => ProtocolType::Halfblocks,
60 }
61 }
62}
63
64impl Picker {
66 pub fn from_query_stdio() -> Result<Self> {
78 let (is_tmux, tmux_proto) = detect_tmux_and_outer_protocol_from_env();
80
81 match query_with_timeout(is_tmux, Duration::from_secs(1)) {
83 Ok((capability_proto, font_size)) => {
84 let iterm2_proto = iterm2_from_env();
86
87 let protocol_type = tmux_proto
88 .or(iterm2_proto)
89 .or(capability_proto)
90 .unwrap_or(ProtocolType::Halfblocks);
91
92 if let Some(font_size) = font_size {
93 Ok(Self {
94 font_size,
95 background_color: DEFAULT_BACKGROUND,
96 protocol_type,
97 is_tmux,
98 })
99 } else {
100 Err(Errors::NoFontSize)
101 }
102 }
103 Err(Errors::NoCap) => Ok(Self {
104 font_size: (10, 20),
108 background_color: DEFAULT_BACKGROUND,
109 protocol_type: ProtocolType::Halfblocks,
110 is_tmux,
111 }),
112 Err(err) => Err(err),
113 }
114 }
115
116 pub fn from_fontsize(font_size: FontSize) -> Self {
128 let (is_tmux, tmux_proto) = detect_tmux_and_outer_protocol_from_env();
130
131 let iterm2_proto = iterm2_from_env();
133
134 let protocol_type = tmux_proto
135 .or(iterm2_proto)
136 .unwrap_or(ProtocolType::Halfblocks);
137
138 Self {
139 font_size,
140 background_color: DEFAULT_BACKGROUND,
141 protocol_type,
142 is_tmux,
143 }
144 }
145
146 pub fn protocol_type(self) -> ProtocolType {
147 self.protocol_type
148 }
149
150 pub fn set_protocol_type(&mut self, protocol_type: ProtocolType) {
151 self.protocol_type = protocol_type;
152 }
153
154 pub fn font_size(self) -> FontSize {
155 self.font_size
156 }
157
158 pub fn set_background_color<T: Into<Rgba<u8>>>(&mut self, background_color: T) {
160 self.background_color = background_color.into();
161 }
162
163 pub fn new_protocol(
165 &self,
166 image: DynamicImage,
167 size: Rect,
168 resize: Resize,
169 ) -> Result<Protocol> {
170 let source = ImageSource::new(image, self.font_size, self.background_color);
171
172 let (image, area) =
173 match resize.needs_resize(&source, self.font_size, source.desired, size, false) {
174 Some(area) => {
175 let font_size = if self.protocol_type == ProtocolType::Halfblocks {
179 (self.font_size.0, self.font_size.1 / 2)
180 } else {
181 self.font_size
182 };
183 let image = resize.resize(&source, font_size, size, self.background_color);
184 (image, area)
185 }
186 None => (source.image, source.desired),
187 };
188
189 match self.protocol_type {
190 ProtocolType::Halfblocks => Ok(Protocol::Halfblocks(Halfblocks::new(image, area)?)),
191 ProtocolType::Sixel => Ok(Protocol::Sixel(Sixel::new(image, area, self.is_tmux)?)),
192 ProtocolType::Kitty => Ok(Protocol::Kitty(Kitty::new(
193 image,
194 area,
195 rand::random(),
196 self.is_tmux,
197 )?)),
198 ProtocolType::Iterm2 => Ok(Protocol::ITerm2(Iterm2::new(image, area, self.is_tmux)?)),
199 }
200 }
201
202 pub fn new_resize_protocol(&self, image: DynamicImage) -> StatefulProtocol {
204 let source = ImageSource::new(image, self.font_size, self.background_color);
205 let protocol_type = match self.protocol_type {
206 ProtocolType::Halfblocks => StatefulProtocolType::Halfblocks(Halfblocks::default()),
207 ProtocolType::Sixel => StatefulProtocolType::Sixel(Sixel {
208 is_tmux: self.is_tmux,
209 ..Sixel::default()
210 }),
211 ProtocolType::Kitty => {
212 StatefulProtocolType::Kitty(StatefulKitty::new(random(), self.is_tmux))
213 }
214 ProtocolType::Iterm2 => StatefulProtocolType::ITerm2(Iterm2 {
215 is_tmux: self.is_tmux,
216 ..Iterm2::default()
217 }),
218 };
219 StatefulProtocol::new(source, self.font_size, protocol_type)
220 }
221}
222
223fn detect_tmux_and_outer_protocol_from_env() -> (bool, Option<ProtocolType>) {
224 if !env::var("TERM").is_ok_and(|term| term.starts_with("tmux"))
226 && !env::var("TERM_PROGRAM").is_ok_and(|term_program| term_program == "tmux")
227 {
228 return (false, None);
229 }
230
231 let _ = std::process::Command::new("tmux")
232 .args(["set", "-p", "allow-passthrough", "on"])
233 .stdin(std::process::Stdio::null())
234 .stdout(std::process::Stdio::null())
235 .stderr(std::process::Stdio::null())
236 .spawn()
237 .and_then(|mut child| child.wait()); const OUTER_TERM_HINTS: [(&str, ProtocolType); 3] = [
244 ("KITTY_WINDOW_ID", ProtocolType::Kitty), ("ITERM_SESSION_ID", ProtocolType::Iterm2),
246 ("WEZTERM_EXECUTABLE", ProtocolType::Iterm2),
247 ];
248 for (hint, proto) in OUTER_TERM_HINTS {
249 if env::var(hint).is_ok_and(|s| !s.is_empty()) {
250 return (true, Some(proto));
251 }
252 }
253 (true, None)
254}
255
256fn iterm2_from_env() -> Option<ProtocolType> {
257 if env::var("TERM_PROGRAM").is_ok_and(|term_program| {
258 term_program.contains("iTerm")
259 || term_program.contains("WezTerm")
260 || term_program.contains("mintty")
261 || term_program.contains("vscode")
262 || term_program.contains("Tabby")
263 || term_program.contains("Hyper")
264 || term_program.contains("rio")
265 }) {
266 return Some(ProtocolType::Iterm2);
267 }
268 if env::var("LC_TERMINAL").is_ok_and(|lc_term| lc_term.contains("iTerm")) {
269 return Some(ProtocolType::Iterm2);
270 }
271 None
272}
273
274#[cfg(not(windows))]
275fn enable_raw_mode() -> Result<impl FnOnce() -> Result<()>> {
276 use rustix::termios::{self, LocalModes, OptionalActions};
277
278 let stdin = io::stdin();
279 let mut termios = termios::tcgetattr(&stdin)?;
280 let termios_original = termios.clone();
281
282 termios.local_modes &= !LocalModes::ICANON;
284 termios.local_modes &= !LocalModes::ECHO;
285 termios::tcsetattr(&stdin, OptionalActions::Drain, &termios)?;
286
287 Ok(move || {
288 Ok(termios::tcsetattr(
289 io::stdin(),
290 OptionalActions::Now,
291 &termios_original,
292 )?)
293 })
294}
295
296#[cfg(windows)]
297fn enable_raw_mode() -> Result<impl FnOnce() -> Result<()>> {
298 use windows::{
299 core::PCWSTR,
300 Win32::{
301 Foundation::{GENERIC_READ, GENERIC_WRITE, HANDLE},
302 Storage::FileSystem::{
303 self, FILE_FLAGS_AND_ATTRIBUTES, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING,
304 },
305 System::Console::{
306 self, CONSOLE_MODE, ENABLE_ECHO_INPUT, ENABLE_LINE_INPUT, ENABLE_PROCESSED_INPUT,
307 },
308 },
309 };
310
311 let utf16: Vec<u16> = "CONIN$\0".encode_utf16().collect();
312 let utf16_ptr: *const u16 = utf16.as_ptr();
313
314 let in_handle = unsafe {
315 FileSystem::CreateFileW(
316 PCWSTR(utf16_ptr),
317 (GENERIC_READ | GENERIC_WRITE).0,
318 FILE_SHARE_READ | FILE_SHARE_WRITE,
319 None,
320 OPEN_EXISTING,
321 FILE_FLAGS_AND_ATTRIBUTES(0),
322 HANDLE::default(),
323 )
324 }?;
325
326 let mut original_in_mode = CONSOLE_MODE::default();
327 unsafe { Console::GetConsoleMode(in_handle, &mut original_in_mode) }?;
328
329 let requested_in_modes = !ENABLE_ECHO_INPUT & !ENABLE_LINE_INPUT & !ENABLE_PROCESSED_INPUT;
330 let in_mode = original_in_mode & requested_in_modes;
331 unsafe { Console::SetConsoleMode(in_handle, in_mode) }?;
332
333 Ok(move || {
334 unsafe { Console::SetConsoleMode(in_handle, original_in_mode) }?;
335 Ok(())
336 })
337}
338
339#[cfg(not(windows))]
340fn font_size_fallback() -> Option<FontSize> {
341 use rustix::termios::{self, Winsize};
342
343 let winsize = termios::tcgetwinsize(io::stdout()).ok()?;
344 let Winsize {
345 ws_xpixel: x,
346 ws_ypixel: y,
347 ws_col: cols,
348 ws_row: rows,
349 } = winsize;
350 if x == 0 || y == 0 || cols == 0 || rows == 0 {
351 return None;
352 }
353
354 Some((x / cols, y / rows))
355}
356
357#[cfg(windows)]
358fn font_size_fallback() -> Option<FontSize> {
359 None
360}
361
362fn query_stdio_capabilities(is_tmux: bool) -> Result<(Option<ProtocolType>, Option<FontSize>)> {
363 let query = Parser::query(is_tmux);
371 io::stdout().write_all(query.as_bytes())?;
372 io::stdout().flush()?;
373
374 let mut parser = Parser::new();
375 let mut capabilities = vec![];
376 'out: loop {
377 let mut charbuf: [u8; 50] = [0; 50];
378 let result = io::stdin().read(&mut charbuf);
379 match result {
380 Ok(read) => {
381 for ch in charbuf.iter().take(read) {
382 let mut more_caps = parser.push(char::from(*ch));
383 if more_caps[..] == [Capability::Status] {
384 break 'out;
385 } else {
386 capabilities.append(&mut more_caps);
387 }
388 }
389 }
390 Err(err) => {
391 return Err(err.into());
392 }
393 }
394 }
395
396 if capabilities.is_empty() {
397 return Err(Errors::NoCap);
398 }
399
400 let mut proto = None;
401 let mut font_size = None;
402 if capabilities.contains(&Capability::Kitty) {
403 proto = Some(ProtocolType::Kitty);
404 } else if capabilities.contains(&Capability::Sixel) {
405 proto = Some(ProtocolType::Sixel);
406 }
407
408 for cap in capabilities {
409 if let Capability::CellSize(Some((w, h))) = cap {
410 font_size = Some((w, h));
411 }
412 }
413 font_size = font_size.or_else(font_size_fallback);
415
416 Ok((proto, font_size))
417}
418
419fn query_with_timeout(
420 is_tmux: bool,
421 timeout: Duration,
422) -> Result<(Option<ProtocolType>, Option<FontSize>)> {
423 use std::{sync::mpsc, thread};
424 let (tx, rx) = mpsc::channel();
425
426 thread::spawn(move || {
427 let _ = tx.send(
428 enable_raw_mode()
429 .map_err(Errors::into)
430 .and_then(|disable_raw_mode| {
431 let result = query_stdio_capabilities(is_tmux);
432 disable_raw_mode()?;
434 result
435 }),
436 );
437 });
438
439 match rx.recv_timeout(timeout) {
440 Ok(result) => Ok(result?),
441 Err(_recvtimeout) => Err(Errors::NoStdinResponse),
442 }
443}
444
445#[cfg(test)]
446mod tests {
447 use std::assert_eq;
448
449 use crate::picker::{Picker, ProtocolType};
450
451 #[test]
452 fn test_cycle_protocol() {
453 let mut proto = ProtocolType::Halfblocks;
454 proto = proto.next();
455 assert_eq!(proto, ProtocolType::Sixel);
456 proto = proto.next();
457 assert_eq!(proto, ProtocolType::Kitty);
458 proto = proto.next();
459 assert_eq!(proto, ProtocolType::Iterm2);
460 proto = proto.next();
461 assert_eq!(proto, ProtocolType::Halfblocks);
462 }
463
464 #[test]
465 fn test_from_query_stdio_no_hang() {
466 let _ = Picker::from_query_stdio();
467 }
468}