OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux / AvaloniaShell / DopesheetCanvas.cs

405 lines · csharp ref:
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;

using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Media;

using IFSEngine.Animation;

using IFSRenderer.Shared;

namespace IFSRenderer.AvaloniaShell;

/// <summary>
/// Draws the dopesheet: a time ruler with an optional spectrogram, one row per channel,
/// a diamond per keyframe, and the playhead. Editing is direct - drag a diamond to move it
/// in time, right-click to delete, click the ruler to scrub.
/// </summary>
internal sealed class DopesheetCanvas : Control
{
    private const double RulerHeight = 26;
    private const double RowHeight = 22;
    private const double LeftPad = TimeView.LeftPad;
    private const double KeyRadius = 5;

    private Dopesheet _sheet;
    private AudioClipSource _audio;
    private float[] _spectrogram;      // column-major loudness, built once per audio/size change
    private int _spectrogramColumns;

    private Keyframe _dragKey;
    private string _dragChannel;
    private bool _scrubbing;
    private bool _panning;
    private double _panOriginX;
    private double _panStart;

    public string SelectedChannel { get; set; }
    public Keyframe SelectedKeyframe { get; private set; }
    public double CurrentTime { get; set; }

    public event Action<double> SeekRequested;
    public event Action<Keyframe> KeyframeSelected;
    public event Action Edited;

    /// <summary>
    /// Asks the host to record an undo state BEFORE a keyframe is moved or deleted.
    /// </summary>
    /// <remarks>
    /// Canonical snapshots on both paths — AnimationViewModel.cs:345-352 for a reposition and
    /// :273-290 for a delete. Neither was recorded here, so dopesheet edits were invisible to
    /// undo.
    /// </remarks>
    public Action SnapshotRequested { get; set; }

    // Canonical values, from WpfDisplay/Views/Animation/AnimationPanel.xaml and
    // DopeButtonResources.xaml. The port had invented every one of these, and they had drifted
    // the whole panel cool and dark relative to the original - see PARITY-AUDIT.md.
    /// <summary>Panel ground, AnimationPanel.xaml:206.</summary>
    private static readonly IBrush Bg = new SolidColorBrush(Color.Parse("#333333"));

    /// <summary>Channel rows are transparent over the ground, AnimationPanel.xaml:116.</summary>
    private static readonly IBrush RowBg = Brushes.Transparent;

    /// <summary>Selected row, AnimationPanel.xaml:119.</summary>
    private static readonly IBrush RowBgAlt = new SolidColorBrush(Color.Parse("#3f3f3f"));

    /// <summary>The scrubber track, AnimationPanel.xaml:143.</summary>
    private static readonly IBrush TrackBg = new SolidColorBrush(Color.Parse("#111111"));

    private static readonly IBrush Ink = Brushes.White;

    /// <summary>AnimationPanel.xaml:229,234.</summary>
    private static readonly IBrush Muted = new SolidColorBrush(Color.Parse("#aaaaaa"));

    /// <summary>DopeButtonResources.xaml:25 - DarkOrange, with Orange on hover at :28.</summary>
    private static readonly IBrush KeyFill = new SolidColorBrush(Color.Parse("#B0B0B0"));
    private static readonly IBrush KeyFillSelected = new SolidColorBrush(Colors.DarkOrange);

    /// <summary>Row rules are black, AnimationPanel.xaml:170.</summary>
    private static readonly IPen GridPen = new Pen(Brushes.Black, 1);

    /// <summary>The time cursor is white over a dark halo, AnimationPanel.xaml:212-222.</summary>
    private static readonly IPen PlayheadPen = new Pen(Brushes.White, 1.5);
    private static readonly IPen PlayheadHaloPen =
        new Pen(new SolidColorBrush(Color.Parse("#88000000")), 4);

    public DopesheetCanvas()
    {
        ClipToBounds = true;
        Focusable = true;
    }

    public void SetDopesheet(Dopesheet sheet)
    {
        _sheet = sheet;
        SelectedKeyframe = null;
        InvalidateVisual();
    }

    public void SetAudio(AudioClipSource audio)
    {
        _audio = audio;
        _spectrogram = null; // rebuilt lazily on next render
        InvalidateVisual();
    }

    private double Length => Math.Max(_sheet?.Length.TotalSeconds ?? 10, 0.001);
    private double TrackWidth => TimeView.TrackWidth(Bounds.Width);
    private double TimeToX(double t) => View.TimeToX(t, Bounds.Width);
    private double XToTime(double x) => View.XToTime(x, Bounds.Width);

    /// <summary>
    /// The shared visible time window. Assigned by the panel so this and the curve editor
    /// scroll and zoom as one surface.
    /// </summary>
    public TimeView View
    {
        get => _view;
        set
        {
            if (ReferenceEquals(_view, value) || value is null)
                return;
            if (_view is not null)
                _view.Changed -= InvalidateVisual;
            _view = value;
            _view.Changed += InvalidateVisual;
            InvalidateVisual();
        }
    }

