ASCII Preview

Render circuits as deterministic ASCII schematics with --preview.

circ-compile <foo.circ> --preview renders a digital circuit as a styled ASCII schematic to stdout. The output is deterministic — the same .circ source produces byte-identical output on every invocation — and includes the wires, gate glyphs, fan-out taps, and ┼ crossings that make the diagram readable in a terminal.

Quick start

zig-out/bin/circ-compile tests/fixtures/circuits/single_gate.circ --preview

Expected output:

╭───╮     ╭───╮     ╭─────╮
│ a ├○───▶┤NOT├○───▶┤ out │
╰───╯     ╰───╯     ╰─────╯

Every node is a labeled box: input pin a, the NOT gate, and output pin out. The ○ glyph immediately outside each port is a bubble showing where a wire enters or leaves the cell; the ▶ arrowhead marks the destination end of each wire.

For a circuit using a builtin macro (xor in this case):

zig-out/bin/circ-compile tests/fixtures/circuits/builtin_xor.circ --preview
╭───╮     ╭───────╮            
│ a ├○───▶┤       │     ╭─────╮
╰───╯     │[xor:g]├○───▶┤ out │
      ╭──▶┤       │     ╰─────╯
╭───╮ │   ╰───────╯            
│ b ├○╯                        
╰───╯                          

The [xor:g] box represents the entire xor g(...) instance as a single labeled subcircuit: the label sits on the row between the two left-side input ports, with the output port on the right. To render the macro’s primitive expansion instead, add --expand-macros.

Flags

FlagPurpose
--previewSelects preview mode. Mutually exclusive with --emit-zig, --inspect, --truth-table, and --sim. -o is rejected at parse time.
--expand-macrosRenders subcircuits as their full primitive expansion instead of as a single labeled box. Only valid with --preview.
--expand-displayRenders a multi-bit led[N] (widths 2..7) as a row of · indicator glyphs inside its box, LSB on the left, instead of the 0x? hex display; a width of 8 or more falls back to the hex display and warns on stderr. Only valid with --preview.
--color=auto|always|neverEnables ANSI color (per-kind: input pins green, gates cyan, LEDs yellow, macros magenta, wires dim). Defaults to auto (color when stdout is a TTY and NO_COLOR is unset). always overrides NO_COLOR per the convention used by git/ls/grep.

The render path is fully in-memory: parse → resolve → translate → topology build → layout → render → stdout. No .wasm is written, no temp directory, no subprocess.

Rendering conventions

Per-kind glyphs. Every node renders as a bordered box. The box body always uses ╭───╮ / ╰───╯ for the corners, with the kind-specific label and ports on the rows in between:

KindCell shapeCell size
input_pin╭───╮ / │ <n> ├○ / ╰───╯ — name centered, output bubble on right edge5×3 (wider for long names)
output_pin╭───╮ / ┤ <n> │ / ╰───╯ — input port ┤ on left edge5×3 (wider for long names)
not_gate╭───╮ / ┤NOT├○ / ╰───╯ — input ┤ left, output ├○ right5×3
and_gate╭───╮ / ┤ │ / │AND├○ / ┤ │ / ╰───╯ — two stacked input ports flanking the label row5×5
led╭───╮ / ┤LED│ / ╰───╯ — input port ┤ on the left edge like every sink; a multi-bit LED shows 0x? (one ? per nibble) or, with --expand-display, one · per bit5×3 (wider for the multi-bit labels)
subcircuit (opaque)╭─...─╮ / ┤ │ / │[<sub>:<alias>]├○ / ┤ │ / ╰─...─╯ — width grows to fit the label, one input port per odd border rowmax(8, label width + 2) × (2·inputs + 1, at least 3)
rom╭─...─╮ / ┤ rom <name>[W,A] ├○ / ╰─...─╯ — one input port addr(label width + 4) × 3
ramsame label as rom; input ports addr, din, we, clk on rows 1, 3, 5, 7(label width + 4) × 9

