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
- Grid
- Cell model
- Border composition
- Unicode and text drawing
- Screen synchronization
- Future diff rendering
- Known gaps
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¶
Cells are edge-centric, not character-centric, during layout:
- Border drawing sets edge flags on grid cells.
- Before flush,
EdgesToRune()composes a Unicode box-drawing rune from the flags. Grid.Drawwrites 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 TableWidget → CellBuffer + 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.
WireTTYfeeds bytes into the xterm emulator (CompositeTerminal).Paintcopies 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 = true→DrawANSITextparses 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) andCellStyle(per column: marker glyphs, gutter background, syntax spans). Viewport.ANSI = false→ rune loop inviewport.gocallsSetContentdirectly.- 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:
| 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:
- Uses
utf8.DecodeRuneInStringfor correct wide-character iteration. - Clips at canvas width.
- PTY terminal panes: the xterm emulator in
CompositeTerminalresolves ANSI/SGR before paint, so child-process colors render correctly. - Viewport ANSI path: SGR parsing when
Viewport.ANSIis true (ConsolePane.SetANSI). Native panes (ANSI=false) use plain UTF-8 +CellStyleinstead — 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; EventKey → AppApi.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):
screen.Sync()reconciles internal size state.- New
Gridallocated at updated dimensions. - 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 Canvas → Grid.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:
- Clear
backBuffer. - Widgets draw into
backBuffer(allSetContentroutes through grid — prerequisite). - Compare
backBuffer.CellsvsfrontBuffer.Cells(rune + style). - Emit
screen.SetContentonly for diffs. - 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 |
Related documentation¶
- UI_ARCHITECTURE.md — Canvas API
- WINDOW_MANAGEMENT.md — split separators
- WINDOW_MANAGEMENT.md — where borders come from