crossterm-rs / crossterm

Cross platform terminal library rust
MIT License
3.19k stars 276 forks source link

`event::read` incorrectly returns `Esc` key press event while scrolling the mouse wheel #854

Open unixzii opened 8 months ago

unixzii commented 8 months ago

Describe the bug event::read sometimes returns Esc key press event while scrolling the mouse wheel very fast. This will likely happen on macOS if the user is using a trackpad (which can produce high frequency events because of the inertia simulation).

Precondition: The terminal is in a mode that translates mouse wheel events into arrow key events.

To Reproduce The minimal reproducible example:

use anyhow::Result;
use crosstermion::terminal::AlternateRawScreen;

fn main() -> Result<()> {
    // Help to get into alternative screen mode, this should not
    // affect the final result.
    let _alt_screen = AlternateRawScreen::try_from(io::stderr())?;

    while let Ok(event) = crossterm::event::read() {
        match event {
            crossterm::event::Event::Key(key) => {
                if key.code == crossterm::event::KeyCode::Esc {
                    panic!("wrong event received");
                }
            }
            _ => {}
        }
    }
}

Steps to reproduce the behavior:

  1. Build and run the example program.
  2. Scroll very fast using the trackpad (or Logitech MX Master mouse, which will also work).
  3. See the program panic.

Expected behavior The program should not receive Esc key press events when the user didn't press Esc key.

OS Verified on macOS 13.6

Terminal/Console

unixzii commented 8 months ago

I noticed that the parser parsed Esc key press because the tty buffer is insufficient.

image

The byte sequence read from stdin in one poll ends with 27, which is cut off. Maybe there are some bugs in the terminal emulator that the control sequence is not written atomically. Or the operating system doesn't give us the whole buffer at once.

image
blacknon commented 3 months ago

I have a similar issue with iTerm2.

blacknon commented 3 weeks ago

After further investigation, it seems to be an issue related to Blocking I/O. The problem is resolved by switching between Blocking and NonBlocking before and after executing crossterm::event::read() as shown below.

use crossterm::{
    event::{DisableMouseCapture, EnableMouseCapture},
    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
    execute,
};
use ratatui::{
    backend::CrosstermBackend,
    Terminal,
};
use std::time::Duration;

#[cfg(any(target_os = "linux", target_os = "macos"))]
use std::io::{self, stdin};
use std::os::unix::io::AsRawFd;

#[cfg(any(target_os = "linux", target_os = "macos"))]
use nix::fcntl::{fcntl, FcntlArg::*, OFlag};

#[cfg(any(target_os = "linux", target_os = "macos"))]
fn set_non_blocking() -> nix::Result<()> {
    let stdin_fd = stdin().as_raw_fd();
    let flags = fcntl(stdin_fd, F_GETFL)?;
    let new_flags = OFlag::from_bits_truncate(flags) | OFlag::O_NONBLOCK;
    fcntl(stdin_fd, F_SETFL(new_flags))?;
    Ok(())
}

#[cfg(any(target_os = "linux", target_os = "macos"))]
pub fn set_blocking() -> nix::Result<()> {
    let stdin_fd = stdin().as_raw_fd();
    let flags = fcntl(stdin_fd, F_GETFL)?;
    let new_flags = OFlag::from_bits_truncate(flags) & !OFlag::O_NONBLOCK;
    fcntl(stdin_fd, F_SETFL(new_flags))?;
    Ok(())
}

fn main() -> Result<(), io::Error> {
    enable_raw_mode()?;

    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::new(backend)?;
    let _ = terminal.clear();

    loop {
        if crossterm::event::poll(Duration::from_millis(100))? {
            #[cfg(any(target_os = "linux", target_os = "macos"))]
            let _ = set_non_blocking();
            // read event
            if let Ok(event) = crossterm::event::read() {
                match event {
                    crossterm::event::Event::Key(key) => {
                        if key.code == crossterm::event::KeyCode::Esc {
                            break;
                        }
                    }
                    _ => {}
                }
            }
            #[cfg(any(target_os = "linux", target_os = "macos"))]
            let _ = set_blocking();
        }
    }

    disable_raw_mode()?;
    let backend = CrosstermBackend::new(io::stdout());
    let mut terminal = Terminal::new(backend)?;

    let _ = execute!(
        terminal.backend_mut(),
        LeaveAlternateScreen,
        DisableMouseCapture
    );
    let _ = terminal.show_cursor();

    print!("catch esc keyinput event.");

    Ok(())
}

Considering usability, it seems preferable to switch between Blocking/NonBlocking within the read() function itself.