#+TITLE: Dirty Tracking #+STARTUP: content #+FILETAGS: :cl-tty:components: * 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-mixin-default-is-dirty~ This test verifies that a freshly created ~dirty-mixin~ instance starts with ~dirty~ set to ~t~. This is the core invariant of the dirty tracking system — without this, the first render pass would skip new components, making them invisible until something explicitly marked them dirty. #+BEGIN_SRC lisp :tangle ../src/components/dirty-tests.lisp (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"))) #+END_SRC ** ~mark-clean-clears-dirty~ This test checks that calling ~mark-clean~ on a dirty component sets its ~dirty-p~ to ~nil~. This is called after a component is rendered, signaling that it is up-to-date and does not need re-render until the next change. Without this, every component would be re-rendered every frame. #+BEGIN_SRC lisp :tangle ../src/components/dirty-tests.lisp (in-package :cl-tty-box-test) (in-suite box-suite) (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"))) #+END_SRC ** ~mark-dirty-sets-dirty~ This test verifies that a component that has been cleaned can be re-marked as dirty via ~mark-dirty~. This exercises the full lifecycle: new (dirty) → render (mark-clean) → state change (mark-dirty) → render again. It ensures the dirty flag is not a one-shot toggle. #+BEGIN_SRC lisp :tangle ../src/components/dirty-tests.lisp (in-package :cl-tty-box-test) (in-suite box-suite) (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"))) #+END_SRC * 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. #+BEGIN_SRC lisp :tangle ../src/components/dirty.lisp (in-package :cl-tty.box) ;; ── Dirty Tracking ───────────────────────────────────────────── (defclass dirty-mixin () ((dirty :initform t :accessor dirty-p))) #+END_SRC 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. #+BEGIN_SRC lisp :tangle ../src/components/dirty.lisp (defgeneric mark-clean (component) (:method ((c dirty-mixin)) (setf (dirty-p c) nil))) #+END_SRC ~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. #+BEGIN_SRC lisp :tangle ../src/components/dirty.lisp (defgeneric mark-dirty (component) (:method ((c dirty-mixin)) (setf (dirty-p c) t))) #+END_SRC ~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.