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 (wincon), GUI (WinGUI), VT-like terminal (VT) and others

Yes: Linux, macOS, Windows and WebAssembly

Terminal model

Reads the terminfo database for TERM

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.

flowchart LR A["draw into windows: mvwprintw, box, waddch"] --> B["wnoutrefresh: copy a window into the virtual screen"] B --> C["doupdate: compare with the physical screen"] C --> D["minimal escape sequences to the terminal"] A -. "wrefresh = wnoutrefresh + doupdate" .-> D

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

setlocale(LC_ALL, "")

Must come before initscr. Without it ncurses assumes ISO 8859-1 printability and multi-byte UTF-8 text is not drawn correctly.

initscr()

Initialises curses and returns stdscr. If it fails it writes a diagnostic to standard error and exits the program. TERM selects the terminal description; with TERM unset or empty it uses a generic unknown entry that cannot do full-screen output.

newterm(type, out, in)

The alternative for programs that manage a terminal explicitly: it takes the terminal type and the output and input FILE streams and returns a SCREEN pointer (or ERR on allocation failure). Needed to run curses on a stream other than standard output, for example when standard output is a pipe.

endwin()

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 refresh or doupdate resumes curses; call delscreen to free it.

isendwin()

True when no refresh has happened since the last endwin: useful in signal-driven shutdown code.

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

cbreak()

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).

raw()

Like cbreak, and in addition the interrupt, quit, suspend and flow-control characters reach the program as ordinary input. The program must provide its own way to quit. Calling cbreak afterwards overrides raw.

noecho()

The terminal driver’s echo is always turned off by ncurses; noecho also stops ncurses writing typed characters into the window. echo() turns that on, which is what a line-entry prompt such as wgetnstr needs.

keypad(win, TRUE)

Enables function-key decoding for input read from win: arrow keys arrive as KEY_UP and so on instead of as separate bytes of an escape sequence. Off by default.

nodelay(win, TRUE), timeout(ms), wtimeout(win, ms)

Non-blocking read (nodelay, or timeout(0)), wait up to ms milliseconds, or block (a negative value). A read that times out returns ERR.

halfdelay(tenths)

cbreak with a timeout of 1 to 255 tenths of a second.

notimeout(win, TRUE)

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

newwin(lines, cols, y, x)

A window with its own storage. Delete it with delwin.

derwin(parent, lines, cols, y, x), subwin

A subwindow that shares the parent’s character storage; derwin gives the origin relative to the parent, subwin relative to the screen. Draw in the parent and refresh the parent.

newpad(lines, cols)

A window that is not limited by the screen size. It cannot be refreshed with wrefresh.

prefresh(pad, pminrow, pmincol, sminrow, smincol, smaxrow, smaxcol), pnoutrefresh

Shows the part of the pad whose top-left corner is pminrow, pmincol in the screen rectangle given by the last four arguments. Both rectangles must lie inside their structures; the call returns ERR otherwise.

subpad, pechochar

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

has_colors()

True if the terminal supports colour. Can be called right after initscr.

start_color()

Enables colour, sets COLORS and COLOR_PAIRS and initialises pair 0. Call it straight after initscr.

use_default_colors()

ncurses extension (documented in default_colors(3x)): after it, init_pair accepts -1 for the terminal’s default foreground or background, so the program does not paint a black rectangle over a light theme.

init_pair(n, fg, bg), COLOR_PAIR(n)

Define a pair and use it as an attribute. Redefining a pair refreshes every cell that uses it. COLOR_PAIR only holds small pair numbers; larger ones need the wide interface (attr_set, init_extended_pair).

can_change_color(), init_color

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.

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.

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

-lncursesw on Linux. On macOS the system -lncurses is already a wide build (confirmed with the compiler here). The ncurses documentation describes the wide configuration as built with --enable-widec.

Header requirement

Define _XOPEN_SOURCE to a value such as 700, or NCURSES_WIDECHAR, before including curses.h.

Naming

Wide variants add w or _w: waddch becomes wadd_wch, plus addwstr, mvwaddwstr, get_wch, wget_wch.

Types

cchar_t, wchar_t, wint_t.

Reading a key

get_wch(&ch) returns OK for a character and KEY_CODE_YES for a function key; ch then holds a KEY_ code.

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

-lpanel

A stack of windows with overlap handling. new_panel(win) puts a window on top, top_panel, hide_panel and show_panel reorder it, update_panels recomputes the virtual screen (it does not call doupdate), and del_panel frees the panel but not the window.

menu

-lmenu

new_item, new_menu, post_menu, unpost_menu, free_menu, free_item, set_menu_win, set_menu_sub, set_menu_format, item_name, current_item and menu_driver, which takes a request such as REQ_DOWN_ITEM and returns E_UNKNOWN_COMMAND for a request it does not know.

form

-lform

