1mod app;
2mod config;
3mod core;
4mod file_info;
5mod fs;
6mod path_display;
7mod preview;
8mod shell;
9mod ui;
10mod zoxide;
11
12use crate::app::{App, PendingTerminalTask};
13use anyhow::Result;
14use crossterm::{
15 cursor::{RestorePosition, SavePosition, SetCursorStyle},
16 event::{
17 self, DisableFocusChange, EnableFocusChange, Event, KeyboardEnhancementFlags, MouseEvent,
18 MouseEventKind, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
19 },
20 execute,
21 terminal::{
22 BeginSynchronizedUpdate, EndSynchronizedUpdate, EnterAlternateScreen, LeaveAlternateScreen,
23 disable_raw_mode, enable_raw_mode, supports_keyboard_enhancement,
24 },
25};
26use ratatui::{
27 Terminal,
28 backend::CrosstermBackend,
29 buffer::{Buffer, Cell},
30 layout::Rect,
31};
32use std::{
33 fs as std_fs,
34 io::{self, ErrorKind, Write},
35 path::{Path, PathBuf},
36 process::Command,
37 time::{Duration, Instant},
38};
39
40const IDLE_POLL_INTERVAL: Duration = Duration::from_millis(100);
41const ACTIVE_SCROLL_POLL_INTERVAL: Duration = Duration::from_millis(12);
42const WINDOWS_TERMINAL_ACTIVE_POLL_INTERVAL: Duration = Duration::from_millis(24);
43const RELATIVE_TIME_REFRESH_INTERVAL: Duration = Duration::from_secs(1);
44
45#[derive(Debug, Default)]
46pub struct RunOptions {
47 pub start_dir: Option<PathBuf>,
48 pub cwd_file: Option<PathBuf>,
49}
50
51pub fn run() -> Result<()> {
52 run_with_options(RunOptions::default())
53}
54
55pub fn run_at(cwd: PathBuf) -> Result<()> {
56 run_with_options(RunOptions {
57 start_dir: Some(cwd),
58 cwd_file: None,
59 })
60}
61
62pub fn run_with_options(options: RunOptions) -> Result<()> {
63 config::initialize();
64 ui::theme::initialize();
65 let mut terminal = init_terminal()?;
66 let result = run_app(&mut terminal, options.start_dir);
67 restore_terminal(&mut terminal)?;
68 if let Some(final_cwd) = result? {
69 write_cwd_file_if_requested(options.cwd_file.as_deref(), &final_cwd)?;
70 }
71 Ok(())
72}
73
74fn init_terminal() -> Result<Terminal<CrosstermBackend<io::Stdout>>> {
75 match try_init_terminal() {
76 Ok(terminal) => Ok(terminal),
77 Err(error) => {
78 let _ = cleanup_terminal_state();
79 Err(error)
80 }
81 }
82}
83
84fn try_init_terminal() -> Result<Terminal<CrosstermBackend<io::Stdout>>> {
85 enable_raw_mode()?;
86 let mut stdout = io::stdout();
87 execute!(
88 stdout,
89 EnterAlternateScreen,
90 event::EnableMouseCapture,
91 EnableFocusChange
92 )?;
93
94 write!(stdout, "\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h")?;
102
103 write!(stdout, "\x1b[>4;1m")?;
107
108 stdout.flush()?;
109 push_keyboard_enhancement_if_supported(&mut stdout)?;
110
111 let backend = CrosstermBackend::new(stdout);
112 let mut terminal = Terminal::new(backend)?;
113 terminal.clear()?;
114 terminal.hide_cursor()?;
115 Ok(terminal)
116}
117
118fn suspend_terminal(
121 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
122 leave_alternate: bool,
123) -> Result<()> {
124 let backend = terminal.backend_mut();
125 write!(backend, "\x1b[>4;0m")?;
126 write!(backend, "\x1b[?1006l\x1b[?1003l\x1b[?1002l\x1b[?1000l")?;
127 backend.flush()?;
128 pop_keyboard_enhancement_if_supported(terminal.backend_mut())?;
129 execute!(
130 terminal.backend_mut(),
131 event::DisableMouseCapture,
132 DisableFocusChange,
133 SetCursorStyle::DefaultUserShape
134 )?;
135 if leave_alternate {
136 execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
137 } else {
138 terminal.clear()?;
139 }
140 disable_raw_mode()?;
141 terminal.show_cursor()?;
142 Ok(())
143}
144
145fn resume_terminal(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
148 enable_raw_mode()?;
149 let mut stdout = io::stdout();
150 execute!(
151 stdout,
152 EnterAlternateScreen,
153 event::EnableMouseCapture,
154 EnableFocusChange,
155 )?;
156 write!(stdout, "\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h")?;
157 write!(stdout, "\x1b[>4;1m")?;
158 stdout.flush()?;
159 push_keyboard_enhancement_if_supported(&mut stdout)?;
160 terminal.clear()?;
161 terminal.hide_cursor()?;
162 Ok(())
163}
164
165fn run_blocking_in_terminal(program: &str, args: &[String]) {
169 let _ = Command::new(program).args(args).status();
170}
171
172fn refresh_after_shell(app: &mut App, cwd: &Path) {
173 let cwd_label = path_display::user_facing(cwd);
174 match cwd.try_exists() {
175 Ok(true) => {
176 if let Err(error) = app.reload() {
177 app.report_runtime_error("Shell refresh failed", &error);
178 }
179 }
180 Ok(false) => app.set_status_message(format!(
181 "Current folder was removed while shell was open: {}",
182 cwd_label
183 )),
184 Err(error) => app.set_status_message(format!(
185 "Could not refresh {cwd_label} after shell: {error}"
186 )),
187 }
188}
189
190fn apply_zoxide_query_result(app: &mut App, result: zoxide::QueryResult) {
191 match result {
192 zoxide::QueryResult::Selected(path) => app.open_zoxide_selection(path),
193 zoxide::QueryResult::Cancelled => {}
194 zoxide::QueryResult::NotFound => app.set_status_message("zoxide not found"),
195 zoxide::QueryResult::PickerNotFound => app.set_status_message("fzf not found"),
196 zoxide::QueryResult::Empty => app.set_status_message("No zoxide directory history found"),
197 zoxide::QueryResult::OnlyCurrentDirectory => {
198 app.set_status_message("Zoxide history only contains the current directory")
199 }
200 zoxide::QueryResult::LaunchFailed => app.set_status_message("Could not run zoxide"),
201 }
202}
203
204fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
205 let backend = terminal.backend_mut();
208 write!(backend, "\x1b[>4;0m")?; write!(backend, "\x1b[?1006l\x1b[?1003l\x1b[?1002l\x1b[?1000l")?; backend.flush()?;
211 pop_keyboard_enhancement_if_supported(terminal.backend_mut())?;
212 execute!(
213 terminal.backend_mut(),
214 event::DisableMouseCapture,
215 DisableFocusChange,
216 SetCursorStyle::DefaultUserShape,
217 LeaveAlternateScreen
218 )?;
219 disable_raw_mode()?;
220 terminal.show_cursor()?;
221 Ok(())
222}
223
224fn cleanup_terminal_state() -> io::Result<()> {
225 let mut stdout = io::stdout();
226 let _ = write!(stdout, "\x1b[>4;0m");
227 let _ = write!(stdout, "\x1b[?1006l\x1b[?1003l\x1b[?1002l\x1b[?1000l");
228 let _ = stdout.flush();
229 let _ = execute!(
230 stdout,
231 event::DisableMouseCapture,
232 DisableFocusChange,
233 SetCursorStyle::DefaultUserShape,
234 LeaveAlternateScreen,
235 );
236 disable_raw_mode()?;
237 Ok(())
238}
239
240fn push_keyboard_enhancement_if_supported<W: Write>(writer: &mut W) -> io::Result<()> {
241 if !matches!(supports_keyboard_enhancement(), Ok(true)) {
242 return Ok(());
243 }
244
245 match execute!(
246 writer,
247 PushKeyboardEnhancementFlags(
248 KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
249 | KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES
250 | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
251 )
252 ) {
253 Ok(()) => Ok(()),
254 Err(error) if keyboard_enhancement_is_unsupported(&error) => Ok(()),
255 Err(error) => Err(error),
256 }
257}
258
259fn pop_keyboard_enhancement_if_supported<W: Write>(writer: &mut W) -> io::Result<()> {
260 match execute!(writer, PopKeyboardEnhancementFlags) {
261 Ok(()) => Ok(()),
262 Err(error) if keyboard_enhancement_is_unsupported(&error) => Ok(()),
263 Err(error) => Err(error),
264 }
265}
266
267fn keyboard_enhancement_is_unsupported(error: &io::Error) -> bool {
268 error.kind() == ErrorKind::Unsupported
269 && error
270 .to_string()
271 .contains("Keyboard progressive enhancement not implemented")
272}
273
274fn write_cwd_file_if_requested(cwd_file: Option<&Path>, final_cwd: &Path) -> Result<()> {
275 let Some(cwd_file) = cwd_file else {
276 return Ok(());
277 };
278
279 write_cwd_file(cwd_file, final_cwd)
280}
281
282#[cfg(unix)]
283fn write_cwd_file(cwd_file: &Path, final_cwd: &Path) -> Result<()> {
284 use std::os::unix::ffi::OsStrExt;
285
286 std_fs::write(cwd_file, final_cwd.as_os_str().as_bytes())?;
287 Ok(())
288}
289
290#[cfg(not(unix))]
291fn write_cwd_file(cwd_file: &Path, final_cwd: &Path) -> Result<()> {
292 std_fs::write(cwd_file, final_cwd.to_string_lossy().as_bytes())?;
293 Ok(())
294}
295
296fn run_app(
297 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
298 cwd: Option<PathBuf>,
299) -> Result<Option<PathBuf>> {
300 let mut app = match cwd {
301 Some(cwd) => App::new_at(cwd)?,
302 None => App::new()?,
303 };
304
305 app.enable_terminal_image_previews();
312
313 let mut dirty = true;
314 let mut search_cursor_active = false;
315 let mut terminal_focused = true;
316 let mut last_relative_time_refresh_at = Instant::now();
317
318 loop {
319 if app.should_quit {
320 break;
321 }
322
323 if terminal_focused
324 && last_relative_time_refresh_at.elapsed() >= RELATIVE_TIME_REFRESH_INTERVAL
325 {
326 dirty = true;
327 last_relative_time_refresh_at = Instant::now();
328 }
329
330 if terminal_focused && app.process_background_jobs() {
331 dirty = true;
332 }
333
334 if terminal_focused && app.process_pdf_preview_timers() {
335 dirty = true;
336 }
337
338 if terminal_focused && app.process_pending_scroll() {
339 dirty = true;
340 }
341
342 if terminal_focused && app.process_preview_refresh_timers() {
343 dirty = true;
344 }
345
346 if terminal_focused && app.process_preview_prefetch_timers() {
347 dirty = true;
348 }
349
350 if terminal_focused && app.process_directory_stats_timer() {
351 dirty = true;
352 }
353
354 if terminal_focused && app.process_directory_item_count_timer() {
355 dirty = true;
356 }
357
358 if terminal_focused && app.process_browser_wheel_timers() {
359 dirty = true;
360 }
361
362 if terminal_focused && app.process_image_preview_timers() {
363 dirty = true;
364 }
365
366 if terminal_focused && app.process_sidebar_refresh() {
367 dirty = true;
368 }
369
370 if terminal_focused {
371 match app.process_auto_reload() {
372 Ok(changed) => {
373 dirty |= changed;
374 }
375 Err(error) => {
376 app.report_runtime_error("Auto-reload failed", &error);
377 dirty = true;
378 }
379 }
380 }
381
382 if dirty && terminal_focused {
383 dirty = draw_terminal_frame(terminal, &mut app)?;
384 }
385
386 let wants_search_cursor = app.search_is_open()
387 || app.create_is_open()
388 || app.rename_is_open()
389 || app.bulk_rename_is_open();
390 if wants_search_cursor != search_cursor_active {
391 if wants_search_cursor {
392 terminal.show_cursor()?;
393 } else {
394 terminal.hide_cursor()?;
395 }
396 execute!(
397 terminal.backend_mut(),
398 if wants_search_cursor {
399 SetCursorStyle::SteadyBar
400 } else {
401 SetCursorStyle::DefaultUserShape
402 }
403 )?;
404 search_cursor_active = wants_search_cursor;
405 }
406
407 let base_poll_interval = if !terminal_focused {
408 IDLE_POLL_INTERVAL
409 } else if app.has_pending_scroll()
410 || app.has_pending_auto_reload()
411 || app.has_pending_background_work()
412 {
413 if app.is_windows_terminal() {
414 WINDOWS_TERMINAL_ACTIVE_POLL_INTERVAL
415 } else {
416 ACTIVE_SCROLL_POLL_INTERVAL
417 }
418 } else {
419 IDLE_POLL_INTERVAL
420 };
421 let poll_interval = event_poll_interval(
422 base_poll_interval,
423 terminal_focused,
424 [
425 app.pending_pdf_preview_timer(),
426 app.pending_image_preview_timer(),
427 app.pending_preview_refresh_timer(),
428 app.pending_preview_prefetch_timer(),
429 app.pending_directory_stats_timer(),
430 app.pending_directory_item_count_timer(),
431 app.pending_browser_wheel_timer(),
432 ],
433 );
434
435 if event::poll(poll_interval)? {
436 loop {
441 let event = event::read()?;
442 if std::env::var_os("ELIO_LOG_MOUSE").is_some()
443 && let Event::Mouse(m) = &event
444 {
445 let _ = std::fs::OpenOptions::new()
446 .create(true)
447 .append(true)
448 .open(std::env::temp_dir().join("elio-mouse.log"))
449 .and_then(|mut f| {
450 writeln!(f, "{:?} col={} row={}", m.kind, m.column, m.row)
451 });
452 }
453 if matches!(event, Event::FocusLost) {
454 terminal_focused = false;
455 } else if matches!(event, Event::FocusGained) {
456 terminal_focused = true;
457 app.handle_terminal_image_focus_gained();
458 dirty = true;
459 } else if matches!(event, Event::Resize(_, _)) {
460 app.handle_terminal_image_resize();
461 dirty |= terminal_focused;
462 } else {
463 let needs_render = !matches!(
468 event,
469 Event::Mouse(MouseEvent {
470 kind: MouseEventKind::Moved,
471 ..
472 })
473 );
474 let _ = app.handle_event(event);
475 if needs_render && terminal_focused {
476 dirty = true;
477 }
478 }
479 if !event::poll(Duration::ZERO)? {
481 break;
482 }
483 }
484
485 if app.should_quit {
486 break;
487 }
488
489 if let Some(task) = app.pending_terminal_task.take() {
492 let zoxide_result = match task {
493 PendingTerminalTask::Command { program, args } => {
494 suspend_terminal(terminal, true)?;
495 run_blocking_in_terminal(&program, &args);
496 resume_terminal(terminal)?;
497 None
498 }
499 PendingTerminalTask::Shell { cwd } => {
500 suspend_terminal(terminal, true)?;
501 let shell_result = shell::run_in_current_terminal(&cwd);
502 resume_terminal(terminal)?;
503 match shell_result {
504 Ok(()) => refresh_after_shell(&mut app, &cwd),
505 Err(error) => app.set_status_message(error),
506 }
507 None
508 }
509 PendingTerminalTask::Zoxide => {
510 let cwd = app.navigation.cwd.clone();
511 if let Some(result) = zoxide::preflight(&cwd) {
512 Some(result)
513 } else {
514 suspend_terminal(terminal, false)?;
515 let result = zoxide::run_query_in_terminal(&cwd);
516 resume_terminal(terminal)?;
517 Some(result)
518 }
519 }
520 };
521 if let Some(result) = zoxide_result {
522 apply_zoxide_query_result(&mut app, result);
523 }
524 dirty = true;
525 }
526 }
527 }
528
529 let final_cwd = app
530 .should_change_directory_on_quit
531 .then(|| app.navigation.cwd.clone());
532 app.queue_forced_iterm_preview_erase();
533 let mut overlay_bytes = app.clear_preview_overlay()?;
534 overlay_bytes.extend(app.iterm_pre_draw_erase());
535 if !overlay_bytes.is_empty() {
536 terminal.backend_mut().write_all(&overlay_bytes)?;
537 terminal.backend_mut().flush()?;
538 }
539 Ok(final_cwd)
540}
541
542fn draw_terminal_frame(
543 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
544 app: &mut App,
545) -> Result<bool> {
546 execute!(terminal.backend_mut(), BeginSynchronizedUpdate)?;
547
548 let draw_result = (|| -> Result<bool> {
549 if app.take_pending_resize_clear() {
550 terminal.clear()?;
551 }
552
553 let pre_erase = app.iterm_pre_draw_erase();
560 let kitty_erase = app.kitty_pre_draw_erase();
561 if !pre_erase.is_empty() || !kitty_erase.is_empty() {
562 terminal.backend_mut().write_all(&pre_erase)?;
563 terminal.backend_mut().write_all(&kitty_erase)?;
564 }
565 let mut frame_state = app::FrameState::default();
566 let (
567 dirty,
568 image_behind_modal,
569 sixel_collision_erase,
570 popup_restore,
571 modal_erase,
572 skip_overlay_present,
573 ) = {
574 let completed = terminal.draw(|frame| ui::render(frame, app, &mut frame_state))?;
575 let dirty = app.set_frame_state(frame_state);
576 let modal_rects = app.collect_popup_rects();
577 if !app.browser_wheel_burst_active()
578 && app.should_repaint_iterm_inline_under_modal(&modal_rects)
579 {
580 let image_behind_modal = app.present_preview_overlay_behind_modal()?;
581 let popup_restore = collect_buffer_cells(&modal_rects, completed.buffer);
582 let modal_erase = app.modal_image_post_draw_erase(&modal_rects, completed.buffer);
583 (
584 dirty,
585 image_behind_modal,
586 Vec::new(),
587 popup_restore,
588 modal_erase,
589 true,
590 )
591 } else if !app.browser_wheel_burst_active()
592 && app.should_repaint_sixel_under_modal(&modal_rects)
593 {
594 let image_behind_modal = app.present_preview_overlay_behind_modal()?;
595 let (sixel_collision_rects, sixel_collision_erase) =
596 app.sixel_modal_collision_erase(&modal_rects);
597 let popup_restore = collect_buffer_cells(&sixel_collision_rects, completed.buffer);
598 let modal_erase = app.modal_image_post_draw_erase(&modal_rects, completed.buffer);
599 (
600 dirty,
601 image_behind_modal,
602 sixel_collision_erase,
603 popup_restore,
604 modal_erase,
605 true,
606 )
607 } else {
608 let (sixel_collision_rects, sixel_collision_erase) =
609 app.sixel_modal_collision_erase(&modal_rects);
610 let popup_restore = collect_buffer_cells(&sixel_collision_rects, completed.buffer);
611 let modal_erase = app.modal_image_post_draw_erase(&modal_rects, completed.buffer);
612 (
613 dirty,
614 Vec::new(),
615 sixel_collision_erase,
616 popup_restore,
617 modal_erase,
618 false,
619 )
620 }
621 };
622 write_bytes_preserving_cursor(terminal.backend_mut(), &image_behind_modal)?;
623 write_bytes_preserving_cursor(terminal.backend_mut(), &sixel_collision_erase)?;
624 draw_cells_preserving_cursor(terminal.backend_mut(), &popup_restore)?;
625 write_bytes_preserving_cursor(terminal.backend_mut(), &modal_erase)?;
626 if !skip_overlay_present && !app.browser_wheel_burst_active() {
627 let overlay_bytes = app.present_preview_overlay()?;
628 write_bytes_preserving_cursor(terminal.backend_mut(), &overlay_bytes)?;
629 }
630 terminal.backend_mut().flush()?;
631 Ok(dirty)
632 })();
633
634 let end_result = execute!(terminal.backend_mut(), EndSynchronizedUpdate);
635 match (draw_result, end_result) {
636 (Ok(dirty), Ok(())) => Ok(dirty),
637 (Err(error), Ok(())) => Err(error),
638 (Ok(_), Err(error)) => Err(error.into()),
639 (Err(error), Err(_)) => Err(error),
640 }
641}
642
643fn write_bytes_preserving_cursor<W: Write>(writer: &mut W, bytes: &[u8]) -> io::Result<()> {
644 if bytes.is_empty() {
645 return Ok(());
646 }
647 execute!(writer, SavePosition)?;
648 writer.write_all(bytes)?;
649 execute!(writer, RestorePosition)?;
650 Ok(())
651}
652
653fn draw_cells_preserving_cursor<W: Write>(
654 backend: &mut CrosstermBackend<W>,
655 cells: &[(u16, u16, Cell)],
656) -> io::Result<()> {
657 if cells.is_empty() {
658 return Ok(());
659 }
660 execute!(backend, SavePosition)?;
661 ratatui::backend::Backend::draw(backend, cells.iter().map(|(x, y, cell)| (*x, *y, cell)))?;
662 execute!(backend, RestorePosition)?;
663 Ok(())
664}
665
666fn collect_buffer_cells(rects: &[Rect], buffer: &Buffer) -> Vec<(u16, u16, Cell)> {
667 let bounds = *buffer.area();
668 let mut cells = Vec::new();
669 for rect in rects {
670 let Some(area) = intersect_rect(*rect, bounds) else {
671 continue;
672 };
673 for y in area.y..area.y.saturating_add(area.height) {
674 for x in area.x..area.x.saturating_add(area.width) {
675 let Some(cell) = buffer.cell((x, y)) else {
676 continue;
677 };
678 if cell.skip {
679 continue;
680 }
681 cells.push((x, y, cell.clone()));
682 }
683 }
684 }
685 cells
686}
687
688fn intersect_rect(a: Rect, b: Rect) -> Option<Rect> {
689 let x1 = a.x.max(b.x);
690 let y1 = a.y.max(b.y);
691 let x2 = a.x.saturating_add(a.width).min(b.x.saturating_add(b.width));
692 let y2 =
693 a.y.saturating_add(a.height)
694 .min(b.y.saturating_add(b.height));
695 (x2 > x1 && y2 > y1).then_some(Rect {
696 x: x1,
697 y: y1,
698 width: x2.saturating_sub(x1),
699 height: y2.saturating_sub(y1),
700 })
701}
702
703fn event_poll_interval<I>(
704 base_poll_interval: Duration,
705 terminal_focused: bool,
706 timers: I,
707) -> Duration
708where
709 I: IntoIterator<Item = Option<Duration>>,
710{
711 if !terminal_focused {
712 return base_poll_interval;
713 }
714
715 timers
716 .into_iter()
717 .flatten()
718 .min()
719 .map(|delay| delay.min(base_poll_interval))
720 .unwrap_or(base_poll_interval)
721}
722
723#[cfg(test)]
724mod tests {
725 use crate::{
726 ACTIVE_SCROLL_POLL_INTERVAL, IDLE_POLL_INTERVAL, collect_buffer_cells, event_poll_interval,
727 };
728 use ratatui::{
729 buffer::Buffer,
730 layout::Rect,
731 style::{Color, Modifier, Style},
732 };
733 use std::{
734 fs, io,
735 path::{Path, PathBuf},
736 time::{Duration, SystemTime, UNIX_EPOCH},
737 };
738
739 fn temp_path(label: &str) -> PathBuf {
740 let unique = SystemTime::now()
741 .duration_since(UNIX_EPOCH)
742 .expect("system time should be after unix epoch")
743 .as_nanos();
744 std::env::temp_dir().join(format!("elio-lib-{label}-{unique}"))
745 }
746
747 #[test]
748 fn cwd_file_is_not_written_when_absent() {
749 crate::write_cwd_file_if_requested(None, Path::new("/tmp"))
750 .expect("absent cwd file should be a no-op");
751 }
752
753 #[test]
754 fn cwd_file_writes_path_without_trailing_newline() {
755 let root = temp_path("cwd-file");
756 fs::create_dir_all(&root).expect("temp directory should be created");
757 let cwd_file = root.join("cwd");
758 let final_cwd = root.join("nested");
759 fs::create_dir_all(&final_cwd).expect("nested temp directory should be created");
760
761 crate::write_cwd_file_if_requested(Some(&cwd_file), &final_cwd)
762 .expect("cwd file should be written");
763
764 let bytes = fs::read(&cwd_file).expect("cwd file should be readable");
765 assert!(!bytes.ends_with(b"\n"));
766 assert_eq!(String::from_utf8_lossy(&bytes), final_cwd.to_string_lossy());
767
768 fs::remove_dir_all(root).expect("temp directory should be removed");
769 }
770
771 #[test]
772 fn ratatui_diff_preserves_positions_beyond_u16_max_cells() {
773 let area = Rect::new(0, 0, 400, 200);
774 let previous = Buffer::empty(area);
775 let mut next = Buffer::empty(area);
776 next.set_string(123, 180, "X", Style::default());
777
778 let diff = previous.diff(&next);
779
780 assert!(
781 diff.iter()
782 .any(|(x, y, cell)| *x == 123 && *y == 180 && cell.symbol() == "X"),
783 "expected diff to keep the changed cell at (123, 180), got: {:?}",
784 diff.iter()
785 .map(|(x, y, cell)| (*x, *y, cell.symbol().to_string()))
786 .collect::<Vec<_>>()
787 );
788 }
789
790 #[test]
791 fn collect_buffer_cells_captures_popup_cells_with_styles() {
792 let mut buffer = Buffer::empty(Rect::new(0, 0, 8, 4));
793 buffer.set_string(
794 2,
795 1,
796 "OK",
797 Style::default()
798 .fg(Color::LightGreen)
799 .bg(Color::Rgb(1, 2, 3))
800 .add_modifier(Modifier::BOLD),
801 );
802
803 let cells = collect_buffer_cells(&[Rect::new(2, 1, 2, 1)], &buffer);
804
805 assert_eq!(cells.len(), 2);
806 assert_eq!((cells[0].0, cells[0].1, cells[0].2.symbol()), (2, 1, "O"));
807 assert_eq!((cells[1].0, cells[1].1, cells[1].2.symbol()), (3, 1, "K"));
808 assert_eq!(cells[0].2.fg, Color::LightGreen);
809 assert_eq!(cells[0].2.bg, Color::Rgb(1, 2, 3));
810 assert!(cells[0].2.modifier.contains(Modifier::BOLD));
811 }
812
813 #[test]
814 fn event_poll_interval_stays_idle_while_terminal_is_unfocused() {
815 let interval = event_poll_interval(
816 IDLE_POLL_INTERVAL,
817 false,
818 [
819 Some(Duration::from_millis(25)),
820 Some(Duration::from_millis(10)),
821 ],
822 );
823
824 assert_eq!(interval, IDLE_POLL_INTERVAL);
825 }
826
827 #[test]
828 fn event_poll_interval_uses_pending_timer_when_terminal_is_focused() {
829 let delay = Duration::from_millis(25);
830 let interval = event_poll_interval(
831 ACTIVE_SCROLL_POLL_INTERVAL,
832 true,
833 [None, Some(delay), Some(Duration::from_millis(50))],
834 );
835
836 assert!(interval <= delay);
837 }
838
839 #[test]
840 fn keyboard_enhancement_unsupported_detection_matches_crossterm_error() {
841 let error = io::Error::new(
842 io::ErrorKind::Unsupported,
843 "Keyboard progressive enhancement not implemented for the legacy Windows API.",
844 );
845
846 assert!(crate::keyboard_enhancement_is_unsupported(&error));
847 }
848
849 #[test]
850 fn keyboard_enhancement_unsupported_detection_rejects_other_errors() {
851 let error = io::Error::other("some other terminal error");
852
853 assert!(!crate::keyboard_enhancement_is_unsupported(&error));
854 }
855}