Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ proptest = { version = "1.0.0", default-features = false, features = [
"bit-set",
"break-dead-code",
] }
rand = "0.9.2"
regex = "1.4.2"

[[example]]
Expand Down
2 changes: 2 additions & 0 deletions src/kb.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ pub enum Key {
Unknown,
/// Unrecognized sequence containing Esc and a list of chars
UnknownEscSeq(Vec<char>),
/// Cursor position (x, y), zero-indexed
CursorPosition(usize, usize),
ArrowLeft,
ArrowRight,
ArrowUp,
Expand Down
9 changes: 9 additions & 0 deletions src/term.rs
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,15 @@ impl Term {
move_cursor_right(self, n)
}

/// Get the position of the cursor.
///
/// Returns the current zero-indexed cursor position as a tuple of (x, y).
#[cfg(unix)]
#[inline]
pub fn cursor_position(&self) -> io::Result<(usize, usize)> {
cursor_position()
}

/// Clear the current line.
///
/// Position the cursor at the beginning of the current line.
Expand Down
55 changes: 54 additions & 1 deletion src/unix_term.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use core::ptr;
use core::{fmt::Display, mem, str};
use std::env;
use std::fs;
use std::io::{self, BufRead, BufReader};
use std::io::{self, BufRead, BufReader, Write};
use std::os::fd::{AsRawFd, RawFd};

#[cfg(not(target_os = "macos"))]
Expand Down Expand Up @@ -258,6 +258,44 @@ fn read_single_key_impl(fd: RawFd) -> Result<Key, io::Error> {
'H' => Ok(Key::Home),
'F' => Ok(Key::End),
'Z' => Ok(Key::BackTab),
'0'..='9' => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it make sense to keep this inside read_single_key_impl() when it effectively is only expected inside a call to read_single_key()?

(Part of this is is the very deeply nested indentation level making this code harder to follow.)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could refactor this to reduce the indentation at several levels. I was trying to keep my edits minimal. If it were me I would refactor the block to be something like:

loop {
  match read_single_char(fd)? {
    Some('\x1b') => break match read_single_char(fd)? {
      Some('[') => match read_single_char(fd)? {
        Some(c) => match c {
          'A' => Ok(Key::ArrowUp),
          //...
          '0'..='9' => read_cursor_pos(c),
          _ => read_tilde_seq(c),
        }
        _ => Ok(Key::UnknownEscSeq(vec![c])),
      }
      Some(c) => Ok(Key::UnknownEscSeq(vec![c])),
      _ => Ok(Key::Escape)
    }
    Some(c) => read_non_escape_seq(c),
    None => {
      // there is no subsequent byte ready to be read, block and wait for
      // input negative timeout means that it will block indefinitely
      match select_or_poll_term_fd(fd, -1) {
        Ok(_) => continue,
        Err(_) => break Err(io::Error::last_os_error()),
      }
    }
  }
}

fn read_cursor_pos(c: char) -> Result<Key, io::Error> {
  //...
}

fn read_tilde_seq(c: char) -> Result<Key, io::Error> {
  //...
}

fn read_non_escape_seq(c: char) -> Result<Key, io::Error> {
  //...
}

Do you think moving the match arm bodies to functions is easier to read?

Copy link
Member

@djc djc Sep 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I might prefer that but let's not do it in this PR.

For this PR, do you think we need the specific read_cursor_pos() stuff inside the generic read_single_key_impl(), or can we get away with just having it in cursor_position()?

// This is a special case for handling the response to a cursor
// position request ("\x1b[6n"). The response is given as
// "\x1b[<row>;<col>R", where <row> and <col> are numbers."
let mut buf = String::new();
buf.push(c2);
while let Some(c) = read_single_char(fd)? {
if c == 'R' {
break;
} else if c.is_ascii_digit() || c == ';' {
buf.push(c);
if buf.len() > 64 {
// Prevent infinite loop in case of malformed input
return Ok(Key::UnknownEscSeq(
buf.chars().collect(),
));
}
} else {
// If we encounter an unexpected character, we treat it
// as an unknown escape sequence
return Ok(Key::UnknownEscSeq(vec![c1, c2, c]));
}
}
// buf now contains "<row>;<col>"
let v = buf
.split(';')
.map(|s| s.parse::<usize>().unwrap_or(0))
.collect::<Vec<_>>();
if v.len() == 2 {
// x is column, y is row
Ok(Key::CursorPosition(
v[1].saturating_sub(1),
v[0].saturating_sub(1),
))
} else {
Ok(Key::UnknownEscSeq(buf.chars().collect()))
}
}
_ => {
let c3 = read_single_char(fd)?;
if let Some(c3) = c3 {
Expand Down Expand Up @@ -335,6 +373,21 @@ fn read_single_key_impl(fd: RawFd) -> Result<Key, io::Error> {
}
}

pub(crate) fn cursor_position() -> io::Result<(usize, usize)> {
// Send the cursor position request escape sequence
print!("\x1b[6n");
io::stdout().flush()?;

// Read the response from the terminal
match read_single_key(false)? {
Key::CursorPosition(x, y) => Ok((x, y)),
_ => Err(io::Error::new(
io::ErrorKind::InvalidData,
"Unexpected response to cursor position request",
)),
}
}

pub(crate) fn read_single_key(ctrlc_key: bool) -> io::Result<Key> {
let input = Input::unbuffered()?;

Expand Down
Loading