Summary
On Windows, cursor::position() is documented to return a window-relative
position ("The top left cell is represented 0,0"). When the console's screen
buffer is taller than its window — the default for cmd.exe/conhost, whose
buffer is 9001 rows — it instead returns the buffer-absolute row whenever
that row is less than or equal to the window height.
Callers that work out how much room is left below the cursor by subtracting the
reported row from the terminal height therefore understate it by srWindow.Top,
and scroll the screen when they need not.
Affected code
src/cursor/sys/windows.rs (unchanged on master at the time of writing):
pub fn parse_relative_y(y: i16) -> std::io::Result<i16> {
let window = ScreenBuffer::current()?.info()?;
let window_size = window.terminal_window();
let screen_size = window.terminal_size();
if y <= screen_size.height {
Ok(y)
} else {
Ok(y - window_size.top)
}
}
y is dwCursorPosition.Y from GetConsoleScreenBufferInfo, which is always
absolute to the screen buffer, so the window-relative row is always
y - srWindow.Top. How y compares to the window height says nothing about
whether the window has scrolled, so the first branch returns an absolute row
exactly when:
- the window has scrolled within a taller buffer (
srWindow.Top > 0), and
- the cursor is still within the first window-height rows of the buffer.
That combination is easy to reach: fill past the bottom of the window (the
console moves the window down inside the buffer, so Top > 0) and then place the
cursor a few rows into the window, as a prompt redraw does.
Environment
- crossterm 0.29.0, and
master as quoted above
- Windows 11,
cmd.exe / conhost — screen buffer 9001 rows, window 52 rows
- Not reproducible in Windows Terminal, which keeps the buffer and window the
same height, so srWindow.Top never leaves 0 and the branch is unreachable
Reproduction
Cargo.toml:
[dependencies]
crossterm = "0.29"
src/main.rs — it takes ground truth straight from GetConsoleScreenBufferInfo
and positions the cursor with SetConsoleCursorPosition, so the setup itself
cannot be affected by the behaviour under test:
//! Reproduction for crossterm 0.29's Windows `cursor::position()` returning a
//! buffer-absolute row where a window-relative one is required.
//!
//! `src/cursor/sys/windows.rs`:
//!
//! ```ignore
//! if y <= screen_size.height { Ok(y) } else { Ok(y - window_size.top) }
//! ```
//!
//! `y` is `dwCursorPosition.Y`, which Windows documents as *screen-buffer*
//! coordinates, so the window-relative row is always `y - srWindow.Top`. Whether
//! `y` is below the window *height* says nothing about whether the window has
//! scrolled, so the first branch returns an absolute row whenever the window has
//! moved down inside a taller buffer (`srWindow.Top > 0`) and the cursor is still
//! within the first *window-height* rows of the buffer.
//!
//! ## Why an inflated row matters
//!
//! Consumers subtract the queried row from the screen height to work out how much
//! room is left below. reedline's painter does exactly that
//! (`painting/painter.rs`):
//!
//! ```ignore
//! pub fn remaining_lines(&self) -> u16 {
//! self.screen_height()
//! .saturating_sub(self.prompt_start_row.last_known_row()) // from position()
//! }
//! ...
//! } else if required_lines >= remaining_lines {
//! let extra = required_lines.saturating_sub(remaining_lines);
//! self.queue_universal_scroll(extra)?; // scrolls the screen
//! }
//! ```
//!
//! An absolute row is larger than the true window-relative row by `srWindow.Top`,
//! so the space below is *understated* by that amount. Content that genuinely
//! fits is judged not to, and the screen is scrolled needlessly — once per redraw.
//! In a shell that re-anchors its prompt from `position()` on every line, each
//! prompt is pushed further down and leaves a blank gap behind it.
//!
//! Run in a legacy console (`cmd.exe` / conhost), whose buffer is far taller than
//! its window. Windows Terminal keeps the two the same height, so `srWindow.Top`
//! never leaves 0 and the faulty branch is unreachable there.
//!
//! cargo run
//!
//! Do not redirect the output: a pipe or file means there is no console screen
//! buffer to inspect.
use std::ffi::c_void;
use crossterm::cursor;
use crossterm::execute;
const STD_OUTPUT_HANDLE: u32 = 0xFFFF_FFF5; // (DWORD)-11
#[repr(C)]
#[derive(Default, Clone, Copy)]
struct Coord {
x: i16,
y: i16,
}
#[repr(C)]
#[derive(Default, Clone, Copy)]
struct SmallRect {
left: i16,
top: i16,
right: i16,
bottom: i16,
}
#[repr(C)]
#[derive(Default, Clone, Copy)]
struct ConsoleScreenBufferInfo {
size: Coord,
cursor_position: Coord,
attributes: u16,
window: SmallRect,
maximum_window_size: Coord,
}
#[link(name = "kernel32")]
unsafe extern "system" {
fn GetStdHandle(which: u32) -> *mut c_void;
fn GetConsoleScreenBufferInfo(handle: *mut c_void, info: *mut ConsoleScreenBufferInfo) -> i32;
fn SetConsoleCursorPosition(handle: *mut c_void, pos: Coord) -> i32;
}
/// Ground truth from Win32: absolute cursor row plus the window rectangle.
fn console_info() -> Option<ConsoleScreenBufferInfo> {
unsafe {
let h = GetStdHandle(STD_OUTPUT_HANDLE);
if h.is_null() || h as isize == -1 {
return None;
}
let mut info = ConsoleScreenBufferInfo::default();
if GetConsoleScreenBufferInfo(h, &mut info) == 0 {
return None;
}
Some(info)
}
}
/// Positions the cursor at an absolute buffer row, bypassing crossterm so the
/// setup cannot be affected by the behaviour under test.
fn set_absolute_row(row: i16) {
unsafe {
let h = GetStdHandle(STD_OUTPUT_HANDLE);
if !h.is_null() && h as isize != -1 {
SetConsoleCursorPosition(h, Coord { x: 0, y: row });
}
}
}
/// One measurement, taken without printing (printing would scroll the window and
/// change what is being measured).
struct Sample {
offset_into_window: i16,
absolute_row: i16,
reported_row: i16,
}
fn main() {
let Some(info) = console_info() else {
eprintln!("No console screen buffer. Run this in cmd.exe directly, without redirecting.");
return;
};
let buffer_height = info.size.y;
let window_height = info.window.bottom - info.window.top + 1;
if buffer_height <= window_height {
println!("screen buffer height : {buffer_height}");
println!("window height : {window_height}");
println!();
println!(
"Buffer and window are the same height, so srWindow.Top can never leave 0 and the\n\
faulty branch is unreachable. Run this in cmd.exe / conhost (default buffer height\n\
9001), not Windows Terminal."
);
return;
}
// Step 1: print past the bottom of the window so the console moves the window
// down inside the taller buffer, giving srWindow.Top > 0. (`ScrollUp` on a
// nearly empty buffer only shifts content and leaves Top at 0.)
for i in 0..(window_height + 5) {
println!("filler line {i}");
}
let top = console_info().map(|i| i.window.top).unwrap_or(0);
if top == 0 {
println!();
println!("srWindow.Top is still 0 after filling the window; cannot reach the faulty branch.");
return;
}
// Step 2: place the cursor a few rows *into* the moved window. Its absolute
// row is then small (<= window height) while Top > 0 — precisely the state the
// `if y <= screen_size.height` shortcut mishandles. Measure first, print later.
let mut samples = Vec::new();
for offset in [0i16, 3, 10] {
set_absolute_row(top + offset);
let reported = cursor::position().expect("position()").1 as i16;
samples.push(Sample {
offset_into_window: offset,
absolute_row: top + offset,
reported_row: reported,
});
}
// Informational: which coordinate space does MoveTo use? It passes the row
// straight to SetConsoleCursorPosition, i.e. buffer-absolute — so position()
// and MoveTo do not agree once position() returns a relative row.
set_absolute_row(top + 3);
let before = cursor::position().expect("position()");
execute!(std::io::stdout(), cursor::MoveTo(before.0, before.1)).expect("MoveTo");
let moveto_absolute = console_info().map(|i| i.cursor_position.y).unwrap_or(0);
// Back to a harmless row before printing the report.
set_absolute_row(top + window_height - 4);
println!();
println!("screen buffer height : {buffer_height}");
println!("window height : {window_height} (reedline's `screen_height`)");
println!("srWindow.Top : {top} (window has scrolled this far into the buffer)");
println!();
println!("position() vs ground truth");
println!("--------------------------");
let mut bug_seen = false;
for s in &samples {
let truth = s.offset_into_window; // absolute_row - top
let ok = s.reported_row == truth;
if !ok {
bug_seen = true;
}
println!(
" cursor {:>2} row(s) into window: absolute={:<4} EXPECTED(relative)={:<4} \
REPORTED={:<4} {}",
s.offset_into_window,
s.absolute_row,
truth,
s.reported_row,
if ok { "ok" } else { "<-- WRONG" }
);
}
// The mechanism: what the inflated row does to the space calculation, and to
// the scroll decision built on it.
let sample = &samples[1]; // cursor 3 rows into the window
let truth_row = sample.offset_into_window;
let reported_row = sample.reported_row;
let remaining_true = window_height - truth_row;
let remaining_reported = window_height - reported_row;
println!();
println!("consequence for `remaining_lines = screen_height - prompt_start_row`");
println!("-------------------------------------------------------------------");
println!(" with the true relative row {truth_row:<4}: remaining_lines = {window_height} - {truth_row} = {remaining_true}");
println!(" with the reported row {reported_row:<4}: remaining_lines = {window_height} - {reported_row} = {remaining_reported}");
println!(
" space below is understated by {} row(s)",
remaining_true - remaining_reported
);
// Choose a redraw that genuinely fits in the space available.
let required_lines = remaining_true - 1;
let scroll_correct = required_lines >= remaining_true;
let scroll_buggy = required_lines >= remaining_reported;
println!();
println!("consequence for `if required_lines >= remaining_lines {{ scroll }}`");
println!("---------------------------------------------------------------");
println!(" a redraw needing {required_lines} row(s) — it fits in the {remaining_true} really available");
println!(" correct decision: {required_lines} >= {remaining_true} = {scroll_correct} (no scroll)");
println!(
" actual decision : {required_lines} >= {remaining_reported} = {scroll_buggy} ({})",
if scroll_buggy {
format!(
"SPURIOUS SCROLL of {} row(s)",
required_lines - remaining_reported + 1
)
} else {
"no scroll".to_string()
}
);
println!();
println!("informational: coordinate space of MoveTo");
println!("----------------------------------------");
println!(
" position() reported row {}, MoveTo(that row) left the cursor at absolute row {} \
(window top {top})",
before.1, moveto_absolute
);
println!(" MoveTo passes the row to SetConsoleCursorPosition unchanged, i.e. buffer-absolute,");
println!(" so position() and MoveTo disagree by srWindow.Top whenever position() returns a");
println!(" window-relative row. A complete upstream fix has to settle a convention for both.");
println!();
if bug_seen && scroll_buggy && !scroll_correct {
println!("RESULT: CONFIRMED. position() returned a buffer-absolute row, which understates");
println!(" the space below by srWindow.Top and turns a redraw that fits into a scroll.");
} else if bug_seen {
println!("RESULT: position() returned a buffer-absolute row, but the scroll decision above");
println!(" did not flip on this console — report the numbers as printed.");
} else {
println!("RESULT: no problem observed on this console; position() matched ground truth.");
}
}
Run it in cmd.exe directly, without redirecting the output:
Actual behaviour
screen buffer height : 9001
window height : 52 (reedline's `screen_height`)
srWindow.Top : 10 (window has scrolled this far into the buffer)
position() vs ground truth
--------------------------
cursor 0 row(s) into window: absolute=10 EXPECTED(relative)=0 REPORTED=10 <-- WRONG
cursor 3 row(s) into window: absolute=13 EXPECTED(relative)=3 REPORTED=13 <-- WRONG
cursor 10 row(s) into window: absolute=20 EXPECTED(relative)=10 REPORTED=20 <-- WRONG
consequence for `remaining_lines = screen_height - prompt_start_row`
-------------------------------------------------------------------
with the true relative row 3 : remaining_lines = 52 - 3 = 49
with the reported row 13 : remaining_lines = 52 - 13 = 39
space below is understated by 10 row(s)
consequence for `if required_lines >= remaining_lines { scroll }`
---------------------------------------------------------------
a redraw needing 48 row(s) — it fits in the 49 really available
correct decision: 48 >= 49 = false (no scroll)
actual decision : 48 >= 39 = true (SPURIOUS SCROLL of 10 row(s))
RESULT: CONFIRMED. position() returned a buffer-absolute row, which understates
the space below by srWindow.Top and turns a redraw that fits into a scroll.
Expected behaviour
position() returns the window-relative row, so it is absolute - srWindow.Top
whatever that row happens to be.
Why it matters
Anything that measures the space below the cursor from the reported row is thrown
off by srWindow.Top. reedline's painter
does exactly that:
pub fn remaining_lines(&self) -> u16 {
self.screen_height()
.saturating_sub(self.prompt_start_row.last_known_row()) // from position()
}
...
} else if required_lines >= remaining_lines {
let extra = required_lines.saturating_sub(remaining_lines);
self.queue_universal_scroll(extra)?;
}
An inflated row makes it believe it is nearer the bottom than it is, so it scrolls
on a redraw that would have fitted — once per line. In a shell that re-anchors its
prompt from position() on every line, each prompt is drawn further down and
leaves a blank gap behind it, accumulating one gap per line entered. Because it
cannot happen in Windows Terminal, it presents as a terminal-specific mystery
rather than a coordinate-space mismatch.
Suggested fix
Convert unconditionally:
pub fn parse_relative_y(y: i16) -> std::io::Result<i16> {
let window = ScreenBuffer::current()?.info()?;
-
- let window_size = window.terminal_window();
- let screen_size = window.terminal_size();
-
- if y <= screen_size.height {
- Ok(y)
- } else {
- Ok(y - window_size.top)
- }
+ Ok(y - window.terminal_window().top)
}
One caveat worth deciding on
MoveTo passes its row straight to SetConsoleCursorPosition, i.e.
buffer-absolute, so position() (aiming to be window-relative) and MoveTo do
not share a coordinate space. That mismatch already exists today in the else
branch; the change above makes position() self-consistent and match its
documentation, but does not by itself reconcile the two.
I am happy to open a PR — either the minimal fix above, or a broader one that
settles the convention for both functions, whichever you would prefer.
Summary
On Windows,
cursor::position()is documented to return a window-relativeposition ("The top left cell is represented
0,0"). When the console's screenbuffer is taller than its window — the default for
cmd.exe/conhost, whosebuffer is 9001 rows — it instead returns the buffer-absolute row whenever
that row is less than or equal to the window height.
Callers that work out how much room is left below the cursor by subtracting the
reported row from the terminal height therefore understate it by
srWindow.Top,and scroll the screen when they need not.
Affected code
src/cursor/sys/windows.rs(unchanged onmasterat the time of writing):yisdwCursorPosition.YfromGetConsoleScreenBufferInfo, which is alwaysabsolute to the screen buffer, so the window-relative row is always
y - srWindow.Top. Howycompares to the window height says nothing aboutwhether the window has scrolled, so the first branch returns an absolute row
exactly when:
srWindow.Top > 0), andThat combination is easy to reach: fill past the bottom of the window (the
console moves the window down inside the buffer, so
Top > 0) and then place thecursor a few rows into the window, as a prompt redraw does.
Environment
masteras quoted abovecmd.exe/ conhost — screen buffer 9001 rows, window 52 rowssame height, so
srWindow.Topnever leaves 0 and the branch is unreachableReproduction
Cargo.toml:src/main.rs— it takes ground truth straight fromGetConsoleScreenBufferInfoand positions the cursor with
SetConsoleCursorPosition, so the setup itselfcannot be affected by the behaviour under test:
Run it in
cmd.exedirectly, without redirecting the output:Actual behaviour
Expected behaviour
position()returns the window-relative row, so it isabsolute - srWindow.Topwhatever that row happens to be.
Why it matters
Anything that measures the space below the cursor from the reported row is thrown
off by
srWindow.Top. reedline's painterdoes exactly that:
An inflated row makes it believe it is nearer the bottom than it is, so it scrolls
on a redraw that would have fitted — once per line. In a shell that re-anchors its
prompt from
position()on every line, each prompt is drawn further down andleaves a blank gap behind it, accumulating one gap per line entered. Because it
cannot happen in Windows Terminal, it presents as a terminal-specific mystery
rather than a coordinate-space mismatch.
Suggested fix
Convert unconditionally:
pub fn parse_relative_y(y: i16) -> std::io::Result<i16> { let window = ScreenBuffer::current()?.info()?; - - let window_size = window.terminal_window(); - let screen_size = window.terminal_size(); - - if y <= screen_size.height { - Ok(y) - } else { - Ok(y - window_size.top) - } + Ok(y - window.terminal_window().top) }One caveat worth deciding on
MoveTopasses its row straight toSetConsoleCursorPosition, i.e.buffer-absolute, so
position()(aiming to be window-relative) andMoveTodonot share a coordinate space. That mismatch already exists today in the
elsebranch; the change above makes
position()self-consistent and match itsdocumentation, but does not by itself reconcile the two.
I am happy to open a PR — either the minimal fix above, or a broader one that
settles the convention for both functions, whichever you would prefer.