Skip to main content

elio/
lib.rs

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    // Force mouse tracking modes explicitly after EnableMouseCapture. Crossterm should
95    // already send these, but some terminals require an explicit flush or are sensitive
96    // to the exact byte sequence arriving in a single write.
97    //   1000 = click tracking
98    //   1002 = button-event tracking (drag with button held)
99    //   1003 = any-event tracking (all motion, needed for hover-based scroll routing)
100    //   1006 = SGR extended coordinates (required for columns > 223)
101    write!(stdout, "\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h")?;
102
103    // Ask the terminal to forward Shift+mouse to the app instead of using it for text
104    // selection. Ghostty and some xterm-compatible terminals honor XTSHIFTESCAPE.
105    // Terminals that don't support it ignore this silently.
106    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
118/// Temporarily tears down the TUI so a blocking terminal app can use stdout.
119/// Call [`resume_terminal`] afterwards to restore the TUI.
120fn 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
145/// Restores the TUI after [`suspend_terminal`].  Forces a full redraw on the
146/// next render cycle so no stale content is left on screen.
147fn 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
165/// Runs `program args` blocking in the current terminal, inheriting
166/// stdin/stdout/stderr.  Errors are ignored — a broken command (e.g. nvim
167/// unable to open a file) should not crash the file manager.
168fn 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    // Disable in reverse order and do it before leaving the alternate screen so the
206    // terminal processes the escape sequences while still in the right mode.
207    let backend = terminal.backend_mut();
208    write!(backend, "\x1b[>4;0m")?; // reset XTSHIFTESCAPE
209    write!(backend, "\x1b[?1006l\x1b[?1003l\x1b[?1002l\x1b[?1000l")?; // disable mouse modes
210    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    // Enable terminal image previews. Detection handles the current policy:
306    // Kitty, Ghostty, Warp, WezTerm, iTerm2, and Konsole auto-enable supported
307    // image protocols;
308    // ELIO_IMAGE_PREVIEWS=1 force-enables Kitty graphics on otherwise unrecognized terminals.
309    // All image bytes are routed through terminal.backend_mut() so they never bypass
310    // crossterm and cannot corrupt mouse reporting.
311    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            // Batch all immediately-available events into one render cycle.
437            // This prevents lag when events (especially scroll events from high-frequency
438            // terminals) arrive faster than the app can render: instead of one render per
439            // event we accumulate all queued events first and render the final state once.
440            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                    // Mouse move events only update the hover/target state — nothing
464                    // visual changes, so they don't need a re-render. Skipping dirty here
465                    // avoids the constant re-render storm that ?1003h (any-event tracking)
466                    // causes in terminals like Alacritty, Ghostty, and Gnome Terminal.
467                    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                // Stop batching once there are no more immediately available events.
480                if !event::poll(Duration::ZERO)? {
481                    break;
482                }
483            }
484
485            if app.should_quit {
486                break;
487            }
488
489            // A terminal task (e.g. nvim from Open With, or zoxide) needs the real terminal.
490            // Suspend the TUI, run the task blocking, then restore.
491            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        // Erase stale image cells before terminal.draw() so ratatui can
554        // overpaint them with the correct panel background in the same pass.
555        // - iTerm2: images are drawn at pixel level; erasing prevents ghost pixels.
556        // - Kitty unicode placeholder: placeholder chars are terminal cells;
557        //   ratatui's differential renderer skips "unchanged" cells leaving
558        //   stale image content visible after navigation or resize.
559        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}