1use std::fs::{File, OpenOptions};
5use std::os::fd::AsFd;
6use std::time::{Duration, Instant};
7
8use katui_proto::kitty::IdSpace;
9use katui_proto::probe::CellPixels;
10use katui_proto::probe::{Capabilities, Env};
11use katui_proto::probe::{Transport, probe, sniff};
12use rustix::event::{PollFd, PollFlags, Timespec, poll};
13use rustix::fd::OwnedFd;
14use rustix::fs::{Mode, OFlags};
15use rustix::termios::{
16 InputModes, LocalModes, OptionalActions, QueueSelector, SpecialCodeIndex, Termios, Winsize,
17 tcflush, tcgetattr, tcgetwinsize, tcsetattr,
18};
19
20use crate::RtError;
21
22fn open_tty() -> Result<File, RtError> {
27 let flags = OFlags::RDWR | OFlags::NONBLOCK;
28 let fd: OwnedFd = rustix::fs::open("/dev/tty", flags, Mode::empty())?;
29 Ok(File::from(fd))
30}
31
32struct RawTty {
37 tty: File,
38 saved: Termios,
39}
40
41impl RawTty {
42 fn enter(tty: File, with_signals: bool) -> Result<Self, RtError> {
43 let saved = tcgetattr(tty.as_fd())?;
44 let mut raw = saved.clone();
45 let mut modes = LocalModes::ECHO | LocalModes::ICANON;
46 if !with_signals {
47 modes |= LocalModes::ISIG;
48 }
49 raw.local_modes -= modes;
50 raw.input_modes -= InputModes::IXON | InputModes::ICRNL;
51 raw.special_codes[SpecialCodeIndex::VMIN] = 1;
54 raw.special_codes[SpecialCodeIndex::VTIME] = 0;
55 tcsetattr(tty.as_fd(), OptionalActions::Now, &raw)?;
56 Ok(Self { tty, saved })
57 }
58}
59
60impl Drop for RawTty {
61 fn drop(&mut self) {
62 let _ = tcflush(self.tty.as_fd(), QueueSelector::IFlush);
66 let _ = tcsetattr(self.tty.as_fd(), OptionalActions::Now, &self.saved);
67 }
68}
69
70const SPURIOUS_READY_BACKOFF: Duration = Duration::from_millis(4);
72
73pub struct RawInput(RawTty);
78
79impl RawInput {
80 pub fn open() -> Result<Self, RtError> {
82 Ok(Self(RawTty::enter(open_tty()?, false)?))
83 }
84
85 pub fn read_chunk(&mut self, wait: Duration) -> Option<Vec<u8>> {
87 let deadline = Instant::now() + wait;
88 let mut buffer = [0u8; 4096];
89 loop {
90 let remaining = deadline.saturating_duration_since(Instant::now());
91 let mut fds = [PollFd::new(&self.0.tty, PollFlags::IN)];
92 if !matches!(poll(&mut fds, Some(×pec(remaining))), Ok(ready) if ready > 0) {
93 return None;
94 }
95 match rustix::io::read(self.0.tty.as_fd(), &mut buffer) {
96 Ok(count) if count > 0 => return buffer.get(..count).map(<[u8]>::to_vec),
97 Err(rustix::io::Errno::AGAIN) if !remaining.is_zero() => {
100 std::thread::sleep(SPURIOUS_READY_BACKOFF);
101 }
102 _ => return None,
103 }
104 }
105 }
106}
107
108pub struct TtyTransport {
112 raw: RawTty,
113 deadline: Instant,
114}
115
116impl TtyTransport {
117 pub fn open(budget: Duration) -> Result<Self, RtError> {
120 let tty = open_tty()?;
121 Self::from_tty(tty, budget)
122 }
123
124 fn from_tty(tty: File, budget: Duration) -> Result<Self, RtError> {
128 Ok(Self {
129 raw: RawTty::enter(tty, true)?,
130 deadline: Instant::now()
132 .checked_add(budget)
133 .unwrap_or_else(|| Instant::now() + Duration::from_secs(86_400 * 365)),
134 })
135 }
136}
137
138fn window_size() -> Result<Winsize, RtError> {
139 let tty = OpenOptions::new().read(true).write(true).open("/dev/tty")?;
140 Ok(tcgetwinsize(tty.as_fd())?)
141}
142
143pub fn size() -> Result<katui_scene::CellSize, RtError> {
145 let winsize = window_size()?;
146 Ok(katui_scene::CellSize {
147 columns: winsize.ws_col,
148 rows: winsize.ws_row,
149 })
150}
151
152#[must_use]
155pub fn probe_default() -> Capabilities {
156 let mut caps = probe_terminal();
157 if caps.multiplexed() {
158 caps.id_space = IdSpace::of_process(std::process::id());
159 }
160 caps
161}
162
163fn probe_terminal() -> Capabilities {
164 let env = Env::capture();
165 let caps = probe_once(&env, Duration::from_millis(600));
166 if worth_retrying(&env, &caps) {
167 let retried = probe_once(&env, Duration::from_millis(1200));
168 if retried.kitty {
169 return retried;
170 }
171 }
172 caps
173}
174
175fn worth_retrying(env: &Env, caps: &Capabilities) -> bool {
179 env.inside_multiplexer()
180 && env.launched_from_kitty()
181 && env.forced_graphics().is_none()
182 && !caps.kitty
183}
184
185fn probe_once(env: &Env, budget: Duration) -> Capabilities {
186 match TtyTransport::open(budget) {
187 Ok(mut transport) => {
188 let caps = probe(&mut transport, env);
189 let until = Instant::now() + Duration::from_millis(200);
194 while Instant::now() < until {
195 let _ = transport.read_timeout(Duration::from_millis(50));
196 }
197 caps
198 }
199 Err(_) => sniff(env),
200 }
201}
202
203#[must_use]
205fn measured_cell() -> Option<CellPixels> {
206 let window = window_size().ok()?;
207 let area = CellPixels {
208 width: window.ws_xpixel,
209 height: window.ws_ypixel,
210 };
211 let cells = katui_scene::CellSize {
212 columns: window.ws_col,
213 rows: window.ws_row,
214 };
215 cell_from_area(area, cells)
216}
217
218#[must_use]
225pub fn cell_size(caps: &Capabilities) -> CellPixels {
226 pick_cell(measured_cell(), caps.cell, caps.text_area.zip(size().ok()))
227}
228
229fn pick_cell(
230 measured: Option<CellPixels>,
231 probed: Option<CellPixels>,
232 area: Option<(CellPixels, katui_scene::CellSize)>,
233) -> CellPixels {
234 measured
235 .or(probed)
236 .or_else(|| area.and_then(|(area, cells)| cell_from_area(area, cells)))
237 .unwrap_or(CellPixels {
238 width: 10,
239 height: 20,
240 })
241}
242
243fn cell_from_area(area: CellPixels, cells: katui_scene::CellSize) -> Option<CellPixels> {
244 let width = area.width.checked_div(cells.columns)?;
245 let height = area.height.checked_div(cells.rows)?;
246 (width > 0 && height > 0).then_some(CellPixels { width, height })
247}
248
249fn timespec(wait: Duration) -> Timespec {
250 Timespec {
251 tv_sec: wait.as_secs().min(i64::MAX.unsigned_abs()).cast_signed(),
252 tv_nsec: i64::from(wait.subsec_nanos()),
253 }
254}
255
256impl Transport for TtyTransport {
257 fn write(&mut self, bytes: &[u8]) -> std::io::Result<()> {
258 let mut written = 0;
259 while let Some(rest) = bytes.get(written..) {
260 if rest.is_empty() {
261 break;
262 }
263 match rustix::io::write(self.raw.tty.as_fd(), rest) {
264 Ok(0) => break,
265 Ok(n) => written += n,
266 Err(rustix::io::Errno::INTR) => {}
267 Err(rustix::io::Errno::AGAIN) => {
271 let mut fds = [PollFd::new(&self.raw.tty, PollFlags::OUT)];
272 match poll(&mut fds, Some(×pec(Duration::from_millis(100)))) {
273 Ok(_) | Err(rustix::io::Errno::INTR) => {}
274 Err(errno) => return Err(std::io::Error::from(errno)),
275 }
276 }
277 Err(errno) => return Err(std::io::Error::from(errno)),
278 }
279 }
280 Ok(())
281 }
282
283 fn read_timeout(&mut self, wait: Duration) -> Option<Vec<u8>> {
284 let start = Instant::now();
285 if start >= self.deadline {
286 return None;
287 }
288 let end = (start + wait).min(self.deadline);
289 while Instant::now() < end {
290 let timeout = timespec(end - Instant::now());
291 let mut fds = [PollFd::new(&self.raw.tty, PollFlags::IN)];
292 let ready = loop {
294 match poll(&mut fds, Some(&timeout)) {
295 Ok(ready) => break ready,
296 Err(rustix::io::Errno::INTR) => {}
297 Err(_) => return None,
298 }
299 };
300 if ready == 0 {
301 return None;
302 }
303 let mut buffer = [0u8; 4096];
304 match rustix::io::read(self.raw.tty.as_fd(), &mut buffer) {
305 Ok(0) => return None,
306 Ok(n) => return buffer.get(..n).map(<[u8]>::to_vec),
307 Err(rustix::io::Errno::AGAIN) => {}
311 Err(_) => return None,
312 }
313 }
314 None
315 }
316}
317
318#[cfg(test)]
319mod area_tests {
320 use katui_proto::probe::CellPixels;
321 use katui_scene::CellSize;
322
323 #[test]
324 fn a_text_area_over_its_cells_gives_the_cell() {
325 let area = CellPixels {
326 width: 560,
327 height: 700,
328 };
329 let cells = CellSize {
330 columns: 80,
331 rows: 50,
332 };
333 assert_eq!(
334 super::cell_from_area(area, cells),
335 Some(CellPixels {
336 width: 7,
337 height: 14
338 })
339 );
340 let empty = CellSize {
341 columns: 0,
342 rows: 0,
343 };
344 assert_eq!(super::cell_from_area(area, empty), None);
345 }
346}
347
348#[cfg(test)]
349mod tests {
350 use std::time::{Duration, Instant};
351
352 use super::{LocalModes, OpenOptions, TtyTransport, tcgetattr};
353 use katui_proto::probe::CellPixels;
354 use std::os::fd::AsFd as _;
355
356 #[allow(clippy::print_stderr)] fn pty_slave() -> Option<(rustix::fd::OwnedFd, std::fs::File)> {
361 use rustix::pty::{OpenptFlags, openpt, ptsname, unlockpt};
362
363 let master = openpt(OpenptFlags::RDWR | OpenptFlags::NOCTTY)
364 .expect("opening a pty pair cannot fail");
365 unlockpt(&master).expect("unlocking the pty cannot fail");
366 let name = [0u8; 64];
367 let name = ptsname(&master, name).expect("the pty has a name");
368 let path = name.to_string_lossy().into_owned();
369 if let Ok(slave) = OpenOptions::new().read(true).write(true).open(&path) {
370 Some((master, slave))
371 } else {
372 eprintln!("skipping pty test: no permission to open {path}");
373 None
374 }
375 }
376
377 #[test]
378 fn opening_the_controlling_terminal_never_panics() {
379 if TtyTransport::open(Duration::from_millis(1)).is_ok() {}
382 }
383
384 #[test]
387 fn raw_mode_flips_and_restores_on_a_pty() {
388 let Some((_master, slave)) = pty_slave() else {
389 return;
390 };
391 let witness = slave.try_clone().expect("duplicating the fd cannot fail");
392 let before = tcgetattr(witness.as_fd()).expect("the pty answers tcgetattr");
393
394 let transport = TtyTransport::from_tty(slave, Duration::from_millis(50))
395 .expect("a pty enters raw mode");
396 let during = tcgetattr(witness.as_fd()).expect("the pty answers tcgetattr");
397 assert!(
398 !during
399 .local_modes
400 .contains(LocalModes::ICANON | LocalModes::ECHO),
401 "raw mode clears line buffering and echo"
402 );
403 drop(transport);
404
405 let after = tcgetattr(witness.as_fd()).expect("the pty answers tcgetattr");
406 assert_eq!(
407 before.local_modes, after.local_modes,
408 "drop restores the saved local modes"
409 );
410 assert_eq!(
411 before.input_modes, after.input_modes,
412 "drop restores the saved input modes"
413 );
414 }
415
416 #[test]
420 fn the_probe_budget_ends_a_silent_exchange() {
421 use katui_proto::probe::ProbeSource;
422 use katui_proto::probe::{Env, probe};
423
424 let Some((_master, slave)) = pty_slave() else {
425 return;
426 };
427
428 let env = Env {
429 term: Some("xterm-256color".to_owned()),
430 ..Env::default()
431 };
432 let mut transport = TtyTransport::from_tty(slave, Duration::from_millis(300))
433 .expect("a pty enters raw mode");
434 let start = Instant::now();
435 let caps = probe(&mut transport, &env);
436 assert_eq!(
437 caps.trust,
438 ProbeSource::Sniffed,
439 "a silent pty proves nothing"
440 );
441 assert!(
442 start.elapsed() < Duration::from_secs(2),
443 "the budget ends the exchange, not a blocked read"
444 );
445 }
446
447 #[test]
448 fn the_driver_cell_wins_then_the_probe_then_the_area() {
449 let cell = |width, height| CellPixels { width, height };
450 let cells = katui_scene::CellSize {
451 columns: 10,
452 rows: 10,
453 };
454 let area = Some((cell(90, 180), cells));
455 assert_eq!(
456 super::pick_cell(Some(cell(8, 16)), Some(cell(7, 14)), area),
457 cell(8, 16)
458 );
459 assert_eq!(super::pick_cell(None, Some(cell(7, 14)), area), cell(7, 14));
460 assert_eq!(super::pick_cell(None, None, area), cell(9, 18));
461 assert_eq!(super::pick_cell(None, None, None), cell(10, 20));
462 }
463
464 #[test]
465 fn only_a_kitty_launched_multiplexer_without_graphics_retries() {
466 use katui_proto::probe::{Capabilities, Env};
467
468 let tmux_in_kitty = Env {
469 tmux: Some("pane".to_owned()),
470 kitty_window_id: Some("1".to_owned()),
471 ..Env::default()
472 };
473 let lost = Capabilities::default();
474 assert!(super::worth_retrying(&tmux_in_kitty, &lost));
475 let found = Capabilities {
476 kitty: true,
477 ..Capabilities::default()
478 };
479 assert!(!super::worth_retrying(&tmux_in_kitty, &found));
480 let forced = Env {
481 graphics: Some("blocks".to_owned()),
482 ..tmux_in_kitty.clone()
483 };
484 assert!(!super::worth_retrying(&forced, &lost));
485 let bare_kitty = Env {
486 tmux: None,
487 ..tmux_in_kitty
488 };
489 assert!(!super::worth_retrying(&bare_kitty, &lost));
490 }
491}