Demos/KeystrokeVisualizer/KeystrokeGrouper.cs
using System.Collections.Generic;
using Goo.Input;

namespace Sandbox.KeystrokeViz;

public enum EntryKind { Text, Chord, Special }

public readonly record struct Entry( EntryKind Kind, string Label, float SpawnTime, float LastInputTime );

public readonly record struct GroupingConfig(
    float IdleGap,
    bool  CaptureModifierAlone,
    bool  ShiftAsChord,
    int   MaxTypingRunLength );

public static class KeystrokeGrouper
{
    public static void Apply(
        List<Entry>                  queue,
        IReadOnlyList<KeyDescriptor> justPressed,
        ModifierState                mods,
        float                        now,
        GroupingConfig               cfg )
    {
        for ( int i = 0; i < justPressed.Count; i++ )
        {
            var d = justPressed[i];
            switch ( d.Class )
            {
                case KeyClass.Modifier:
                {
                    if ( cfg.CaptureModifierAlone )
                        queue.Add( new Entry( EntryKind.Special, d.DisplayName, now, now ) );
                    break;
                }
                case KeyClass.Special:
                {
                    string label = mods.Any
                        ? ComposeChordLabel( mods, d.DisplayName )
                        : d.DisplayName;
                    queue.Add( new Entry( EntryKind.Special, label, now, now ) );
                    break;
                }
                case KeyClass.Printable:
                {
                    char ch = mods.Shift && d.ShiftGlyph != '\0' ? d.ShiftGlyph : d.BaseGlyph;
                    bool isChord = mods.AnyNonShift || ( cfg.ShiftAsChord && mods.Shift );
                    if ( isChord )
                    {
                        string label = ComposeChordLabel( mods, ch.ToString() );
                        queue.Add( new Entry( EntryKind.Chord, label, now, now ) );
                    }
                    else if ( queue.Count > 0
                              && queue[^1].Kind == EntryKind.Text
                              && now - queue[^1].LastInputTime <= cfg.IdleGap
                              && queue[^1].Label.Length < cfg.MaxTypingRunLength )
                    {
                        var last = queue[^1];
                        queue[^1] = last with { Label = last.Label + ch, LastInputTime = now };
                    }
                    else
                    {
                        queue.Add( new Entry( EntryKind.Text, ch.ToString(), now, now ) );
                    }
                    break;
                }
            }
        }
    }

    static string ComposeChordLabel( ModifierState mods, string head )
    {
        string s = "";
        if ( mods.Ctrl  ) s += "Ctrl + ";
        if ( mods.Shift ) s += "Shift + ";
        if ( mods.Alt   ) s += "Alt + ";
        if ( mods.Meta  ) s += "Meta + ";
        return s + head;
    }

    // An entry is considered fully expired once it has been idle for holdTime + fadeOutDuration.
    public static void Sweep( List<Entry> queue, float now, float holdTime, float fadeOutDuration )
    {
        float idleLifetime = holdTime + fadeOutDuration;
        int removeUpTo = 0;
        while ( removeUpTo < queue.Count && now - queue[removeUpTo].LastInputTime > idleLifetime )
            removeUpTo++;
        if ( removeUpTo > 0 )
            queue.RemoveRange( 0, removeUpTo );
    }

    // When over capacity, rewind the oldest excess entries' LastInputTime to (now - holdTime)
    // so they start fading immediately.
    public static void EnforceCap( List<Entry> queue, float now, int maxEntries, float holdTime )
    {
        int excess = queue.Count - maxEntries;
        if ( excess <= 0 ) return;
        float targetLastInput = now - holdTime;
        for ( int i = 0; i < excess; i++ )
        {
            var e = queue[i];
            if ( e.LastInputTime > targetLastInput )
                queue[i] = e with { LastInputTime = targetLastInput };
        }
    }

    public static bool HasAnimatingEntries(
        IReadOnlyList<Entry> queue,
        float now,
        float slideInDuration,
        float holdTime,
        float fadeOutDuration )
    {
        for ( int i = 0; i < queue.Count; i++ )
        {
            var e = queue[i];
            float age = now - e.SpawnTime;
            if ( age < slideInDuration ) return true;
            float idleAge = now - e.LastInputTime;
            if ( idleAge >= holdTime && idleAge < holdTime + fadeOutDuration ) return true;
        }
        return false;
    }
}