Render Pipeline
TermFlow now uses a coalesced runtime scheduler with framebuffer diff rendering to reduce flicker under high-frequency updates.
Pipeline
state events
-> mark frame dirty
-> coalesce queued commands
-> build latest view (once per commit)
-> materialize next frame buffer
-> diff(previous, current)
-> emit minimal ANSI patch
-> restore logical input cursor
Key Invariants
- At most one render commit is active at a time.
- Rendering is latest-state wins; intermediate states can be dropped during bursts.
- Diff output includes cleanup for shrink cases (line tail and removed rows).
- Cursor visibility is runtime-owned (startup/shutdown/interrupt), not frame-owned.
- Each frame writes one buffered ANSI payload from renderer perspective.
Why Flicker Dropped
- Most frames update a small subset of cells instead of repainting everything.
- Command bursts are coalesced before render, avoiding redundant intermediate paints.
- Cursor movement is deterministic and restored after body updates.
Regression Focus Areas
- Prompt/input updates must not leave stale tail characters.
- Shrinking content must clear removed rows/columns.
- Borders at terminal edges must remain stable across rapid updates.
- Ctrl-C / unexpected exits must always restore cursor visibility.
Snapshot Testing
The termflow.testkit package (published on the test->test classpath) drives a TuiApp synchronously — bypassing TuiRuntime — and lets tests compare the resulting RenderFrame against on-disk golden files. This is the primary regression safety net for the items above.
Writing a snapshot test
import org.scalatest.funsuite.AnyFunSuite
import termflow.testkit.{GoldenSupport, TuiTestDriver}
class MyAppSnapshotSpec extends AnyFunSuite with GoldenSupport:
test("initial frame"):
val d = TuiTestDriver(MyApp.App, width = 40, height = 10)
d.init()
d.send(MyApp.Msg.DoSomething)
assertGoldenFrame(d.frame, "after-do-something")
TuiTestDriverexposesmodel,frame,send(msg),cmds,exited, andobservedErrors.- Subscriptions (
Sub.Every,Sub.InputKey,Sub.TerminalResize) are never started — tests stay deterministic. For prompt-driven apps, construct the wrappingMsgdirectly (e.g.Msg.ConsoleInputKey(KeyDecoder.InputKey.CharKey('+'))) instead of waiting on the input thread. Cmd.FCmdmust wrap a pre-resolvedFuture.successful(...); the driver will not block on unresolved futures.
Golden file format
Goldens are stored under src/test/resources/termflow/golden/<SuiteName>/<name>.golden and use a pipe-wrapped, chars-only format:
# width=40 height=13
# cursor=6,10
|┌──────────────────────────────────┐ |
|│Current count: 0 │ |
...
The leading/trailing | markers make every row's width unambiguous and preserve trailing whitespace across editors and CI. Style/colour information is intentionally omitted — layout is what the render pipeline regresses on.
Narrow assertions for prompt/cursor rows
Full-frame snapshots are brittle for tests that care about a single row (e.g. a prompt repaint regression). Use assertGoldenString with a one-row extract:
private def row(frame: RenderFrame, rowIndex: Int): String =
val cells = frame.cells(rowIndex - 1)
val sb = new StringBuilder("|")
(0 until frame.width).foreach(c => sb.append(cells(c).ch))
sb.append("|").toString
assertGoldenString(row(d.frame, 7), "cursor-row-after-abc")
InputLineReproSpec uses this pattern to pin the exact cursor-math regressions behind #73 and #74.
Updating goldens
Record or refresh a snapshot:
sbt -Dtermflow.update-goldens=true "termflowSample/testOnly *MyAppSnapshot*"
# or:
UPDATE_GOLDENS=1 sbt "termflowSample/testOnly *MyAppSnapshot*"
Always review the resulting git diff before committing. The -Dtermflow.update-goldens=true system property is forwarded into forked test JVMs by build.sbt.
What snapshot tests catch
- Repaint regressions (stale cells left behind on shrink/clear)
- Cursor-position drift when the prompt viewport scrolls
- Layout drift when terminal dimensions change behaviour
- Border / text overflow interactions at box edges
They do not exercise timer-driven or random sources — those apps (DigitalClock, SineWaveApp, FutureCounter) are deliberately excluded from the golden suite.