building a keystroke hud

poll the keyboard and animate a stack of chips that pop in and fade out

by the end of this guide you will have a stack of key chips in the corner of the screen. press a key and a chip slides in; leave it alone and it holds for a moment, then fades away. it is the moving-overlay companion to building a compass: same screen-anchored root, but driven by input and animated per entry.

the finished demo is Code/Demos/KeystrokeVisualizerUI.cs (in this repo, the pre-restructure demos live at archive/Code/Demo/KeystrokeVisualizerUI.cs; the path above is where the published goo site keeps it). this guide builds its core loop (poll, queue, animate, expire) from a blank file. the demo layers grouping on top (typing runs collapse into one chip, modifier combos become chords); that is left to the demo so the lifecycle stays in focus here.

read the keyboard

goo has no input blob. you read the keyboard yourself once per frame and feed the result into the tree like any other state. the helper is Goo.Input.KeyTracker: it polls a catalog of keys and hands you the ones that were just pressed this frame.

create Code/Demos/KeystrokeHudUI.cs and stand up the tracker. it extends GooPanel<Container>, the same base your first panel covers, so Build() and Rebuild() behave exactly as that article describes:

using System.Collections.Generic;
using Goo;
using Goo.Animation;
using Goo.Input;
using Sandbox.UI;
using PanelTransform = Goo.PanelTransform;

namespace Sandbox;

public sealed class KeystrokeHudUI : GooPanel<Container>
{
    readonly record struct Entry( int Id, string Label, float SpawnTime );

    readonly List<Entry> _queue = new();
    readonly KeyTracker  _tracker = new();
    int   _nextId;
    float _now;

    protected override void OnEnabled()
    {
        base.OnEnabled();
        _queue.Clear();
        _tracker.Reset();
        _now = 0f;
        Panel.Style.Width  = Length.Percent( 100 );
        Panel.Style.Height = Length.Percent( 100 );
    }
}

OnEnabled resets the tracker and clears the queue so a re-enable starts clean, then sizes the host panel to cover the viewport. Entry carries an Id alongside its label and spawn time; the render step needs it to key the chip list stably once entries start expiring from the front. reading input covers the full KeyTracker surface: the descriptor for each key, the modifier snapshot, and the chord re-emit option the demo turns on.

queue an entry per press

drive the clock in OnUpdate: advance _now, poll once, and push a chip for every key that fired this frame.

protected override void OnUpdate()
{
    _now += Time.Delta;
    _tracker.Poll();

    foreach ( var key in _tracker.JustPressed )
        _queue.Add( new Entry( _nextId++, key.DisplayName, _now ) );

    base.OnUpdate();
}

each Entry records a stable id, its label, and the time it spawned. _nextId is a plain counter, minted once per press, never reused. the spawn time is all the animation needs: an entry's age is _now - SpawnTime, and age alone drives the whole slide-in, hold, and fade lifecycle.

render the stack

the chips hang from a corner overlay. build the screen-filling, click-through root by hand (see overlay layout), then stack the chips in a column.

because the queue length changes every time you press or release a key, you cannot write the children out by hand. Children.AddRange runs a builder once per item:

const float ChipHeight = 64f;

protected override Container Build()
{
    var root = new Container
    {
        Position       = PositionMode.Absolute,
        Width          = Length.Percent( 100 ),
        Height         = Length.Percent( 100 ),
        Padding        = 32f,
        FlexDirection  = FlexDirection.Column,   // stack top-to-bottom, not yoga's row default
        JustifyContent = Justify.FlexEnd,        // push the stack to the bottom
        AlignItems     = Align.FlexStart,        // and to the left
        PointerEvents  = PointerEvents.None,
    };

    // Column so the newest chip sits at the bottom, nearest the anchored edge.
    var column = new Container { FlexDirection = FlexDirection.Column, Gap = 8f };
    column.Children.AddRange( _queue, e => e.Id, ( i, e ) => Chip( e ) );

    root.Children.Add( column );
    return root;
}

AddRange takes a keyOf selector here (e => e.Id) instead of the plain index-keyed overload. _queue drops entries from the front as they expire, which shifts every remaining entry's position down by one; an index key would read that shift as a change of identity every time an old chip clears. keying by Id instead means each chip's identity follows the entry, not its slot. dynamic children covers AddRange in full, including why a reordering or middle-deleting list needs this over the index-keyed form.

animate by age

Goo.Animation.AgePhase is the pure projection for the spawn-hold-expire lifecycle: give it three durations, call Project per entry per frame, get a normalized slide ramp and opacity in 0..1.

the render step already called Chip( e ). here is that method. the slide ramp becomes a vertical offset that eases the chip up into place, and the opacity drops straight onto Opacity:

static readonly AgePhase s_phase = new( 0.1f, 1.5f, 0.25f );  // slide-in, hold, fade-out (seconds)

Container Chip( Entry e )
{
    float age = _now - e.SpawnTime;
    var p = s_phase.Project( age, age );          // single press: idle age equals age

    float yOff = ChipHeight * 0.1f * (1f - p.Slide);   // start 10% low, settle to 0

    return new Container
    {
        Height          = ChipHeight,
        PaddingLeft     = 14,
        PaddingRight    = 14,
        BackgroundColor = Color.Black.WithAlpha( 0.6f ),
        BorderRadius    = 6f,
        Opacity         = p.Opacity,
        Transform       = PanelTransform.Translate( 0, yOff ),
        JustifyContent  = Justify.Center,
        AlignItems      = Align.Center,
        Children        = { new Text( e.Label ) { FontSize = 32, FontColor = Color.White } },
    };
}

