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();
}
}