    private TimeView _view = new();

    /// <summary>Mirrors a selection made in the curve editor, without echoing it back.</summary>
    public void SyncSelection(Keyframe key)
    {
        SelectedKeyframe = key;
        InvalidateVisual();
    }

    // ------------------------------------------------------------------ drawing

    public override void Render(DrawingContext ctx)
    {
        ctx.FillRectangle(Bg, new Rect(Bounds.Size));
        if (_sheet is null)
            return;

        DrawSpectrogram(ctx);
        DrawRuler(ctx);

        var channels = _sheet.Channels.ToList();
        for (int i = 0; i < channels.Count; i++)
        {
            double y = RulerHeight + i * RowHeight;
            if (y > Bounds.Height)
                break;

            var rowRect = new Rect(0, y, Bounds.Width, RowHeight);
            ctx.FillRectangle(channels[i].Key == SelectedChannel ? RowBgAlt : RowBg, rowRect);
            ctx.DrawLine(GridPen, new Point(0, y + RowHeight), new Point(Bounds.Width, y + RowHeight));

            foreach (Keyframe k in channels[i].Value.Keyframes)
            {
                double x = TimeToX(k.t);
                bool selected = ReferenceEquals(k, SelectedKeyframe);
                var diamond = new StreamGeometry();
                using (var g = diamond.Open())
                {
                    double cy = y + RowHeight / 2;
                    g.BeginFigure(new Point(x, cy - KeyRadius), true);
                    g.LineTo(new Point(x + KeyRadius, cy));
                    g.LineTo(new Point(x, cy + KeyRadius));
                    g.LineTo(new Point(x - KeyRadius, cy));
                    g.EndFigure(true);
                }
                ctx.DrawGeometry(selected ? KeyFillSelected : KeyFill, null, diamond);
            }
        }

        double px = TimeToX(CurrentTime);
        ctx.DrawLine(PlayheadHaloPen, new Point(px, 0), new Point(px, Bounds.Height));
        ctx.DrawLine(PlayheadPen, new Point(px, 0), new Point(px, Bounds.Height));
    }

    private void DrawRuler(DrawingContext ctx)
    {
        ctx.DrawRectangle(TrackBg, null, new Rect(0, 0, Bounds.Width, RulerHeight));
        ctx.DrawLine(GridPen, new Point(0, RulerHeight), new Point(Bounds.Width, RulerHeight));

        // A tick roughly every 80px, on a round interval. Derived from the VISIBLE span rather
        // than the clip length, so zooming in subdivides into tenths instead of staying at 1s.
        double step = CurveCanvas.NiceStep(View.Span / Math.Max(TrackWidth / 80.0, 1));
        string format = step < 0.01 ? "0.000" : step < 0.1 ? "0.00" : step < 1 ? "0.0" : "0.#";
        for (double t = Math.Floor(View.Start / step) * step; t <= View.End + step; t += step)
        {
            if (t < -1e-9 || t > Length + 1e-9)
                continue;
            double x = TimeToX(t);
            ctx.DrawLine(GridPen, new Point(x, RulerHeight - 6), new Point(x, RulerHeight));
            var label = new FormattedText($"{t.ToString(format, CultureInfo.CurrentCulture)}s",
                CultureInfo.CurrentCulture, FlowDirection.LeftToRight, Typeface.Default, 10, Muted);
            ctx.DrawText(label, new Point(x + 2, 2));
        }
    }

    /// <summary>
    /// Renders the loaded clip as a faint spectrogram behind the ruler, so beats are
    /// visible when placing keys. Columns are cached; the FFT is far too slow per frame.
    /// </summary>
    private void DrawSpectrogram(DrawingContext ctx)
    {
        if (_audio is null || TrackWidth < 10)
            return;

        const int bands = 24;
        int columns = (int)Math.Min(TrackWidth / 3, 400);
        if (columns < 4)
            return;

        if (_spectrogram is null || _spectrogramColumns != columns)
        {
            _spectrogramColumns = columns;
            _spectrogram = new float[columns * bands];
            for (int c = 0; c < columns; c++)
            {
                double t = c / (double)(columns - 1) * Math.Min(Length, _audio.Length);
                float[] spectrum = _audio.GetLogSpectrum(0, t, 20, 20000);
                for (int b = 0; b < bands; b++)
                {
                    int from = (int)((long)b * spectrum.Length / bands);
                    int to = (int)((long)(b + 1) * spectrum.Length / bands);
                    float peak = 0;
                    for (int i = from; i < to && i < spectrum.Length; i++)
                        peak = Math.Max(peak, Math.Abs(spectrum[i]));
                    _spectrogram[c * bands + b] = peak;
                }
            }
        }

        double columnWidth = TrackWidth / _spectrogramColumns;
        for (int c = 0; c < _spectrogramColumns; c++)
        {
            for (int b = 0; b < bands; b++)
            {
                float v = _spectrogram[c * bands + b];
                if (v <= 0.0005f)
                    continue;
                byte a = (byte)Math.Clamp(v * 900f, 0f, 150f);
                // Viridis, as the canonical uses (Spectrogram.xaml.cs:95). A single-hue ramp
                // cannot show amplitude structure the way a perceptually uniform one does.
                var brush = new SolidColorBrush(SpectrogramPalette.Sample(v, a));
                double h = RulerHeight / bands;
                ctx.FillRectangle(brush, new Rect(
                    LeftPad + c * columnWidth, RulerHeight - (b + 1) * h, Math.Max(columnWidth, 1), h));
            }
        }
    }

