Demos/BeatPad/BeatPadUI.cs
using System;
using Goo;
using Goo.Animation;
using Goo.Input;
using Sandbox.UI;
using Sandbox.BeatPad;
using static Sandbox.BeatPadTokens;

namespace Sandbox;

// MPD-style beat pad demo. Root owns ALL state and ALL animators in one AnimationSet;
// PadBlob/KnobBlob/ModeButtonBlob are pure presenters. See
// docs/superpowers/specs/2026-05-31-goo-beatpad-design.md.
public sealed class BeatPadUI : GooPanel<Container>
{
    // ---- constants ----
    const int Pads = BeatPadKits.PadCount; // 16
    const int Knobs = 6;                    // VOL/PCH/FLT/REV/SWG/TMP (OUT replaced by scope, TMP fills 6th cell)
    const int Modes = 6;

    static readonly string[] KnobLabels = { "VOL", "PCH", "FLT", "REV", "SWG", "TMP" };
    const int SwingKnob = 4;
    const int TempoKnob = 5;
    static readonly string[] ModeLabels = { "BANK", "NOTE", "CTRL", "PROG", "PLAY", "REC" };
    const int BankMode = 0;
    const int NoteMode = 1;

    // Shared width for the left column (knobs over modes) so pads + scope left-align.
    const float SideW = ModeW * 2 + 20f;

    // ---- oscilloscope (synthetic, scrolling; replaces the OUT knob) ----
    const int ScopeSamples = 48;
    readonly float[] _scope = new float[ScopeSamples]; // ring contents, newest at end
    float _scopePhase;        // running oscillator phase for the active pad's tone
    float _scopeFreq = 24f;   // radians/sec-ish; set per hit from pad index
    float _scopeNoise;        // 0..1 noise mix for hats/cymbals
    int _scopeSeed = 1;       // tiny LCG state so we avoid Math.Random (banned)

    // ---- state ----
    int _activeKit;                              // 0 = A, 1 = B
    readonly float[] _knobValues = new float[Knobs];
    readonly bool[] _modeActive = new bool[Modes];
    int _lastPad = -1;
    bool _noteHeld;
    float _hitTimer;                             // seconds remaining on HIT overlay
    string _hitLabel = "";
    NoteRepeatClock _noteClock = new(NoteRepeatClock.DefaultIntervalSeconds);

    // ---- sequencer ----
    const int PlayMode = 4; // index into ModeLabels ("PLAY")
    const int RecMode = 5;  // index into ModeLabels ("REC")
    readonly SequencerClock _seqClock = new();
    readonly StepPattern _pattern = new();
    readonly System.Collections.Generic.List<int> _firedSteps = new();
    readonly System.Collections.Generic.List<int> _padsAtStep = new();
    readonly RackTracks _tracks = new();
    // per-cell + per-track click handlers (stable per key, preserve diffing)
    readonly HandlerTable<(int, int)> _cellClick;
    readonly HandlerTable<int> _trackDelete;
    readonly HandlerTable<int> _trackMute;
    readonly HandlerTable<int> _trackSolo;
    readonly System.Collections.Generic.List<ChannelRackBlob.Track> _rackTracks = new();
    bool _recArmed;
    bool _recDown;           // REC button is physically held right now
    float _recHoldTime;      // seconds REC has been held (for tap-vs-hold + clear)
    bool _recErasing;        // true once the hold passes the threshold this press
    const float RecHoldThreshold = 0.3f; // tap below this, hold (erase) above
    const float RecClearHold = 3.0f;     // hold this long with no pad -> wipe all

    readonly Goo.Input.KeyTracker _keys = new();

    // ---- animators ----
    readonly DecayFloat[] _litFade = new DecayFloat[Pads];   // 1 (hot) -> 0 (paper)
    readonly SpringFloat[] _padPress = new SpringFloat[Pads]; // 0 -> 4 -> 0 px
    readonly SpringFloat[] _padBounce = new SpringFloat[Pads];// scale ~1, overshoot on release
    readonly DecayFloat[] _knobFlash = new DecayFloat[Knobs]; // readout fade 1 -> 0
    readonly SmoothFloat[] _modeFlood = new SmoothFloat[Modes];// active crossfade 0..1
    readonly SpringFloat[] _modePress = new SpringFloat[Modes];
    DecayFloat _outMeter = new(0f, 0.18f);

