OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux / AvaloniaShell / AnimationPanel.cs

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

using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Threading;

using IFSEngine.Animation;
using IFSEngine.Animation.ChannelDrivers;
using IFSEngine.Model;

using IFSRenderer.Shared;

namespace IFSRenderer.AvaloniaShell;

/// <summary>A parameter the user can key, as offered in the animation panel.</summary>
public sealed record AnimatableParam(string Label, string Path);

/// <summary>
/// Dopesheet timeline: transport, channel list, keyframes and audio-driven channels.
/// </summary>
/// <remarks>
/// The animation model lives entirely in the engine - <see cref="Dopesheet"/>,
/// <see cref="Channel"/>, <see cref="Keyframe"/> and <c>IFS.EvaluateAt</c> - and already runs
/// on Linux. This panel is only an editor over it: it never interpolates anything itself, it
/// edits keyframes and asks the engine to evaluate.
/// </remarks>
public sealed class AnimationPanel : Grid
{
    private IFS _fractal;
    private AudioClipSource _audio;

    private readonly DopesheetCanvas _sheet = new();
    private readonly CurveCanvas _curve = new();
    private readonly ListBox _channelList = new() { Background = Brushes.Transparent };
    private readonly ComboBox _paramCombo = new() { MaxDropDownHeight = 400, MinWidth = 220 };
    private readonly TextBlock _timeLabel = new() { VerticalAlignment = VerticalAlignment.Center, MinWidth = 96 };

    /// <summary>Renames the selected channel, as ChannelEditor.xaml:25 does.</summary>
    private readonly TextBox _channelName = new();

    /// <summary>Typed playhead position, so an exact time or frame is reachable.</summary>
    private readonly NumericUpDown _time = new()
    {
        Minimum = 0, Maximum = 3600, Increment = 0.1m, FormatString = "0.###", Width = 90,
        VerticalAlignment = VerticalAlignment.Center,
    };
    private readonly Button _playButton = new() { Content = "▶", Width = 40 };
    private readonly NumericUpDown _length = new() { Minimum = 0.1m, Maximum = 3600, Increment = 1, FormatString = "0.##", Width = 90 };
    private readonly NumericUpDown _fps = new() { Minimum = 1, Maximum = 240, Increment = 1, FormatString = "0", Width = 74 };

    private readonly ComboBox _keyType = new() { MinWidth = 130 };
    private readonly ComboBox _handles = new() { MinWidth = 130 };
    private readonly NumericUpDown _dynamicParam = new()
    {
        Minimum = 0, Maximum = 100, Increment = 0.05m, FormatString = "0.###", Width = 84,
    };
    private readonly TextBlock _dynamicCaption = Caption("overshoot");
    private readonly NumericUpDown _elasticPeriod = new()
    {
        Minimum = 0, Maximum = 10, Increment = 0.05m, FormatString = "0.###", Width = 84,
    };

    // Rows that only apply to some keyframe types, hidden for the rest. Assigned as the
    // editor is built.
    private StackPanel _handleRow;
    private StackPanel _dynamicRow;
    private StackPanel _periodRow;
    private StackPanel _legacyRow;
    private StackPanel _easingRow;
    private StackPanel _powerRow;

    private readonly ComboBox _interpolation = new() { MinWidth = 130 };
    private readonly ComboBox _easing = new() { MinWidth = 110 };
    private readonly NumericUpDown _easingPower = new() { Minimum = 0.1m, Maximum = 10, Increment = 0.1m, FormatString = "0.##", Width = 84 };
    // Bounded so a typed value cannot exceed what ToDecimal will show back.
    private readonly NumericUpDown _keyValue = new()
    {
        Increment = 0.05m, FormatString = "0.###", Width = 110,
        Minimum = -KeyValueLimit, Maximum = KeyValueLimit,
    };

    private readonly TextBlock _audioLabel = new() { VerticalAlignment = VerticalAlignment.Center, Foreground = Brushes.Gray };
    /// <summary>
    /// Mutes the driver without unbinding it — ChannelEditor.xaml:55.
    /// </summary>
    /// <remarks>
    /// ChannelViewModel.cs:46-56 defines "enabled" as a non-zero effect strength, so this
    /// toggles the multiplier to 0 and back rather than adding model state. That is what lets
    /// you A/B a driver's contribution without losing its frequency range and gain.
    /// </remarks>
    private readonly CheckBox _audioEnabled = new() { Content = "On", VerticalAlignment = VerticalAlignment.Center };

    /// <summary>Which channel of a stereo/multichannel clip drives this parameter.</summary>
    private readonly ComboBox _audioChannel = new() { Width = 96, VerticalAlignment = VerticalAlignment.Center };

    /// <summary>Gain remembered while the driver is muted, so switching back restores it.</summary>
    private double _mutedGain = 1;

    private readonly NumericUpDown _audioMin = new() { Minimum = 0, Maximum = 20000, Increment = 10, FormatString = "0", Width = 88 };
    private readonly NumericUpDown _audioMax = new() { Minimum = 0, Maximum = 20000, Increment = 10, FormatString = "0", Width = 88 };
    private readonly NumericUpDown _audioGain = new() { Minimum = -100, Maximum = 100, Increment = 0.1m, FormatString = "0.##", Width = 84 };

    private GridSplitter _curveSplitter;
    private Grid _curvePanes;

    /// <summary>The visible time window, shared by both panes and the scrollbar.</summary>
    private readonly TimeView _view = new();

    /// <summary>Pans the time axis, as the wiki's "bottom scrollbar" does.</summary>
    private readonly ScrollBar _timeScroll = new()
    {
        Orientation = Orientation.Horizontal,
        Minimum = 0,
        Visibility = ScrollBarVisibility.Visible,
        Height = 12,
    };

    private readonly StackPanel _editorHost = new();
    private StackPanel _keyframeEditor;
    private StackPanel _audioEditor;

    private Window _detachedWindow;
    private ContentControl _dockHost;
    private ToggleButton _detachToggle;

    private DispatcherTimer _playTimer;
    private List<string> _channelPaths = [];
    private bool _syncing;

