Files
cl-tty/org/dirty.org
Hermes Agent ba5cb360db literate: create org/dirty.org as proof of literate programming workflow
org/dirty.org is now the source of truth for dirty.lisp and
dirty-tests.lisp. The process:
  Overview → Contract → Tests → Implement → Tangle → Test (GREEN)

Hand-written .lisp files were deleted and regenerated from org alone
to prove the pipeline works.
2026-05-12 17:03:15 +00:00

3.8 KiB

Dirty Tracking

Overview

The dirty tracking module provides a mixin class and protocol for marking components as needing re-render. This is the foundation of the incremental rendering pipeline.

Without dirty tracking, every frame would re-render every component. With it, only components that changed (and their ancestors, for layout recomputation) get re-processed. The makes the difference between a 60fps terminal UI and a flickering mess.

This module is intentionally minimal: a single mixin class and two generic functions. The complexity lives in the propagation logic (see render.lisp), but the dirty state itself is trivial.

Contract

dirty-mixin

A class that adds a dirty slot. Components that need dirty tracking inherit from this.

  • (dirty-p component) — returns t if the component needs re-render, nil if it's up-to-date. New instances start dirty (t).

mark-clean

  • (mark-clean component) — sets dirty to nil. Called after rendering.
  • Specialized on dirty-mixin; default method is a no-op.

mark-dirty

  • (mark-dirty component) — sets dirty to t. Called when the component's state changes (user typed a character, selection changed, etc.).
  • Specialized on dirty-mixin; default method is a no-op.

Tests

;; Dirty tracking tests are in box-tests.lisp (same test suite)
(in-package :cl-tty-box-test)
(in-suite box-suite)

(test dirty-mixin-default-is-dirty
  "A dirty-mixin starts as dirty"
  (let ((c (make-instance 'dirty-mixin)))
    (is-true (dirty-p c) "new component should be dirty")))

(test mark-clean-clears-dirty
  "mark-clean sets dirty to nil"
  (let ((c (make-instance 'dirty-mixin)))
    (mark-clean c)
    (is-false (dirty-p c) "after mark-clean, should not be dirty")))

(test mark-dirty-sets-dirty
  "mark-dirty sets dirty to t"
  (let ((c (make-instance 'dirty-mixin)))
    (mark-clean c)
    (mark-dirty c)
    (is-true (dirty-p c) "after mark-dirty, should be dirty again")))

Implementation

The entire module is a class and two generic functions. The design choice: make this a separate mixin rather than part of the base component class. This lets non-UI objects (layout nodes, render commands) opt into dirty tracking without inheriting from component.

(in-package :cl-tty.box)

;; ── Dirty Tracking ─────────────────────────────────────────────

(defclass dirty-mixin ()
  ((dirty :initform t :accessor dirty-p)))

The initform t is critical: new components are dirty by default so the first render pass doesn't skip them. If this default were nil, new components would be invisible until something explicitly marked them dirty.

(defgeneric mark-clean (component)
  (:method ((c dirty-mixin))
    (setf (dirty-p c) nil)))

mark-clean is called at the end of a render cycle. The default method (for non-dirty-mixin components) is a no-op — they have no dirty state to clear.

(defgeneric mark-dirty (component)
  (:method ((c dirty-mixin))
    (setf (dirty-p c) t)))

mark-dirty is called whenever the component's visual state changes. Together with propagate-dirty in the render pipeline, this ensures that when a text input gains a character, not just the input component but its containing box, tab, and screen all get re-rendered.

These are generic functions (not plain functions) so other mixins or base classes can provide their own methods. The :method on dirty-mixin provides the default implementation for anything that includes this mixin.