    readonly AnimationSet _anims = new();

    // ---- pointer drag state for knobs ----
    int _draggingKnob = -1;
    float _dragLastY;

    // ---- stable pointer handlers (stable per key; preserve structural diffing) ----
    readonly HandlerTable<int> _padDown;
    readonly HandlerTable<int> _padUp;
    readonly HandlerTable<int> _padEnter;
    readonly HandlerTable<int> _padLeave;
    readonly HandlerTable<int> _knobDown;
    readonly HandlerTable<int> _knobUp;
    readonly HandlerTable<int> _knobMove;
    readonly HandlerTable<int> _modeDown;
    readonly HandlerTable<int> _modeUp;
    readonly bool[] _padHover = new bool[Pads];

    public BeatPadUI()
    {
        _padDown = new(OnPadDown);
        _padUp = new(OnPadUp);
        _padEnter = new(k => _padHover[k] = true);
        _padLeave = new(k => _padHover[k] = false);
        _knobDown = new(OnKnobDown);
        _knobUp = new(OnKnobUp);
        _knobMove = new(OnKnobMove);
        _modeDown = new(OnModeDown);
        _modeUp = new(OnModeUp);
        _cellClick = new((k, _) => OnCellClick(k.Item1, k.Item2));
        _trackDelete = new(OnTrackDelete);
        _trackMute = new(k => _tracks.ToggleMute(k));
        _trackSolo = new(k => _tracks.ToggleSolo(k));
    }

    protected override void OnEnabled()
    {
        base.OnEnabled();

        for (int i = 0; i < Pads; i++)
        {
            _litFade[i] = new DecayFloat(0f, 0.06f);
            _padPress[i] = new SpringFloat(0f, 18f, 0.6f);
            _padBounce[i] = new SpringFloat(1f, 14f, 0.35f);
        }
        for (int i = 0; i < Knobs; i++) _knobFlash[i] = new DecayFloat(0f, 0.18f);
        for (int i = 0; i < Modes; i++)
        {
            _modeFlood[i] = new SmoothFloat(0f, 0.08f);
            _modePress[i] = new SpringFloat(0f, 18f, 0.6f);
        }

        // Knob defaults: VOL audible, others centered.
        _knobValues[0] = 0.8f; // VOL
        _knobValues[1] = 0.5f; // PCH (mid = unity pitch)
        _knobValues[2] = 0.5f; // FLT (center = neutral bipolar filter)
        _knobValues[3] = 0f;   // REV (dry)
        _knobValues[4] = 0.5f; // SWG (mid = straight)
        _knobValues[5] = QuantizeMath.BpmToValue(120f); // TMP -> 120 BPM (0.5)

        _anims.Clear();
        for (int i = 0; i < Pads; i++)
        {
            int k = i;
            _anims.Add(dt => { _litFade[k].Update(dt); return !_litFade[k].IsSettled; });
            _anims.Add(dt => { _padPress[k].Update(dt); return !_padPress[k].IsSettled; });
            _anims.Add(dt => { _padBounce[k].Update(dt); return !_padBounce[k].IsSettled; });
        }
        for (int i = 0; i < Knobs; i++)
        {
            int k = i;
            _anims.Add(dt => { _knobFlash[k].Update(dt); return !_knobFlash[k].IsSettled; });
        }
        for (int i = 0; i < Modes; i++)
        {
            int k = i;
            _anims.Add(dt => { _modeFlood[k].Update(dt); return !_modeFlood[k].IsSettled; });
            _anims.Add(dt => { _modePress[k].Update(dt); return !_modePress[k].IsSettled; });
        }
        _anims.Add(dt => { _outMeter.Update(dt); return !_outMeter.IsSettled; });

        Panel.Style.Width = Length.Percent(100);
        Panel.Style.Height = Length.Percent(100);
        Panel.Style.JustifyContent = Justify.Center;
        Panel.Style.AlignItems = Align.Center;
    }

