Skip to content

Windows: cursor::position() returns a buffer-absolute row once the console window has scrolled #1095

Description

@james-kassabian

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:

cargo run

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions