Skip to content

Rendering System

termforge renders through an off-screen Grid of Cells, composed by widgets via Canvas, and flushed to tcell. This document covers the cell model, border drawing, Unicode text, screen synchronization, and the path to diff-based rendering.

Companion docs: UI_ARCHITECTURE.md · WINDOW_MANAGEMENT.md


Table of contents


Rendering overview

Widgets
Canvas        (local Rect, shared Grid)
Grid          ([][]Cell framebuffer)
tcell Screen  (terminal backend)
flowchart LR
    W["Widgets"]
    C["Canvas<br/>(local Rect)"]
    G["Grid<br/>(Cell framebuffer)"]
    T["tcell Screen"]

    W -->|"Draw(c Canvas)"| C
    C -->|"writes Cells"| G
    G -->|"Draw / diff"| T

Source: diagrams/rendering_pipeline.mermaid

Design rationale: an intermediate grid decouples what changed from how the terminal is updated. Without it, every widget would call screen.SetContent directly, making dirty tracking impossible.


Grid

type Grid struct {
    W, H int
    Cells     [][]Cell
    BackCells [][]Cell
    // cursor state (ShowCursor / HideCursor)
}
Method Purpose
NewGrid(w, h) Allocate cell storage
SetContent(x, y, ch, style) Write rune and style to a cell
Print(x, y, style, text) Write a string through SetContent
Clear() Zero all cells
DrawVertical(x, y1, y2, bold) Mark vertical edge segments
DrawHorizontal(y, x1, x2, bold) Mark horizontal edge segments
Draw(screen) Compose runes, diff against BackCells, flush changes to tcell
ClearLine(y, style) Clear one row

App holds:

Buffer Role
frontBuffer Shared draw target and flush source

A separate backBuffer for full double-buffered diff is planned but not allocated yet. Today BackCells inside frontBuffer tracks the last flushed cell state for incremental updates.

On terminal resize, UpdateCanvas() calls screen.Sync(), reads new dimensions, and reallocates frontBuffer.

Implementation: grid.go, app.go.


Cell model

type Cell struct {
    Up, Down, Left, Right bool
    Rune                  rune
    Bold                  bool
    Style                 tcell.Style
}

Cells are edge-centric, not character-centric, during layout:

  1. Border drawing sets edge flags on grid cells.
  2. Before flush, EdgesToRune() composes a Unicode box-drawing rune from the flags.
  3. Grid.Draw writes composed runes to tcell.

Design decision: edge flags allow adjacent panes to share border cells without double-drawing. When a vertical split meets a horizontal split, corner cells resolve to crosses or tees automatically.

Edge-to-rune mapping

Cell.EdgesToRune() handles:

Pattern Light Bold
Cross (+)
T-junctions ├┤┬┴ ┣┫┳┻
Corners ┌┐└┘ ┏┓┗┛
Vertical line
Horizontal line
No edges space space

Comment in source notes that mixed-weight corners (light meeting bold) are not yet handled — a future enhancement when focus highlighting uses bold borders.

Implementation: cell.go.


Border composition

Split layout draws borders during BuildLayout:

// Vertical split at column leftW
c.DrawVerticalLocal(leftW, 0, c.H(), false)

// Horizontal split at row topH
c.DrawHorizontalLocal(topH, 0, c.W(), false)

These call into Grid.DrawVertical / DrawHorizontal, setting edge flags on the separator line. During the draw phase, WidgetTree.redrawGrid repeats these calls after widget content is drawn so separators recover from overwrites. Border cells reset Rune = 0 and Style = tcell.StyleDefault before edge flags are applied.

flowchart TB
    Build["WidgetTree.BuildLayout"]
    Draw["WidgetTree.Draw"]
    DV["DrawVerticalLocal"]
    DH["DrawHorizontalLocal"]
    Grid["Grid edge flags"]
    Compose["EdgesToRune"]
    Tcell["screen.SetContent"]

    Build --> DV --> Grid
    Build --> DH --> Grid
    Draw --> DV
    Draw --> DH
    Grid --> Compose --> Tcell

Design decision: borders belong to the WidgetTree geometry pass, not widgets. Widgets should not draw their own outer frame — this prevents double borders and misaligned corners in nested splits.