    /// <summary>Current playhead position in seconds.</summary>
    public double CurrentTime { get; private set; }

    public bool IsPlaying { get; private set; }

    /// <summary>Supplies the list of parameters that can be keyed.</summary>
    public Func<IReadOnlyList<AnimatableParam>> ParamsProvider { get; set; }

    /// <summary>Raised when the playhead moves - the host applies EvaluateAt and re-renders.</summary>
    public event Action<double> TimeChanged;

    /// <summary>Raised when the dopesheet itself was edited.</summary>
    public event Action Changed;

    /// <summary>
    /// Asks the host to record an undo state BEFORE a dopesheet edit.
    /// </summary>
    /// <remarks>
    /// Canonical snapshots on clip length (AnimationViewModel.cs:163-172), fps (:96-105),
    /// channel removal (:297-305), keyframe value/easing (KeyframeViewModel.cs:61-69) and every
    /// audio-driver control (ChannelViewModel.cs:70-122, :165-188). None of it was recorded here.
    /// </remarks>
    public Action SnapshotRequested
    {
        get => _snapshotRequested;
        set
        {
            _snapshotRequested = value;
            _sheet.SnapshotRequested = value;
            _curve.SnapshotRequested = value;
        }
    }

    private Action _snapshotRequested;

    /// <summary>Raised when the user asks to render the clip out.</summary>
    public event Action ExportRequested;

    /// <summary>Asks the host to pick an audio file; returns null if cancelled.</summary>
    public Func<System.Threading.Tasks.Task<string>> AudioFilePicker { get; set; }

    /// <summary>
    /// The loaded audio clip's path, or null. Exposed so a video export can mux it: the clip is
    /// visible in the scrubber and can drive channels, so exporting without it produced a silent
    /// file that looked like a working export.
    /// </summary>
    public string AudioPath => _audio?.Path;

    /// <summary>Asks the host for a CSV file to import animation channels from.</summary>
    public Func<System.Threading.Tasks.Task<string>> ChannelCsvPicker { get; set; }