new_field, new_form, post_form, form_driver, field_buffer, set_field_back, field_opts_off, free_form, free_field: fields with editing and validation.

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;
}

Source: panel(3x), menu(3x) and form(3x). Compiled with -lform -lmenu -lpanel -lncurses and run on macOS, where Tab, arrow keys and Enter produced selected: Code Complete / Ana.

Building

Platform Compile and link

macOS

cc prog.c -lncurses (the system library is a wide build; add -lpanel -lmenu -lform as needed).

Linux

cc prog.c -lncursesw. pkg-config --cflags --libs ncursesw prints the flags where the .pc files are installed.

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 exit between initscr and endwin leaves the terminal in raw mode with the cursor hidden. Register endwin with atexit, never call _exit while curses is active, and reset is what the user types to recover.

  • Writing to stdout while curses is active. printf output is interleaved with curses' own output and is lost at the next refresh. Print results after endwin, as dialog.c does.

  • Forgetting setlocale. UTF-8 text appears as garbage without it, even in a wide build.

  • Resizing. Windows created with newwin keep their old size after a resize; rebuild them on KEY_RESIZE.

  • Refresh order. Refreshing stdscr last paints over the other windows. Queue everything with wnoutrefresh and call doupdate once.

  • 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. keypad is a per-window setting; the program calls it on the window it reads from.

  • Width of printf field. %s counts 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

wincon

The Windows console (formerly win32). The right port for a program that runs in cmd.exe, PowerShell or Windows Terminal.

WinGUI

Windows graphics mode: the program gets its own window instead of a console.

VT

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.

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 (mintty -P on or MSYS=enable_pcon). The FAQ recommends Cascadia Code or Cascadia Mono in Windows Terminal and reports problems with Consolas and Courier New.

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 (libnotcurses-sys), Zig, Julia, Nim, Dart, Ada and Raku.

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.

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

screen

Low-level rendering: colours, cells, the terminal.

dom

Layout and composition: a tree of Element values built with functions such as hbox, vbox, text and border.

component

User interaction: widgets, events and the main loop (ScreenInteractive).

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

text, vtext, paragraph (with paragraphAlignLeft, Right, Center, Justify), spinner, graph, canvas, emptyElement

Decoration

border (and borderLight, Dashed, Heavy, Double, Rounded, Empty, borderStyled), window(title, content), separator (and its light, dashed, heavy, double, empty and styled variants), gauge (and gaugeLeft, Right, Up, Down)

Styling

bold, dim, italic, inverted, underlined, blink, strikethrough, color(Color), bgcolor(Color) (also gradients), hyperlink

Layout containers

hbox, vbox, dbox (layers on top of each other), gridbox, flexbox (with hflow and vflow)

Flexibility

flex, flex_grow, flex_shrink, with x and y variants and flex_factor(grow, shrink)

Sizing and scrolling

size(WIDTH or HEIGHT, EQUAL or LESS_THAN or GREATER_THAN, n), frame, vscroll_indicator, focus, select

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

Container::Vertical, Container::Horizontal, Container::Tab, Container::Stacked

Widgets

Button, Checkbox, Input, Menu, MenuEntry, Radiobox, Dropdown, Toggle, Slider, Collapsible, Window

Layout helpers

ResizableSplit, ResizableSplitLeft, Right, Top, Bottom

Composition

Renderer(child, fn) (pair a component with the element it draws), CatchEvent(child, fn) (intercept events; return true when handled), Maybe(child, show), Modal(main, modal, show), Hoverable

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

ScreenInteractive::Fullscreen()

Takes the whole terminal and uses the alternate screen, so the user’s scrollback is untouched (same as FullscreenAlternateScreen()).

ScreenInteractive::FullscreenPrimaryScreen()

Full size but on the primary screen; a resize may leave old content mixed with the new.

ScreenInteractive::FitComponent()

Width and height match the component, drawn inline.

ScreenInteractive::TerminalOutput()

Terminal width, component height, drawn inline: the right mode for a progress display that leaves its last frame in the scrollback.

ScreenInteractive::FixedSize(w, h)

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 COLS by two by hand; FTXUI’s flex and hbox do it, and a resize needs no code (there is no equivalent of KEY_RESIZE handling in the program).

  • State lives in your variables. Menu and Input take 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 Renderer lambda; compute in the event handler or on_change.

  • Focus decides who gets keys. Keys go to the focused child of a Container. A CatchEvent that reacts to a letter must check which child has focus, as shelf_ftxui.cpp does with search->Focused(), or the letter cannot be typed into the search box.

  • Input is multi-line by default. InputOption::multiline defaults 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

newwin with sizes from COLS and LINES

hbox, window, flex

Redraw

Explicit draw() every loop pass

The library calls the Renderer when an event arrives

Resize

Handle KEY_RESIZE and rebuild windows

Automatic

Search input

wgetnstr prompt on the status line

An Input component with on_change

Terminal restore

atexit(endwin) plus signal flag

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.