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.
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)— returnstif the component needs re-render,nilif it's up-to-date. New instances start dirty (t).
mark-clean
(mark-clean component)— sets dirty tonil. Called after rendering.- Specialized on
dirty-mixin; default method is a no-op.
mark-dirty
(mark-dirty component)— sets dirty tot. 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.