Deterministic spatial layout for discrete character buffers
Keel is a deterministic layout engine for terminal applications. You describe a
layout hierarchy, Keel deterministically allocates space along rows and
columns, and frames render content and optional lipgloss styles. Rendering
is strict by default: if frames or content don't fit the allocation, Keel
returns an ExtentTooSmallError unless a fit mode permits fitting.
Row/Coldefine stacks that split space along an axis.- Frame constructors (
Exact,Clip,Wrap,WrapStrict,Overflow) identify frames byKeelID. ExtentConstraint(Fixed,Flex,FlexMin,FlexMax,FlexMinMax) controls how space is allocated along the stack axis.Sizedescribes the available width/height for arrange/render.- Fit modes (
Exact,Clip,Wrap,WrapStrict,Overflow) control how content fits inside a frame. RendererprovidesContentProvider,StyleProvider, and render configuration.- Flex max caps are soft: if all flex slots hit their max and space remains, the remainder is distributed ignoring max caps.
package main
import (
"fmt"
gloss "github.com/charmbracelet/lipgloss"
"github.com/trippwill/keel"
)
func main() {
layout := keel.Col(keel.FlexUnit(),
keel.Exact(keel.Fixed(3), "header"),
keel.Row(keel.FlexUnit(),
keel.Exact(keel.FlexMin(1, 10), "nav"),
keel.Exact(keel.FlexMin(2, 20), "body"),
),
)
renderer := keel.NewRenderer(
layout,
func(id string) *gloss.Style {
if id == "header" {
style := gloss.NewStyle().Bold(true).Padding(0, 1)
return &style
}
return nil
},
func(id string, _ keel.FrameInfo) (string, error) {
switch id {
case "header":
return "Dashboard", nil
case "nav":
return "nav", nil
case "body":
return "content", nil
default:
return "", &keel.UnknownFrameIDError{ID: id}
}
},
)
size := keel.Size{Width: 80, Height: 24}
out, err := renderer.Render(size)
if err != nil {
panic(err)
}
fmt.Println(out)
}There is a runnable demo in examples/dashboard that uses the shared fixtures
in examples.
Here's a small example using soft max caps:
layout := keel.Row(keel.FlexUnit(),
keel.Exact(keel.FlexMinMax(1, 10, 20), "nav"),
keel.Exact(keel.FlexMax(2, 30), "body"),
)Renderers cache the arranged layout for the last size. Call Render with the
current size; it will re-arrange only when the size changes. If you mutate a
spec in place, call renderer.Invalidate() to force a re-arrange. For a new
spec, construct a new renderer.
size := keel.Size{Width: 80, Height: 24}
out, err := renderer.Render(size)
if err != nil {
panic(err)
}Rendering errors fall into a small set of stable types:
ExtentTooSmallErrorreports when the terminal (or a frame/content allocation) is too small. It includes the axis (Horizontal/Vertical), required size, available size, and a short source/reason string for diagnostics.SpecErrorreports configuration issues in the spec tree. It wrapsErrConfigurationInvalid, and includes a kind (spec,axis,slot,extent) plus an optional index and reason string.
Keel can emit render logs through the renderer config logger. Log events include stack
allocations, frame renders, and render errors. Paths are slash-delimited slot
indices rooted at / (e.g. /0/1).
// import "log/slog"
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelDebug,
}))
renderer := keel.NewRenderer(layout, styleProvider, contentProvider)
renderer.Config().SetLogger(logger)
size := keel.Size{Width: 80, Height: 24}
out, err := renderer.Render(size)Keel logs are structured and include event and path attributes alongside
event-specific metadata like sizes, frame IDs, and errors.
Keel does not perform intrinsic measurement, or stateful rendering. It exists solely to map hierarchical layout intent onto terminal geometry.
-
No intrinsic sizing
- Frames don't ask "how big do you want to be?"
-
No focus / input model
- Keel never knows about: cursor, focus, keybindings
- Those go in the engine layer, keyed by KeelID
One time setup
- install Mise
mise trustmise installto set up a new development environment- May require network access to fetch dependencies
Development commands
mise run demoto run the example dashboardmise run testto run tests (no cache)mise run benchto run benchmarksmise run precommitto run fmt, vet, build, and testsmise run bench-reportto updatecurrent_bench_result.txtandBENCHMARKS.md
Or standard Go commands:
go test ./...go test ./... -bench='BenchmarkRender|BenchmarkArrange' -benchmemgo generate ./...go fmt ./...go vet ./...go build ./...