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;
}
}