Planned: focused pane gets bold=true on its bordering edges for visual feedback. Today, focus is indicated by the per-pane status line (▎ {name}) at the bottom of the focused leaf.


Viewport: two paint paths (PTY ANSI vs native Canvas)

Line-based panes use termforge.Viewport over a platform.Buffer. Tabular list panes use TableWidgetCellBuffer + RectViewport instead — see TableWidget paint path below.

At draw time, Viewport.Draw picks one of two painters — a mux on the ANSI flag (not a separate type):

flowchart TB
    Buf["platform.Buffer line"]
    VP["Viewport.Draw"]
    Mux{"v.ANSI ?"}
    PTY["DrawANSIText<br/>parse \\x1b SGR"]
    Native["[]rune loop<br/>SetContent per cell"]
    Hooks["RowStyle + CellStyle + search"]
    Grid["Grid / tcell"]

    Buf --> VP --> Mux
    Mux -->|true| PTY --> Grid
    Mux -->|false| Native --> Hooks --> Grid
Path Viewport.ANSI Buffer contents Paint API Typical panes
PTY / foreign true May contain \x1b[…m from terminal tools Canvas.DrawANSIText parses SGR → SetContent Any pane fed by a child process on a PTY
Native / app-built false (default) Plain UTF-8 only SetContent(rune, tcell.Style) per column Panes whose text the application generates
Table lists N/A (no Viewport) Column cells in Table CellBuffer blit → Canvas Row/column list panes

Path 1 — data from TTY / PTY (CompositeTerminal panes)

  • Child processes such as compilers, shells, and REPLs send already-colored bytes.
  • WireTTY feeds bytes into the xterm emulator (CompositeTerminal).
  • Paint copies xterm cells (with SGR already resolved) onto the tcell canvas.
  • Used by any pane that shows the live output of a process on a PTY.

Path 1b — Viewport ANSI panes (line-based REPLs, legacy)

  • ConsolePane.SetANSI(true)Viewport.ANSI = trueDrawANSIText parses SGR in-buffer.
  • Only for line-based REPL scrollback, not the xterm terminal panes above.

Path 2 — data the application builds (no ANSI in buffer)

  • Widget rebuild() writes plain text, e.g. "━━▶ 0x… add %rsp" — no \x1b.
  • Color comes from RowStyle (whole line) and CellStyle (per column: marker glyphs, gutter background, syntax spans).
  • Viewport.ANSI = false → rune loop in viewport.go calls SetContent directly.
  • This is the normal tcell path for UI you own.

Rule: do not embed \x1b in buffers you paint with path 2. Do not set ANSI=true on panes whose buffer is plain text.

Implementation: viewport.go (Draw, ANSI field), utf.go (DrawANSIText). See internal/demo for a native Viewport pane.


TableWidget paint path

Tabular list panes do not use platform.Buffer / Viewport. Paint stack:

SetFill(model) → Table layout → RectViewport (origin) → CellBuffer (window) → Canvas → Grid
Piece Role
Table Columns, rows, auto column width, sticky title/header
RectViewport Pan when contentW/contentH exceeds pane; EnsureRowVisible scrolls Y only
CellBuffer Off-screen rune+style grid for visible slice
TablePaintState RowStyleFunc + /search highlight spans

Row colors (selection, markers, gutter) come from the application widget's SetRowStyleFunc, not embedded \x1b sequences.

Implementation: table.go, table_widget.go, table_paint.go. Applications supply the row model and style callbacks.


Unicode and text drawing

UTF-8 text

Canvas.DrawANSIText iterates UTF-8 runes and calls SetContent per column:

func (c Canvas) DrawANSIText(localX, localY int, text string, baseStyle tcell.Style)
  • Uses utf8.DecodeRuneInString for correct wide-character iteration.
  • Clips at canvas width.
  • PTY terminal panes: the xterm emulator in CompositeTerminal resolves ANSI/SGR before paint, so child-process colors render correctly.
  • Viewport ANSI path: SGR parsing when Viewport.ANSI is true (ConsolePane.SetANSI). Native panes (ANSI=false) use plain UTF-8 + CellStyle instead — see Viewport: two paint paths. Copy selection strips ANSI to plain text.

Gap: no grapheme cluster / East Asian width handling yet. For mostly-ASCII content this is acceptable short-term; panes that display internationalized text will need runewidth or equivalent.

Box drawing

Border runes use Unicode Box Drawing block (U+2500–U+257F) with Heavy variants (U+2501+) for bold edges.

Terminal emulators with UTF-8 enabled (the default in modern terminals) render these correctly. Fallback to ASCII +--| is not implemented — a future compatibility mode.

Implementation: utf.go, cell.go.


Per-pane status line

The focused workspace pane paints a one-row status band below its content area. This happens in the WidgetTree.Draw phase after widgets draw and split borders are restored:

Step Function Purpose
1 drawWidgets Pane content on rows 0..H-1
2 clearStatusRows ClearStatusLine — reset status row to tcell.StyleDefault
3 redrawGrid Re-apply DrawVertical / DrawHorizontal with default style
4 drawStatusLines PaintStatusBar on focused leaf only

PaintStatusBar fills the pane width on row c.H() and writes ▎ {name}. Widgets should not draw on the status row inside Draw — use PaneName on BaseWidget or override DrawStatusLine.

Implementation: status_line.go, widget_tree.go, base_widget.go.


Screen synchronization

Current frame loop (App.Run):

for !app.exit {
    select {
    case ev := <-app.events:
        app.Api.HandleCoreEvents(ev)
    default:
        ev := app.screen.PollEvent()
        app.HandleEvent(ev)   // Ctrl+D, resize, redraw interrupt; keys → AppApi.HandleKey
        app.Draw(Canvas{rect: app.canvas.Rect(), grid: app.frontBuffer})
        app.frontBuffer.Draw(app.screen)
        app.screen.Show()
    }
}
Step Purpose
select Drain pending termforge.Event messages before polling tcell
PollEvent Block for input or resize
HandleEvent Global keys, resize → UpdateCanvas; EventKeyAppApi.HandleKey
Draw Widgets render into shared frontBuffer via Canvas
frontBuffer.Draw Diff changed cells → tcell
Show Batch update to terminal

Ownership: App owns tcell.Screen (poll, lifecycle, Show). Grid receives the screen only at flush time.

On resize (EventResize):

  1. screen.Sync() reconciles internal size state.
  2. New Grid allocated at updated dimensions.
  3. Widgets receive resize events on next poll.

Design decision: single-threaded draw loop — no concurrent SetContent calls. Async producers use PostEvent to marshal data onto this thread.


Future diff rendering

Goal: send only modified cells to tcell each frame, reducing bandwidth for remote sessions and large terminals.

Current state: Grid.Draw already compares each cell against BackCells and skips unchanged cells. Widget drawing routes through CanvasGrid.SetContent, so rune and style changes are tracked.

Remaining work for full double-buffered diff:

flowchart LR
    Draw["Widget Draw"]
    Back["backBuffer"]
    Front["frontBuffer"]
    Diff["Compare cells"]
    Patch["SetContent changed only"]
    Swap["Swap buffers"]

    Draw --> Back
    Back --> Diff
    Front --> Diff
    Diff --> Patch
    Patch --> Swap

Steps:

  1. Clear backBuffer.
  2. Widgets draw into backBuffer (all SetContent routes through grid — prerequisite).
  3. Compare backBuffer.Cells vs frontBuffer.Cells (rune + style).
  4. Emit screen.SetContent only for diffs.
  5. Swap buffer pointers.

Additional optimizations:

Technique Benefit
Separate backBuffer draw target Avoid drawing over previous frame in-place
Per-widget dirty flags Skip draw for unchanged panes
Damage regions Limit diff to affected rects
Idle skip No flush when no events and no dirty

Known gaps

Gap Impact Mitigation plan
No per-frame grid clear Stale cells if a pane shrinks Clear or full redraw at frame start
No separate backBuffer In-place draw + diff only Allocate second grid; swap after flush
Grid cursor not applied to tcell ShowCursor state unused at flush Apply cursor in Grid.Draw or App
ANSI / SGR in consoles Working for PTY-backed scrollback Preserve ESC bytes end to end; SetANSI(true) on the pane
No wide-char width Misaligned columns for CJK Integrate runewidth
Mixed bold/light corners Visual glitches at focus borders Corner weight resolver