Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
7 changes: 6 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[package]
name = "console"
description = "A terminal and console abstraction for Rust"
version = "0.16.0"
version = "0.17.0"
keywords = ["cli", "terminal", "colors", "console", "ansi"]
license = "MIT"
edition = "2021"
Expand Down 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 All @@ -56,6 +57,10 @@ required-features = ["std"]
name = "cursor_at"
required-features = ["std"]

[[example]]
name = "cursor_position"
required-features = ["std"]

[[example]]
name = "keyboard"
required-features = ["std"]
Expand Down
50 changes: 50 additions & 0 deletions examples/cursor_position.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
extern crate console;
use console::Term;
use std::io;

fn draw_point(term: &Term, width: usize) -> io::Result<()> {
// no cheating...get the cursor position here
let (x, y) = term.cursor_position()?;
let str = format!("({x}, {y})");
let w = str.len() + 2;
if x + w > width {
term.move_cursor_left(w - 1)?;
term.write_str(&format!("{str} •"))?;
} else {
term.write_str(&format!("• {str}"))?;
}
Ok(())
}

fn main() -> io::Result<()> {
let term = Term::stdout();
term.hide_cursor()?;
term.clear_screen()?;

let (height, width): (usize, usize) = (term.size().0 as usize, term.size().1 as usize);

// draw the four corners
term.move_cursor_to(0, 0)?;
draw_point(&term, width)?;
// this tests the formatting logic
for i in 0..20 {
term.move_cursor_to(width - i - 1, i)?;
draw_point(&term, width)?;
}
term.move_cursor_to(0, height - 2)?;
draw_point(&term, width)?;
term.move_cursor_to(width, height - 2)?;
draw_point(&term, width)?;

for _ in 0..10 {
let x = rand::random_range(..=width - 1);
let y = rand::random_range(1..=height - 3);
term.move_cursor_to(x, y)?;
draw_point(&term, width)?;
}

term.move_cursor_to(0, height)?;
term.show_cursor()?;

Ok(())
}
2 changes: 1 addition & 1 deletion src/ansi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ fn find_ansi_code_exclusive(it: &mut Peekable<CharIndices>) -> Option<(usize, us

/// Helper function to strip ansi codes.
#[cfg(feature = "alloc")]
pub fn strip_ansi_codes(s: &str) -> Cow<str> {
pub fn strip_ansi_codes(s: &str) -> Cow<'_, str> {
let mut char_it = s.char_indices().peekable();
match find_ansi_code_exclusive(&mut char_it) {
Some(_) => {
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
8 changes: 8 additions & 0 deletions src/term.rs
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,14 @@ 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).
#[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
57 changes: 56 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,23 @@ 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
let key = read_single_key(false)?;

match key {
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