Files
cl-tty/org/dirty.org
Hermes Agent 29f99a576d literate: restructure all 19 org files with per-function blocks and prose
Every function, defclass, defstruct, defgeneric, defmethod, defmacro,
defvar, and defparameter in every org file now has its own #+BEGIN_SRC
block with literate prose above it explaining the design reasoning.

Block counts before → after:
  package.org:           1 → 7
  container-package.org: 1 → 1 (prose expanded)
  dirty.org:             4 → 6
  render.org:           10 → 25
  theme.org:             6 → 19
  box-renderable.org:    9 → 29
  scrollbox.org:         8 → 26
  tabbar.org:            5 → 10
  backend-protocol.org:  8 → 66
  modern-backend.org:   17 → 53
  detection.org:         4 → 6
  layout-engine.org:     9 → 36
  framebuffer.org:       8 → 37
  markdown-renderer.org:13 → 38
  dialog.org:           17 → 23 (merged dual structure)
  mouse.org:             4 → 25
  select.org:           12 → 30
  slot.org:              4 → 12
  text-input.org:       11 → 53

Total: ~153 blocks → ~502 blocks

Bugs fixed during restructuring:
- render.org: stray π character typo (backenπd → backend)
- modern-backend.org: sgr-attr missing closing paren + #+END_SRC
- detection.org: invalid #\Esc character reference
- select.org: extra closing paren in select-visible-options

All 13 test suites pass at 100%.
2026-05-12 18:55:07 +00:00

144 lines
4.9 KiB
Org Mode

#+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.