Lesson 3
Define the Surface contract
Create the smallest boundary through which a complete logical frame can reach any surface.
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.
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?
flowchart TD
Tree --> Build[Build complete logical frame]
Build --> Validate[Validate and test]
Validate --> Submit[Submit once]
Submit --> AdapterA 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
classDiagram
class Surface {
<<interface>>
+present(SurfaceFrame)
}
class SurfaceFrame {
+SurfaceSize size
+List~SurfaceCommand~ commands
}
class SurfaceCommand {
<<sealed>>
}
class RecordingSurface
Surface <|.. RecordingSurface
Surface --> SurfaceFrame
SurfaceFrame --> SurfaceCommandThe 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
- Why should
Surface.presentnot accept anAtlasNode? - What benefit does immutability provide at the adapter boundary?
- 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.