C & C++ TUIs: ncurses, PDCurses & FTXUI
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages. |
A full-screen terminal program takes over the whole terminal: it draws text at arbitrary positions, reads single keys, and
puts the terminal back as it found it when it exits. In C and C there are three families of libraries for this.
**curses** (ncurses on Linux and macOS, PDCurses and PDCursesMod on Windows) is the classic procedural API that has
existed since the 1980s. **notcurses** is a newer C library that targets modern terminals. **FTXUI** is a C library with
a functional, React-inspired style, built from elements for layout and components for interaction. This page shows the
Bookshelf shelf tui catalogue browser (a list of books, a details pane and a search box) written once with
ncurses and once with FTXUI, so the two styles can be compared on the same screen.
The page was written against ncurses 6.6 (the documentation was read from the 6.6 manual pages), PDCursesMod
v4.5.4, notcurses v3.0.17 and FTXUI v7.0.3, on Windows 10 and 11, macOS and Linux. The ncurses programs were
compiled with Apple clang 21 and run on macOS against the system ncurses (6.0, patch 20150808, a much older release than
6.6) inside a pseudo-terminal, which exercised startup, arrow keys, search, resizing, SIGINT and the terminal restore
sequence. FTXUI v7.0.3 was compiled from its sources with Apple clang as C++17 (without CMake), and both FTXUI programs were
run in a pseudo-terminal. These parts were not compiled or run while the page was written: everything on Linux (the
ncursesw library name, the pkg-config files), everything on Windows (PDCursesMod, MSVC, MinGW), notcurses, and the CMake
files, because no CMake installation, Windows host or Linux host was available. Each snippet that falls in that group says so.
How a terminal works (the line discipline, raw mode, the pseudo-terminal) is on Terminals, TTYs & Pseudo-Terminals; the escape sequences that these libraries emit, and the terminfo database they read, are on Escape Sequences & Terminal Capabilities; the general concepts of a TUI (the main loop, cell buffers and diffing, layout systems) are on TUI Architecture & Concepts; a hand-written single-key reader in C is on Command-Line Programs in C; the C++ command-line side is on Command-Line Programs in C++; and the rule that a TUI is only a view on top of a scriptable command is on TUIs vs. CLIs.
Choosing a library
| ncurses | PDCursesMod | FTXUI | |
|---|---|---|---|
Language |
C (usable from C++) |
C (usable from C++) |
C++17 (the library requires C++17; C++20 modules are optional) |
Style |
Procedural: draw into windows, then refresh |
The same curses API on other back ends |
Declarative: build a tree of elements, the library lays out and draws it |
Windows |
No native port (use PDCursesMod, or WSL) |
Console ( |
Yes: Linux, macOS, Windows and WebAssembly |
Terminal model |
Reads the terminfo database for |
Talks to the console (or an SDL or X11 window) directly |
Emits xterm-style sequences; no terminfo lookup is documented |
Where it fits |
Portable POSIX programs and terminals that are not xterm-compatible |
Giving an existing curses program a Windows build |
New C++ programs that want layout and ready-made widgets |
Source: ncurses(3x), PDCursesMod README and FTXUI documentation. The "Terminal model" row for FTXUI is from the FTXUI source and changelog rather than from a page that states it, so it is a description of what the code does and not a documented guarantee.
Two more libraries come up. PDCurses 3.9 is the original Windows (and DOS, OS/2, X11, SDL) curses library; its site lists v3.9 as the stable release, and the project is dormant, so new Windows work should prefer PDCursesMod, the actively maintained fork. notcurses is covered in notcurses.
ncurses
The model: windows, a virtual screen and one update
ncurses does not write to the terminal when a drawing function is called. Drawing functions (mvwprintw, waddch,
box) change the contents of a window, an in-memory grid of characters with attributes. A refresh copies a window
into ncurses' virtual screen, and ncurses compares the virtual screen with what it believes is on the physical
terminal and emits only the escape sequences needed to make the two match. This is why a curses program can redraw
everything on every key press without flickering or flooding the terminal.
wrefresh(win) does both steps for one window. A program with several windows calls wnoutrefresh on each and
doupdate once, which sends one optimised update instead of one per window. The shelf tui example below does exactly
that.
Source: ncurses(3x) (refresh functions: wnoutrefresh, doupdate, and the minimal-update behaviour).
Starting and stopping
| Call | What it does |
|---|---|
|
Must come before |
|
Initialises curses and returns |
|
The alternative for programs that manage a terminal explicitly: it takes the terminal type and the output and input |
|
Restores the terminal: resets colours, moves the cursor to the lower-left corner, makes it visible, leaves cursor-addressing mode and restores the saved terminal modes. It does not free the screen, so a later |
|
True when no refresh has happened since the last |
Source: curs_initscr(3x) and ncurses(3x) (the setlocale requirement).
The guarantee that matters most is that endwin runs on every way out. shelf tui registers it with atexit and turns
SIGINT and SIGTERM into a flag, as described under Keys, timeouts, signals and resizing.
Input modes
| Call | Effect |
|---|---|
|
Characters are available as soon as they are typed (no line buffering, no erase and kill editing), but the interrupt, quit and suspend characters still generate signals (Ctrl+C, Ctrl+Backslash, Ctrl+Z). |
|
Like |
|
The terminal driver’s echo is always turned off by ncurses; |
|
Enables function-key decoding for input read from |
|
Non-blocking read ( |
|
|
|
Stops the timer that distinguishes a lone Esc key from the start of an escape sequence; ncurses then waits indefinitely for more bytes. |
Source: curs_inopts(3x).
The manual page for curs_inopts says that the escape timer is controlled by ESCDELAY; the ncurses(3x) overview gives
the details. ESCDELAY is a global variable and an environment variable, in milliseconds, with a default of 1000 and a
cap of 30000 for the environment value. It matters in a TUI that uses the Esc key: with the default, pressing Esc alone
takes a second to be reported. set_escdelay and get_escdelay are the thread-aware ncurses extensions. If keypad
is off for the window, ESCDELAY plays no role.
A complete example: the shelf catalogue browser
The program below is shelf tui in ncurses. It has a list window and a details window side by side, a status line, a
search prompt on /, colours that fall back to the terminal’s own background, and re-layout on a terminal resize. The
windows are queued with wnoutrefresh and flushed once with doupdate. A signal handler without SA_RESTART turns
SIGINT and SIGTERM into a flag: because a signal makes wgetch fail (ERR), the loop sees the flag and ends
normally, so endwin always runs.
#define _XOPEN_SOURCE 700
#include <curses.h>
#include <locale.h>
#include <signal.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
struct book {
const char *title;
const char *author;
const char *borrower; /* NULL when on the shelf */
};
static struct book books[] = {
{"The Pragmatic Programmer", "Hunt, Thomas", NULL},
{"Structure and Interpretation", "Abelson, Sussman", "Ana"},
{"The C Programming Language", "Kernighan, Ritchie", NULL},
{"Designing Data-Intensive Apps", "Kleppmann", NULL},
{"Code Complete", "McConnell", "Luis"},
{"Refactoring", "Fowler", NULL},
};
enum { NBOOKS = sizeof books / sizeof books[0] };
enum { PAIR_SELECTED = 1, PAIR_LOANED = 2, PAIR_STATUS = 3 };
static void restore_terminal(void) { endwin(); }
static volatile sig_atomic_t stop_requested;
static void on_signal(int sig) { (void)sig; stop_requested = 1; }
static void install_handlers(void) {
struct sigaction sa;
memset(&sa, 0, sizeof sa);
sa.sa_handler = on_signal;
sigemptyset(&sa.sa_mask); /* no SA_RESTART: getch must return ERR */
sigaction(SIGINT, &sa, NULL);
sigaction(SIGTERM, &sa, NULL);
}
struct ui {
WINDOW *list, *details, *status;
int selected;
char filter[64];
};
static void destroy_windows(struct ui *ui) {
if (ui->list) delwin(ui->list);
if (ui->details) delwin(ui->details);
if (ui->status) delwin(ui->status);
ui->list = ui->details = ui->status = NULL;
}
static void layout(struct ui *ui) {
destroy_windows(ui);
int left = COLS / 2;
ui->list = newwin(LINES - 1, left, 0, 0);
ui->details = newwin(LINES - 1, COLS - left, 0, left);
ui->status = newwin(1, COLS, LINES - 1, 0);
keypad(ui->list, TRUE);
}
/* strcasestr is not in POSIX, so the case-insensitive search is written out */
static int contains_nocase(const char *text, const char *needle) {
size_t n = strlen(needle);
for (; *text; text++) {
size_t k = 0;
while (k < n && text[k] && tolower((unsigned char)text[k]) == tolower((unsigned char)needle[k])) k++;
if (k == n) return 1;
}
return n == 0;
}
static int matches(const struct ui *ui, int i) {
return contains_nocase(books[i].title, ui->filter);
}
static void draw(struct ui *ui) {
werase(ui->list);
box(ui->list, 0, 0);
mvwprintw(ui->list, 0, 2, " Books ");
int row = 1;
for (int i = 0; i < NBOOKS && row < getmaxy(ui->list) - 1; i++) {
if (!matches(ui, i)) continue;
if (i == ui->selected) wattron(ui->list, COLOR_PAIR(PAIR_SELECTED) | A_BOLD);
else if (books[i].borrower) wattron(ui->list, COLOR_PAIR(PAIR_LOANED));
mvwprintw(ui->list, row++, 1, "%-*.*s", getmaxx(ui->list) - 2, getmaxx(ui->list) - 2,
books[i].title);
wattrset(ui->list, A_NORMAL);
}
werase(ui->details);
box(ui->details, 0, 0);
mvwprintw(ui->details, 0, 2, " Details ");
mvwprintw(ui->details, 2, 2, "Title: %s", books[ui->selected].title);
mvwprintw(ui->details, 3, 2, "Author: %s", books[ui->selected].author);
mvwprintw(ui->details, 4, 2, "Borrower: %s",
books[ui->selected].borrower ? books[ui->selected].borrower : "(on the shelf)");
werase(ui->status);
wbkgd(ui->status, COLOR_PAIR(PAIR_STATUS));
mvwprintw(ui->status, 0, 1, "j/k move / search l loan/return q quit filter: %s", ui->filter);
/* queue every window, then flush once: one minimal update of the terminal */
wnoutrefresh(ui->list);
wnoutrefresh(ui->details);
wnoutrefresh(ui->status);
doupdate();
}
static void move_selection(struct ui *ui, int step) {
for (int i = ui->selected + step; i >= 0 && i < NBOOKS; i += step)
if (matches(ui, i)) { ui->selected = i; return; }
}
static void read_filter(struct ui *ui) {
werase(ui->status);
mvwprintw(ui->status, 0, 1, "search: ");
wrefresh(ui->status);
echo();
curs_set(1);
wgetnstr(ui->status, ui->filter, (int)sizeof ui->filter - 1);
curs_set(0);
noecho();
if (!matches(ui, ui->selected)) move_selection(ui, 1);
if (!matches(ui, ui->selected)) move_selection(ui, -1);
}
int main(void) {
setlocale(LC_ALL, ""); /* before initscr, for UTF-8 */
install_handlers();
if (initscr() == NULL) return 1;
atexit(restore_terminal);
cbreak();
noecho();
curs_set(0);
if (has_colors()) {
start_color();
use_default_colors();
init_pair(PAIR_SELECTED, COLOR_BLACK, COLOR_CYAN);
init_pair(PAIR_LOANED, COLOR_YELLOW, -1);
init_pair(PAIR_STATUS, COLOR_WHITE, COLOR_BLUE);
}
struct ui ui = {0};
layout(&ui);
while (!stop_requested) {
draw(&ui);
int ch = wgetch(ui.list);
switch (ch) {
case 'q': stop_requested = 1; break;
case 'j': case KEY_DOWN: move_selection(&ui, 1); break;
case 'k': case KEY_UP: move_selection(&ui, -1); break;
case 'l': books[ui.selected].borrower = books[ui.selected].borrower ? NULL : "you"; break;
case '/': read_filter(&ui); break;
case KEY_RESIZE: layout(&ui); break; /* ncurses already updated LINES and COLS */
case ERR: break; /* interrupted by a signal: loop tests the flag */
}
}
destroy_windows(&ui);
return 0; /* atexit runs restore_terminal */
}
Source: curs_getch(3x) (KEY_RESIZE, ERR on EINTR), curs_color(3x) (start_color, init_pair, COLOR_PAIR, -1 after use_default_colors), curs_initscr(3x) (endwin) and ncurses(3x) (wnoutrefresh, doupdate).
Build and run it with:
cc -std=c11 -Wall -Wextra shelf_tui.c -lncurses -o shelf_tui # macOS
cc -std=c11 -Wall -Wextra shelf_tui.c -lncursesw -o shelf_tui # Linux, wide-character build
./shelf_tui
The program compiled without warnings with -Wall -Wextra -Wpedantic and ran correctly on macOS. It uses nothing
beyond POSIX and ncurses, with the exception of sigaction, which Windows does not have: for a PDCursesMod build, guard
the handler installation with #ifndef _WIN32 (see PDCurses and PDCursesMod on Windows). The Linux command line was not run.
Windows, subwindows and pads
| Function | Use |
|---|---|
|
A window with its own storage. Delete it with |
|
A subwindow that shares the parent’s character storage; |
|
A window that is not limited by the screen size. It cannot be refreshed with |
|
Shows the part of the pad whose top-left corner is |
|
A window inside a pad, and a single-character write that tells the optimiser only one cell changed. |
Source: curs_pad(3x) and ncurses(3x).
A pad is the right tool for a long note or a table with more rows than the terminal, because scrolling is just a change
of pminrow. Pads are not refreshed automatically by scrolling or by input echoing: the program calls
pnoutrefresh (and doupdate) after every change.
Colours
| Call | Meaning |
|---|---|
|
True if the terminal supports colour. Can be called right after |
|
Enables colour, sets |
|
ncurses extension (documented in default_colors(3x)): after it, |
|
Define a pair and use it as an attribute. Redefining a pair refreshes every cell that uses it. |
|
Redefining the RGB of a palette entry, when the terminal allows it. |
Pair 0 means "the terminal’s own colours" and cannot be changed meaningfully. A TUI that must respect NO_COLOR simply
skips start_color and uses attributes such as A_BOLD and A_REVERSE; the shelf example takes that path whenever
has_colors is false, and could add the NO_COLOR environment test in front of it.
Source: curs_color(3x) and ncurses(3x).
Keys, timeouts, signals and resizing
With keypad on, wgetch returns key codes such as KEY_UP, KEY_DOWN and KEY_BACKSPACE, and two non-key events:
KEY_RESIZE when ncurses handled a SIGWINCH since the last call (it is returned even without keypad), and
KEY_MOUSE when a mouse event is pending. On a resize ncurses has already called resizeterm and updated LINES and
COLS; the program only needs to rebuild windows that depend on the size, as layout() does in the example.
wgetch returns ERR if the screen is not initialised, the input is closed, a signal interrupts it, or a timeout
expires. A TUI that sets its own SIGINT handler without SA_RESTART uses that: the interrupted read returns ERR,
and the loop re-tests its stop flag. The ncurses manual pages read for this page do not say whether ncurses installs its
own handlers for SIGINT or SIGTERM, so the safe design is the one in the example: install handlers that only set a
flag, and do the restoring in normal code.
The arrow-key bytes depend on the terminal. With keypad on, ncurses switches the terminal to application keypad
mode using the terminfo smkx capability, and a terminal in that mode sends ESC O B for the down arrow, not
ESC [ B. This matters when a test script drives the program through a pseudo-terminal, as the test for this page
did: sending ESC [ B is not recognised as KEY_DOWN, while the sequence from infocmp (kcud1) is.
Source: curs_getch(3x) and curs_inopts(3x).
Wide characters
Narrow ncurses handles one byte per cell. For UTF-8 text (accents, CJK, box drawing) the program links the wide-character
build, calls setlocale(LC_ALL, "") before initscr, and uses the wide API.
| Item | Detail |
|---|---|
Library |
|
Header requirement |
Define |
Naming |
Wide variants add |
Types |
|
Reading a key |
|
The example scrolls a pad that contains accented Spanish text and Japanese characters; a wide CJK character occupies two terminal cells, which ncurses accounts for.
#define _XOPEN_SOURCE_EXTENDED 1
#include <curses.h>
#include <locale.h>
#include <wchar.h>
#define NOTE_LINES 60
int main(void) {
setlocale(LC_ALL, "");
initscr();
cbreak();
noecho();
keypad(stdscr, TRUE);
curs_set(0);
/* A pad is a window that is not limited by the screen size. */
WINDOW *pad = newpad(NOTE_LINES, 200);
for (int i = 0; i < NOTE_LINES; i++) {
wchar_t line[96];
swprintf(line, sizeof line / sizeof line[0],
L"%02d Biblioteca: ñ é ü 本の棚 (width %d)", i + 1, 4);
mvwaddwstr(pad, i, 0, line);
}
int top = 0;
wint_t ch;
int kind;
for (;;) {
mvprintw(0, 0, "Notes (arrows scroll, q quits) top=%d", top);
wnoutrefresh(stdscr);
/* pad row/col to show, then the screen rectangle that receives it */
pnoutrefresh(pad, top, 0, 2, 0, LINES - 1, COLS - 1);
doupdate();
kind = get_wch(&ch); /* KEY_CODE_YES for function keys, OK for text */
if (kind == KEY_CODE_YES && ch == KEY_DOWN && top < NOTE_LINES - 1) top++;
else if (kind == KEY_CODE_YES && ch == KEY_UP && top > 0) top--;
else if (kind == KEY_CODE_YES && ch == KEY_RESIZE) { clear(); }
else if (kind == OK && ch == L'q') break;
}
delwin(pad);
endwin();
return 0;
}
Source: ncurses(3x) (wide-character support, setlocale), curs_pad(3x) and curs_getch(3x). get_wch and KEY_CODE_YES are documented on curs_get_wch(3x), which this page cites only through the overview; the example ran on macOS, and the Linux build was not run.
Panels, menus and forms
ncurses ships three companion libraries, each linked in addition to ncurses.
| Library | Link with | What it adds |
|---|---|---|
panel |
|
A stack of windows with overlap handling. |
menu |
|
|
form |
|
|
All three must be used after curses has been initialised, and the manual says to call setlocale(LC_ALL, "") first. The
manual pages for the menu and form libraries only index their routines and point at one page per routine; the
request codes and options used in the example (REQ_DOWN_ITEM, REQ_UP_ITEM, REQ_DEL_PREV, REQ_VALIDATION, O_AUTOSKIP) are from those
per-routine pages, which this page did not read, and were checked by compiling and running the example.
The next program is a modal "loan" dialog: a menu of books in one panel and a one-field form in another, switched with Tab.
#define _XOPEN_SOURCE 700
#include <curses.h>
#include <form.h>
#include <locale.h>
#include <menu.h>
#include <panel.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
/* A modal "loan" dialog: a menu of books in one panel, a form in another. */
int main(void) {
setlocale(LC_ALL, "");
initscr();
cbreak();
noecho();
keypad(stdscr, TRUE);
mvprintw(0, 0, "shelf: F2 loan dialog, q quit");
refresh();
/* menu */
const char *titles[] = {"Refactoring", "Code Complete", "The C Programming Language", NULL};
ITEM *items[4];
int n = 0;
for (; titles[n]; n++) items[n] = new_item(titles[n], "");
items[n] = NULL;
MENU *menu = new_menu(items);
WINDOW *mwin = newwin(8, 40, 3, 4);
keypad(mwin, TRUE);
set_menu_win(menu, mwin);
set_menu_sub(menu, derwin(mwin, 4, 36, 2, 2));
set_menu_mark(menu, "> ");
box(mwin, 0, 0);
mvwprintw(mwin, 0, 2, " Pick a book ");
post_menu(menu);
/* form */
FIELD *fields[2];
fields[0] = new_field(1, 20, 0, 10, 0, 0);
fields[1] = NULL;
set_field_back(fields[0], A_UNDERLINE);
field_opts_off(fields[0], O_AUTOSKIP);
FORM *form = new_form(fields);
WINDOW *fwin = newwin(5, 40, 12, 4);
keypad(fwin, TRUE);
set_form_win(form, fwin);
set_form_sub(form, derwin(fwin, 1, 34, 2, 2));
box(fwin, 0, 0);
mvwprintw(fwin, 0, 2, " Borrower ");
post_form(form);
mvwprintw(fwin, 2, 2, "Name:");
form_driver(form, REQ_FIRST_FIELD);
PANEL *pm = new_panel(mwin);
PANEL *pf = new_panel(fwin);
top_panel(pm);
int in_menu = 1;
for (;;) {
update_panels(); /* recompute the stacking order... */
doupdate(); /* ...then touch the terminal once */
int ch = wgetch(in_menu ? mwin : fwin);
if (ch == 'q' && in_menu) break;
if (ch == '\t') { in_menu = !in_menu; top_panel(in_menu ? pm : pf); continue; }
if (in_menu) {
if (ch == KEY_DOWN) menu_driver(menu, REQ_DOWN_ITEM);
else if (ch == KEY_UP) menu_driver(menu, REQ_UP_ITEM);
} else if (ch == KEY_BACKSPACE || ch == 127) form_driver(form, REQ_DEL_PREV);
else if (ch == '\n') break;
else form_driver(form, ch);
}
form_driver(form, REQ_VALIDATION); /* copy the editing buffer into the field */
char name[32];
strncpy(name, field_buffer(fields[0], 0), sizeof name - 1);
name[sizeof name - 1] = '\0';
const char *book = item_name(current_item(menu));
/* panels first, then forms and menus, then the windows */
del_panel(pf);
del_panel(pm);
unpost_form(form); free_form(form); free_field(fields[0]);
unpost_menu(menu); free_menu(menu);
for (int i = 0; i < n; i++) free_item(items[i]);
delwin(fwin);
delwin(mwin);
endwin();
printf("selected: %s / %s\n", book, name);
return 0;
}
Building
| Platform | Compile and link |
|---|---|
macOS |
|
Linux |
|
Windows |
No native ncurses. Use PDCursesMod (PDCurses and PDCursesMod on Windows), or build for WSL where it is Linux. |
A CMake file that uses pkg-config for the wide libraries is:
cmake_minimum_required(VERSION 3.20)
project(shelf_tui C)
set(CMAKE_C_STANDARD 11)
find_package(PkgConfig REQUIRED)
pkg_check_modules(NCURSES REQUIRED IMPORTED_TARGET ncursesw panelw menuw formw)
add_executable(shelf_tui shelf_tui.c)
target_link_libraries(shelf_tui PRIVATE PkgConfig::NCURSES)
This CMake file was not run (no CMake was available), and the .pc file names (ncursesw, panelw, menuw, formw) are the usual names on Linux distributions, not something the ncurses manual pages state.
Pitfalls
-
Not restoring the terminal. A crash or
exitbetweeninitscrandendwinleaves the terminal in raw mode with the cursor hidden. Registerendwinwithatexit, never call_exitwhile curses is active, andresetis what the user types to recover. -
Writing to
stdoutwhile curses is active.printfoutput is interleaved with curses' own output and is lost at the next refresh. Print results afterendwin, asdialog.cdoes. -
Forgetting
setlocale. UTF-8 text appears as garbage without it, even in a wide build. -
Resizing. Windows created with
newwinkeep their old size after a resize; rebuild them onKEY_RESIZE. -
Refresh order. Refreshing
stdscrlast paints over the other windows. Queue everything withwnoutrefreshand calldoupdateonce. -
Threads. ncurses is not safe to call from several threads from several threads; draw from one thread (curs_threads(3x) describes the thread-related extensions).
-
Reading a key from the wrong window.
keypadis a per-window setting; the program calls it on the window it reads from. -
Width of
printffield.%scounts bytes; use the wide functions or compute widths when UTF-8 text must align.
PDCurses and PDCursesMod on Windows
PDCurses is a curses library for environments that do not fit the termcap and terminfo model: it implements most of the X/Open and System V R4 functions on the Windows console, DOS, OS/2, X11 and SDL. Its stable release is 3.9, and the project is only intermittently active. PDCursesMod is a modified, extended fork that has taken most of the upstream changes and adds more back ends; the README says it "appears to be permanent". For a new Windows program, prefer PDCursesMod.
| Port | What it is |
|---|---|
|
The Windows console (formerly |
|
Windows graphics mode: the program gets its own window instead of a console. |
|
A VT-like terminal console; the README lists it without further detail. |
SDL, X11, framebuffer, DOS, OS/2, Plan 9, OpenGL |
Other back ends; the SDL and X11 ones let a curses program become a windowed application. |
The README states that most programs that compile and run with PDCurses or ncurses also compile with PDCursesMod, for the
common subset of functionality. In practice a program such as shelf_tui.c builds with PDCursesMod after three changes:
remove sigaction (use the C signal function, or do nothing), replace any POSIX-only calls, and do not rely on
ESCDELAY or terminfo, which do not exist in this model.
Building the wincon port (from the wincon README):
nmake -f Makefile.vc WIDE=Y UTF8=Y
WIDE=Y builds the Unicode library, UTF8=Y makes narrow strings UTF-8 regardless of the system locale (it only has
an effect together with WIDE=Y, and exists to work around the poor UTF-8 support of the Windows console), and DLL=Y
builds a DLL (an application then defines PDC_DLL_BUILD). MinGW builds use make -f Makefile. The output is
pdcurses.lib (Visual C++) or pdcurses.a (MinGW). The demos target builds the sample programs. These commands are
taken from the README and were not run, since there was no Windows host.
What the PDCursesMod documentation read for this page does not say: it does not describe how KEY_RESIZE behaves
(the porting document describes resize_term and the PDC_resize_screen hook), it does not document wide-character
support beyond the build options, and it does not mention Windows Terminal. A program that has to behave identically on
all three systems should test resizing and wide characters on Windows rather than assume ncurses behaviour.
Source: PDCurses, PDCursesMod README, wincon README and PDCursesMod implementation notes.
For a Windows application that wants no curses at all, FTXUI (below) runs natively on Windows, and the Win32 console and virtual-terminal sequences are described on Windows Console & Processes.
notcurses
notcurses is a C library for complex text user interfaces and character graphics on modern terminal emulators, with
24-bit colour, multimedia, threads and Unicode. Its README is explicit that it is not a source-compatible X/Open curses
implementation and not a drop-in replacement for ncurses: the central abstractions are planes (ncplane) and cells
(nccell) rather than windows.
| Aspect | Status |
|---|---|
Platforms |
Linux 2.6+, FreeBSD 11+, DragonFly BSD 5.9+, macOS 11.4+ and Windows 10 are listed as runtime requirements. |
Windows |
Needs the ConPTY layer. Under Cygwin (3.2.0 and later) it is on by default; under MSYS it is disabled by default and must be enabled ( |
Terminal handling |
Uses terminfo where possible but also queries the terminal at startup to detect advanced features; supports Sixel, the Kitty graphics protocol and the Linux framebuffer for bitmaps. |
Modes |
"TUI mode" for full-screen applications and "CLI mode" for scrolling output. A "direct mode" writes output as instructed without tracking screen state; the FAQ says it is much slower and has fewer capabilities, and recommends CLI mode instead. |
Bindings |
C++ and Python in the repository; external wrappers for Rust ( |
Licence |
Apache 2.0. |
The latest release is v3.0.17. No notcurses code was compiled for this page. Reach for it when images, video-like output or very rich Unicode rendering matter more than portability to old terminals or Windows consoles without ConPTY.
Source: notcurses README and FAQ and notcurses.com.
FTXUI
FTXUI ("Functional Terminal User Interface") is a C++ library with no dependencies that runs on Linux, macOS, Windows and WebAssembly. It supports keyboard and mouse navigation, UTF-8 and full-width characters, animations and drawing. It is organised in three modules:
| Module | Role |
|---|---|
|
Low-level rendering: colours, cells, the terminal. |
|
Layout and composition: a tree of |
|
User interaction: widgets, events and the main loop ( |
Version 7 added a single umbrella header and CMake target (ftxui::ftxui), an amalgamated single-header distribution, C++20
modules and a Meson build, and renamed ScreenInteractive to App; the old name is kept as an alias, so the examples
below compile with both spellings and with earlier releases. The library itself requires C++17.
Source: FTXUI documentation and the v7.0.3 changelog (7.0.0 entries: renames, umbrella target, modules, amalgamation). The documentation site currently shows a newer, unreleased 7.1.0 version string; the latest published release is v7.0.3 (2026-08-06).
Installing
cmake_minimum_required(VERSION 3.20)
project(shelf_ftxui CXX)
set(CMAKE_CXX_STANDARD 17)
include(FetchContent)
FetchContent_Declare(ftxui
GIT_REPOSITORY https://github.com/ArthurSonzogni/ftxui
GIT_TAG v7.0.3
)
FetchContent_MakeAvailable(ftxui)
add_executable(shelf_ftxui shelf_ftxui.cpp)
target_link_libraries(shelf_ftxui PRIVATE ftxui::ftxui)
The FetchContent and target_link_libraries lines are from the FTXUI README; the surrounding project lines are the
usual minimum. The submodule targets ftxui::component, ftxui::dom and ftxui::screen can be linked instead of the
umbrella target. This CMake file was not run. FTXUI is also available from package managers (vcpkg, Conan, system
packages) and as a single-header amalgamation for vendoring.
Source: FTXUI README (Build using CMake).
Elements: the DOM
An Element describes what to draw. Elements are combined with container functions and modified with the pipe operator.
The smallest program renders a document once and prints it, with no event loop; this one lists the loans.
#include <iostream>
#include <ftxui/dom/elements.hpp>
#include <ftxui/dom/table.hpp>
#include <ftxui/screen/screen.hpp>
int main() {
using namespace ftxui;
Table table({
{"Id", "Title", "Borrower", "Due"},
{"2", "Structure and Interpretation", "Ana", "2026-11-02"},
{"5", "Code Complete", "Luis", "2026-11-09"},
});
table.SelectAll().Border(LIGHT);
table.SelectRow(0).Decorate(bold);
table.SelectColumns(0, -1).SeparatorVertical(LIGHT);
Element document = vbox({text("Loans") | bold, table.Render()});
// Dimension::Fit sizes the screen to the document; nothing is interactive.
auto screen = Screen::Create(Dimension::Fit(document), Dimension::Fit(document));
Render(screen, document);
screen.Print();
std::cout << std::endl;
}
Run on macOS (colour sequences removed) it prints:
Loans ┌──┬────────────────────────────┬────────┬──────────┐ │Id│Title │Borrower│Due │ │2 │Structure and Interpretation│Ana │2026-11-02│ │5 │Code Complete │Luis │2026-11-09│ └──┴────────────────────────────┴────────┴──────────┘
Source: FTXUI documentation (the hbox, text, border, flex, Screen::Create and Dimension::Fit example) and the table.hpp header of FTXUI v7.0.3.
The elements declared in ftxui/dom/elements.hpp (v7.0.3) are:
| Group | Elements |
|---|---|
Text and content |
|
Decoration |
|
Styling |
|
Layout containers |
|
Flexibility |
|
Sizing and scrolling |
|
Source: ftxui/dom/elements.hpp.
Components and the main loop
A Component owns state and reacts to events. The component module provides ready-made widgets, containers that
route the keyboard focus, and decorators:
| Kind | Functions in ftxui/component/component.hpp (v7.0.3) |
|---|---|
Containers |
|
Widgets |
|
Layout helpers |
|
Composition |
|
The main loop is run by a ScreenInteractive (an alias of App in v7). Its constructors choose how the program occupies
the terminal:
| Constructor | Behaviour |
|---|---|
|
Takes the whole terminal and uses the alternate screen, so the user’s scrollback is untouched (same as |
|
Full size but on the primary screen; a resize may leave old content mixed with the new. |
|
Width and height match the component, drawn inline. |
|
Terminal width, component height, drawn inline: the right mode for a progress display that leaves its last frame in the scrollback. |
|
A fixed-size area. |
Loop(component) runs until Exit() is called (ExitLoopClosure() returns a function that does the same, to use as a
button callback). PostEvent(event) and Post(task) queue work for the loop and are thread-safe, which is how a
background thread asks for a redraw. TrackMouse(false) turns mouse tracking off, which is on by default;
HandlePipedInput (on by default, Linux and macOS only) lets a program read piped data from standard input while still
receiving keyboard events from the terminal.
Source: ftxui/component/component.hpp and ftxui/component/app.hpp (its comment says the class was previously named ScreenInteractive). The official documentation page read for this page links component examples but does not contain one; the example below was written against these headers and run.
A complete example: the shelf catalogue browser
The program has a search box and a menu on the left and a details pane on the right, with the layout declared once in a
Renderer. Typing in the search box re-filters the menu; Tab moves the focus to the menu; l toggles a
loan and q quits. A background thread posts three custom events to show the thread-safe redraw path.
#include <algorithm>
#include <cctype>
#include <chrono>
#include <ftxui/component/component.hpp>
#include <ftxui/component/screen_interactive.hpp>
#include <ftxui/dom/elements.hpp>
#include <string>
#include <thread>
#include <vector>
using namespace ftxui;
struct Book {
std::string title;
std::string author;
std::string borrower; // empty when on the shelf
};
static bool contains_nocase(const std::string& text, const std::string& needle) {
auto lower = [](std::string s) {
std::transform(s.begin(), s.end(), s.begin(),
[](unsigned char c) { return std::tolower(c); });
return s;
};
return lower(text).find(lower(needle)) != std::string::npos;
}
int main() {
std::vector<Book> books = {
{"The Pragmatic Programmer", "Hunt, Thomas", ""},
{"Structure and Interpretation", "Abelson, Sussman", "Ana"},
{"The C Programming Language", "Kernighan, Ritchie", ""},
{"Designing Data-Intensive Apps", "Kleppmann", ""},
{"Code Complete", "McConnell", "Luis"},
{"Refactoring", "Fowler", ""},
};
std::string filter;
std::vector<int> visible; // indices into books
std::vector<std::string> entries; // what the menu shows
int selected = 0;
auto refilter = [&] {
visible.clear();
entries.clear();
for (int i = 0; i < static_cast<int>(books.size()); ++i) {
if (contains_nocase(books[i].title, filter)) {
visible.push_back(i);
entries.push_back(books[i].title);
}
}
selected = std::clamp(selected, 0, std::max(0, static_cast<int>(entries.size()) - 1));
};
refilter();
auto screen = ScreenInteractive::Fullscreen();
auto menu = Menu(&entries, &selected);
InputOption search_option;
search_option.content = &filter;
search_option.placeholder = "search title";
search_option.multiline = false; // Enter must not insert a newline
search_option.on_change = refilter;
auto search = Input(search_option);
auto layout = Container::Vertical({search, menu});
auto view = Renderer(layout, [&] {
Element details = text("No book matches") | dim;
if (!visible.empty()) {
const Book& b = books[visible[selected]];
details = vbox({
text("Title: " + b.title),
text("Author: " + b.author),
text("Borrower: " + (b.borrower.empty() ? std::string("(on the shelf)") : b.borrower)),
separator(),
gauge(static_cast<float>(std::count_if(books.begin(), books.end(),
[](const Book& x) { return !x.borrower.empty(); })) /
static_cast<float>(books.size())) | color(Color::Yellow),
text("share of the catalogue on loan") | dim,
});
}
return vbox({
hbox({
window(text(" Books "), vbox({search->Render() | border, menu->Render() | vscroll_indicator | frame | flex})) | flex,
window(text(" Details "), details) | flex,
}) | flex,
text(" Tab switches pane l loan/return q quit ") | inverted,
});
});
auto app = CatchEvent(view, [&](Event event) {
if (event == Event::Character('q') && search->Focused() == false) {
screen.Exit();
return true;
}
if (event == Event::Character('l') && !search->Focused() && !visible.empty()) {
Book& b = books[visible[selected]];
b.borrower = b.borrower.empty() ? "you" : "";
return true;
}
return false;
});
// A background thread can ask the UI thread for a redraw.
std::thread ticker([&] {
using namespace std::chrono_literals;
for (int i = 0; i < 3; ++i) {
std::this_thread::sleep_for(200ms);
screen.PostEvent(Event::Custom);
}
});
screen.Loop(app);
ticker.join();
return 0;
}
Build it with the CMake file above, or without CMake once the library is built:
c++ -std=c++17 -Wall -Wextra -I<ftxui>/include shelf_ftxui.cpp libftxui.a -o shelf_ftxui
The program compiled without warnings against FTXUI v7.0.3 built from source, and ran in a pseudo-terminal: it drew the
filtered list and the details pane, handled Tab, l and the arrow keys, and exited with status 0 after restoring the terminal
(leaving the alternate screen and showing the cursor). It was not run on Linux or Windows.
Source: FTXUI documentation and the v7.0.3 headers component.hpp, component_options.hpp and app.hpp.
How it differs from ncurses
-
Layout is declared, not computed. The ncurses program divides
COLSby two by hand; FTXUI’sflexandhboxdo it, and a resize needs no code (there is no equivalent ofKEY_RESIZEhandling in the program). -
State lives in your variables.
MenuandInputtake pointers (&selected,&filter) and write into them. The program must keep them alive for as long as the component exists; a pointer to a local that goes out of scope is the commonest crash. -
The render function is called often. Do not allocate or block in a
Rendererlambda; compute in the event handler oron_change. -
Focus decides who gets keys. Keys go to the focused child of a
Container. ACatchEventthat reacts to a letter must check which child has focus, asshelf_ftxui.cppdoes withsearch->Focused(), or the letter cannot be typed into the search box. -
Inputis multi-line by default.InputOption::multilinedefaults to true, so a newline can be inserted with Enter; set it to false for a one-line search box.
Animations, input and platform notes
Components support animated variants (MenuOption::HorizontalAnimated, ButtonOption::Animated). The changelog states
that since 7.0.2 animation frames are produced only when requested through animation::RequestAnimationFrame() (which
animation::Animator calls), not on every event. No animated example was compiled for this page.
The changelog also records platform behaviour that a portable program should know: version 7.0.1 honours NO_COLOR (colours
degrade to the terminal defaults), reports Apple’s Terminal.app as a 256-colour terminal and not as 24-bit, defaults to
24-bit colour on Windows Terminal, and downgrades colours on legacy Windows consoles that reject virtual-terminal
processing. Version 7 handles SIGINT, SIGQUIT, SIGHUP and crash signals through sigaction, restoring the terminal;
App::ForceHandleCtrlC(false) and ForceHandleCtrlZ(false) let the application take Ctrl+C and Ctrl+Z over, and
WithRestoredIO(fn) temporarily returns the terminal to its normal state so the program can run an external editor or
a pager.
Source: FTXUI v7.0.3 changelog and the app.hpp header.
Comparing the two on the same screen
| Concern | ncurses shelf_tui.c |
FTXUI shelf_ftxui.cpp |
|---|---|---|
Lines of code |
157 |
118 (and 25 for the static table) |
Layout |
|
|
Redraw |
Explicit |
The library calls the |
Resize |
Handle |
Automatic |
Search input |
|
An |
Terminal restore |
|
Done by the library, including on signals |
Windows |
PDCursesMod and some source changes |
Same source |
Both programs implement the same behaviour; the FTXUI version is shorter because layout, scrolling and focus are library services, and the ncurses version gives the program more control over exactly which cells change.
Pitfalls common to both
-
A TUI must still be scriptable: provide the same operations as plain subcommands (
shelf list,shelf loan), and start the full-screen mode only when standard input and output are terminals (isatty). See TUIs vs. CLIs. -
Text from untrusted sources (a book title from a shared file) can contain escape sequences. Both libraries draw cells and not raw bytes, which blunts the risk, but strip control characters before storing text; see Escape Sequences & Terminal Capabilities.
-
Assume nothing about the terminal size; handle very small terminals by showing a "terminal too small" message.
-
Test through a pseudo-terminal rather than a pipe; see Testing Terminal Apps & TUIs.