GooView.cs

A UI Panel that hosts a Goo virtual DOM root. Each frame it optionally runs OnTick, invokes the user-provided Build delegate to produce a Blob tree, diffs it with the Reconciler, logs warnings, applies ops to the real UI with Applier, and manages a BuildContext and Fiber lifecycle.

Native Interop
using System;
using Sandbox;
using Sandbox.UI;

namespace Goo;

/// <summary>
/// A Razor markup element that hands its children to Goo. Drop <c>&lt;GooView Build=@MyBuild/&gt;</c>
/// into any .razor panel; every frame it runs the Goo reconciler pump (<see cref="Reconciler"/> to
/// <see cref="Applier"/>) against itself. Razor owns everything outside the tag; Goo owns everything
/// inside. GooView imposes no intrinsic sizing: size it like any panel (a zero-content-size view can
/// collapse, same as a bare &lt;div&gt;).
/// </summary>
public class GooView : Panel
{
    Fiber? _fiber;
    readonly BuildContext _ctx = new();

    /// <summary>Returns the single Goo root. Invoked every frame; the build closure may freely read
    /// external (Razor-owned) state because the view re-diffs each frame, so changes to that state appear
    /// with no explicit dirty signal. A dirty-on-reassign scheme cannot work here: the engine de-dupes a
    /// re-assigned Build delegate by hashcode, so re-running the reconciler each frame is what reflects
    /// outside state.</summary>
    public BlobBuilder? Build { get; set; }

    /// <summary>Optional per-frame driver for animation or polled state the build closure reads. Runs every
    /// frame before the diff. Its return value is ignored (the view always re-diffs).</summary>
    public Func<float, bool>? OnTick { get; set; }

    /// <summary>Optional tag-to-effect manifest. A Blob with a matching <see cref="Container.Tag"/> and no
    /// inline <c>Effect</c> resolves its effect from here.</summary>
    public EffectMap? Effects { get; set; }

    public override void Tick()
    {
        base.Tick();

        // Advance any per-frame state the build closure reads.
        OnTick?.Invoke(Time.Delta);

        if (Build is null) return;

        // Re-diff every frame. A GooView's build closure reads state owned by the surrounding Razor
        // component, and the engine de-dupes the Build delegate by hashcode, so nothing signals GooView
        // when that state changes. Re-running the reconciler each frame is the only way to reflect it.
        // Reconciler.Diff emits no ops when nothing changed, so an idle view costs a build + tree-walk
        // but no Apply. Save/set/restore _current so a GooView can nest inside a GooPanel or another GooView.
        var prev = BuildContext._current;
        BuildContext._current = _ctx;
        _ctx.RootRebuild = null; // no dirty flag; the per-frame diff also reflects in-subtree events
        _ctx.EffectManifest = Effects;
        try
        {
            IBlob next = Build();
            var result = Reconciler.Diff(ref _fiber, next);
            foreach (var w in result.Warnings) Log.Warning(w);
            Applier.Apply(this, result.Ops);
        }
        finally
        {
            _ctx.ReturnAll();
            BuildContext._current = prev;
        }
    }

    public override void OnDeleted()
    {
        base.OnDeleted();
        // Dispose any IDisposable Cell instances in the subtree before the panel goes away.
        Reconciler.TeardownFiberTree(_fiber);
        _fiber = null;
    }
}