Demos/KeystrokeVisualizerUI.cs
using System.Collections.Generic;
using Goo;
using Goo.Animation;
using Goo.Input;
using Sandbox.KeystrokeViz;
using Sandbox.UI;

namespace Sandbox;

public class KeystrokeVisualizerUI : GooPanel<Container>
{
    [Property, Range( 1, 20 )]     public int   MaxEntries         { get; set; } = 4;
    [Property]                     public Layout.Anchor Anchor     { get; set; } = Layout.Anchor.BottomLeft;
    [Property, Range( 0f, 200f )]  public float AnchorMargin       { get; set; } = 32f;
    [Property, Range( 0.1f, 10f )] public float HoldTime           { get; set; } = 1.5f;
    [Property, Range( 0f, 1f )]    public float SlideInDuration    { get; set; } = 0.1f;
    [Property, Range( 0f, 2f )]    public float FadeOutDuration    { get; set; } = 0.20f;
    [Property, Range( 0f, 1f )]    public float SlideRiseFraction  { get; set; } = 0.1f;
    [Property, Range( 0f, 3f )]    public float IdleGap            { get; set; } = 1f;
    [Property, Range( 16f, 96f )]  public float ChipHeight         { get; set; } = 64f;
    [Property, Range( 0f, 32f )]   public float ChipGap            { get; set; } = 8f;
    [Property, Range( 8f, 48f )]   public float FontSize           { get; set; } = 32f;
    [Property, Range( 0f, 1f )]    public float ChipOpacity        { get; set; } = 0.8f;
    [Property, Range( 1, 64 )]     public int   MaxTypingRunLength { get; set; } = 16;
    [Property] public bool ShowHintWhenEmpty    { get; set; } = true;
    [Property] public bool CaptureModifierAlone { get; set; } = false;
    [Property] public bool ShiftAsChord         { get; set; } = true;

    readonly List<Entry> _queue   = new();
    readonly KeyTracker  _tracker = new() { ReemitHeldOnModifierRise = true };
    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 );
    }

    protected override void OnUpdate()
    {
        _now += Time.Delta;
        _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 );

        // hadInput catches typing-run extensions that don't change count and aren't animating.
        if ( hadInput || _queue.Count != countBefore || NeedsRebuild() )
            Rebuild();

        base.OnUpdate();
    }

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

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

        var root = Hud.Overlay() with
        {
            Padding        = AnchorMargin,
            JustifyContent = r.JustifyContent,
            AlignItems     = r.AlignItems,
        };

        if ( _queue.Count == 0 && ShowHintWhenEmpty )
        {
            root.Children.Add( HintBanner.Build( FontSize ) );
            return root;
        }

        var column = new Container { 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 ) );

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