Skip to main content

dapple: Terminal Graphics, Composed

I live in the terminal. Most of my tools are CLIs. When I want to see something visual (an image, a plot, a table of results), I do not want to leave the terminal to see it.

Terminal graphics tools exist, but they are fragmented. One library does braille characters. Another does quadrant blocks. A third handles sixel. Each has its own API, its own conventions, its own way of thinking about the same problem.

dapple unifies them. One Canvas class, seven pluggable renderers, and eleven CLI tools built on top. The core depends only on numpy.

The Idea

The insight is that “render a bitmap to the terminal” is a single problem with multiple encodings. Braille characters pack 2x4 dots per cell. Quadrant blocks give you 2x2 with color. Sextants give 2x3. Sixel and kitty give true pixels if your terminal supports them. These are all the same operation: map a grid of values to a grid of characters.

So dapple makes the renderer a parameter, not an architecture decision:

from dapple import Canvas, braille, quadrants, sextants
from dapple.adapters import from_pil
from PIL import Image

canvas = from_pil(Image.open("photo.jpg"), width=80)
canvas.out(braille)      # Unicode braille (2x4 dots per cell)
canvas.out(quadrants)    # block characters with ANSI color
canvas.out(sextants)     # higher vertical resolution

Load once, render anywhere. The renderers are frozen dataclasses. braille(threshold=0.3) returns a new renderer with different settings; nothing mutates. They write directly to a TextIO stream, never building the full output as an intermediate string.

Renderers

RendererCell SizeColorsBest For
braille2x4mono/gray/trueStructure, edges, piping, accessibility
quadrants2x2ANSI 256/truePhotos, balanced resolution and color
sextants2x3ANSI 256/trueHigher vertical resolution
ascii1x2noneUniversal compatibility
sixel1x1paletteTrue pixels (xterm, mlterm, foot)
kitty1x1trueTrue pixels (kitty, wezterm)
fingerprint8x16noneArtistic glyph matching

In practice I use braille and sextants. They work everywhere. Kitty protocol broke things completely inside Claude Code (a TUI), and I have not tested sixel enough to trust it. Braille and sextants are the universal goto.

One honest limitation: Claude Code hides terminal output behind a Ctrl-O expand, so my carefully rendered graphics end up collapsed by default. I think recent hooks or tool-result handling might fix this, but I have not confirmed it yet.

Three Layers

The architecture has strict boundaries:

  1. Core (numpy only): Canvas, renderers, color handling, preprocessing, layout primitives (Frame, Grid).
  2. Adapters (optional deps): Bridge PIL, matplotlib, cairo, and ANSI art to Canvas.
  3. Extras (optional deps): The CLI tools. Each one is a separate install group.
pip install dapple                 # core only
pip install dapple[imgcat]         # image viewer
pip install dapple[all-tools]      # everything

Core never imports PIL. Adapters never import extras. This matters because the core is tiny and fast, and the CLI tools pull in their own dependencies without bloating each other.

The CLI Tools

I built eleven tools, each owning a domain rather than a file format. “Show me this data” should not require knowing whether the file is JSON or CSV. “Display these images” should not require a different tool for one image versus twelve.

ToolDomain
imgcatImages (single + grid)
datcatStructured data (JSON/JSONL/CSV/TSV)
vidcatVideo (stacked frames, playback, asciinema export)
mdcatMarkdown
htmlcatHTML
pdfcatPDFs
funcatMath and parametric plots
ansicatANSI art
compcatRenderer comparison
plotcatFaceted data plots
dashcatYAML-driven dashboards

A few worth explaining:

datcat

datcat handles JSON, JSONL, CSV, and TSV. Format detection is automatic. Internally everything becomes list[dict]. CSV rows become dicts on parse. One representation means the downstream code (table formatting, chart extraction, plotting) does not branch on input format.

datcat records.json              # JSON table
datcat events.jsonl --bar event  # JSONL bar chart
datcat weather.csv --sort temp_c # CSV sorted by column

imgcat

When imgcat receives multiple images, it switches to grid mode automatically. The layout uses dapple’s Frame and Grid primitives, the same ones compcat and dashcat use.

imgcat photo.jpg                     # single image
imgcat photos/*.jpg --cols 3         # 3-column contact sheet

Preprocessing flags (--contrast, --dither, --invert) apply to every image in the grid.

vidcat

The --play flag renders frames in-place using ANSI cursor movement. Instead of printing each frame below the last (which scrolls your terminal into oblivion), it overwrites the previous frame.

vidcat video.mp4 --play              # 10 fps default
vidcat video.mp4 --play --fps 24     # faster

The mechanism: render the first frame, count its output lines, then for each subsequent frame write \033[{N}A\033[J (cursor up N, clear to end) before rendering. Falls back to stacked output if stdout is not a TTY.

htmlcat

Converts HTML to markdown via markdownify, then renders through Rich using the same pipeline as mdcat. Good for documentation and articles. Not designed for CSS-heavy web apps.

Layout and Charts

Frame and Grid are layout primitives. Frame adds borders and titles around a canvas. Grid arranges canvases in rows and columns. These compose: a Grid of Framed canvases, a Frame around a Grid. dashcat uses this to build terminal dashboards from YAML config.

Two chart APIs:

  • Bitmap charts (dapple.charts): sparklines, line plots, bar charts, histograms, heatmaps. These return Canvas objects, composable with everything else.
  • Text charts (dapple.textchart): text-mode bar charts and sparklines, returning ANSI strings. Used by datcat for quick inline visualization.

Status

dapple is on PyPI. Docs at queelius.github.io/dapple.

The core and the CLI tools are stable. I use them daily. I have been experimenting with sextants (the 2x3 block characters give surprisingly good results) and tried generalizing the fingerprint renderer to match over a much larger set of Unicode glyphs. That did not work very well. The architecture is settled and the tools work.

Discussion