    protected override bool Tick(float dt)
    {
        bool dirty = false;

        _keys.Poll();

        _seqClock.SetBpm(QuantizeMath.ValueToBpm(_knobValues[TempoKnob]));
        _seqClock.SetSwing(QuantizeMath.ValueToSwing(_knobValues[SwingKnob]));

        var pressed = _keys.JustPressed;
        for (int i = 0; i < pressed.Count; i++)
        {
            if (BeatPadKeys.TryMap(pressed[i].EngineName, out int pad))
            {
                _padPress[pad].Target = BeatPadTokens.PadShadow;
                _padPress[pad].Velocity = 0f;
                TriggerPad(pad);
                RecordLiveHit(pad);
                // keyboard has no key-up wiring; ease the press back immediately
                _padPress[pad].Target = 0f;
            }
        }

        if (_noteHeld && _lastPad >= 0)
        {
            int fires = _noteClock.Tick(dt);
            for (int f = 0; f < fires; f++) { TriggerPad(_lastPad); RecordLiveHit(_lastPad); }
        }

        if (_recDown)
        {
            _recHoldTime += dt;
            if (_recHoldTime >= RecHoldThreshold && !_recErasing)
            {
                _recErasing = true; // entered erase mode; release will not toggle arm
                _modeFlood[RecMode].Target = 1f; // light REC to show erase is active
            }
            if (_recErasing && _recHoldTime >= RecClearHold)
            {
                _pattern.ClearAll();
                _tracks.Clear();
                dirty = true;
            }
        }

        if (_seqClock.Playing)
        {
            _firedSteps.Clear();
            _seqClock.Advance(dt, _firedSteps);
            for (int i = 0; i < _firedSteps.Count; i++)
            {
                _pattern.CollectPadsAt(_firedSteps[i], _padsAtStep);
                for (int j = 0; j < _padsAtStep.Count; j++)
                    if (_tracks.ShouldPlay(_padsAtStep[j])) TriggerPad(_padsAtStep[j]);
            }
        }

        bool moving = _anims.UpdateAll(dt);
        if (_hitTimer > 0f) { _hitTimer -= dt; moving = true; }

        // Scope: while the envelope is live, scroll fresh synthetic samples in.
        if (_outMeter.Current > 0.001f)
        {
            AdvanceScope(dt);
            moving = true;
        }

        return dirty || moving || _seqClock.Playing;
    }

    void TriggerPad(int index)
    {
        string sound = BeatPadKits.Sounds(_activeKit)[index];
        float flt = _knobValues[2];
        BeatPadAudio.Play(sound, _knobValues[0], KnobMath.ValueToPitch(_knobValues[1]),
            KnobMath.ValueToLowPassCutoff(flt), KnobMath.ValueToHighPassCutoff(flt),
            KnobMath.ValueToReverbMix(_knobValues[3]));

        float halflife = BeatPadAudio.ResolveHalflife(sound);
        _litFade[index] = new DecayFloat(1f, halflife);
        _litFade[index].Target = 0f;

        _padBounce[index].Current = 0.96f;
        _padBounce[index].Target = 1f;
        _padBounce[index].Velocity = 6f;

        _outMeter.Current = 1f;
        _outMeter.Target = 0f;

        // Seed the scope tone from the pad: low pads = low hum, high pads = bright + noisy.
        float row = index / 4;                 // 0 (bottom) .. 3 (top)
        _scopeFreq = 14f + row * 16f + (index % 4) * 2f;
        _scopeNoise = row >= 2 ? 0.35f + 0.2f * (row - 2) : 0.05f;
        _scopePhase = 0f;

        _lastPad = index;
        _hitLabel = BeatPadKits.Labels(_activeKit)[index];
        _hitTimer = 0.5f;
    }

    // Push fresh synthetic samples into the scope ring. Amplitude follows the decaying
    // hit envelope (_outMeter); tone is a damped sine plus per-pad noise. Scrolls L<-R.
    void AdvanceScope(float dt)
    {
        // a few samples per frame keeps the trace moving at ~display rate
        const int perFrame = 3;
        float amp = _outMeter.Current;
        for (int s = 0; s < perFrame; s++)
        {
            _scopePhase += _scopeFreq * dt / perFrame;
            float tone = MathF.Sin(_scopePhase);
            float noise = (NextNoise() * 2f - 1f) * _scopeNoise;
            float v = amp * (tone * (1f - _scopeNoise) + noise);

            // shift left, append newest
            Array.Copy(_scope, 1, _scope, 0, ScopeSamples - 1);
            _scope[ScopeSamples - 1] = v;
        }
    }

