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

4.9 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-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.

(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")))

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.

(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")))

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.

(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")))

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.