    // -------------------------------------------------------------------- input

    private (string Path, Keyframe Key) HitKeyframe(Point p)
    {
        if (_sheet is null || p.Y < RulerHeight)
            return (null, null);

        int row = (int)((p.Y - RulerHeight) / RowHeight);
        var channels = _sheet.Channels.ToList();
        if (row < 0 || row >= channels.Count)
            return (null, null);

        foreach (Keyframe k in channels[row].Value.Keyframes)
        {
            if (Math.Abs(TimeToX(k.t) - p.X) <= KeyRadius + 3)
                return (channels[row].Key, k);
        }
        return (channels[row].Key, null);
    }

    protected override void OnPointerPressed(PointerPressedEventArgs e)
    {
        base.OnPointerPressed(e);
        Point p = e.GetPosition(this);
        var props = e.GetCurrentPoint(this).Properties;

        if (props.IsMiddleButtonPressed)
        {
            _panning = true;
            _panOriginX = p.X;
            _panStart = View.Start;
            e.Pointer.Capture(this);
            return;
        }

        if (p.Y < RulerHeight)
        {
            _scrubbing = true;
            e.Pointer.Capture(this);
            SeekRequested?.Invoke(XToTime(p.X));
            return;
        }

        (string path, Keyframe key) = HitKeyframe(p);
        if (path is not null)
            SelectedChannel = path;

        if (key is null)
        {
            SelectedKeyframe = null;
            KeyframeSelected?.Invoke(null);
            InvalidateVisual();
            return;
        }

        if (props.IsRightButtonPressed)
        {
            DeleteKeyframe(path, key);
            return;
        }

        SelectedKeyframe = key;
        SnapshotRequested?.Invoke();
        _dragKey = key;
        _dragChannel = path;
        e.Pointer.Capture(this);
        KeyframeSelected?.Invoke(key);
        InvalidateVisual();
    }

    protected override void OnPointerMoved(PointerEventArgs e)
    {
        base.OnPointerMoved(e);
        Point p = e.GetPosition(this);

        if (_panning)
        {
            View.ScrollTo(_panStart - (p.X - _panOriginX) / TrackWidth * View.Span);
            return;
        }

        if (_scrubbing)
        {
            SeekRequested?.Invoke(XToTime(p.X));
            return;
        }

        if (_dragKey is not null)
        {
            _dragKey.t = XToTime(p.X);
            InvalidateVisual();
        }
    }

    protected override void OnPointerReleased(PointerReleasedEventArgs e)
    {
        base.OnPointerReleased(e);
        bool wasDragging = _dragKey is not null;
        _dragKey = null;
        _dragChannel = null;
        _scrubbing = false;
        _panning = false;
        e.Pointer.Capture(null);
        if (wasDragging)
            Edited?.Invoke();
    }

    /// <summary>Wheel zooms the shared time axis about the cursor, as the wiki describes.</summary>
    protected override void OnPointerWheelChanged(PointerWheelEventArgs e)
    {
        base.OnPointerWheelChanged(e);
        View.ZoomAt(XToTime(e.GetPosition(this).X), e.Delta.Y > 0 ? 1 / 1.25 : 1.25);
        e.Handled = true;
    }

    public void DeleteSelectedKeyframe()
    {
        if (SelectedKeyframe is null || SelectedChannel is null)
            return;
        DeleteKeyframe(SelectedChannel, SelectedKeyframe);
    }

    /// <summary>
    /// Removes a keyframe, and the whole channel with its last one - a channel with no
    /// keyframes would throw inside <c>Channel.EvaluateAt</c>.
    /// </summary>
    private void DeleteKeyframe(string path, Keyframe key)
    {
        SnapshotRequested?.Invoke();
        if (_sheet is null || !_sheet.Channels.TryGetValue(path, out Channel channel))
            return;

        channel.Keyframes.Remove(key);
        if (channel.Keyframes.Count == 0)
            _sheet.Channels.Remove(path);

        SelectedKeyframe = null;
        KeyframeSelected?.Invoke(null);
        InvalidateVisual();
        Edited?.Invoke();
    }
}