Skip to content

Notcurses — a cell target that addresses below the cell

Category: cell-only, sub-cell. Last reviewed: August 23, 2026. Pinned at b26048ee; capability notes from the project wiki.

On the list for one question. Friction §5 says sparkles:ui spells sub-cell placement as a compass direction (RuleEdge.top, centerX, …) because the toolkit has no unit below a cell. Notcurses is a terminal library that routinely addresses below the cell, so it has had to answer this without the luxury of continuous coordinates that Slint and Qt enjoy.

FieldValue
LanguageC (with C++, Python, Rust and Nim bindings in-tree or adjacent)
LicenseApache-2.0 (COPYRIGHT; src/fetch/ncart.c is MIT, from Neofetch)
Repositorydankamongmen/notcurses
Documentationnotcurses.com man pages, dankwiki, USAGE.md, TERMINALS.md
Categorycell-only, sub-cell
Pinned revisionb26048eebc74d5d254717d3332fa484718f9efe6
Target rangeterminal emulators only — but spanning ASCII-only through sextant/octant fonts to Sixel/Kitty/iTerm2 pixel graphics
Backends shippedone renderer, many terminals: capability is probed per terminal via terminfo plus terminal identification (TERMINALS.md)
The seamncplane (a cell grid) plus ncblitter_e — a fidelity ladder chosen per draw
The intermediatenccell — 16 bytes: an EGC reference, fg, bg, style (notcurses.h)

Overview

What it solves

Terminals differ not in which drawing calls they accept — they all accept cells — but in how finely a cell can be subdivided: half blocks, quadrants, sextants, octants, Braille, or real pixels via Sixel/Kitty/iTerm2. Notcurses' problem is to let a caller draw an image or a plot at the best fidelity the terminal in front of it can manage, without the caller branching on terminal identity.

Design philosophy

The library's own framing of what it is and is not (README.md):

What it is: a library facilitating complex TUIs on modern terminal emulators, supporting vivid colors, multimedia, threads, and Unicode to the maximum degree possible. […] What it is not: a source-compatible X/Open Curses implementation, nor a replacement for NCURSES on existing systems.

And, from the nccell commentary, the sentence that decides Q1 for this subject (notcurses.h):

Existence is suffering, and thus wcwidth() is not reliable. It's just quoting whether or not the EGC contains a "Wide Asian" double-width character. […] True display width is a function of the font and terminal.

A library that says display width is a function of the font and the terminal has already decided it cannot be a property of the drawing call.

Q1 — measurement

The frame is built per cell — an nccell holds "a theoretically arbitrarily long UTF-8 EGC, a foreground color, a background color, and an attribute set" (notcurses.h) — so advance is a Unicode property resolved above the blitter, not something a blitter answers. Width is exposed as a free function over a string, ncstrwidth(const char* egcs, int* validbytes, int* validwidth), not as a method on any drawing object.

Consistent with Slint, Qt and egui: not the painter's job, even here.

Q2 — degradation is automatic, and refusable

Two details worth taking:

  • Automatic downgrade, as an explicit ladder. lookup_blitset in blit.c is the whole policy: NCBLIT_BRAILLE decays to NCBLIT_4x2 without Braille support, NCBLIT_4x2 to NCBLIT_3x2 without octants, NCBLIT_PIXEL to NCBLIT_3x2 without bitmaps, NCBLIT_3x2 to NCBLIT_2x2 without sextants, NCBLIT_2x2 to NCBLIT_2x1 without quadrants, and NCBLIT_2x1 to NCBLIT_1x1 without half blocks — ending in assert(NCBLIT_1x1 == setid). The caller does not branch on terminal capability, and NCBLIT_1x1 is the floor by construction.
  • NCVISUAL_OPTION_NODEGRADE. The caller can refuse the downgrade and get a failure instead — "If the specified blitter is not available, fail rather than degrading" (notcurses_visual.3.md). The same function implements both halves through one bool may_degrade parameter, and the plot API carries its own NCPLOT_OPTION_NODEGRADE.

That pairing is better than either alone. Degrading by default keeps callers simple; being able to opt out means a caller that genuinely needs fidelity — a golden test, a pixel-exact preview — finds out rather than silently rendering something else. sparkles:ui has the first half (a canvas without rule paints a whole-cell line) and none of the second: there is no way to ask for a hairline and be told no.

Capability itself is discovered by "advanced and extensive runtime querying" of terminfo and terminal identification, which is sparkles:base.term_caps' territory rather than the drawing seam's.

Q3 — primitives over planes

Primitives over planes (ncplane), not widgets. Notcurses ships widgets — ncselector, ncreel, ncprogbar, nctabbed and more are declared in notcurses.h — but they are built on planes and cells, and none of them reaches the blitter as a named operation. The seam is the plane; the widget vocabulary lives entirely above it.

Q4 — command shape

No reified command stream; direct calls against a plane, which then holds the result. Like Ratatui, the thing that persists between "drawing" and "output" is the grid, not the instructions — so the recording, comparison and replay properties RecordingCanvas gives us come from the cell buffer here rather than from an op stream.

Q5 — name a fidelity, not a position

Notcurses' answer is the blitter: a family of encodings that trade resolution against terminal support, chosen per draw. The enum (notcurses.h, mirrored in notcurses_visual.3.md):