The renderer truncates names longer than the cell width; shorter names pad with spaces. The ○ port-side bubbles aren’t part of the box itself: they sit one column outside the ╭╮╰╯ border, so a 5-column box with bubbles looks 6 columns wide on the wire side.

Wire line art. The renderer routes wires as orthogonal segments and draws them with these glyphs:

  • ─ horizontal rail, │ vertical rail.
  • ╭ ╮ ╰ ╯ corners between perpendicular segments. The renderer picks the glyph from the two segment directions: {W,S} → ╮, {E,S} → ╭, {W,N} → ╯, {E,N} → ╰.
  • ┬ ┴ ├ ┤ 3-way junctions. The same neighbour-inspection pass that handles corners picks them. Under the channel router every fan-out branches at a ● tap, so no current golden contains one; the glyphs remain for a wire that branches into a T off another.
  • ┼ 4-way crossings — drawn on any cell where two wires cross and neither diverges there: two wires that share a source or a destination but merely pass over each other still get ┼. The renderer prefers ┼ over the older “jump-arc” trick.
  • ● fan-out / fan-in branch point — drawn where two wires of one net diverge (one corners at the cell while the other passes straight through), and on any source cell from which three or more wires leave.
  • ○ port-side bubble — drawn only on the cell immediately outside a component’s output port (the cell where the wire begins); the destination end always carries an arrowhead.
  • ▶ ◀ ▲ ▼ arrowhead — drawn on the destination end of every wire, just before it enters the target port. Direction matches the segment’s last step.
  • + fallback — appears only on cells with no connecting neighbours in any direction. It’s a visible warning glyph meaning the router placed a wire that nothing connects to; if you see one, something is off.

Layout determinism. Rendering uses a five-stage layout pipeline (collapse → layering → ordering → coords → channels, described below), followed by a junction-picker pass that resolves crossings into the right corner/T-glyph. Every decision uses ascending node id as the universal tie-breaker; hash-map iteration is forbidden as an ordering source. The same .circ source produces byte-identical output across runs and platforms.

Multi-bit pins

A pin declared with a width annotation (input[4] a, wire[8] bus) renders with the width appended to its label as [N]; a multi-bit LED shows a hex placeholder instead (led_4bit_default.circ):

╭──────╮     ╭─────╮
│ a[4] ├○───▶┤ 0x? │
╰──────╯     ╰─────╯

A scalar pin omits the suffix, so the label width-marker is the visual cue that distinguishes a 1-bit and an N-bit wire. The wire glyph itself is the same: no “bus” glyph exists.

LED labels

The preview is static, so it never knows a signal’s value; an LED’s label depends only on its width and the flags:

LabelWhen
LEDwidth 1
0x?…? (one ? per nibble)width > 1 (default)
···· (one · per bit, LSB left)width 2..7 with --expand-display; width 8 or more keeps the hex form and warns once on stderr

Macro modes

Built-in macros (or, nand, nor, xor, xnor) and user-imported subcircuits expand into primitive gates during compilation. The renderer can display them two ways:

  • Opaque (default). All primitives that came from one subcircuit instance collapse into a single labeled box [<sub>:<alias>]. Connections to/from the subcircuit’s published ports flow into the box’s edges.
  • Expanded (--expand-macros). Every primitive appears individually, with its origin chain visible in the topology metadata. Useful for understanding what a macro does or for debugging unexpected behaviour from a builtin.

Where the data comes from

The renderer reads from a versioned topology payload embedded in compiled .wasm artifacts as WASM custom sections:

SectionContains
circ.topology.v0.minFlat primitive components (id, kind, width: u8, plus kind-dispatched aux bytes: slice carries lo/hi, rom/ram the address width) + connections. Magic CIRC, version 0x03. The “lightweight” payload — what the runtime needs.
circ.topology.v0.fullAdds per-component instance names + subcircuit-origin chains. Magic CIRF, version 0x03. The “rich” payload — what the renderer (and any future inspection tooling) needs.

