OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux / AvaloniaShell / CurveCanvas.cs

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

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

using IFSEngine.Animation;

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