    public AnimationPanel()
    {
        RowDefinitions = new RowDefinitions("Auto,*,Auto");
        Background = new SolidColorBrush(Color.Parse("#17171b"));

        _sheet.View = _view;
        _curve.View = _view;
        _view.Changed += SyncTimeScrollBar;

        // Dragging the scrollbar scrolls the shared window; SyncTimeScrollBar writes back.
        _timeScroll.Scroll += (_, _) =>
        {
            if (_syncing)
                return;
            _view.ScrollTo(_timeScroll.Value);
        };

        // ---- transport ----
        var jumpStart = new Button { Content = "⏮", Width = 40 };
        jumpStart.Click += (_, _) => Seek(0);
        _playButton.Click += (_, _) => TogglePlay();

        var keyButton = new Button { Content = "◆ Key" };
        keyButton.Click += (_, _) => KeySelectedParam();

        var exportButton = new Button { Content = "Render clip…" };
        exportButton.Click += (_, _) => ExportRequested?.Invoke();

        var curveToggle = new ToggleButton { Content = "📈 Curve", IsChecked = true };
        ToolTip.SetTip(curveToggle, "Show the value-over-time plot for the selected channel.");
        curveToggle.IsCheckedChanged += (_, _) => ShowCurveEditor(curveToggle.IsChecked == true);

        var fitCurve = new Button { Content = "Fit" };
        ToolTip.SetTip(fitCurve,
            "Fit both axes: the whole clip in time, the channel's range in value "
            + "(or press F over the plot).");
        fitCurve.Click += (_, _) => { _view.Reset(); _curve.FitToChannel(); };

        _detachToggle = new ToggleButton { Content = "⧉" };
        ToolTip.SetTip(_detachToggle, "Detach the animation panel into its own window.");
        _detachToggle.Click += (_, _) => ToggleDetached();

        var importChannels = new Button { Content = "+ Import…" };
        ToolTip.SetTip(importChannels,
            "Import channels from a CSV file where columns are the channel IDs and rows are the "
            + "frames. Make sure to adjust the Framerate to match the CSV data.");
        importChannels.Click += async (_, _) => await ImportChannelsFromCsv();

        _time.ValueChanged += (_, _) =>
        {
            if (_syncing || _fractal is null)
                return;
            Seek((double)(_time.Value ?? 0));
        };

        _length.ValueChanged += (_, _) =>
        {
            if (_syncing || _fractal is null) return;
            _snapshotRequested?.Invoke();
            _fractal.Dopesheet.Length = TimeSpan.FromSeconds((double)(_length.Value ?? 10));
            _view.Length = _fractal.Dopesheet.Length.TotalSeconds;
            _sheet.InvalidateVisual();
            Changed?.Invoke();
        };
        _fps.ValueChanged += (_, _) =>
        {
            if (_syncing || _fractal is null) return;
            _snapshotRequested?.Invoke();
            _fractal.Dopesheet.Fps = (int)(_fps.Value ?? 30);
            _curve.SnapFps = _fractal.Dopesheet.Fps;
            Changed?.Invoke();
        };

        var transport = new StackPanel
        {
            Orientation = Orientation.Horizontal,
            Spacing = 6,
            Margin = new Thickness(8, 6, 8, 6),
            Children =
            {
                jumpStart, _playButton, _timeLabel,
                Caption("Time"), _time,
                Caption("Length"), _length,
                Caption("fps"), _fps,
                new Border { Width = 12 },
                _paramCombo, keyButton,
                new Border { Width = 12 },
                curveToggle, fitCurve, _detachToggle,
                new Border { Width = 12 },
                exportButton,
            },
        };
        SetRow(transport, 0);
        Children.Add(transport);

        // ---- channel list + dopesheet ----
        _channelList.SelectionChanged += (_, _) =>
        {
            if (_syncing) return;
            _sheet.SelectedChannel = _channelList.SelectedIndex >= 0 && _channelList.SelectedIndex < _channelPaths.Count
                ? _channelPaths[_channelList.SelectedIndex] : null;
            _curve.SetChannel(_sheet.SelectedChannel);
            _sheet.InvalidateVisual();
            RefreshAudioFields();
            RefreshChannelName();
        };

        _sheet.SeekRequested += Seek;
        _sheet.KeyframeSelected += k => { _curve.SyncSelection(k); RefreshKeyframeFields(); };
        _sheet.Edited += () => { _curve.InvalidateVisual(); RefreshKeyframeFields(); Changed?.Invoke(); };

        // The curve editor edits the SAME keyframes, so selection is shared in both
        // directions: whichever pane you pick a key in, the editor row below edits that key.
        _curve.SeekRequested += Seek;
        _curve.KeyframeSelected += k => { _sheet.SyncSelection(k); RefreshKeyframeFields(); };
        _curve.Edited += () => { _sheet.InvalidateVisual(); RefreshKeyframeFields(); Changed?.Invoke(); };

        var middle = new Grid { ColumnDefinitions = new ColumnDefinitions("200,*") };
        var listHost = new DockPanel();
        var removeChannel = new Button { Content = "Remove channel", HorizontalAlignment = HorizontalAlignment.Stretch, Margin = new Thickness(4) };
        removeChannel.Click += (_, _) => RemoveSelectedChannel();
        DockPanel.SetDock(removeChannel, Dock.Bottom);
        importChannels.HorizontalAlignment = HorizontalAlignment.Stretch;
        importChannels.Margin = new Thickness(4, 0, 4, 0);
        DockPanel.SetDock(importChannels, Dock.Bottom);

        _channelName.Watermark = "Channel name";
        _channelName.Margin = new Thickness(4, 4, 4, 0);
        _channelName.TextChanged += (_, _) => RenameSelectedChannel();
        DockPanel.SetDock(_channelName, Dock.Top);

        listHost.Children.Add(_channelName);
        listHost.Children.Add(importChannels);
        listHost.Children.Add(removeChannel);
        listHost.Children.Add(_channelList);

        // Channel list on top, the on-demand editors beneath it, scrolling if both are open.
        var leftColumn = new Grid { RowDefinitions = new RowDefinitions("*,Auto") };
        SetRow(listHost, 0);
        // Filled once the editors are built further down; they cannot be added here because
        // they do not exist yet, and the children collection rejects nulls.
        var editorStack = new ScrollViewer
        {
            MaxHeight = 260,
            HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled,
            Content = _editorHost,
        };
        SetRow(editorStack, 1);
        leftColumn.Children.Add(listHost);
        leftColumn.Children.Add(editorStack);
        Grid.SetColumn(leftColumn, 0);

        // Dopesheet over curve editor, sharing one time axis (TimeAxis) so a keyframe sits at
        // the same x in both. Splittable, because how much of each you want depends entirely
        // on whether you are placing keys or shaping a segment.
        _curveSplitter = new GridSplitter { Height = 4, Background = new SolidColorBrush(Color.Parse("#1b1b1b")) };
        var panes = new Grid { RowDefinitions = new RowDefinitions("3*,Auto,2*") };
        SetRow(_sheet, 0);
        SetRow(_curveSplitter, 1);
        SetRow(_curve, 2);
        panes.Children.Add(_sheet);
        panes.Children.Add(_curveSplitter);
        panes.Children.Add(_curve);
        _curvePanes = panes;

        Grid.SetColumn(panes, 1);
        middle.Children.Add(leftColumn);
        middle.Children.Add(panes);
        SetRow(middle, 1);
        Children.Add(middle);

        // ---- keyframe + audio editors ----
        _keyType.ItemsSource = KeyTypes;
        _handles.ItemsSource = HandleTypes;
        // Two InterpolationMode enums exist since the palette merge: this is the keyframe's.
        _interpolation.ItemsSource = Enum.GetValues<IFSEngine.Animation.InterpolationMode>();
        _easing.ItemsSource = Enum.GetValues<EasingDirection>();

        _keyType.SelectionChanged += (_, _) => EditKeyframe(k =>
        {
            if (_keyType.SelectedItem is KeyTypeItem item)
                k.Type = item.Value;
        });

        _handles.SelectionChanged += (_, _) => EditKeyframe(k =>
        {
            if (_handles.SelectedItem is not HandleItem item)
                return;
            // Seed the stored coordinates from wherever the derived handles already are, so
            // choosing Free or Aligned keeps the curve that was on screen a moment ago rather
            // than collapsing both handles onto the origin.
            if (item.Value is HandleType.Free or HandleType.Aligned && CurrentChannel is Channel c)
            {
                (Handle left, Handle right) = c.ResolveHandles(k);
                k.LeftHandleT = left.T;
                k.LeftHandleValue = left.Value;
                k.RightHandleT = right.T;
                k.RightHandleValue = right.Value;
            }
            k.LeftHandleType = item.Value;
            k.RightHandleType = item.Value;
        });

        _dynamicParam.ValueChanged += (_, _) => EditKeyframe(k =>
        {
            double v = (double)(_dynamicParam.Value ?? 0);
            if (k.Type == KeyframeType.Back)
                k.BackOvershoot = v;
            else if (k.Type == KeyframeType.Elastic)
                k.ElasticAmplitude = v;
        });

        // Zero means auto for both elastic parameters, which is why their minimum is 0 rather
        // than something positive - it is a real setting, not an empty box.
        _elasticPeriod.ValueChanged += (_, _) =>
            EditKeyframe(k => k.ElasticPeriod = (double)(_elasticPeriod.Value ?? 0));

        _interpolation.SelectionChanged += (_, _) => EditKeyframe(k =>
        {
            if (_interpolation.SelectedItem is IFSEngine.Animation.InterpolationMode m)
                k.InterpolationMode = m;
        });
        _easing.SelectionChanged += (_, _) => EditKeyframe(k =>
        {
            if (_easing.SelectedItem is EasingDirection d) k.EasingDirection = d;
        });
        _easingPower.ValueChanged += (_, _) => EditKeyframe(k => k.EasingPower = (double)(_easingPower.Value ?? 1));
        _keyValue.ValueChanged += (_, _) => EditKeyframe(k => k.Value = (double)(_keyValue.Value ?? 0));

        var deleteKey = new Button { Content = "Delete key" };
        deleteKey.Click += (_, _) => _sheet.DeleteSelectedKeyframe();

        var loadAudio = new Button { Content = "Load audio…" };
        loadAudio.Click += async (_, _) => await LoadAudio();
        var clearAudio = new Button { Content = "Unbind" };
        clearAudio.Click += (_, _) => BindAudio(false);
        var bindAudio = new Button { Content = "Drive channel" };
        bindAudio.Click += (_, _) => BindAudio(true);

        _audioEnabled.IsCheckedChanged += (_, _) => ToggleAudioDriver();
        _audioChannel.SelectionChanged += (_, _) => SelectAudioChannel();

        foreach (NumericUpDown box in new[] { _audioMin, _audioMax, _audioGain })
            box.ValueChanged += (_, _) => ApplyAudioSettings();

        // The editors live in the LEFT column, stacked, and appear only when there is something
        // to edit - which is what WpfDisplay does with ChannelEditor (AnimationPanel.xaml:243-247,
        // shown through a nullVisibilityConverter on EditedChannel). Keeping them permanently
        // across the full width was the single biggest reason this tab felt cluttered: it put
        // roughly twenty controls on screen that are meaningless with nothing selected.
        foreach (Control c in new Control[]
                 { _keyValue, _keyType, _handles, _dynamicParam, _elasticPeriod,
                   _interpolation, _easing, _easingPower })
            c.HorizontalAlignment = HorizontalAlignment.Stretch;

        // Built before the panel: a collection initializer takes members, not assignments,
        // so these cannot be captured inline in Children.
        _handleRow = Row(Caption("handles"), _handles);
        _dynamicRow = Row(_dynamicCaption, _dynamicParam);
        _periodRow = Row(Caption("period"), _elasticPeriod);
        _legacyRow = Row(Caption("interp"), _interpolation);
        _easingRow = Row(Caption("easing"), _easing);
        _powerRow = Row(Caption("power"), _easingPower);

        _keyframeEditor = new StackPanel
        {
            Margin = new Thickness(4),
            Spacing = 2,
            IsVisible = false,
            Children =
            {
                Caption("KEYFRAME"),
                Row(Caption("value"), _keyValue),
                Row(Caption("type"), _keyType),
                _handleRow,
                _dynamicRow,
                _periodRow,
                _legacyRow,
                _easingRow,
                _powerRow,
                deleteKey,
            },
        };

        _audioEditor = new StackPanel
        {
            Margin = new Thickness(4),
            Spacing = 2,
            IsVisible = false,
            Children =
            {
                Caption("AUDIO DRIVER"),
                Row(loadAudio, _audioEnabled),
                _audioLabel,
                Row(Caption("ch"), _audioChannel),
                Row(Caption("Hz"), _audioMin, _audioMax),
                Row(Caption("gain"), _audioGain),
                Row(bindAudio, clearAudio),
            },
        };

        _audioLabel.FontSize = CaptionFontSize;
        _audioLabel.TextTrimming = TextTrimming.CharacterEllipsis;

        _editorHost.Children.Add(_keyframeEditor);
        _editorHost.Children.Add(_audioEditor);

        SetRow(_timeScroll, 2);
        Children.Add(_timeScroll);
        SyncTimeScrollBar();
        Compact(this);

        static StackPanel Row(params Control[] children)
        {
            var row = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 4 };
            foreach (Control c in children)
                row.Children.Add(c);
            return row;
        }