    // Deterministic 0..1 noise (Math.Random is banned in this harness).
    float NextNoise()
    {
        _scopeSeed = unchecked(_scopeSeed * 1103515245 + 12345);
        return ((_scopeSeed >> 16) & 0x7fff) / 32767f;
    }

    void OnPadDown(int i)
    {
        if (_recErasing) { _tracks.Remove(i); _pattern.Erase(i); return; } // hold-REC + pad = wipe pad
        _padPress[i].Target = BeatPadTokens.PadShadow; // press translate +4
        TriggerPad(i);
        RecordLiveHit(i);
    }

    // Record a live pad hit at the current quantized step. Only writes while the loop is
    // running AND record-arm is on -- so loop playback (which calls TriggerPad directly)
    // never self-records. Quantizes to the nearest 1/16 of the bar.
    void RecordLiveHit(int pad)
    {
        if (!_recArmed || !_seqClock.Playing) return;
        float bpm = QuantizeMath.ValueToBpm(_knobValues[TempoKnob]);
        int step = QuantizeMath.NearestStep(
            _seqClock.PlayheadFraction * QuantizeMath.BarDuration(bpm), bpm);
        _pattern.Record(step, pad);
        _tracks.EnsureTrack(pad);
    }

    // Click a cell to add/remove a hit; audition the pad when turning it on.
    void OnCellClick(int step, int pad)
    {
        if (_pattern.Toggle(step, pad)) TriggerPad(pad);
    }

    // Delete a whole track (its row + every hit on that pad).
    void OnTrackDelete(int pad)
    {
        _tracks.Remove(pad);
        _pattern.Erase(pad);
    }

    void OnPadUp(int i)
    {
        _padPress[i].Target = 0f; // release; spring eases back, bounce already kicked
    }

    void OnKnobDown(int i, MousePanelEvent e)
    {
        _draggingKnob = i;
        _dragLastY = e.LocalPosition.y;
    }

    void OnKnobUp(int i)
    {
        if (_draggingKnob == i) _draggingKnob = -1;
    }

    void OnKnobMove(int i, MousePanelEvent e)
    {
        if (_draggingKnob != i) return;
        float dy = e.LocalPosition.y - _dragLastY;
        _dragLastY = e.LocalPosition.y;
        _knobValues[i] = KnobMath.ApplyDrag(_knobValues[i], dy, 2f);
        _knobFlash[i].Current = 1f;
        _knobFlash[i].Target = 0f;
    }

    void OnModeDown(int i)
    {
        _modePress[i].Target = BeatPadTokens.ModeShadow;

        switch (i)
        {
            case BankMode:
                _activeKit = 1 - _activeKit;
                _modeActive[BankMode] = _activeKit == 1; // lit reflects kit B selected; reverts on kit A
                _modeFlood[BankMode].Target = _modeActive[BankMode] ? 1f : 0f;
                break;
            case NoteMode:
                _noteHeld = true;
                _noteClock.Reset();
                _modeActive[NoteMode] = true;
                _modeFlood[NoteMode].Target = 1f;
                break;
            case PlayMode:
                if (_seqClock.Playing) _seqClock.Stop();
                else _seqClock.Start();
                _modeActive[PlayMode] = _seqClock.Playing;
                _modeFlood[PlayMode].Target = _seqClock.Playing ? 1f : 0f;
                break;
            case RecMode:
                // start the hold timer; tap-vs-hold decided on release / over time (OnUpdate)
                _recDown = true;
                _recHoldTime = 0f;
                _recErasing = false;
                break;
            default:
                // CTRL/PROG: visual-only toggle/momentary
                _modeActive[i] = !_modeActive[i];
                _modeFlood[i].Target = _modeActive[i] ? 1f : 0f;
                break;
        }
    }

