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