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.