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. 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:

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( string Label, float SpawnTime );

    readonly List<Entry> _queue = new();
    readonly KeyTracker  _tracker = new();
    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. 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( key.DisplayName, _now ) );

    base.OnUpdate();
}

each Entry records its label and the time it spawned. that 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,
        JustifyContent = Justify.FlexEnd,        // push the stack to the bottom
        AlignItems     = Align.FlexStart,        // and to the left
        PointerEvents  = PointerEvents.None,
    };

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

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

dynamic children covers AddRange in full, including how it assigns each chip an index key so the list diffs cleanly as entries come and go.

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( key.DisplayName, _now ) );

    float lifetime = 0.1f + 1.5f + 0.25f;                 // slide + 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.

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.

see also