BlitterSub-cell resolutionMechanism
NCBLIT_DEFAULTchosen for you"let the ncvisual pick"
NCBLIT_1x1nonespaces + background colour; works in ASCII
NCBLIT_2x12 verticaladds half blocks (▄▀)
NCBLIT_2x24adds left/right halves (▌▐) and quadrants (▖▗▟▙)
NCBLIT_3x26adds sextants
NCBLIT_4x28adds octants
NCBLIT_BRAILLE8 (4 rows × 2 cols)Braille
NCBLIT_PIXELtrue pixelsSixel, Kitty, or iTerm2 protocol

Two further enumerators, NCBLIT_4x1 and NCBLIT_8x1, add quarter and eighth vertical blocks; they are "intended for use with plots, and are not really applicable for general visuals" (notcurses_visual.3.md).

The caller says how finely it wants to draw; the library says what the terminal can actually do. That is a different axis from ours entirely: we enumerate six positions a hairline may occupy, they enumerate seven general-purpose resolutions a cell may be subdivided into. Ours grows an enumerator every time a new place needs a thin thing; theirs does not, because position falls out of the resolution.

NOTE

An earlier pass on this subject spelled these NCBLITTER_*. The symbols are NCBLIT_*; the table above is transcribed from the enum at the pinned revision.

Q6 — resolved or semantic styling

Resolved — each nccell carries a concrete foreground, background and style, with alpha bits for the small compositing model the header documents. There is no semantic role to re-resolve, because there is no consumer below the cell that could resolve one differently.

Q7 — payload ownership

The plane owns its cells. An nccell is 16 static bytes and stores longer grapheme clusters by reference into the plane's own egcpool (notcurses.h), so a payload's lifetime is the plane's. There is no borrowed-payload problem because there is no deferred command to outlive a frame.

Q8 — extent query

Planes have explicit dimensions — ncplane_dim_yx(const struct ncplane*, unsigned*, unsigned*) — so extent is a property of the target, as in Qt. Notcurses adds one wrinkle sparkles:ui will meet: ncvgeom reports maxpixely/maxpixelx, defined only for NCBLIT_PIXEL (notcurses.h), i.e. the unit of the extent answer depends on the fidelity chosen.

Strengths

  • Fidelity is a named, ordered ladder, so a caller expresses intent ("as fine as you can, at least quadrants") rather than a position.
  • The degradation policy is one function. lookup_blitset is the entire fallback story, readable top to bottom, terminating in a provable floor.
  • Degradation is refusable through the same code path (bool may_degrade), so correctness-sensitive callers are not second-class.
  • Position falls out of resolution, so new sub-cell placements cost no new vocabulary.
  • Capability probing is separated from drawing — terminfo and terminal identification, not a method on the plane.
  • A single 16-byte cell makes the intermediate cheap enough to keep, diff and reason about.

Weaknesses

  • The ladder is hard-coded, not data. Adding a fidelity means editing a cascade of if statements in blit.c and the enum in lockstep.
  • The floor is an assert, not a type: assert(NCBLIT_1x1 == setid) is the only statement that the bottom of the ladder is reachable.
  • NCBLIT_BRAILLE sits awkwardly in the ordering — same nominal 8× density as NCBLIT_4x2 but a different aspect ratio, and the man page warns it does not "tend to work out very well for images".
  • The extent unit is fidelity-dependent (maxpixely/maxpixelx only for NCBLIT_PIXEL), so a caller must know which blitter it got before reading the answer.
  • Terminal-only by construction. Nothing here is a seam a GPU backend could implement; the design's clarity comes from having exactly one device class.
  • Refusal is a flag, not a type. NODEGRADE is duplicated per API family (NCVISUAL_OPTION_NODEGRADE, NCPLOT_OPTION_NODEGRADE) rather than being one policy.

Key design decisions and trade-offs

DecisionRationaleTrade-off
Express sub-cell drawing as a fidelity enum, not coordinatesTerminals differ in resolution, not in geometry; one axis captures the whole differenceThe caller cannot ask for an arbitrary sub-cell position — only for a subdivision the ladder names
Degrade automatically down a fixed ladderThe common caller never branches on terminal identityThe ladder's order encodes aesthetic judgements (Braille vs octants) that a caller cannot override
Make degradation refusable (NODEGRADE, may_degrade)Golden tests and pixel-exact previews must fail loudly rather than render something elseTwo behaviours through one function; every caller must decide which it wants
Probe capability from terminfo + terminal identification, outside the drawing APIKeeps the drawing seam free of capability questionsA large, terminal-specific database (TERMINALS.md) to maintain
Plane owns its cells, EGCs pooled per plane16-byte cells stay cheap while arbitrary-length graphemes remain representableCells are not self-contained values; a cell means nothing without its plane's pool
Widgets built above the plane, never at the seamThe blitter never has to learn what a progress bar isA terminal with better native affordances cannot render a known widget its own way
Width as a free function (ncstrwidth), not a painter methodDisplay width is a property of font and terminal, not of a drawing callThe library must maintain its own width oracle rather than deferring to wcwidth

Bearing on the proposal

  1. Replace RuleEdge with a resolution, not more enumerators. A seam that says "draw this at hairline fidelity within this rect" lets a pixel backend use one device pixel, a cell backend fill a cell, and a sextant-capable terminal do something in between — without the toolkit naming positions.
  2. Add the refusable half of degradation. Silent degradation is right by default and wrong when a caller is verifying output.
  3. Confirms that even a cell-native library keeps text advance above the drawing layer.
  4. Write the ladder as one function. lookup_blitset is 80 lines and is the policy; sparkles:ui's degradation is currently scattered across __traits(compiles) sites in the interpreter, which is the same decision made in a dozen places.

Sources

Every path verified to resolve at b26048eebc74d5d254717d3332fa484718f9efe6 over raw.githubusercontent.com.