    void OnModeUp(int i)
    {
        _modePress[i].Target = 0f;
        if (i == RecMode)
        {
            _recDown = false;
            if (!_recErasing)
            {
                // a tap -> toggle record-arm
                _recArmed = !_recArmed;
                _modeActive[RecMode] = _recArmed;
                _modeFlood[RecMode].Target = _recArmed ? 1f : 0f;
                // arming record starts the loop so the playhead sweeps and hits land in time
                if (_recArmed && !_seqClock.Playing)
                {
                    _seqClock.Start();
                    _modeActive[PlayMode] = true;
                    _modeFlood[PlayMode].Target = 1f;
                }
            }
            else
            {
                // was an erase hold -> restore the arm light, do not toggle
                _modeFlood[RecMode].Target = _recArmed ? 1f : 0f;
            }
            _recErasing = false;
        }
        if (i == NoteMode)
        {
            _noteHeld = false;
            _modeActive[NoteMode] = false;
            _modeFlood[NoteMode].Target = 0f;
        }
    }

    protected override Container Build()
    {
        return new Container
        {
            Key = "chassis",
            FlexDirection = FlexDirection.Column,
            BackgroundColor = Paper,
            BorderColor = Ink,
            BorderWidth = OutlineWidth,
            BorderRadius = ChassisRadius,
            PaddingLeft = 18f, PaddingTop = 14f, PaddingRight = 18f, PaddingBottom = 18f,
            Children =
            {
                BuildTitleStrip(),
                new Container
                {
                    Key = "bodyrow",
                    FlexDirection = FlexDirection.Row,
                    AlignItems = Align.FlexStart,
                    Children = { BuildBody(), BuildRack() },
                },
            },
        };
    }

    Container BuildTitleStrip()
    {
        int bpm = (int)System.MathF.Round(QuantizeMath.ValueToBpm(_knobValues[TempoKnob]));
        string status =
            _hitTimer > 0f ? $"HIT {_hitLabel}" :
            (_seqClock.Playing && _recArmed) ? $"REC · {bpm} BPM" :
            _seqClock.Playing ? $"PLAY · {bpm} BPM" :
            $"BANK {BeatPadKits.KitName(_activeKit)} · {bpm} BPM";

        return new Container
        {
            Key = "title",
            FlexDirection = FlexDirection.Row,
            JustifyContent = Justify.SpaceBetween,
            AlignItems = Align.Center,
            MarginBottom = 14f,
            Children =
            {
                new Text("GOO MPD")
                {
                    Key = "brand",
                    FontSize = 18f, FontColor = Ink, FontFamily = FontLabel,
                    FontWeight = 600, TextTransform = TextTransform.Uppercase, LetterSpacing = 3f,
                },
                new Container
                {
                    Key = "screen",
                    BackgroundColor = Ink,
                    BorderRadius = 4f,
                    // Pin a stable LCD width so transport-state strings (BANK/PLAY/REC) don't
                    // reflow the title row as they swap; longest status is ~16 mono chars.
                    MinWidth = 156f,
                    PaddingLeft = 10f, PaddingRight = 10f, PaddingTop = 4f, PaddingBottom = 4f,
                    Children =
                    {
                        new Text(status) { FontSize = 12f, FontColor = Hot, FontFamily = FontMono },
                    },
                },
            },
        };
    }

    // 2x2 cluster: knobs | pads  (top)  /  modes | scope  (bottom).
    Container BuildBody()
    {
        return new Container
        {
            Key = "body",
            FlexDirection = FlexDirection.Column,
            Children =
            {
                new Container
                {
                    Key = "toprow",
                    FlexDirection = FlexDirection.Row,
                    Children = { BuildKnobGrid(), BuildPadGrid() },
                },
                new Container
                {
                    Key = "botrow",
                    FlexDirection = FlexDirection.Row,
                    Children = { BuildModeGrid(), BuildScope() },
                },
            },
        };
    }

