Lesson 3

Define the Surface contract

Create the smallest boundary through which a complete logical frame can reach any surface.

3 min readUpdated Jul 17, 2026

Learning goal

Create the smallest boundary through which a complete logical frame can reach any surface.

Think in frames, not platform calls

The toolkit should not know whether a frame becomes pixels, hardware instructions, remote messages, or test data.

Diagram
flowchart LR
    Painter --> Frame[SurfaceFrame]
    Frame --> Contract{{Surface.present}}
    Contract --> Recorder[RecordingSurface]
    Contract --> Device[Future device adapter]
    Contract --> Remote[Future remote adapter]

Typed surface vocabulary

Start with explicit units and commands:

package atlas.surface

data class SurfaceSize(val width: Int, val height: Int) {
    init {
        require(width >= 0)
        require(height >= 0)
    }
}

data class SurfacePoint(val x: Int, val y: Int)

data class SurfaceRect(
    val x: Int,
    val y: Int,
    val width: Int,
    val height: Int,
) {
    init {
        require(width >= 0)
        require(height >= 0)
    }
}

// Non-premultiplied sRGB encoded as 0xAARRGGBB.
data class AtlasColor(val argb: UInt)

sealed interface SurfaceCommand {
    data class Fill(
        val bounds: SurfaceRect,
        val color: AtlasColor,
    ) : SurfaceCommand

    data class DrawText(
        val origin: SurfacePoint,
        val text: String,
        val color: AtlasColor,
    ) : SurfaceCommand
}

data class SurfaceFrame(
    val size: SurfaceSize,
    val commands: List<SurfaceCommand>,
)

fun interface Surface {
    fun present(frame: SurfaceFrame)
}

Each present replaces the previously presented logical content. Commands execute in list order and are clipped to frame.size; untouched locations use the adapter’s documented blank value. DrawText preserves content, origin, and color, while shaping and metrics remain an explicit capability of a richer contract.

The command vocabulary is intentionally tiny. It exists to teach the boundary, not to predict every future backend.

Why submit complete frames?

Diagram
flowchart TD
    Tree --> Build[Build complete logical frame]
    Build --> Validate[Validate and test]
    Validate --> Submit[Submit once]
    Submit --> Adapter

A complete frame, treated as immutable after construction, provides:

  • deterministic tests;
  • freedom for an adapter to batch or diff commands;
  • no accidental drawing during composition;
  • a clear lifetime: build, submit, discard.

Kotlin List is read-only, not deeply immutable. Producers must not mutate command storage after construction, and a surface that retains a frame must snapshot its command list, as RecordingSurface does below.

An advanced surface may later accept retained objects or incremental patches. That is a different contract and should be introduced only when measurements justify it.

Recording implementation

class RecordingSurface : Surface {
    private val recorded = mutableListOf<SurfaceFrame>()

    val frames: List<SurfaceFrame>
        get() = recorded.toList()

    override fun present(frame: SurfaceFrame) {
        recorded += frame.copy(commands = frame.commands.toList())
    }

    fun latestFrame(): SurfaceFrame =
        recorded.lastOrNull() ?: error("No frame has been presented")
}

Now the output of the toolkit is observable without a platform:

val surface = RecordingSurface()

surface.present(
    SurfaceFrame(
        size = SurfaceSize(80, 24),
        commands = listOf(
            SurfaceCommand.DrawText(
                origin = SurfacePoint(2, 1),
                text = "Hello, surface",
                color = AtlasColor(0xFFFFFFFFu),
            )
        ),
    )
)

check(surface.latestFrame().commands.size == 1)

Contract ownership

Diagram
classDiagram
    class Surface {
      <<interface>>
      +present(SurfaceFrame)
    }
    class SurfaceFrame {
      +SurfaceSize size
      +List~SurfaceCommand~ commands
    }
    class SurfaceCommand {
      <<sealed>>
    }
    class RecordingSurface
    Surface <|.. RecordingSurface
    Surface --> SurfaceFrame
    SurfaceFrame --> SurfaceCommand

The core owns the contract because the core knows what it needs to express. Concrete adapters own translation from this vocabulary into an external API.

What is deliberately absent

  • Compose types: the surface does not know about Composer, nodes, or recomposition.
  • Mutable tree objects: a submitted frame is a snapshot, not shared engine state.
  • Platform handles: those remain inside an adapter.
  • Input: output and input evolve independently; Lesson 10 introduces a separate event source.

Visual checkpoint

Rename the first diagram’s device adapter to EInkSurface and annotate the existing RemoteSurface. In a second sketch, show the node tree feeding the painter and the painter feeding the contract—never either adapter directly.

Review questions

  1. Why should Surface.present not accept an AtlasNode?
  2. What benefit does immutability provide at the adapter boundary?
  3. When would an incremental surface contract be justified?

Recap

SurfaceFrame is output data.
Surface is an output boundary.
RecordingSurface is deterministic evidence.
None of them participate in composition.

Finished this lesson?

Your progress stays only in this browser.