#1095

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

Openjames-kassabianopened 3mo ago

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.

Activity

0

No activity yet.