--preview builds the full payload in memory (skipping the .wasm write) and feeds it directly into the layout. Tools that consume a .wasm artifact from disk can parse the same payload via lib/topology/full_decoder.zig:decode.

The min and full sections are independent variants on a single version axis; they coexist in every produced artifact. Pre-1.0, the schemas are freely revvable: bump vN.{min,full} rather than carrying compatibility shims. See lib/topology/full_format.zig for the wire format.

How the layout is computed

lib/preview/layout/ turns the full topology into a LayoutGrid (boxes with port cells, wires as axis-aligned segments) that lib/preview/render.zig paints. It is a layered-graph layout in the Sugiyama tradition, with channel routing:

  1. Collapse (collapse.zig). Wires, slices, and concats fold away; in opaque mode every primitive that came from one subcircuit instance becomes one box with a synthetic id.
  2. Layering (layering.zig). Longest-path layers: input pins in layer 0, every other node one past its furthest upstream, led/output pins forced to the last layer. Edges that close a cycle (a DFS back edge) and edges that sink-forcing turns leftward are flagged as back edges. A forward edge spanning several layers is split through one dummy node per intermediate layer, so every later stage sees only layer-adjacent segments.
  3. Ordering (ordering.zig). Inside each layer, barycenter sweeps down and up over the neighbours’ port positions (an and’s a sits above its b), kept while they lower the crossing count, then adjacent swaps to a fixed point. Integer arithmetic throughout; ties fall back to ascending id.
  4. Coordinates (coords.zig). Every node gets its own row: the top that makes the wire into its highest input port a straight horizontal, packed in the ordering with one free row between boxes; dummies are wire rows on their source’s port row. A node with one outgoing wire may move down to straighten it. Columns are each layer’s widest box plus the gap the router asks for.
  5. Channels (channels.zig). The stage routes each inter-layer gap as a channel: a net is one source port and its sinks in the next layer; a net whose terminals share a row is a straight horizontal; every other net gets a vertical track in the gap, assigned by the left-edge rule under a constraint graph (a source rail and a sink rail on one row order their tracks so no rail reaches another net’s corner). The router breaks constraint cycles with a dogleg on a free row, or a spacer row when none is free; back edges leave down a track to a return row below the diagram, run along it, and rise to their sink; a gap is tracks + 2 cells wide (never under five). A net no track can hold falls back to a bounded cell search — counted, never silent.

Every stage is deterministic: the same topology produces byte-identical output. tests/preview/layout_conformance_test.zig pins the grid of every previewable fixture as JSON under tests/fixtures/preview/layouts-json/ plus one table of invariants (tests/fixtures/preview/layout-invariants.golden): no wire cell inside a box, no cell shared by two nets running the same way, no junction between nets that is not a clean ┼ crossing, every net a tree — asserted over the whole corpus, with the crossings, bends, and size of each fixture on record. The renderer at circ-renderer/src/layout/ ports the same stages, so the canvas and the ASCII preview agree.

Known limitations

  • The --color flag has no effect outside --preview mode (no other mode renders to a terminal). It’s accepted in any mode but harmlessly stored.
  • A macro input port the topology cannot name is drawn as one port. The collapse stage maps every subcircuit input whose name is not a, in, or b onto in, so a parametric macro with inputs such as data and select receives two wires on one port cell; the two rails necessarily share that cell (a ● merge in the picture). The six such fixtures are the only rows of layout-invariants.golden with a non-zero I1/I2, exempted by name of the cause, not of the fixture.
  • Dense circuits get taller and wider than they did under the fixed-gutter layout: every wire now owns its cells, a gap is as wide as its track count, and a return lane adds a row per back edge (stress_grid_10x10 is 47×599 cells). The layout has no wrapping and no height cap.
  • A + glyph in the rendered output indicates a routed cell with no connecting neighbours — a router bug rather than a stylistic fallback. If one appears, check the relevant fixture against the goldens under tests/fixtures/preview/renders/.