    // The FL-style channel rack to the right of the pads: one row per active track,
    // built in recording order from _tracks, occupancy read live from _pattern.
    Container BuildRack()
    {
        _rackTracks.Clear();
        var labels = BeatPadKits.Labels(_activeKit);
        var order = _tracks.Order;
        for (int i = 0; i < order.Count; i++)
        {
            int pad = order[i];
            var occ = new bool[QuantizeMath.StepsPerBar];
            for (int s = 0; s < QuantizeMath.StepsPerBar; s++) occ[s] = _pattern.Has(s, pad);
            _rackTracks.Add(new ChannelRackBlob.Track(
                pad, labels[pad], occ,
                _tracks.IsMuted(pad), _tracks.IsSoloed(pad), !_tracks.ShouldPlay(pad)));
        }
        return new Container
        {
            Key = "rackwrap",
            MarginLeft = 16f,
            Children =
            {
                ChannelRackBlob.Build(new ChannelRackBlob.Props(
                    _rackTracks, QuantizeMath.StepsPerBar, _seqClock.PlayheadFraction,
                    _cellClick, _trackDelete, _trackMute, _trackSolo)),
            },
        };
    }

    Container BuildKnobGrid()
    {
        var grid = new Container
        {
            Key = "knobs",
            FlexDirection = FlexDirection.Row,
            FlexWrap = Wrap.Wrap,
            Width = SideW,
            MarginRight = 20f,
            MarginBottom = 16f,
        };
        for (int i = 0; i < Knobs; i++)
        {
            grid.Children.Add(new Container
            {
                Key = $"knobcell-{i}",
                MarginRight = 16f, MarginBottom = 8f,
                Children =
                {
                    KnobBlob.Build(new KnobBlob.Props(
                        i, KnobLabels[i],
                        _knobValues[i],
                        _knobFlash[i].Current,
                        ReadOnly: false,
                        OnDown: _knobDown[i], OnUp: _knobUp[i], OnMove: _knobMove[i])),
                },
            });
        }
        return grid;
    }

    // Big oscilloscope LCD filling the area under the pad grid (right of the modes).
    Container BuildScope()
    {
        return new Container
        {
            Key = "scope",
            Width = 4f * (PadSize + PadGap),  // match the pad grid width above it
            Height = ScopeHeight,
            Children =
            {
                OscilloscopeBlob.Build(new OscilloscopeBlob.Props(_scope, _outMeter.Current)),
            },
        };
    }

    Container BuildModeGrid()
    {
        var grid = new Container
        {
            Key = "modes",
            FlexDirection = FlexDirection.Row,
            FlexWrap = Wrap.Wrap,
            Width = SideW,
            MarginRight = 20f,
        };
        for (int i = 0; i < Modes; i++)
        {
            grid.Children.Add(new Container
            {
                Key = $"modecell-{i}",
                MarginRight = 8f, MarginBottom = 8f,
                Children =
                {
                    ModeButtonBlob.Build(new ModeButtonBlob.Props(
                        i, ModeLabels[i], _modeFlood[i].Current, _modePress[i].Current,
                        Hovered: false, OnDown: _modeDown[i], OnUp: _modeUp[i])),
                },
            });
        }
        return grid;
    }

    Container BuildPadGrid()
    {
        var labels = BeatPadKits.Labels(_activeKit);
        var grid = new Container
        {
            Key = "pads",
            FlexDirection = FlexDirection.Column,
        };
        // rows top (12..15) down to bottom (0..3) so visual order matches MPD numbering
        for (int row = 3; row >= 0; row--)
        {
            var rowC = new Container { Key = $"row-{row}", FlexDirection = FlexDirection.Row };
            for (int col = 0; col < 4; col++)
            {
                int idx = row * 4 + col;
                rowC.Children.Add(new Container
                {
                    Key = $"padcell-{idx}",
                    MarginRight = PadGap, MarginBottom = PadGap,
                    Children =
                    {
                        PadBlob.Build(new PadBlob.Props(
                            idx, labels[idx],
                            _litFade[idx].Current, _padPress[idx].Current, _padBounce[idx].Current,
                            Hovered: _padHover[idx],
                            OnDown: _padDown[idx], OnUp: _padUp[idx],
                            OnEnter: _padEnter[idx], OnLeave: _padLeave[idx])),
                    },
                });
            }
            grid.Children.Add(rowC);
        }
        return grid;
    }
}