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;
namespace IFSRenderer.AvaloniaShell;
/// <summary>
/// Plots the selected channel's value against time, and lets keyframes be dragged in both
/// axes - the "graph editor" the dopesheet alone cannot be.
/// </summary>
/// <remarks>
/// <para>
/// The canonical WPF app has no working equivalent: <c>Views/Animation/CurveEditor.xaml</c> is
/// a placeholder reading "Curve editor / 🚧 Under development" over a spectrogram toggle. So
/// this is a new control rather than a port, and it is built to the engine's model rather than
/// to Blender's.
/// </para>
/// <para>
/// TWO properties of that model shape everything here.
/// </para>
/// <para>
/// First, the curve is drawn by SAMPLING <see cref="Channel.EvaluateAt"/>, never by
/// interpolating in the view. Segment shape depends on the LEFT keyframe's
/// <see cref="InterpolationMode"/> and easing (and, for <see cref="EasingDirection.InOut"/>, on
/// the RIGHT keyframe's <see cref="Keyframe.EasingPower"/> too, Channel.cs:44), and an
/// <c>AudioChannelDriver</c> is applied on top of the interpolated value (Channel.cs:68-71).
/// Re-deriving any of that here would let the picture drift from what actually renders.
/// </para>
/// <para>
/// Second, a <see cref="KeyframeType.Bezier"/> keyframe HAS handles, and they are drawn and
/// dragged here. They are resolved through <see cref="Channel.ResolveHandles"/> rather than
/// recomputed locally, for the same reason the curve is sampled rather than interpolated: the
/// automatic handle types are derived from the neighbours, and a second implementation of that
/// derivation would eventually disagree with the one that renders.
/// </para>
/// <para>
/// Dragging a derived handle makes it <see cref="HandleType.Free"/>, as Blender does - the
/// alternative is a handle that springs back the instant it is released, because its position
/// is a function of its neighbours rather than of the pointer.
/// </para>
/// </remarks>
internal sealed class CurveCanvas : Control
{
private const double VerticalPad = 14;
private const double KeyRadius = 4.5;
private const double HitSlack = 4;
private const double HandleRadius = 3.5;
private const double MinValueSpan = 1e-3;
/// <summary>Curve samples per render. Caps the cost on CSV-imported channels.</summary>
private const int MaxSamples = 600;
/// <summary>How close, in px, a press must be to the playhead to scrub instead of select.</summary>
private const double PlayheadGrab = 4;
private Dopesheet _sheet;
private string _channelPath;
private double _viewMin = -1;
private double _viewMax = 1;
private bool _autoFit = true;
private Keyframe _dragKey;
private Keyframe _handleKey;
private bool _draggingLeftHandle;
private Point _dragOrigin;
private bool _dragAxisLocked;
private bool _dragLockToValue;
private bool _scrubbing;
private bool _panning;
private double _panOriginY;
private double _panOriginX;
private double _panMin;
private double _panMax;
private double _panTimeStart;
public Keyframe SelectedKeyframe { get; private set; }
public double CurrentTime { get; set; }
/// <summary>Frame grid that Ctrl snaps a dragged keyframe onto.</summary>
public int SnapFps { get; set; } = 30;
public event Action<Keyframe> KeyframeSelected;
public event Action Edited;
public event Action<double> SeekRequested;
/// <summary>Records an undo state BEFORE a mutation, as the dopesheet does.</summary>
public Action SnapshotRequested { get; set; }
// Shared with DopesheetCanvas so the two panes read as one surface.
private static readonly IBrush Bg = new SolidColorBrush(Color.Parse("#2b2b2b"));
private static readonly IBrush Muted = new SolidColorBrush(Color.Parse("#aaaaaa"));
private static readonly IPen GridPen = new Pen(new SolidColorBrush(Color.Parse("#3d3d3d")), 1);
private static readonly IPen ZeroPen = new Pen(new SolidColorBrush(Color.Parse("#5a5a5a")), 1);
private static readonly IPen CurvePen = new Pen(Brushes.White, 1.5);
/// <summary>The segment the selected keyframe governs, highlighted so easing reads.</summary>
private static readonly IPen ActiveSegmentPen =
new Pen(new SolidColorBrush(Color.Parse("#ffb74d")), 2.5);
private static readonly IBrush KeyFill = new SolidColorBrush(Color.Parse("#B0B0B0"));
private static readonly IBrush KeyFillSelected = new SolidColorBrush(Colors.DarkOrange);
private static readonly IPen KeyStroke = new Pen(new SolidColorBrush(Color.Parse("#1b1b1b")), 1);
private static readonly IPen HandlePen =
new Pen(new SolidColorBrush(Color.Parse("#ffb74d")), 1);
private static readonly IBrush HandleFill = new SolidColorBrush(Color.Parse("#ffb74d"));
private static readonly IBrush HandleFillDerived = new SolidColorBrush(Color.Parse("#7a6a4a"));
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 CurveCanvas()
{
ClipToBounds = true;
Focusable = true;
}
public void SetDopesheet(Dopesheet sheet)
{
_sheet = sheet;
SelectedKeyframe = null;
_autoFit = true;
InvalidateVisual();
}
/// <summary>Points the plot at a channel. Re-fits the value axis, since scales differ wildly.</summary>
public void SetChannel(string path)
{
if (_channelPath == path)
return;
_channelPath = path;
SelectedKeyframe = null;
_autoFit = true;
InvalidateVisual();
}
/// <summary>Mirrors a selection made in the dopesheet, without echoing it back.</summary>
public void SyncSelection(Keyframe key)
{
SelectedKeyframe = key;
InvalidateVisual();
}
public void FitToChannel()
{
_autoFit = true;
InvalidateVisual();
}
/// <summary>
/// Re-fits the value axis to the channel, unless a drag is defining a value THROUGH that
/// axis right now.
/// </summary>
/// <remarks>
/// The exclusion is the whole point, and skipping it crashed the app. Auto-fit pads the
/// range it finds by 15%, so re-fitting mid-drag creates a feedback loop: the axis widens
/// to include the dragged key, the same pointer position then maps THROUGH the wider axis
/// to a larger value, which widens the axis again. Span grows about 1.3x per frame, and a
/// drag held for a couple of seconds runs the value past what a decimal can hold - which
/// surfaced as an OverflowException in the panel's keyframe fields.
/// <para>
/// A drag is a statement about where the pointer is on the CURRENT axis, so the axis has
/// to stand still while one is in progress.
/// </para>
/// </remarks>
internal void ApplyAutoFit()
{
if (!_autoFit || _dragKey is not null)
return;
Channel channel = Current;
if (channel is not null)
(_viewMin, _viewMax) = FitRange(channel, Length);
}
private Channel Current =>
_sheet is not null && _channelPath is not null
&& _sheet.Channels.TryGetValue(_channelPath, out Channel c) && c.Keyframes.Count > 0
? c
: null;
private double Length => Math.Max(_sheet?.Length.TotalSeconds ?? 10, 0.001);
private double PlotHeight => Math.Max(Bounds.Height - 2 * VerticalPad, 1);
private double Span => Math.Max(_viewMax - _viewMin, MinValueSpan);
internal double TimeToX(double t) => View.TimeToX(t, Bounds.Width);
internal double XToTime(double x) => View.XToTime(x, Bounds.Width);
/// <summary>
/// The shared visible time window, so this pane and the dopesheet zoom and scroll as one.
/// </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();
internal double ValueToY(double v) => VerticalPad + (_viewMax - v) / Span * PlotHeight;
internal double YToValue(double y) => _viewMax - (y - VerticalPad) / PlotHeight * Span;
// ------------------------------------------------------------------ sampling
/// <summary>
/// Samples the channel across the clip, through the engine's own evaluator.
/// </summary>
internal static List<(double T, double Value)> Sample(Channel channel, double length, int count) =>
Sample(channel, 0, length, count);
/// <summary>Samples a span of the clip - the visible window, once the view can zoom.</summary>
internal static List<(double T, double Value)> Sample(Channel channel, double from, double to, int count)
{
var samples = new List<(double, double)>(count);
if (channel is null || channel.Keyframes.Count == 0)
return samples;
for (int i = 0; i < count; i++)
{
double t = from + (to - from) * i / Math.Max(count - 1, 1);
samples.Add((t, channel.EvaluateAt(t)));
}
return samples;
}
/// <summary>
/// The value range to plot: the sampled curve AND the keyframes, since a CatmullRom
/// segment overshoots its endpoints and that overshoot is exactly what needs to be seen.
/// </summary>
internal static (double Min, double Max) FitRange(Channel channel, double length)
{
if (channel is null || channel.Keyframes.Count == 0)
return (-1, 1);
double min = double.MaxValue, max = double.MinValue;
foreach ((double _, double v) in Sample(channel, length, 128))
Include(v);
foreach (Keyframe k in channel.Keyframes)
Include(k.Value);
// Every sample was NaN or infinite - there is no meaningful range to show.
if (min > max)
return (-1, 1);
double pad = Math.Max((max - min) * 0.15, 0.05);
return (min - pad, max + pad);
void Include(double v)
{
// A non-finite sample would poison the axis: Math.Max propagates NaN, so a single
// one makes Span NaN and every value the pointer maps through it NaN too.
if (double.IsNaN(v) || double.IsInfinity(v))
return;
min = Math.Min(min, v);
max = Math.Max(max, v);
}
}
// ------------------------------------------------------------------- drawing
public override void Render(DrawingContext ctx)
{
ctx.FillRectangle(Bg, new Rect(Bounds.Size));
Channel channel = Current;
if (channel is null)
{
var hint = new FormattedText(
_sheet is null || _sheet.Channels.Count == 0
? "No channels yet — key a value with its ◇ to start one."
: "Select a channel to plot it.",
CultureInfo.CurrentCulture, FlowDirection.LeftToRight, Typeface.Default, 12, Muted);
ctx.DrawText(hint, new Point(TimeView.LeftPad + 4, VerticalPad));
return;
}
ApplyAutoFit();
DrawValueGrid(ctx);
DrawCurve(ctx, channel);
DrawHandles(ctx, channel);
DrawKeyframes(ctx, channel);
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));
var readout = new FormattedText(
$"{channel.Name} = {channel.EvaluateAt(CurrentTime):0.####}",
CultureInfo.CurrentCulture, FlowDirection.LeftToRight, Typeface.Default, 11, Muted);
ctx.DrawText(readout, new Point(TimeView.LeftPad + 4, 2));
}
private void DrawValueGrid(DrawingContext ctx)
{
double step = NiceStep(Span / 5);
double first = Math.Ceiling(_viewMin / step) * step;
for (double v = first; v <= _viewMax; v += step)
{
double y = ValueToY(v);
ctx.DrawLine(Math.Abs(v) < step * 0.001 ? ZeroPen : GridPen,
new Point(0, y), new Point(Bounds.Width, y));
var label = new FormattedText(v.ToString("0.###", CultureInfo.CurrentCulture),
CultureInfo.CurrentCulture, FlowDirection.LeftToRight, Typeface.Default, 10, Muted);
ctx.DrawText(label, new Point(2, y - label.Height - 1));
}
}
/// <summary>A 1/2/5 x 10^n step, so gridline labels stay round as the axis zooms.</summary>
internal static double NiceStep(double raw)
{
if (raw <= 0 || double.IsNaN(raw) || double.IsInfinity(raw))
return 1;
double magnitude = Math.Pow(10, Math.Floor(Math.Log10(raw)));
double normalized = raw / magnitude;
double factor = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10;
return magnitude * factor;
}
private void DrawCurve(DrawingContext ctx, Channel channel)
{
int count = (int)Math.Clamp(TimeView.TrackWidth(Bounds.Width), 2, MaxSamples);
var samples = Sample(channel, View.Start, View.End, count);
if (samples.Count < 2)
return;
var geometry = new StreamGeometry();
using (var g = geometry.Open())
{
g.BeginFigure(new Point(TimeToX(samples[0].T), ValueToY(samples[0].Value)), false);
for (int i = 1; i < samples.Count; i++)
g.LineTo(new Point(TimeToX(samples[i].T), ValueToY(samples[i].Value)));
g.EndFigure(false);
}
ctx.DrawGeometry(null, CurvePen, geometry);
DrawActiveSegment(ctx, channel, samples);
}
/// <summary>
/// Redraws the span the selected keyframe governs, so it is visible WHICH segment the
/// interpolation and easing controls are about to change.
/// </summary>
private void DrawActiveSegment(DrawingContext ctx, Channel channel, List<(double T, double Value)> samples)
{
if (SelectedKeyframe is null)
return;
var ordered = channel.Keyframes.OrderBy(k => k.t).ToList();
int index = ordered.IndexOf(SelectedKeyframe);
if (index < 0 || index >= ordered.Count - 1)
return;
double from = ordered[index].t, to = ordered[index + 1].t;
var span = samples.Where(s => s.T >= from && s.T <= to).ToList();
if (span.Count < 2)
return;
var geometry = new StreamGeometry();
using (var g = geometry.Open())
{
g.BeginFigure(new Point(TimeToX(span[0].T), ValueToY(span[0].Value)), false);
for (int i = 1; i < span.Count; i++)
g.LineTo(new Point(TimeToX(span[i].T), ValueToY(span[i].Value)));
g.EndFigure(false);
}
ctx.DrawGeometry(null, ActiveSegmentPen, geometry);
}
/// <summary>
/// Draws the selected bezier keyframe's two handles.
/// </summary>
/// <remarks>
/// Only the selected one, as Blender does. Drawing every key's handles on a channel of any
/// length turns the plot into a thicket, and the handles that matter are the ones attached
/// to the key being shaped. A derived handle is drawn dimmer than a positioned one, so it
/// reads as something the curve is computing rather than something you placed.
/// </remarks>
private void DrawHandles(DrawingContext ctx, Channel channel)
{
if (SelectedKeyframe is null || SelectedKeyframe.Type != KeyframeType.Bezier)
return;
(Handle left, Handle right) = channel.ResolveHandles(SelectedKeyframe);
var centre = new Point(TimeToX(SelectedKeyframe.t), ValueToY(SelectedKeyframe.Value));
Draw(left, SelectedKeyframe.LeftHandleType);
Draw(right, SelectedKeyframe.RightHandleType);
void Draw(Handle h, HandleType type)
{
var at = new Point(TimeToX(h.T), ValueToY(h.Value));
ctx.DrawLine(HandlePen, centre, at);
bool positioned = type is HandleType.Free or HandleType.Aligned;
ctx.DrawEllipse(positioned ? HandleFill : HandleFillDerived, HandlePen,
at, HandleRadius, HandleRadius);
}
}
private void DrawKeyframes(DrawingContext ctx, Channel channel)
{
foreach (Keyframe k in channel.Keyframes)
{
var centre = new Point(TimeToX(k.t), ValueToY(k.Value));
bool selected = ReferenceEquals(k, SelectedKeyframe);
ctx.DrawEllipse(selected ? KeyFillSelected : KeyFill, KeyStroke, centre,
selected ? KeyRadius + 1 : KeyRadius, selected ? KeyRadius + 1 : KeyRadius);
}
}
// --------------------------------------------------------------------- input
private Keyframe HitKeyframe(Point p)
{
Channel channel = Current;
if (channel is null)
return null;
double best = double.MaxValue;
Keyframe hit = null;
foreach (Keyframe k in channel.Keyframes)
{
double dx = TimeToX(k.t) - p.X, dy = ValueToY(k.Value) - p.Y;
double distance = Math.Sqrt(dx * dx + dy * dy);
if (distance <= KeyRadius + HitSlack && distance < best)
{
best = distance;
hit = k;
}
}
return hit;
}
/// <summary>
/// Whether the pointer is on one of the selected keyframe's handles, and which.
/// </summary>
private bool HitHandle(Point p, out bool leftSide)
{
leftSide = false;
Channel channel = Current;
if (channel is null || SelectedKeyframe is null
|| SelectedKeyframe.Type != KeyframeType.Bezier)
return false;
(Handle left, Handle right) = channel.ResolveHandles(SelectedKeyframe);
double dl = Distance(left), dr = Distance(right);
double reach = HandleRadius + HitSlack;
if (dl > reach && dr > reach)
return false;
leftSide = dl <= dr;
return true;
double Distance(Handle h)
{
double dx = TimeToX(h.T) - p.X, dy = ValueToY(h.Value) - p.Y;
return Math.Sqrt(dx * dx + dy * dy);
}
}
protected override void OnPointerPressed(PointerPressedEventArgs e)
{
base.OnPointerPressed(e);
Focus();
Point p = e.GetPosition(this);
var props = e.GetCurrentPoint(this).Properties;
if (props.IsMiddleButtonPressed)
{
_panning = true;
_panOriginY = p.Y;
_panOriginX = p.X;
_panMin = _viewMin;
_panMax = _viewMax;
_panTimeStart = View.Start;
_autoFit = false;
e.Pointer.Capture(this);
return;
}
// Handles are tested BEFORE keyframes: a handle can be dragged right up against its
// own key, and the key is the larger target, so testing the other way round makes a
// short handle impossible to grab.
if (!props.IsRightButtonPressed && HitHandle(p, out bool leftSide))
{
SnapshotRequested?.Invoke();
_handleKey = SelectedKeyframe;
_draggingLeftHandle = leftSide;
e.Pointer.Capture(this);
return;
}
Keyframe key = HitKeyframe(p);
if (key is null && Math.Abs(TimeToX(CurrentTime) - p.X) <= PlayheadGrab)
{
_scrubbing = true;
e.Pointer.Capture(this);
SeekRequested?.Invoke(XToTime(p.X));
return;
}
if (key is null)
{
if (e.ClickCount >= 2)
{
InsertKeyframeAt(XToTime(p.X));
return;
}
SelectedKeyframe = null;
KeyframeSelected?.Invoke(null);
InvalidateVisual();
return;
}
if (props.IsRightButtonPressed)
{
DeleteKeyframe(key);
return;
}
BeginDrag(key, p);
e.Pointer.Capture(this);
}
/// <summary>Selects a keyframe and starts moving it, recording an undo state first.</summary>
internal void BeginDrag(Keyframe key, Point origin)
{
SelectedKeyframe = key;
SnapshotRequested?.Invoke();
_dragKey = key;
_dragOrigin = origin;
_dragAxisLocked = false;
KeyframeSelected?.Invoke(key);
InvalidateVisual();
}
protected override void OnPointerMoved(PointerEventArgs e)
{
base.OnPointerMoved(e);
Point p = e.GetPosition(this);
if (_panning)
{
// Middle-drag pans both axes at once: time sideways, value vertically.
double shift = (p.Y - _panOriginY) / PlotHeight * (_panMax - _panMin);
_viewMin = _panMin + shift;
_viewMax = _panMax + shift;
View.ScrollTo(_panTimeStart
- (p.X - _panOriginX) / TimeView.TrackWidth(Bounds.Width) * View.Span);
InvalidateVisual();
return;
}
if (_scrubbing)
{
SeekRequested?.Invoke(XToTime(p.X));
return;
}
if (_handleKey is not null)
{
DragHandleTo(p);
return;
}
if (_dragKey is null)
return;
DragTo(p, e.KeyModifiers);
}
/// <summary>
/// Moves the dragged keyframe. Shift locks to whichever axis the drag committed to first,
/// and Ctrl snaps the time onto the frame grid.
/// </summary>
internal void DragTo(Point p, KeyModifiers modifiers)
{
if (_dragKey is null)
return;
double beforeT = _dragKey.t, beforeValue = _dragKey.Value;
bool constrain = modifiers.HasFlag(KeyModifiers.Shift);
if (constrain && !_dragAxisLocked)
{
double dx = Math.Abs(p.X - _dragOrigin.X), dy = Math.Abs(p.Y - _dragOrigin.Y);
if (dx > 2 || dy > 2)
{
_dragLockToValue = dy >= dx;
_dragAxisLocked = true;
}
}
if (!constrain)
_dragAxisLocked = false;
if (!(_dragAxisLocked && _dragLockToValue))
{
double t = XToTime(p.X);
if (modifiers.HasFlag(KeyModifiers.Control))
{
double frame = 1.0 / Math.Max(SnapFps, 1);
t = Math.Clamp(Math.Round(t / frame) * frame, 0, Length);
}
_dragKey.t = t;
}
if (!(_dragAxisLocked && !_dragLockToValue))
_dragKey.Value = YToValue(p.Y);
// Stored handles are absolute (time, value) coordinates, so a key that moves without
// them would leave its own handles behind.
_dragKey.LeftHandleT += _dragKey.t - beforeT;
_dragKey.LeftHandleValue += _dragKey.Value - beforeValue;
_dragKey.RightHandleT += _dragKey.t - beforeT;
_dragKey.RightHandleValue += _dragKey.Value - beforeValue;
InvalidateVisual();
}
/// <summary>
/// Moves one handle of the selected keyframe.
/// </summary>
/// <remarks>
/// The handle is clamped to its own side of the key in TIME. Letting it cross would not
/// break the evaluator - <c>BezierHandles.Correct</c> shortens over-long handles before
/// solving - but it lets the user build a segment whose drawn shape does not match the
/// corrected one that renders.
/// </remarks>
internal void DragHandleTo(Point p)
{
if (_handleKey is null)
return;
double t = XToTime(p.X);
double value = YToValue(p.Y);
if (_draggingLeftHandle)
{
t = Math.Min(t, _handleKey.t);
_handleKey.LeftHandleT = t;
_handleKey.LeftHandleValue = value;
_handleKey.LeftHandleType = Positioned(_handleKey.LeftHandleType);
if (_handleKey.LeftHandleType == HandleType.Aligned)
Align(fromLeft: true);
}
else
{
t = Math.Max(t, _handleKey.t);
_handleKey.RightHandleT = t;
_handleKey.RightHandleValue = value;
_handleKey.RightHandleType = Positioned(_handleKey.RightHandleType);
if (_handleKey.RightHandleType == HandleType.Aligned)
Align(fromLeft: false);
}
InvalidateVisual();
// A derived handle is a function of its neighbours, so dragging one has to make it
// the user's before the position can stick.
static HandleType Positioned(HandleType type) =>
type == HandleType.Aligned ? HandleType.Aligned : HandleType.Free;
}
/// <summary>Reflects the opposite handle through the key, preserving its own length.</summary>
private void Align(bool fromLeft)
{
double dt = (fromLeft ? _handleKey.LeftHandleT : _handleKey.RightHandleT) - _handleKey.t;
double dv = (fromLeft ? _handleKey.LeftHandleValue : _handleKey.RightHandleValue) - _handleKey.Value;
double length = Math.Sqrt(dt * dt + dv * dv);
if (length <= 0)
return;
double otherT = (fromLeft ? _handleKey.RightHandleT : _handleKey.LeftHandleT) - _handleKey.t;
double otherV = (fromLeft ? _handleKey.RightHandleValue : _handleKey.LeftHandleValue) - _handleKey.Value;
double otherLength = Math.Sqrt(otherT * otherT + otherV * otherV);
if (otherLength <= 0)
otherLength = length;
double scale = otherLength / length;
if (fromLeft)
{
_handleKey.RightHandleT = _handleKey.t - dt * scale;
_handleKey.RightHandleValue = _handleKey.Value - dv * scale;
_handleKey.RightHandleType = HandleType.Aligned;
}
else
{
_handleKey.LeftHandleT = _handleKey.t - dt * scale;
_handleKey.LeftHandleValue = _handleKey.Value - dv * scale;
_handleKey.LeftHandleType = HandleType.Aligned;
}
}
protected override void OnPointerReleased(PointerReleasedEventArgs e)
{
base.OnPointerReleased(e);
_scrubbing = false;
_panning = false;
e.Pointer.Capture(null);
EndHandleDrag();
EndDrag();
}
/// <summary>Ends a handle drag, so the edit reaches the viewport.</summary>
internal void EndHandleDrag()
{
if (_handleKey is null)
return;
_handleKey = null;
InvalidateVisual();
Edited?.Invoke();
}
/// <summary>Ends a keyframe drag, releasing the axis back to auto-fit.</summary>
internal void EndDrag()
{
if (_dragKey is null)
return;
_dragKey = null;
InvalidateVisual();
Edited?.Invoke();
}
/// <summary>
/// Wheel zooms TIME, shared with the dopesheet so the two panes stay in register; Ctrl+wheel
/// zooms this pane's value axis, which only it has.
/// </summary>
protected override void OnPointerWheelChanged(PointerWheelEventArgs e)
{
base.OnPointerWheelChanged(e);
Point p = e.GetPosition(this);
if (e.KeyModifiers.HasFlag(KeyModifiers.Control))
{
double anchor = YToValue(p.Y);
double factor = e.Delta.Y > 0 ? 1 / 1.2 : 1.2;
_viewMin = anchor + (_viewMin - anchor) * factor;
_viewMax = anchor + (_viewMax - anchor) * factor;
_autoFit = false;
InvalidateVisual();
}
else
{
View.ZoomAt(XToTime(p.X), e.Delta.Y > 0 ? 1 / 1.25 : 1.25);
}
e.Handled = true;
}
protected override void OnKeyDown(KeyEventArgs e)
{
base.OnKeyDown(e);
if (e.Key == Key.Delete && SelectedKeyframe is not null)
{
DeleteKeyframe(SelectedKeyframe);
e.Handled = true;
}
else if (e.Key == Key.F)
{
FitToChannel();
e.Handled = true;
}
}
// ------------------------------------------------------------------ mutation
/// <summary>
/// Inserts a keyframe that lands ON the curve: its value is the curve's own value at that
/// time, and it inherits the interpolation and easing of the keyframe to its left, which
/// is the one that governed the segment being split.
/// </summary>
/// <remarks>
/// That makes the insert shape-preserving for a Linear segment with identity easing, and
/// ONLY for that. Easing is applied to each segment's NORMALIZED parameter (Channel.cs:41-47),
/// so splitting a segment gives each half its own full easing curve over half the span - a
/// re-shaping no choice of inherited settings can undo. CatmullRom likewise re-derives its
/// tangents from the new neighbours. The value at the split is preserved either way, so the
/// curve still passes through the point you clicked.
/// </remarks>
internal void InsertKeyframeAt(double t)
{
Channel channel = Current;
if (channel is null)
return;
SnapshotRequested?.Invoke();
double value = channel.EvaluateAt(t);
Keyframe left = channel.Keyframes.Where(k => k.t <= t).MaxBy(k => k.t);
// Goes through the dopesheet so the sub-frame epsilon rule applies, exactly as
// keying from a ValueSlider's diamond does.
_sheet.AddOrUpdateChannel(channel.Name, _channelPath,
TimeOnly.FromTimeSpan(TimeSpan.FromSeconds(t)), value);
Keyframe inserted = channel.Keyframes
.Where(k => Math.Abs(k.t - t) < 1.0 / Math.Max(SnapFps, 1))
.MinBy(k => Math.Abs(k.t - t));
if (inserted is not null && left is not null && !ReferenceEquals(inserted, left))
{
inserted.Type = left.Type;
inserted.InterpolationMode = left.InterpolationMode;
inserted.EasingDirection = left.EasingDirection;
inserted.EasingPower = left.EasingPower;
inserted.BackOvershoot = left.BackOvershoot;
inserted.ElasticAmplitude = left.ElasticAmplitude;
inserted.ElasticPeriod = left.ElasticPeriod;
// The handle TYPES carry over, the coordinates do not: they are absolute, so the
// left key's would put this key's handles somewhere else entirely.
//
// The derived positions are read BEFORE the types are assigned, and the order is
// load-bearing: Resolve returns the STORED coordinates for Free and Aligned, so
// seeding after the assignment would read the zeroes the key was created with and
// collapse both handles onto the origin. A new keyframe is auto-clamped, so
// reading first derives them properly.
(Handle lh, Handle rh) = channel.ResolveHandles(inserted);
inserted.LeftHandleT = lh.T;
inserted.LeftHandleValue = lh.Value;
inserted.RightHandleT = rh.T;
inserted.RightHandleValue = rh.Value;
inserted.LeftHandleType = left.LeftHandleType;
inserted.RightHandleType = left.RightHandleType;
}
SelectedKeyframe = inserted;
KeyframeSelected?.Invoke(inserted);
InvalidateVisual();
Edited?.Invoke();
}
/// <summary>
/// Removes a keyframe, and the whole channel with its last one - a channel with no
/// keyframes throws inside <see cref="Channel.EvaluateAt"/>.
/// </summary>
internal void DeleteKeyframe(Keyframe key)
{
Channel channel = Current;
if (channel is null)
return;
SnapshotRequested?.Invoke();
channel.Keyframes.Remove(key);
if (channel.Keyframes.Count == 0)
_sheet.Channels.Remove(_channelPath);
SelectedKeyframe = null;
KeyframeSelected?.Invoke(null);
InvalidateVisual();
Edited?.Invoke();
}
}