OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux / AvaloniaShell / TimeView.cs

125 lines · csharp ref:
using System;

namespace IFSRenderer.AvaloniaShell;

/// <summary>
/// The window of time the animation panes are showing, shared by the dopesheet and the curve
/// editor so a keyframe sits at the same x in both.
/// </summary>
/// <remarks>
/// <para>
/// The panes used to map the WHOLE clip onto their width, which is why neither could zoom: the
/// mapping had nowhere to put a magnified span. This holds the visible window instead -
/// <see cref="Start"/> plus <see cref="Span"/> seconds - and both panes project through it.
/// </para>
/// <para>
/// The canonical WPF panel does this with a <c>ViewScale</c> in px-per-second and a
/// <c>ScrollViewer</c> around a widened canvas (AnimationPanel.xaml:131, ViewModels/
/// AnimationViewModel.cs:61). A visible window is the same idea without needing the canvas to
/// grow, and it keeps both panes in register by construction rather than by scroll-syncing two
/// viewers.
/// </para>
/// </remarks>
internal sealed class TimeView
{
    public const double LeftPad = 8;
    public const double RightPad = 14;

    /// <summary>Never zoom in past this fraction of the clip - the panes stay navigable.</summary>
    private const double MinSpanFraction = 1.0 / 400;

    private double _length = 10;
    private double _start;
    private double _span = 10;

    /// <summary>Raised when the window moved or resized, so the panes repaint.</summary>
    public event Action Changed;

    public double Start => _start;
    public double Span => _span;
    public double End => _start + _span;

    /// <summary>Whether the whole clip is visible, i.e. there is nothing to scroll.</summary>
    public bool IsFullyZoomedOut => _span >= _length - 1e-9;

    public double Length
    {
        get => _length;
        set
        {
            double length = Math.Max(value, 0.001);
            if (Math.Abs(length - _length) < 1e-9)
                return;
            _length = length;
            Clamp();
            Changed?.Invoke();
        }
    }

    public static double TrackWidth(double width) => Math.Max(width - LeftPad - RightPad, 1);

    public double TimeToX(double t, double width) =>
        LeftPad + (t - _start) / _span * TrackWidth(width);

    public double XToTime(double x, double width) =>
        Math.Clamp(_start + (x - LeftPad) / TrackWidth(width) * _span, 0, _length);

    /// <summary>Shows the whole clip.</summary>
    public void Reset()
    {
        _start = 0;
        _span = _length;
        Changed?.Invoke();
    }

    /// <summary>
    /// Zooms about a fixed point in time, so whatever is under the cursor stays under it.
    /// </summary>
    public void ZoomAt(double anchor, double factor)
    {
        double span = Math.Clamp(_span * factor, _length * MinSpanFraction, _length);
        if (Math.Abs(span - _span) < 1e-12)
            return;

        // Keep `anchor` at the same fraction across the track.
        double fraction = _span <= 0 ? 0.5 : (anchor - _start) / _span;
        _start = anchor - fraction * span;
        _span = span;
        Clamp();
        Changed?.Invoke();
    }

    public void PanBy(double seconds)
    {
        if (seconds == 0)
            return;
        _start += seconds;
        Clamp();
        Changed?.Invoke();
    }

    public void ScrollTo(double start)
    {
        double clamped = Math.Clamp(start, 0, Math.Max(_length - _span, 0));
        if (Math.Abs(clamped - _start) < 1e-12)
            return;
        _start = clamped;
        Changed?.Invoke();
    }

    /// <summary>Scrolls the least amount that brings a time into view, for playback.</summary>
    public void EnsureVisible(double t)
    {
        if (t < _start)
            ScrollTo(t);
        else if (t > End)
            ScrollTo(t - _span);
    }

    private void Clamp()
    {
        _span = Math.Clamp(_span, _length * MinSpanFraction, _length);
        _start = Math.Clamp(_start, 0, Math.Max(_length - _span, 0));
    }
}