A widget declares its layer
ci/woodpecker/push/woodpecker Pipeline was successful

Registration order was the only thing deciding what a widget was drawn
over, which forced a domain to keep one ordered list of every widget it
owns — the one shape that cannot be split into a file per widget.

A widget now says where it belongs: Layer, the eight LayerScene..LayerCursor
constants, the optional Layered interface and LayerOf for wrappers. Draw
runs from the bottom layer up, Tick from the top down, and registration
order only breaks ties inside a layer. Every built-in declares its own;
anything that stays quiet sits on LayerHUD, so existing HUDs come out where
they were.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-30 22:19:17 +02:00
co-authored by Claude Opus 5
parent 53f4df0466
commit 2429ada090
16 changed files with 118 additions and 24 deletions
+39 -11
View File
@@ -1018,6 +1018,24 @@ type Widget interface {
Draw(dst *ebiten.Image, ctx *UICtx) Draw(dst *ebiten.Image, ctx *UICtx)
} }
type Layer int
const (
LayerScene Layer = 0 // over the picture, under the HUD
LayerPanel Layer = 100 // the plate the HUD sits on
LayerHUD Layer = 200 // verbs, inventory, status line, top bar
LayerSpeech Layer = 300 // speech bubbles
LayerDialog Layer = 400 // the dialogue box
LayerMenu Layer = 500 // radial verbs, menus
LayerCurtain Layer = 600 // end cards, fades
LayerCursor Layer = 700 // the pointer
)
type Layered interface {
Layer() Layer
}
func LayerOf(w Widget) Layer // Layered, else LayerHUD
type UICtx struct { type UICtx struct {
Game *Game Game *Game
DT float64 DT float64
@@ -1045,13 +1063,23 @@ their own `Bounds` (or compute them dynamically, like `RadialVerbs`).
### 10.2 Z-order and input consumption ### 10.2 Z-order and input consumption
- **`Tick` runs in reverse registration order.** Widgets registered later - **`Draw` runs from the bottom layer up** — a widget on a higher layer is
(drawn on top) get the click first. Each widget calls painted on top. Inside one layer, registration order decides.
`ctx.Game.Input.ConsumeLeft()` / `ConsumeRight()` to claim the event; - **`Tick` runs from the top layer down.** The widget drawn on top gets the
later widgets see `LeftClicked() == false`. click first. Each widget calls `ctx.Game.Input.ConsumeLeft()` /
- **`Draw` runs in registration order** — registered last → painted on top. `ConsumeRight()` to claim the event; widgets below see
- The `Cursor` widget is registered last by convention so it always wins `LeftClicked() == false`.
on visual layer (and effectively never claims clicks). - **A widget declares its layer**, it does not inherit one from the order it
was registered in. Every built-in implements `Layered`; a widget that does
not sits on `LayerHUD`. `Cursor` is on `LayerCursor`, so it always wins on
visual layer (and effectively never claims clicks) no matter when it was
registered.
- Layers are what let a domain register its widgets **one file at a time**
an `init()` per widget, in whatever order the file names happen to fall —
without the HUD coming out shuffled.
- A wrapper widget that forwards to an inner one — a visibility gate, say —
should return `LayerOf(inner)` from its own `Layer`, so that wrapping does
not move the widget.
After all widgets ticked, the engine offers the (possibly consumed) click After all widgets ticked, the engine offers the (possibly consumed) click
to `handleSceneInput`, which is where hotspot interactions live. If a to `handleSceneInput`, which is where hotspot interactions live. If a
@@ -1575,7 +1603,7 @@ if scriptRunner != nil:
return return
clear hoverLabel clear hoverLabel
for w in reversed(WidgetManager): for w in WidgetManager, top layer down:
w.Tick(uictx) // widgets consume input top-down w.Tick(uictx) // widgets consume input top-down
handleSceneInput() // hotspot resolution + right-click reset handleSceneInput() // hotspot resolution + right-click reset
@@ -1589,7 +1617,7 @@ fill Theme.SceneBackdrop (if any)
draw scene background image draw scene background image
for c in characters sorted by Y: for c in characters sorted by Y:
drawCharacter(c) drawCharacter(c)
for w in WidgetManager (registration order): for w in WidgetManager (by layer, then registration order):
w.Draw(screen, uictx) w.Draw(screen, uictx)
draw transition overlay draw transition overlay
``` ```
@@ -1844,8 +1872,8 @@ inkwell/ # module git.teletypegames.org/games/inkwell
├── input.def.go # Input (consume-on-use) ├── input.def.go # Input (consume-on-use)
├── ui.widget.go # Widget interface, UICtx, Size, Align ├── ui.widget.go # Widget interface, Layer, UICtx, Size, Align
├── ui.manager.go # WidgetManager alias + reversed/ordered iterators ├── ui.manager.go # WidgetManager alias + layer-ordered iterators
├── ui.theme.go # Theme + ThemeManager ├── ui.theme.go # Theme + ThemeManager
├── ui.theme_presets.go # 4 preset themes ├── ui.theme_presets.go # 4 preset themes
├── ui.defaults.go # RegisterDefaultUI/RadialVerbUI/RichUI ├── ui.defaults.go # RegisterDefaultUI/RadialVerbUI/RichUI
+1
View File
@@ -28,6 +28,7 @@ type CharacterPanel struct {
} }
func (c *CharacterPanel) GetName() string { return c.Name } func (c *CharacterPanel) GetName() string { return c.Name }
func (c *CharacterPanel) Layer() Layer { return LayerHUD }
func (c *CharacterPanel) Tick(ctx *UICtx) {} func (c *CharacterPanel) Tick(ctx *UICtx) {}
func (c *CharacterPanel) Draw(dst *ebiten.Image, ctx *UICtx) { func (c *CharacterPanel) Draw(dst *ebiten.Image, ctx *UICtx) {
+1
View File
@@ -20,6 +20,7 @@ type ChatLog struct {
} }
func (c *ChatLog) GetName() string { return c.Name } func (c *ChatLog) GetName() string { return c.Name }
func (c *ChatLog) Layer() Layer { return LayerHUD }
func (c *ChatLog) Tick(ctx *UICtx) {} func (c *ChatLog) Tick(ctx *UICtx) {}
func (c *ChatLog) Draw(dst *ebiten.Image, ctx *UICtx) { func (c *ChatLog) Draw(dst *ebiten.Image, ctx *UICtx) {
+1
View File
@@ -13,6 +13,7 @@ type Cursor struct {
} }
func (c *Cursor) GetName() string { return c.Name } func (c *Cursor) GetName() string { return c.Name }
func (c *Cursor) Layer() Layer { return LayerCursor }
func (c *Cursor) Tick(ctx *UICtx) {} func (c *Cursor) Tick(ctx *UICtx) {}
func (c *Cursor) Draw(dst *ebiten.Image, ctx *UICtx) { func (c *Cursor) Draw(dst *ebiten.Image, ctx *UICtx) {
+1
View File
@@ -17,6 +17,7 @@ type DialogBox struct {
} }
func (d *DialogBox) GetName() string { return d.Name } func (d *DialogBox) GetName() string { return d.Name }
func (d *DialogBox) Layer() Layer { return LayerDialog }
func (d *DialogBox) Tick(ctx *UICtx) { func (d *DialogBox) Tick(ctx *UICtx) {
g := ctx.Game g := ctx.Game
+1
View File
@@ -12,6 +12,7 @@ type EndCard struct {
} }
func (e *EndCard) GetName() string { return e.Name } func (e *EndCard) GetName() string { return e.Name }
func (e *EndCard) Layer() Layer { return LayerCurtain }
func (e *EndCard) Tick(ctx *UICtx) { func (e *EndCard) Tick(ctx *UICtx) {
if ctx.Game.endCard == "" { if ctx.Game.endCard == "" {
+1
View File
@@ -16,6 +16,7 @@ type HotspotDebug struct {
} }
func (h *HotspotDebug) GetName() string { return h.Name } func (h *HotspotDebug) GetName() string { return h.Name }
func (h *HotspotDebug) Layer() Layer { return LayerScene }
func (h *HotspotDebug) Tick(ctx *UICtx) { func (h *HotspotDebug) Tick(ctx *UICtx) {
key := h.ToggleKey key := h.ToggleKey
+1
View File
@@ -20,6 +20,7 @@ type InventoryBar struct {
} }
func (b *InventoryBar) GetName() string { return b.Name } func (b *InventoryBar) GetName() string { return b.Name }
func (b *InventoryBar) Layer() Layer { return LayerHUD }
func (b *InventoryBar) slotRect(idx int) Rectangle { func (b *InventoryBar) slotRect(idx int) Rectangle {
cols := b.Cols cols := b.Cols
+18 -9
View File
@@ -1,19 +1,28 @@
package inkwell package inkwell
import "sort"
// WidgetManager registers Widget instances. Same shape as every other manager. // WidgetManager registers Widget instances. Same shape as every other manager.
type WidgetManager = Manager[Widget] type WidgetManager = Manager[Widget]
// reversedWidgets iterates a manager's contents in reverse registration // reversedWidgets iterates from the top layer down — used by the engine
// order — used by the engine for top-down input dispatch (the widget // for top-down input dispatch (the widget drawn on top gets the click
// drawn on top gets the click first). // first).
func reversedWidgets(m *WidgetManager) []Widget { func reversedWidgets(m *WidgetManager) []Widget {
all := m.All() ordered := orderedWidgets(m)
out := make([]Widget, len(all)) out := make([]Widget, len(ordered))
for i, w := range all { for i, w := range ordered {
out[len(all)-1-i] = w out[len(ordered)-1-i] = w
} }
return out return out
} }
// orderedWidgets iterates in registration order — bottom-up draw. // orderedWidgets iterates bottom-up draw order: by layer, and by
func orderedWidgets(m *WidgetManager) []Widget { return m.All() } // registration order inside a layer.
func orderedWidgets(m *WidgetManager) []Widget {
all := m.All()
sort.SliceStable(all, func(i, j int) bool {
return LayerOf(all[i]) < LayerOf(all[j])
})
return all
}
+1
View File
@@ -21,6 +21,7 @@ type Panel struct {
} }
func (p *Panel) GetName() string { return p.Name } func (p *Panel) GetName() string { return p.Name }
func (p *Panel) Layer() Layer { return LayerPanel }
func (p *Panel) Tick(ctx *UICtx) {} func (p *Panel) Tick(ctx *UICtx) {}
func (p *Panel) Draw(dst *ebiten.Image, ctx *UICtx) { func (p *Panel) Draw(dst *ebiten.Image, ctx *UICtx) {
+1
View File
@@ -18,6 +18,7 @@ type SpeechBubble struct {
} }
func (s *SpeechBubble) GetName() string { return s.Name } func (s *SpeechBubble) GetName() string { return s.Name }
func (s *SpeechBubble) Layer() Layer { return LayerSpeech }
func (s *SpeechBubble) Tick(ctx *UICtx) {} func (s *SpeechBubble) Tick(ctx *UICtx) {}
func (s *SpeechBubble) Draw(dst *ebiten.Image, ctx *UICtx) { func (s *SpeechBubble) Draw(dst *ebiten.Image, ctx *UICtx) {
+1
View File
@@ -13,6 +13,7 @@ type StatusLine struct {
} }
func (s *StatusLine) GetName() string { return s.Name } func (s *StatusLine) GetName() string { return s.Name }
func (s *StatusLine) Layer() Layer { return LayerHUD }
func (s *StatusLine) Tick(ctx *UICtx) { func (s *StatusLine) Tick(ctx *UICtx) {
g := ctx.Game g := ctx.Game
+1
View File
@@ -27,6 +27,7 @@ type TopBar struct {
} }
func (t *TopBar) GetName() string { return t.Name } func (t *TopBar) GetName() string { return t.Name }
func (t *TopBar) Layer() Layer { return LayerHUD }
func (t *TopBar) Tick(ctx *UICtx) {} func (t *TopBar) Tick(ctx *UICtx) {}
func (t *TopBar) Draw(dst *ebiten.Image, ctx *UICtx) { func (t *TopBar) Draw(dst *ebiten.Image, ctx *UICtx) {
+1
View File
@@ -19,6 +19,7 @@ type VerbBar struct {
} }
func (v *VerbBar) GetName() string { return v.Name } func (v *VerbBar) GetName() string { return v.Name }
func (v *VerbBar) Layer() Layer { return LayerHUD }
func (v *VerbBar) buttons(ctx *UICtx) []verbButton { func (v *VerbBar) buttons(ctx *UICtx) []verbButton {
cols := v.Cols cols := v.Cols
+1
View File
@@ -43,6 +43,7 @@ type RadialVerbs struct {
} }
func (r *RadialVerbs) GetName() string { return r.Name } func (r *RadialVerbs) GetName() string { return r.Name }
func (r *RadialVerbs) Layer() Layer { return LayerMenu }
func (r *RadialVerbs) Tick(ctx *UICtx) { func (r *RadialVerbs) Tick(ctx *UICtx) {
g := ctx.Game g := ctx.Game
+48 -4
View File
@@ -9,17 +9,61 @@ import "github.com/hajimehoshi/ebiten/v2"
// satisfy this interface. // satisfy this interface.
// //
// Lifecycle: // Lifecycle:
// - Tick runs once per frame in REVERSE registration order so the // - Tick runs once per frame from the TOP layer down so the top-most
// top-most widget gets a chance to consume input first via // widget gets a chance to consume input first via
// ctx.Game.Input.ConsumeLeft / ConsumeRight. // ctx.Game.Input.ConsumeLeft / ConsumeRight.
// - Draw runs once per frame in REGISTRATION order, so widgets // - Draw runs once per frame from the BOTTOM layer up, so a widget on a
// registered later are painted on top. // higher layer is painted on top.
//
// Registration order only decides ties inside one layer — see Layer.
type Widget interface { type Widget interface {
Named Named
Tick(ctx *UICtx) Tick(ctx *UICtx)
Draw(dst *ebiten.Image, ctx *UICtx) Draw(dst *ebiten.Image, ctx *UICtx)
} }
// Layer is a widget's place in the paint order. A widget says where it
// belongs rather than depending on the order it happened to be registered
// in, which frees a domain to register its widgets one file at a time.
type Layer int
const (
// LayerScene is over the picture and under the HUD: hotspot outlines,
// cutscene bars, and the invisible widgets that only tick.
LayerScene Layer = 0
// LayerPanel is the plate the HUD is drawn on.
LayerPanel Layer = 100
// LayerHUD is everything mounted on that plate: verbs, inventory,
// status line, top bar.
LayerHUD Layer = 200
// LayerSpeech is speech bubbles, which float over the scene.
LayerSpeech Layer = 300
// LayerDialog is the dialogue box.
LayerDialog Layer = 400
// LayerMenu is what opens on top of the game: radial verbs, menus.
LayerMenu Layer = 500
// LayerCurtain is what covers the game: end cards, fades.
LayerCurtain Layer = 600
// LayerCursor is the pointer, and nothing else belongs above it.
LayerCursor Layer = 700
)
// Layered is implemented by a widget that declares its own layer. Every
// built-in widget does. A widget that does not sits on LayerHUD.
type Layered interface {
Layer() Layer
}
// LayerOf reports the layer a widget paints on. A wrapper widget that
// forwards to an inner one — a visibility gate, say — should return
// LayerOf(inner) so that wrapping does not move the widget.
func LayerOf(w Widget) Layer {
if l, ok := w.(Layered); ok {
return l.Layer()
}
return LayerHUD
}
// UICtx is the per-tick context handed to widgets. Game is the root // UICtx is the per-tick context handed to widgets. Game is the root
// aggregate; DT is seconds since the previous frame. // aggregate; DT is seconds since the previous frame.
type UICtx struct { type UICtx struct {