Demos/KeystrokeVisualizer/KeystrokeView.cs
using System.Collections.Generic;
using Goo;
using Goo.Animation;
using Goo.Input;
using Sandbox;
using Sandbox.UI;

namespace Sandbox.KeystrokeViz;

// Keystroke chip stack. Polls input each Tick; emits the chip column content (host anchors it).
public sealed class KeystrokeView
{
    public int           MaxEntries           = 4;
    public Layout.Anchor Anchor               = Layout.Anchor.BottomLeft;
    public float         AnchorMargin         = 32f;
    public float         HoldTime             = 1.5f;
    public float         SlideInDuration      = 0.1f;
    public float         FadeOutDuration      = 0.20f;
    public float         SlideRiseFraction    = 0.1f;
    public float         IdleGap              = 1f;
    public float         ChipHeight           = 64f;
    public float         ChipGap              = 8f;
    public float         FontSize             = 32f;
    public float         ChipOpacity          = 0.8f;
    public int           MaxTypingRunLength   = 16;
    public bool          ShowHintWhenEmpty    = true;
    public bool          CaptureModifierAlone = false;
    public bool          ShiftAsChord         = true;

    readonly List<Entry> _queue   = new();
    readonly KeyTracker  _tracker = new() { ReemitHeldOnModifierRise = true };
    float _now;
    bool  _dirty = true;
    void Invalidate() => _dirty = true;

    public void Reset()
    {
        _queue.Clear();
        _tracker.Reset();
        _now = 0f;
        Invalidate();
    }

    public bool Tick( Scene? scene, float dt )
    {
        _now += dt;
        _tracker.Poll();

        int  countBefore = _queue.Count;
        bool hadInput    = _tracker.JustPressed.Count > 0;

        if ( hadInput )
        {
            var cfg = new GroupingConfig( IdleGap, CaptureModifierAlone, ShiftAsChord, MaxTypingRunLength );
            KeystrokeGrouper.Apply( _queue, _tracker.JustPressed, _tracker.Modifiers, _now, cfg );
        }

        KeystrokeGrouper.Sweep( _queue, _now, HoldTime, FadeOutDuration );
        KeystrokeGrouper.EnforceCap( _queue, _now, MaxEntries, HoldTime );

        if ( hadInput || _queue.Count != countBefore || NeedsRebuild() )
            Invalidate();

        bool d = _dirty; _dirty = false; return d;
    }

    bool NeedsRebuild() =>
        KeystrokeGrouper.HasAnimatingEntries( _queue, _now, SlideInDuration, HoldTime, FadeOutDuration );

    public Container Build()
    {
        var r = Layout.ResolveAnchor( Anchor );

        // HintBanner returns a Text; wrap it so Build() always returns a Container the host can anchor.
        if ( _queue.Count == 0 && ShowHintWhenEmpty )
            return new Container { Key = "hint", Children = { HintBanner.Build( FontSize ) } };

        // keyed so the anchored slot stays all-keyed across the empty/non-empty flip (mixed keyed/unkeyed pair warns).
        var column = new Container { Key = "chips", FlexDirection = r.FlexDirection, Gap = ChipGap };
        var anim   = new ChipAnim( new AgePhase( SlideInDuration, HoldTime, FadeOutDuration ), ChipHeight, SlideRiseFraction, FontSize, ChipOpacity, r.StacksUp );

        column.Children.AddRange( _queue, ( i, e ) =>
            EntryChip.Build( i, e.Label, _now - e.SpawnTime, _now - e.LastInputTime, anim ) );

        return column;
    }
}