Editor/Core/UndoResetScheduler.cs

Scheduler used by the editor TeamCreate code to coalesce native undo history resets after remote changes. It tracks requests with timestamps and decides when to run a reset so resets are at most once per IntervalMs while changes stream and one final run when they stop.

using System;

namespace TeamCreate;

/// <summary>
/// Decides when the native undo history is reset after remote changes arrive. The engine's undo entries snapshot whole editor state, so after a
/// remote change is applied the history has to be reset (<c>UndoSystem.Initialize()</c>) or a later Ctrl+Z could restore a snapshot that
/// contains none of it and silently revert other people's work. That reset is a full snapshot, and a remote drag arrives at about 20 packets a
/// second, so doing it per packet costs a full reset each time. The scene is still applied at once; only the history reset is coalesced here:
/// at most one per <see cref="IntervalMs"/> while changes keep arriving, and one more when they stop, so the final state is always reset exactly once.
///
/// Hazard, by design and bounded: between a remote apply and the coalesced reset (at most <see cref="IntervalMs"/>) a native Ctrl+Z could restore
/// a stale snapshot. The library cannot see the keystroke, but every undo it can see goes through <c>CollaborationSession.Undo</c> and
/// <c>TryRedo</c>, and those call <see cref="Flush"/> first, so a reset still pending is run before any undo or redo acts.
///
/// Time is injected as milliseconds from any monotonic clock the caller owns, which keeps this pure. A reading that goes backwards or is not a
/// finite number never throws and never leaves a reset stuck.
/// </summary>
public sealed class UndoResetScheduler
{
    /// <summary>The shortest gap between two runs while requests keep coming, and the quiet gap after which a held request runs.</summary>
    public const double IntervalMs = 250;

    private bool pending;

    private double lastRequestMs;

    private double? lastRunMs;

    /// <summary>True while a requested reset has not run yet.</summary>
    public bool Pending => pending;

    /// <summary>Marks a reset as needed because a remote change was applied at <paramref name="nowMs"/>.</summary>
    public void Request( double nowMs )
    {
        pending = true;
        // An unusable reading counts as "long ago", so the quiet-gap rule releases the request instead of waiting on a number that never arrives.
        lastRequestMs = double.IsFinite( nowMs ) ? nowMs : double.NegativeInfinity;
    }

    /// <summary>
    /// True when the caller should run the reset now (which also consumes the request). A request runs at once if nothing ran in the last
    /// <see cref="IntervalMs"/>; otherwise it is held and runs when that interval ends, or when <see cref="IntervalMs"/> have passed since the
    /// last request, whichever comes first. The second rule is what releases the final request after a clock that stepped backwards.
    /// </summary>
    public bool ShouldRunNow( double nowMs )
    {
        if ( !pending ) return false;
        if ( !double.IsFinite( nowMs ) )
        {
            // Better one reset too many than a pending reset that never runs.
            pending = false;
            lastRunMs = null;
            return true;
        }
        bool intervalOver = lastRunMs == null || nowMs - lastRunMs.Value >= IntervalMs;
        bool quiet = nowMs - lastRequestMs >= IntervalMs;
        if ( !intervalOver && !quiet ) return false;
        pending = false;
        lastRunMs = nowMs;
        return true;
    }

    /// <summary>
    /// For an undo or redo about to act: true when a reset was pending, which the caller must run first. Clears the request and forgets the last
    /// run, so the next request after the undo is not held back by an interval that belonged to the state before it.
    /// </summary>
    public bool Flush()
    {
        bool was = pending;
        pending = false;
        if ( was ) lastRunMs = null;
        return was;
    }
}