Code/GooPanel.cs

A UI panel component generic over a Blob root type that drives a retained-mode UI build/diff/apply cycle. It manages a rebuild flag, per-frame Tick, hotload handling, mounting/unmounting fibers, invokes an abstract Build() to produce a TRoot, diffs via Reconciler.Diff, applies ops to the Panel, and records perf events.

Native Interop
using System.Collections.Generic;
using System.Diagnostics;
using Sandbox;
using Sandbox.UI;

namespace Goo;

public abstract class GooPanel<TRoot> : PanelComponent, IHotloadManaged where TRoot : struct, IBlob
{
    Fiber? _fiber;
    readonly BuildContext _ctx = new();
    bool _needsBuild = true;
    bool _wasMoving;
    bool _hasMounted;

    protected abstract TRoot Build();

    public void Rebuild() => _needsBuild = true;

    void EventRebuild()
    {
        if (Perf.Enabled) Perf.RecordEvent(PerfEvent.PanelEventRebuild);
        Rebuild();
    }

    /// <summary>Creates a State bound to this panel: writes trigger a rebuild automatically.</summary>
    protected State<T> Track<T>(T initial) => new(initial, Rebuild);

    /// <summary>Override to drive per-frame state (animations, polled input).
    /// Return true while a rebuild is still needed next frame (e.g. an animation is in motion).
    /// Runs every frame, before the build gate. On the frame motion stops (this returns false
    /// after the previous frame returned true) one extra rebuild fires so the final settled value paints.</summary>
    protected virtual bool Tick(float dt) => false;

    /// <summary>When true (default), a rebuild is requested automatically after any event
    /// handler on this panel's tree fires. Set false to restore fully manual Rebuild() control.</summary>
    protected virtual bool AutoRebuildOnEvents => true;

    /// <summary>Override to map tags to effects. A Blob with a matching <see cref="Container.Tag"/> and no
    /// inline <c>Effect</c> resolves its effect from here. Default targets nothing.</summary>
    protected virtual EffectMap Effects => EffectMap.Empty;

    // Hotload hook. Engine creates a fresh instance per Component on hotload, then
    // Cecil-migrates field values from the prior instance. We need to trigger a diff
    // against the new Build() output so the panel reflects edited source.
    void IHotloadManaged.Created(IReadOnlyDictionary<string, object> state) => _needsBuild = true;

    protected override void OnEnabled()
    {
        bool perf = Perf.Enabled;
        long start = perf ? Stopwatch.GetTimestamp() : 0;
        // Engine destroys Panel on disable and creates a fresh empty one on enable.
        // Drop our fiber so the next diff is a full mount against the new Panel.
        _fiber = null;
        _needsBuild = true;
        _wasMoving = false;
        if (perf)
        {
            Perf.RecordPhase(PerfPhase.PanelEnable, Stopwatch.GetTimestamp() - start);
            if (_hasMounted) Perf.RecordEvent(PerfEvent.PanelRemount);
        }
    }

    protected override void OnDisabled()
    {
        bool perf = Perf.Enabled;
        long start = perf ? Stopwatch.GetTimestamp() : 0;
        try
        {
            Reconciler.TeardownFiberTree(_fiber);
        }
        finally
        {
            _fiber = null;
            if (perf)
                Perf.RecordPhase(PerfPhase.PanelDisableTeardown, Stopwatch.GetTimestamp() - start);
        }
    }

    protected override void OnUpdate()
    {
        if (Panel == null) return;

        // Per-frame state advances every frame, independent of the build gate.
        // A motion->settled transition rebuilds once more so the final resting value paints.
        bool perf = Perf.Enabled;
        long tickStart = perf ? Stopwatch.GetTimestamp() : 0;
        bool moving = Tick(Time.Delta);
        if (perf)
        {
            Perf.RecordPhase(PerfPhase.PanelTick, Stopwatch.GetTimestamp() - tickStart);
            if (moving) Perf.RecordEvent(PerfEvent.PanelMovingFrame);
        }
        if (moving || _wasMoving) _needsBuild = true;
        _wasMoving = moving;
        if (!_needsBuild)
        {
            if (perf) Perf.RecordEvent(PerfEvent.PanelIdleFrame);
            return;
        }

        var prev = BuildContext._current;
        BuildContext._current = _ctx;
        _ctx.RootRebuild = EventRebuild;
        _ctx.AutoRebuildOnEvents = AutoRebuildOnEvents;
        _ctx.EffectManifest = Effects;
        try
        {
            bool mount = _fiber is null;
            long t0 = perf ? Stopwatch.GetTimestamp() : 0;

            TRoot next = Build();
            long t1 = perf ? Stopwatch.GetTimestamp() : 0;

            var result = Reconciler.Diff(ref _fiber, in next);
            long t2 = perf ? Stopwatch.GetTimestamp() : 0;

            foreach (var w in result.Warnings) Log.Warning(w);
            Applier.Apply(Panel, result.Ops);

            if (perf)
                Perf.RecordRebuild(mount, t1 - t0, t2 - t1, Stopwatch.GetTimestamp() - t2);
            _hasMounted = true;
        }
        finally
        {
            long returnStart = perf ? Stopwatch.GetTimestamp() : 0;
            _ctx.ReturnAll();
            if (perf)
                Perf.RecordPhase(PerfPhase.PanelReturnAll, Stopwatch.GetTimestamp() - returnStart);
            BuildContext._current = prev;
            _needsBuild = false;
        }
    }

    // Goo manages Panel.Children directly via Diff/Apply; skip Razor's render-tree build path.
    protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder)
    {
    }
}