uncurses is a Rust library for building terminal user interfaces. It gives you a direct, framework-free way to draw to the terminal and read input — you own every cell and your own event loop, whether you run inline, take over the full screen, or mix the two.
A diffing renderer that redraws only what changed, Unicode-aware width, truecolor styling with automatic downsampling, hyperlinks, and typed keyboard, mouse, and paste input. It asks the terminal what it supports instead of looking it up in a terminfo database, so the same code runs on Linux, macOS, and Windows.
[dependencies]
uncurses = "0.0.4"Using ratatui? uncurses-ratatui provides a backend.
Guides, concepts, and API reference: uncurses.org
This release is mostly about scrolling and resizing: when the renderer is allowed to move rows, and what an explicit resize promises.
Upgrading
Screen::resize now repaints, whatever the size (#52).
It used to return early when the new size matched the current one. That made a resize report following a font or window change a no-op, even though such a change can move where cells land, so the tracked contents were wrong in a way no diff could see. Working around it meant following every resize with invalidate.
The skip moved to Program::autoresize, which is the better home for it: that call runs on every resize report whether the application asked for one or not, and a terminal emits a report per pixel of a window drag while the cell grid only changes at cell boundaries. So most reports ask for a size the area already has.
If you handle resize events, route them through autoresize:
Event::Resize(_) => {
program.autoresize()?;
}It reads the size the operating system already knows, with no round trip to the terminal, keeps your inline height, and returns without repainting when the area already fits. That makes it safe to call on every report.
Call Screen::resize when you are telling the screen a size it cannot work out for itself, and expect the repaint that comes with it. If you compute a size yourself and call resize every frame, compare against Screen::size first.
Turn scroll detection off for a partial-width layout
Screen::set_scroll_optimize is now public (#16).
Every scroll this renderer emits is full width. Rows move with SU, SD, IL, DL or a bare line feed, and it never sets the left and right margins that would confine those to a column range. So on a layout with a sidebar, a file tree, a gutter or any fixed column, a scroll moves that column too, and the renderer paints it back inside the same frame.
The finished screen is identical either way, which is why comparing it against a full repaint finds nothing wrong, and why this is easy to miss. What you see is the fixed region jumping and being put back, on every frame, for as long as the scrolling continues.
Turn detection off while such a layout is up:
screen.set_scroll_optimize(false);Withholding the sequences instead does not work. A full-view scroll falls back to a bare line feed regardless of which optimizations are enabled, so only this switch keeps the view still.
Scroll detection now requires synchronized output
Detection runs only when the terminal has confirmed DEC 2026 support (#51).
The correction described above rides in the same frame as the scroll. Presented atomically it is invisible; otherwise it is on screen for a moment and reads as flicker. Gating detection on synchronized output means a terminal that cannot present a frame in one step never sees it.
Confirmation takes an explicit query_capabilities. An application that never asks never gets scroll optimization, and rows are redrawn directly instead. That is a real cost for a full-screen application that scrolls, so ask if you want it:
program.query_capabilities(&[])?;A terminal that advertises DEC 2026 without honouring it gets both the markers and the scroll plans that rely on them, so the corrective repaint can still show. Turn scroll optimization off on such a terminal.
Cursor moves staged between frames
Four defects, all on the path where an application moves the cursor outside the diff loop (#44).
The move planner can pay for a short forward move by reprinting the cells it passes over. That is sound inside the diff loop, where the move targets a column the transform has proven equal between the tracked line and the new one. A move staged between frames has no such invariant, and the front buffer it was given is the desired grid rather than what the terminal shows. The planner does not record what it reprints, so the divergence was never diffed away. move_to_between_frames now takes no buffer at all, which makes the whole class unrepresentable.
A staged move was also measured against the size the renderer last rendered, which resize does not update until the frame that answers it. After rendering at 80x24 and growing to 100x30, a move to (90, 29) wrapped the column against 80 and clamped the row to 23, landing at (10, 23). Shrinking failed the same way, leaving the cursor below the surface.
Painting through the rightmost column leaves the cursor there with its wrap pending. A move to that column is a request to wrap, and a shortcut comparison answered it with "you are already there": nothing was emitted, the wrap stayed pending, and tracked_cursor kept reporting a column the surface does not have.
Changelog
Breaking changes
- Resize repaints, autoresize skips unchanged sizes (#52)
Bug Fixes
- Correct three defects in cursor moves staged between frames (#44)
- Require synchronized output for scroll detection (#51)
- Detect single-row scrolls, and say what the docs mean (#54)
Features
- Let an application turn scroll detection off (#16)