Chip is an instance method because it reads _now. Project( age, age ) passes age for both arguments because a single keypress has no separate idle phase. animations documents AgePhase in full, and panel transform covers the Translate op.

expire and gate the rebuild

two jobs remain: drop chips once they have fully faded, and rebuild on the frames that matter. an entry is finished once its age passes the hold plus fade window. and a chip mid-animation needs a rebuild every frame even when nothing was pressed, so the fade is smooth.

fold both into OnUpdate:

protected override void OnUpdate()
{
    _now += Time.Delta;
    _tracker.Poll();

    bool hadInput = _tracker.JustPressed.Count > 0;
    foreach ( var key in _tracker.JustPressed )
        _queue.Add( new Entry( _nextId++, key.DisplayName, _now ) );

    float lifetime = 1.5f + 0.25f;                        // hold + fade
    _queue.RemoveAll( e => _now - e.SpawnTime > lifetime );

    if ( hadInput || _queue.Count > 0 )
        Rebuild();

    base.OnUpdate();
}

while the stack is empty and idle, Build() never runs and the HUD costs nothing; this is the rebuild discipline the build method teaches.

press play, drop KeystrokeHudUI on a screen panel, and type. each key pops a chip into the bottom-left that rises into place, holds, then fades.

show a hint when idle

an empty corner reads as "nothing is here" rather than "press a key to see this work." add a small hint that shows only while the queue is empty. HintBanner is a plain static helper, the same shape as Chip:

static Container HintBanner() => new Container
{
    PaddingLeft     = 14,
    PaddingRight    = 14,
    Height          = 40,
    BackgroundColor = Color.Black.WithAlpha( 0.4f ),
    BorderRadius    = 6f,
    JustifyContent  = Justify.Center,
    AlignItems      = Align.Center,
    Children        = { new Text( "press any key" ) { FontSize = 18, FontColor = Color.White.WithAlpha( 0.6f ) } },
};

drop it into Build() with a nullable ternary, directly in the Children initializer: only the taken branch runs, so HintBanner() is never constructed while the queue has entries.

protected override Container Build()
{
    var column = new Container { FlexDirection = FlexDirection.Column, Gap = 8f };
    column.Children.AddRange( _queue, e => e.Id, ( i, e ) => Chip( e ) );

    return new Container
    {
        Position       = PositionMode.Absolute,
        Width          = Length.Percent( 100 ),
        Height         = Length.Percent( 100 ),
        Padding        = 32f,
        FlexDirection  = FlexDirection.Column,
        JustifyContent = Justify.FlexEnd,
        AlignItems     = Align.FlexStart,
        PointerEvents  = PointerEvents.None,
        Children       = { column, _queue.Count == 0 ? HintBanner() : null },
    };
}

this is the same ternary the blessed KeystrokeVisualizerUI uses behind its ShowHintWhenEmpty property. dynamic children covers the pattern in full.

spark on keypress

a chip sliding in reads clearly on its own, but a keypress is also a discrete little event, and a spark makes the moment itself land. own a ParticleField as a field and burst into it wherever the chips spawn:

readonly ParticleField _fx = new() { Gravity = new Vector2( 0f, 500f ) };

burst inside OnUpdate, once per key that fired this frame, from the same corner the chips rise from:

foreach ( var key in _tracker.JustPressed )
{
    _queue.Add( new Entry( _nextId++, key.DisplayName, _now ) );
    _fx.Burst( new Vector2( 60f, Screen.Height - 60f ), count: 6, speed: 140f, color: Color.White, life: 0.4f, size: 4f );
}

mount the field with Hud.Particles, one more entry in the same Children initializer:

Children = { column, Hud.Particles( _fx ), _queue.Count == 0 ? HintBanner() : null },

Hud.Particles steps and draws the field from its own paint callback every frame, not through Build(), so the spark animates smoothly even on frames the rebuild gate skips. no Tick override is needed for it, the same way none is needed in the blessed ParticleProbeUI confetti demo this pattern is checked against.

measure the rebuild gate

the rebuild gate above claims an idle HUD costs nothing and a busy one costs little. confirm that with the built-in profiler instead of taking it on faith:

Perf.Enabled = true;
Perf.Reset();
// let the HUD sit idle for a few seconds, then type a burst of keys
Log.Info( Perf.Report() );

Perf.Report() breaks down build, diff, and apply time per rebuild plus a per-op-kind table. with Perf.Enabled = true while idle, the report should show zero rebuilds for the idle stretch, since OnUpdate never calls Rebuild() when _queue is empty and nothing was pressed; the count climbs only once typing starts. leave Perf.Enabled off in shipped code: it costs a branch per rebuild and per op even when disabled.

what just happened

the chip stack is the lifecycle pattern in miniature:

from here the demo adds grouping: consecutive letters merge into one growing chip, and modifier combinations render as Ctrl + C chords, in a separate pure helper that rewrites the queue before it draws.

key the root's three siblings (the chip column, the particle overlay, the hint) - the hint toggling inside an unkeyed list makes the reconciler log a keyless-length-change warning.

see also