        _audioMin.Value = 0;
        _audioMax.Value = 20000;
        _audioGain.Value = 1;
    }

    /// <summary>
    /// Mirrors the shared time window onto the scrollbar, and hides it when the whole clip
    /// is visible and there is nothing to pan.
    /// </summary>
    private void SyncTimeScrollBar()
    {
        _syncing = true;
        try
        {
            _timeScroll.Maximum = Math.Max(_view.Length - _view.Span, 0);
            _timeScroll.ViewportSize = _view.Span;
            _timeScroll.LargeChange = _view.Span;
            _timeScroll.SmallChange = _view.Span / 10;
            _timeScroll.Value = Math.Clamp(_view.Start, 0, _timeScroll.Maximum);
            _timeScroll.IsEnabled = !_view.IsFullyZoomedOut;
            _timeScroll.Opacity = _view.IsFullyZoomedOut ? 0.35 : 1;
        }
        finally { _syncing = false; }
    }

    /// <summary>
    /// Moves the whole panel into its own window, and back when that window closes.
    /// </summary>
    /// <remarks>
    /// The wiki calls for detaching the animation panel; on Windows that is AvalonDock making
    /// any pane floatable, which this port does not have. Re-parenting the ONE panel is the
    /// part that actually matters - it is the pane people want on a second monitor while the
    /// render fills the first.
    /// <para>
    /// The panel keeps its identity across the move, so every wire the host set up on it -
    /// ParamsProvider, TimeChanged, SnapshotRequested - survives untouched.
    /// </para>
    /// </remarks>
    public void ToggleDetached()
    {
        if (_detachedWindow is not null)
        {
            _detachedWindow.Close();
            return;
        }

        if (Parent is not ContentControl host)
            return;

        _dockHost = host;
        host.Content = new TextBlock
        {
            Text = "Animation is in its own window.",
            Margin = new Thickness(12),
            Foreground = new SolidColorBrush(Color.Parse("#9a9aa6")),
        };

        var window = new Window
        {
            Title = "Animation",
            Width = 1100,
            Height = 520,
            Content = this,
        };
        window.Closed += (_, _) =>
        {
            // Detach from the window BEFORE re-docking: a control cannot have two parents.
            window.Content = null;
            _dockHost.Content = this;
            _dockHost = null;
            _detachedWindow = null;
            if (_detachToggle is not null)
                _detachToggle.IsChecked = false;
        };

        _detachedWindow = window;

        // Owned by the main window, so closing the app cannot leave this one orphaned.
        if (VisualRoot is Window owner)
            window.Show(owner);
        else
            window.Show();
    }

    /// <summary>Closes the detached window, if the panel is in one. For app shutdown.</summary>
    public void ReDock() => _detachedWindow?.Close();

    /// <summary>Collapses the curve pane to nothing, leaving the dopesheet the whole height.</summary>
    private void ShowCurveEditor(bool show)
    {
        _curve.IsVisible = show;
        _curveSplitter.IsVisible = show;
        _curvePanes.RowDefinitions[2].Height = show ? new GridLength(2, GridUnitType.Star) : new GridLength(0);
    }

    private static TextBlock Caption(string text) => new()
    {
        Text = text,
        Foreground = new SolidColorBrush(Color.Parse("#9a9aa6")),
        FontSize = CaptionFontSize,
        VerticalAlignment = VerticalAlignment.Center,
    };

    // Compact metrics for the whole panel.
    //
    // Nothing in here set a font or a height, so every control rendered at Fluent's default
    // button metrics and the chrome ate the tab. The canonical panel is explicit about this
    // (AnimationPanel.xaml:32-45 styles its timeline buttons Height=15, FontSize=8, Padding=0)
    // and keeps its whole transport inside a 200px column, 90px tall.
    private const double ControlFontSize = 11;
    private const double CaptionFontSize = 10;
    private const double ControlHeight = 22;

    /// <summary>Applies the compact metrics to every control in a subtree.</summary>
    private static T Compact<T>(T root) where T : Control
    {
        foreach (Control c in Descendants(root))
        {
            switch (c)
            {
                case TextBlock text:
                    if (text.FontSize > CaptionFontSize)
                        text.FontSize = CaptionFontSize;
                    break;
                case NumericUpDown numeric:
                    numeric.FontSize = ControlFontSize;
                    numeric.Height = ControlHeight;
                    numeric.Padding = new Thickness(4, 0);
                    break;
                case TemplatedControl templated:
                    templated.FontSize = ControlFontSize;
                    templated.Padding = new Thickness(6, 2);
                    if (templated is Button or ToggleButton or ComboBox or CheckBox)
                        templated.Height = ControlHeight;
                    break;
            }
        }
        return root;
    }

    private static IEnumerable<Control> Descendants(Control root)
    {
        yield return root;
        IEnumerable<Control> children = root switch
        {
            Panel panel => panel.Children.OfType<Control>(),
            ContentControl { Content: Control only } => [only],
            Decorator { Child: Control only } => [only],
            _ => [],
        };
        foreach (Control child in children)
            foreach (Control found in Descendants(child))
                yield return found;
    }

    // ------------------------------------------------------------------- state

    public void SetFractal(IFS fractal)
    {
        _fractal = fractal;
        _sheet.SetDopesheet(fractal?.Dopesheet);
        _curve.SetDopesheet(fractal?.Dopesheet);
        _curve.SnapFps = fractal?.Dopesheet.Fps ?? 30;
        _view.Length = fractal?.Dopesheet.Length.TotalSeconds ?? 10;
        _view.Reset();
        CurrentTime = 0;
        _sheet.CurrentTime = 0;
        _curve.CurrentTime = 0;

        _syncing = true;
        try
        {
            _length.Value = (decimal)(fractal?.Dopesheet.Length.TotalSeconds ?? 10);
            _fps.Value = fractal?.Dopesheet.Fps ?? 30;
        }
        finally { _syncing = false; }

        RefreshParams();
        RefreshChannels();
        UpdateTimeLabel();
    }

    /// <summary>
    /// Re-reads the dopesheet after something outside this panel changed it — specifically a
    /// keyframe added from a ValueSlider's diamond, which is how the canonical UI creates
    /// channels.
    /// </summary>
    public void ReloadChannels() => RefreshChannels();

    /// <summary>Rebuilds the keyable-parameter list (iterators and effects come and go).</summary>
    public void RefreshParams()
    {
        var options = ParamsProvider?.Invoke() ?? [];
        _syncing = true;
        try
        {
            int keep = _paramCombo.SelectedIndex;
            _paramItems = options;
            _paramCombo.ItemsSource = options.Select(o => o.Label).ToList();
            _paramCombo.SelectedIndex = keep >= 0 && keep < options.Count ? keep : (options.Count > 0 ? 0 : -1);
        }
        finally { _syncing = false; }
    }

    private IReadOnlyList<AnimatableParam> _paramItems = [];

    /// <summary>Shows the selected channel's name for editing.</summary>
    /// <summary>
    /// Imports animation channels from a CSV: first row is channel paths, each later row is one
    /// frame.
    /// </summary>
    /// <remarks>
    /// Ported from <c>Workspace.ImportAnimationChannels</c> (Workspace.cs:319-340), including the
    /// details that matter: existing channels with the same path are REPLACED rather than merged,
    /// frame times come from the dopesheet's current fps (so the framerate must be set to match
    /// the data first), malformed or trailing rows are skipped rather than aborting the import,
    /// and fields are parsed invariantly so a comma-decimal locale cannot corrupt them.
    /// </remarks>
    private async System.Threading.Tasks.Task ImportChannelsFromCsv()
    {
        if (ChannelCsvPicker is null || _fractal is null)
            return;
        string path = await ChannelCsvPicker();
        if (string.IsNullOrWhiteSpace(path))
            return;

        string[][] rows;
        try
        {
            rows = (await System.IO.File.ReadAllLinesAsync(path))
                .Select(line => line.Split(',').Select(f => f.Trim()).ToArray())
                .ToArray();
        }
        catch (Exception ex)
        {
            ImportFailed?.Invoke($"Could not read {System.IO.Path.GetFileName(path)}: {ex.Message}");
            return;
        }

        if (rows.Length < 2)
        {
            ImportFailed?.Invoke("That CSV has no frame rows.");
            return;
        }

        _snapshotRequested?.Invoke();

        string[] channelPaths = rows[0];
        foreach (string channelPath in channelPaths)
            _fractal.Dopesheet.Channels.Remove(channelPath);

        int fps = Math.Max(_fractal.Dopesheet.Fps, 1);
        for (int row = 1; row < rows.Length; row++)
        {
            if (rows[row].Length != channelPaths.Length)
                continue; // trailing blank line, or a malformed row

            var frameTime = TimeOnly.FromTimeSpan(TimeSpan.FromSeconds((row - 1.0) / fps));
            for (int column = 0; column < channelPaths.Length; column++)
            {
                if (!double.TryParse(rows[row][column], System.Globalization.NumberStyles.Any,
                        System.Globalization.CultureInfo.InvariantCulture, out double value))
                    continue;
                _fractal.Dopesheet.AddOrUpdateChannel(
                    channelPaths[column], channelPaths[column], frameTime, value);
            }
        }

        RefreshChannels();
        Changed?.Invoke();
        ImportFailed?.Invoke(
            $"{channelPaths.Length} channel(s) imported from {System.IO.Path.GetFileName(path)}.");
    }

    /// <summary>Reports import progress or failure to the host's status bar.</summary>
    public event Action<string> ImportFailed;

    private void RefreshChannelName()
    {
        _syncing = true;
        try
        {
            _channelName.Text =
                _fractal is not null && _sheet.SelectedChannel is string path
                && _fractal.Dopesheet.Channels.TryGetValue(path, out Channel channel)
                    ? channel.Name
                    : string.Empty;
        }
        finally { _syncing = false; }
    }

    /// <summary>Applies a typed channel name, updating the list beside it.</summary>
    private void RenameSelectedChannel()
    {
        if (_syncing || _fractal is null || _sheet.SelectedChannel is not string path)
            return;
        if (!_fractal.Dopesheet.Channels.TryGetValue(path, out Channel channel))
            return;

        string name = _channelName.Text ?? string.Empty;
        if (name == channel.Name)
            return;

        _snapshotRequested?.Invoke();
        channel.Name = name;

        int index = _channelList.SelectedIndex;
        _syncing = true;
        try
        {
            _channelList.ItemsSource = _channelPaths
                .Select(p => _fractal.Dopesheet.Channels[p].Name).ToList();
            _channelList.SelectedIndex = index;
        }
        finally { _syncing = false; }
        Changed?.Invoke();
    }

    private void RefreshChannels()
    {
        if (_fractal is null)
            return;
        _syncing = true;
        try
        {
            _channelPaths = _fractal.Dopesheet.Channels.Keys.ToList();
            _channelList.ItemsSource = _channelPaths
                .Select(p => _fractal.Dopesheet.Channels[p].Name).ToList();
            _channelList.SelectedIndex = _channelPaths.Count > 0 ? 0 : -1;
            _sheet.SelectedChannel = _channelPaths.FirstOrDefault();
        }
        finally { _syncing = false; }
        _curve.SetChannel(_sheet.SelectedChannel);
        _curve.InvalidateVisual();
        _sheet.InvalidateVisual();
        RefreshAudioFields();
        RefreshChannelName();
    }

    // ---------------------------------------------------------------- transport

    private void TogglePlay()
    {
        if (IsPlaying)
        {
            _playTimer?.Stop();
            IsPlaying = false;
            _playButton.Content = "▶";
            return;
        }

        int fps = Math.Max(_fractal?.Dopesheet.Fps ?? 30, 1);
        _playTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1.0 / fps) };
        _playTimer.Tick += (_, _) =>
        {
            double length = _fractal?.Dopesheet.Length.TotalSeconds ?? 10;
            double next = CurrentTime + 1.0 / fps;
            Seek(next >= length ? 0 : next);
        };
        _playTimer.Start();
        IsPlaying = true;
        _playButton.Content = "⏸";
    }

    public void Seek(double seconds)
    {
        double length = _fractal?.Dopesheet.Length.TotalSeconds ?? 10;
        CurrentTime = Math.Clamp(seconds, 0, length);
        _view.EnsureVisible(CurrentTime);
        _sheet.CurrentTime = CurrentTime;
        _curve.CurrentTime = CurrentTime;
        _sheet.InvalidateVisual();
        _curve.InvalidateVisual();
        UpdateTimeLabel();
        TimeChanged?.Invoke(CurrentTime);
    }

    private void UpdateTimeLabel()
    {
        double length = _fractal?.Dopesheet.Length.TotalSeconds ?? 10;
        int frame = (int)Math.Round(CurrentTime * (_fractal?.Dopesheet.Fps ?? 30));
        _timeLabel.Text = $"{CurrentTime:F2}s / {length:F2}s  ·  f{frame}";
        _timeLabel.Foreground = Brushes.White;

        _syncing = true;
        try
        {
            _time.Maximum = (decimal)Math.Max(length, 0.1);
            _time.Value = (decimal)Math.Clamp(CurrentTime, 0, length);
        }
        finally { _syncing = false; }
    }

    // ----------------------------------------------------------------- keyframes

    private void KeySelectedParam()
    {
        if (_fractal is null || _paramCombo.SelectedIndex < 0 || _paramCombo.SelectedIndex >= _paramItems.Count)
            return;

        AnimatableParam param = _paramItems[_paramCombo.SelectedIndex];
        if (!ParamPath.TryGet(_fractal, param.Path, out double value))
            return;

        _snapshotRequested?.Invoke();
        _fractal.Dopesheet.AddOrUpdateChannel(param.Label, param.Path,
            TimeOnly.FromTimeSpan(TimeSpan.FromSeconds(CurrentTime)), value);

        RefreshChannels();
        int index = _channelPaths.IndexOf(param.Path);
        if (index >= 0)
        {
            _syncing = true;
            try { _channelList.SelectedIndex = index; }
            finally { _syncing = false; }
            _sheet.SelectedChannel = param.Path;
        }
        _sheet.InvalidateVisual();
        Changed?.Invoke();
    }

    private void RemoveSelectedChannel()
    {
        if (_fractal is null || _sheet.SelectedChannel is null)
            return;
        _snapshotRequested?.Invoke();
        _fractal.Dopesheet.Channels.Remove(_sheet.SelectedChannel);
        RefreshChannels();
        Changed?.Invoke();
    }

    private void EditKeyframe(Action<Keyframe> edit)
    {
        if (_syncing || _sheet.SelectedKeyframe is null)
            return;
        edit(_sheet.SelectedKeyframe);
        _sheet.InvalidateVisual();
        _curve.InvalidateVisual();
        Changed?.Invoke();
    }

    /// <summary>
    /// Blender's "Set Keyframe Interpolation" presets, in its own menu order: the three
    /// interpolation modes, then easing by strength, then the dynamic effects.
    /// </summary>
    /// <remarks>
    /// <see cref="KeyframeType.Legacy"/> is offered last and deliberately. Every keyframe in
    /// every file written before these existed is Legacy, and forcing those onto a preset the
    /// moment they were selected would silently re-shape animations that already render
    /// correctly. Picking it back is how you get the old evaluator, easing power and all.
    /// <para>
    /// It is labelled just "Legacy", not "Legacy (Catmull-Rom)": it means "evaluate through
    /// <see cref="Keyframe.InterpolationMode"/>", which may be Linear or Constant as easily as
    /// Catmull-Rom. Choosing it reveals the interp row, which says which.
    /// </para>
    /// </remarks>
    private static readonly KeyTypeItem[] KeyTypes =
    [
        new(KeyframeType.Constant, "Constant"),
        new(KeyframeType.Linear, "Linear"),
        new(KeyframeType.Bezier, "Bezier"),
        new(KeyframeType.Sinusoidal, "Sinusoidal"),
        new(KeyframeType.Quadratic, "Quadratic"),
        new(KeyframeType.Cubic, "Cubic"),
        new(KeyframeType.Quartic, "Quartic"),
        new(KeyframeType.Quintic, "Quintic"),
        new(KeyframeType.Exponential, "Exponential"),
        new(KeyframeType.Circular, "Circular"),
        new(KeyframeType.Back, "Back"),
        new(KeyframeType.Bounce, "Bounce"),
        new(KeyframeType.Elastic, "Elastic"),
        new(KeyframeType.Legacy, "Legacy"),
    ];

    private static readonly HandleItem[] HandleTypes =
    [
        new(HandleType.Free, "Free"),
        new(HandleType.Aligned, "Aligned"),
        new(HandleType.Vector, "Vector"),
        new(HandleType.Automatic, "Automatic"),
        new(HandleType.AutoClamped, "Auto Clamped"),
    ];

    /// <summary>ToString is what a ComboBox renders, so the label lives there.</summary>
    private sealed record KeyTypeItem(KeyframeType Value, string Label)
    {
        public override string ToString() => Label;
    }

    private sealed record HandleItem(HandleType Value, string Label)
    {
        public override string ToString() => Label;
    }

    private Channel CurrentChannel =>
        _fractal is not null && _sheet.SelectedChannel is string path
        && _fractal.Dopesheet.Channels.TryGetValue(path, out Channel c)
            ? c
            : null;

    /// <summary>
    /// Reveals only the controls the selected keyframe's type actually uses.
    /// </summary>
    /// <remarks>
    /// Handles belong to Bezier, the easing direction to the easing and dynamic presets,
    /// and interpolation mode plus easing power to Legacy alone. Showing all of them at once
    /// would put a dozen controls on screen of which most do nothing - the clutter that
    /// "Uncrowd the animation tab" was about.
    /// </remarks>
    private void ShowRowsFor(Keyframe k)
    {
        bool legacy = k.Type == KeyframeType.Legacy;
        bool eased = k.Type is not (KeyframeType.Legacy or KeyframeType.Constant
                                    or KeyframeType.Linear or KeyframeType.Bezier);

        if (_handleRow is not null)
            _handleRow.IsVisible = k.Type == KeyframeType.Bezier;
        if (_legacyRow is not null)
            _legacyRow.IsVisible = legacy;
        if (_powerRow is not null)
            _powerRow.IsVisible = legacy;
        if (_easingRow is not null)
            _easingRow.IsVisible = legacy || eased;
        if (_dynamicRow is not null)
            _dynamicRow.IsVisible = k.Type is KeyframeType.Back or KeyframeType.Elastic;
        if (_periodRow is not null)
            _periodRow.IsVisible = k.Type == KeyframeType.Elastic;

        _dynamicCaption.Text = k.Type == KeyframeType.Back ? "overshoot" : "amplitude";
    }

    private void RefreshKeyframeFields()
    {
        Keyframe k = _sheet.SelectedKeyframe;
        _syncing = true;
        try
        {
            bool has = k is not null;
            // Revealed only when there is a keyframe to edit, so the column is empty rather
            // than full of dead controls - WpfDisplay shows ChannelEditor the same way.
            if (_keyframeEditor is not null)
                _keyframeEditor.IsVisible = has;
            if (!has)
                return;
            _keyType.SelectedItem = KeyTypes.FirstOrDefault(i => i.Value == k.Type);
            _handles.SelectedItem = HandleTypes.FirstOrDefault(i => i.Value == k.RightHandleType);
            _dynamicParam.Value = ToDecimal(
                k.Type == KeyframeType.Back ? k.BackOvershoot : k.ElasticAmplitude, 0m, 100m, 0m);
            _elasticPeriod.Value = ToDecimal(k.ElasticPeriod, 0m, 10m, 0m);
            _interpolation.SelectedItem = k.InterpolationMode;
            _easing.SelectedItem = k.EasingDirection;
            _easingPower.Value = ToDecimal(k.EasingPower, 0.1m, 10m, 1m);
            _keyValue.Value = ToDecimal(k.Value, KeyValueLimit * -1, KeyValueLimit, 0m);
            ShowRowsFor(k);
        }
        finally { _syncing = false; }
    }

    /// <summary>
    /// Widest value the keyframe box will show. Far past any useful fractal parameter, and far
    /// short of decimal's ~7.9e28 ceiling.
    /// </summary>
    private const decimal KeyValueLimit = 1_000_000_000m;

    /// <summary>
    /// Converts an engine double into a value a <see cref="NumericUpDown"/> can hold.
    /// </summary>
    /// <remarks>
    /// A plain <c>(decimal)</c> cast throws <see cref="OverflowException"/> on NaN, on either
    /// infinity, and on anything beyond ~7.9e28 - and this runs from a selection-changed
    /// handler, so the throw escaped to the dispatcher and killed the process. Displaying a
    /// clamped number is always better than not displaying one.
    /// </remarks>
    internal static decimal ToDecimal(double value, decimal min, decimal max, decimal fallback)
    {
        if (double.IsNaN(value))
            return fallback;
        if (value <= (double)min)
            return min;
        if (value >= (double)max)
            return max;
        return (decimal)value;
    }

    // --------------------------------------------------------------------- audio

    private async System.Threading.Tasks.Task LoadAudio()
    {
        if (AudioFilePicker is null)
            return;
        string path = await AudioFilePicker();
        if (path is null)
            return;
        try
        {
            _audio = new AudioClipSource(path);
            _audioLabel.Text = $"{System.IO.Path.GetFileName(path)} · {_audio.Length:F1}s · {_audio.Channels}ch";
            _sheet.SetAudio(_audio);
        }
        catch (Exception ex)
        {
            _audioLabel.Text = "could not read audio: " + ex.Message;
            _audio = null;
        }
        _sheet.InvalidateVisual();
    }

    /// <summary>
    /// Binds or unbinds the selected channel to the loaded clip. The driver is a delegate
    /// the engine calls during evaluation, so the audio analysis stays on this side.
    /// </summary>
    private void BindAudio(bool bind)
    {
        if (_fractal is null || _sheet.SelectedChannel is null)
            return;
        if (!_fractal.Dopesheet.Channels.TryGetValue(_sheet.SelectedChannel, out Channel channel))
            return;

        if (!bind)
        {
            channel.AudioChannelDriver = null;
            RefreshAudioFields();
            Changed?.Invoke();
            return;
        }

        if (_audio is null)
        {
            _audioLabel.Text = "load an audio file first";
            return;
        }

        var driver = new AudioChannelDriver
        {
            AudioChannelId = 0,
            MinFrequency = (double)(_audioMin.Value ?? 0),
            MaxFrequency = (double)(_audioMax.Value ?? 20000),
            EffectMultiplier = (double)(_audioGain.Value ?? 1),
        };
        AudioClipSource clip = _audio;
        driver.SetSamplerFunction((d, t) => clip.Sample(d.AudioChannelId, d.MinFrequency, d.MaxFrequency, t));
        channel.AudioChannelDriver = driver;

        RefreshAudioFields();
        Changed?.Invoke();
    }

    private void ApplyAudioSettings()
    {
        if (_syncing || _fractal is null || _sheet.SelectedChannel is null)
            return;
        if (!_fractal.Dopesheet.Channels.TryGetValue(_sheet.SelectedChannel, out Channel channel)
            || channel.AudioChannelDriver is null)
            return;

        channel.AudioChannelDriver.MinFrequency = (double)(_audioMin.Value ?? 0);
        channel.AudioChannelDriver.MaxFrequency = (double)(_audioMax.Value ?? 20000);
        channel.AudioChannelDriver.EffectMultiplier = (double)(_audioGain.Value ?? 1);
        Changed?.Invoke();
    }

    /// <summary>Mutes or restores the driver, keeping its other settings intact.</summary>
    private void ToggleAudioDriver()
    {
        if (_syncing || _fractal is null || _sheet.SelectedChannel is not string path)
            return;
        if (!_fractal.Dopesheet.Channels.TryGetValue(path, out Channel channel)
            || channel.AudioChannelDriver is null)
            return;

        _snapshotRequested?.Invoke();
        AudioChannelDriver driver = channel.AudioChannelDriver;
        if (_audioEnabled.IsChecked == true)
        {
            driver.EffectMultiplier = _mutedGain <= 0 ? 1 : _mutedGain;
        }
        else
        {
            _mutedGain = driver.EffectMultiplier;
            driver.EffectMultiplier = 0;
        }
        RefreshAudioFields();
        Changed?.Invoke();
    }

    /// <summary>Points the driver at a different channel of the loaded clip.</summary>
    private void SelectAudioChannel()
    {
        if (_syncing || _fractal is null || _sheet.SelectedChannel is not string path)
            return;
        if (!_fractal.Dopesheet.Channels.TryGetValue(path, out Channel channel)
            || channel.AudioChannelDriver is null || _audioChannel.SelectedIndex < 0)
            return;

        _snapshotRequested?.Invoke();
        channel.AudioChannelDriver.AudioChannelId = _audioChannel.SelectedIndex;
        Changed?.Invoke();
    }

    private void RefreshAudioFields()
    {
        // Shown whenever a channel is selected: binding a driver is a per-channel action, so
        // the controls are meaningful as soon as there is a channel to bind.
        bool hasChannel = _fractal is not null && _sheet.SelectedChannel is not null
            && _fractal.Dopesheet.Channels.ContainsKey(_sheet.SelectedChannel);
        if (_audioEditor is not null)
            _audioEditor.IsVisible = hasChannel;

        if (!hasChannel)
            return;
        if (!_fractal.Dopesheet.Channels.TryGetValue(_sheet.SelectedChannel, out Channel channel))
            return;

        AudioChannelDriver driver = channel.AudioChannelDriver;
        _syncing = true;
        try
        {
            _audioEnabled.IsEnabled = driver is not null;
            _audioChannel.IsEnabled = driver is not null;
            if (driver is not null)
            {
                _audioMin.Value = (decimal)driver.MinFrequency;
                _audioMax.Value = (decimal)driver.MaxFrequency;
                _audioGain.Value = (decimal)driver.EffectMultiplier;
                _audioEnabled.IsChecked = driver.EffectMultiplier != 0;

                int channels = Math.Max(_audio?.Channels ?? 1, 1);
                _audioChannel.ItemsSource = Enumerable.Range(0, channels)
                    .Select(i => channels == 2 ? (i == 0 ? "Left" : "Right") : $"Ch {i + 1}")
                    .ToList();
                _audioChannel.SelectedIndex = Math.Clamp(driver.AudioChannelId, 0, channels - 1);
            }
        }
        finally { _syncing = false; }
    }
}