#2754·ratatui

Unnecessary cursor Show + MoveTo emitted every frame on unchanged frames

Author: vpelikhCreated Sep 7, 2026Updated Sep 7, 2026
LabelsType: Bug

Description

Terminal::apply_buffer_with_cursor unconditionally emits a cursor Show (\x1b[?25h) + MoveTo pair at the end of every frame in which the app set a cursor position via Frame::set_cursor_position, even when the cursor is already visible at that exact position and nothing changed on screen.

In the macOS Terminal (this was observed while investigating an intermittent "caret blinks very fast" bug in a downstream ratatui app), Show (\x1b[?25h) appears to re-arm the cursor's blink phase. When the app redraws at an animation/streaming cadence (~30–60fps) and every frame re-emits Show + MoveTo, the caret strobes at the redraw rate instead of the terminal's natural ~1–2Hz blink.

Impact on apps

  • Steady-caret apps (input prompts, editors, chat composers) lose a stable cursor blink. Any app that calls Frame::set_cursor_position on every frame and redraws frequently (streaming, animation, spinners at ~30–60fps) re-emits Show + MoveTo every frame. In macOS Terminal the caret then strobes at the redraw rate instead of blinking at the normal ~1–2Hz — this is the "caret blinks very fast" symptom. This is the real, observed app-facing impact.
  • Wasted terminal output. Each unchanged frame writes a Show (\x1b[?25h) and a MoveTo that change nothing. For high-cadence redraws this is redundant I/O on every frame. The magnitude depends on the app's redraw rate; the primary cost is the cursor-blink disruption above.

This path is hit by any app that sets a cursor through Frame::set_cursor_position; streaming/animated apps are the ones that hit it continuously.

To Reproduce

Two consecutive Terminal::draw calls with an unchanged buffer and the same cursor position still emit Show + MoveTo on the second frame, even though nothing changed. Minimal repro (a Backend wrapper that counts cursor calls but writes no cells, so the second frame is a no-op diff):

use ratatui_core::{
    backend::{Backend, ClearType, WindowSize},
    buffer::Cell,
    layout::{Position, Size},
    terminal::{Frame, Terminal},
};

struct Recorder {
    show_calls: usize,
    move_calls: usize,
}

impl Backend for Recorder {
    type Error = std::io::Error;
    fn draw<'a, I>(&mut self, _content: I) -> Result<(), Self::Error>
    where
        I: Iterator<Item = (u16, u16, &'a Cell)>,
    {
        // write nothing: both frames are identical, so the diff is empty
        Ok(())
    }
    fn hide_cursor(&mut self) -> Result<(), Self::Error> {
        Ok(())
    }
    fn show_cursor(&mut self) -> Result<(), Self::Error> {
        self.show_calls += 1;
        Ok(())
    }
    fn get_cursor_position(&mut self) -> Result<Position, Self::Error> {
        Ok(Position::ORIGIN)
    }
    fn set_cursor_position<P: Into<Position>>(&mut self, _p: P) -> Result<(), Self::Error> {
        self.move_calls += 1;
        Ok(())
    }
    fn clear(&mut self) -> Result<(), Self::Error> {
        Ok(())
    }
    fn clear_region(&mut self, _c: ClearType) -> Result<(), Self::Error> {
        Ok(())
    }
    fn size(&self) -> Result<Size, Self::Error> {
        Ok(Size::new(40, 4))
    }
    fn window_size(&mut self) -> Result<WindowSize, Self::Error> {
        Ok(WindowSize {
            columns_rows: self.size()?,
            pixels: Size::new(0, 0),
        })
    }
    fn flush(&mut self) -> Result<(), Self::Error> {
        Ok(())
    }
}

fn draw(term: &mut Terminal<Recorder>) {
    term.draw(|frame: &mut Frame<'_>| {
        frame.set_cursor_position(Position::new(1, 0));
    })
    .unwrap();
}

fn main() {
    let mut term = Terminal::new(Recorder {
        show_calls: 0,
        move_calls: 0,
    })
    .unwrap();

    draw(&mut term); // 1st frame
    draw(&mut term); // 2nd frame: unchanged buffer + same caret

    let b = term.backend();
    println!("show_calls={} move_calls={}", b.show_calls, b.move_calls);
}

Against a recent main this prints:

show_calls=2 move_calls=2

i.e. both frames call show_cursor + set_cursor_position, so the unchanged second frame re-emits the redundant Show + MoveTo.

The visible strobe depends on the terminal treating Show as a blink re-arm, and it was strongest when the app also hid the cursor at the start of each full frame (so each frame is HideShow + MoveTo, a full blink-phase reset rather than only a redundant re-Show to an already-visible caret). The deterministic part — the redundant per-frame Show + MoveTo on unchanged frames — is the programmatic repro above.

Expected behavior

When a frame's flush wrote no cells and the requested cursor position matches the last emitted position, ratatui should not re-emit Show + MoveTo — the physical caret is already visible where it should be.

More specifically:

  • When the cursor was already shown (hidden_cursor == false), ratatui should not re-emit Show; MoveTo alone is enough to reposition a visible caret.
  • Show + MoveTo should be re-emitted only when the caret actually needs to change (position changed, was hidden, or the frame wrote content whose diff moved the physical caret).

With a fix that does this (tracking the last emitted caret position/visibility and skipping on empty diffs), the repro above prints show_calls=0 move_calls=1 — the first frame positions the visible caret, the identical second frame emits nothing.

Screenshots

No screenshots; the relevant evidence is the escape-sequence repro above (redundant Show + MoveTo on unchanged frames). Whether a particular terminal visibly strobes depends on it treating Show as a blink re-arm.

Are you willing to contribute a fix?

  • I am willing to open a PR for this bug.

Environment

  • OS: macOS
  • Terminal Emulator: macOS Terminal
  • Font: N/A
  • Crate version: ratatui-core 0.1.x / ratatui 0.30 (reproduced against main)
  • Backend: crossterm / test backend (the bug is on the Backend-driven apply_buffer_with_cursor path)

Additional context

Root cause. On main, Terminal::apply_buffer_with_cursor re-emits the cursor escapes whenever a position was requested:

match cursor_position {
    None => self.hide_cursor()?,
    Some(position) => { self.show_cursor()?; self.set_cursor_position(position)?; }
}

It does not compare position against the terminal's own tracked last cursor position, and it re-emits Show every frame. The Show — and crucially the HideShow toggle when an app also hides the cursor at the start of full redraws (to stop a "sweeping" caret across re-emitted cells) — re-arms the terminal's blink phase.

Safety caveat for a fix. A naive "skip if the position is unchanged" optimization is not safe on its own. On a real terminal, writing any cell advances the physical cursor past the last cell written, so a skipped MoveTo could strand the caret on a changed cell. The skip is therefore only correct when the frame's flush wrote nothing (guaranteeing the physical caret still sits exactly where the previous set_cursor_position placed it).

Found by. Investigating an intermittent "caret blinks very fast" bug in a downstream ratatui app:

  • The app uses the hardware cursor for its input/composer row and hides it at the start of every full frame. This hide is an app-side guard: during a repaint/scroll, cells above the caret get re-emitted, and the caret would appear to "sweep" across them as the diff's MoveTos pass through the transcript region. Hiding it first hides that transient sweeping.
  • On full frames the app redraws at ~30–60fps while streaming/spinning, and calls Frame::set_cursor_position every frame.
  • ratatui's try_draw/apply_buffer_with_cursor then re-emits Show + MoveTo at the end of each frame (because a position was set), so each full frame becomes HideShow + MoveTo, and in macOS Terminal the caret strobed at the redraw rate instead of blinking at ~1–2Hz.