Editor/GraphicsItems/EditableAltCurve.cs
using Editor;
using Sandbox;
using Sandbox.Diagnostics;
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using static AltCurves.AltCurve;
namespace AltCurves.GraphicsItems;
/// <summary>
/// Contained inside the AltCurveEditor, the EditableAltCurve handles rendering and manipulation of the AltCurve structure.
/// </summary>
public partial class EditableAltCurve : GraphicsItem
{
public record struct SelectionStateData( int SelectedKeyframes, float SelectedTime, float SelectedValue );
public record struct CurveHoverInfo( float HoveredTime, float HoveredValue, bool InvalidKeyframe );
/// <summary>
/// The raw curve keyframes that we are manipulating on our canvas.
/// Could potentially be in an invalid/unsanitized state with keys sharing times.
/// </summary>
private List<Keyframe> _rawCurveKeyframes;
/// <summary>
/// The sanitized processed version of _rawCurveKeyframes, feeds into SanitizedCurve and the resulting JSON
/// </summary>
private List<Keyframe> _sanitizedKeyframes;
/// <summary>
/// Current curve pre-infinity extrapolation (for both raw/sanitized)
/// </summary>
private Extrapolation _extrapolationPreInfinity;
/// <summary>
/// Current curve post-infinity extrapolation (for both raw/sanitized)
/// </summary>
private Extrapolation _extrapolationPostInfinity;
/// <summary>
/// The "Sanitized" curve, which is the raw curve after being sanitized to remove duplicate times, fix ordering etc.
/// Bound to the underlying SerializedProperty that we're representing, writes to this represent external
/// curve modification (JSON reload or external editing)
/// </summary>
public AltCurve SanitizedCurve
{
get => _sanitizedCurve;
// The newly incoming sanitized curve from the binding will stomp any local changes.
set => SetRawCurve( value.Keyframes, value.PreInfinity, value.PostInfinity );
}
private AltCurve _sanitizedCurve;
/// <summary>
/// Widget space/curve space transform
/// </summary>
public CurveWidgetTransform CurveTransform
{
set
{
_curveTransform = value;
foreach ( var keyframe in _keyWidgets ) keyframe.SetTransform( value );
Update();
}
}
private CurveWidgetTransform _curveTransform;
/// <summary>
/// User-defined persistent viewing preferences
/// </summary>
public EditorViewConfig ViewConfig
{
get => _viewConfig;
set
{
var initialConfig = _viewConfig;
_viewConfig = value;
ViewConfigUpdated( initialConfig );
}
}
private EditorViewConfig _viewConfig = new();
/// <summary>
/// Curve render color
/// </summary>
public Color CurveColor { get; set; } = Theme.Green;
/// <summary>
/// Thickness when not hovered
/// </summary>
public float CurveThickness { get; set; } = 2.0f;
/// <summary>
/// Thickness when hovered
/// </summary>
public float CurveThicknessHovered { get; set; } = 3.0f;
/// <summary>
/// Is the cursor hovering on the curve specifically? Determined from a hit-test against the specific evaluated curve.
/// </summary>
public bool HoveringCurve { get; private set; } = false;
/// <summary>
/// Is the cursor hovering on a keyframe specifically? Determined from a hit-test against the specific evaluated curve.
/// </summary>
public bool HoveringKeyframe { get; private set; } = false;
/// <summary>
///
/// </summary>
public bool HoveringTangent { get; private set; } = false;
/// <summary>
/// Is the cursor hovering on the curve or a keyframe? Determined from a hit-test against the specific evaluated curve.
/// </summary>
public bool HoveringCurveOrKeyframe { get; private set; } = false;
/// <summary>
/// True if we actually want the hover tooltip to display, can be suppressed by user preferences
/// </summary>
public bool ShowCurveHoverInfo => HoveringCurveOrKeyframe && !HoveringTangent && ViewConfig.ShowCurveTooltip;
/// <summary>
/// Time the hover event started
/// </summary>
public float CurveStartHoverTime { get; private set; } = -1.0f;
/// <summary>
/// If true ignore any attempts at snapping to the nearest gridline, forcefulyl disabled (alt held)
/// </summary>
public bool ForceDisableSnap { get; set; } = false;
/// <summary>
/// Overall time snapping toggle (bound to toolbar)
/// </summary>
public bool SnapTimeEnabled { get; set; } = true;
/// <summary>
/// Time snapping method (bound to toolbar)
/// </summary>
public TimeSnapOptions SnapTimeMode { get; set; }
/// <summary>
/// Time snapping amount (if custom)
/// </summary>
public float SnapTimeCustom { get; set; }
/// <summary>
/// Overall Value snapping toggle (bound to toolbar)
/// </summary>
public bool SnapValueEnabled { get; set; } = true;
/// <summary>
/// Value snapping method (bound to toolbar)
/// </summary>
public ValueSnapOptions SnapValueMode { get; set; }
/// <summary>
/// Value snapping amount (if custom)
/// </summary>
public float SnapValueCustom { get; set; }
/// <summary>
/// Time to display for the current selected keyframe
/// </summary>
public SelectionStateData SelectionState { get; set; }
/// <summary>
/// Tracks direction during drag operations to allow shift-snap to cardinal directions
/// </summary>
private DragLatch _dragDirectionSnap = new();
/// <summary>
/// Triggered each time undo/redo is performed, bool is false for undo/true for redo, string is the operation undone/redone.
/// </summary>
public Action<bool, string> OnUndoRedo { get; set; }
/// <summary>
/// If not null, all selected keyframes are of this interpolation type/tangent mode
/// </summary>
public InterpTangentMode? SelectedInterpolation { get; private set; }
/// <summary>
/// True if we have at least 1 selected keyframe
/// </summary>
public bool HasSelectedKeyframe => _selectedIndicies.Any();
private record struct HistoryEntry( string Operation, ImmutableArray<Keyframe> Keyframes, Extrapolation PreInfinity, Extrapolation PostInfinity );
private readonly Stack<HistoryEntry> _curveHistory = new(); // In lieu of any sensible undo/redo system in S&box, let's just roll our own. I miss the UE transaction system.
private readonly Stack<HistoryEntry> _curveRedoHistory = new(); // State of the curve gets pushed on undo, popped when redoing
private readonly List<KeyPair> _keyWidgets = new(); // The keyframe list is created from the associated curve keyframes
private Vector2 _lastMousePos;
private bool _draggingSelect = false;
private Vector2 _dragStartPos;
private KeyPair? _hoveredKeyframe = null;
private int _draggingKeyframeIndex = -1;
private readonly ScrollingGrid _gridBackground;
private HashSet<int> _selectedIndicies = new(); // Selected raw keyframe indicies
private Dictionary<int, int> _rawToSanitizedIdMap = new(); // Map indicies between the raw and sanitized keyframes (if they were not removed)
private Dictionary<int, int> _sanitizedToRawIdMap = new(); // Map indicies between the sanitized keyframe and the raw id (each sanitized keyframe always has a raw keyframe)
private IEnumerable<Keyframe> SelectedKeyframes => _selectedIndicies.Select( x => _rawCurveKeyframes[x] );
public EditableAltCurve( AltCurve curve, CurveWidgetTransform curveTransform, ScrollingGrid gridBackground ) : base( null )
{
_curveTransform = curveTransform;
_gridBackground = gridBackground;
HoverEvents = true;
Clip = true;
ClipChildren = true;
SetRawCurve( curve.Keyframes, curve.PreInfinity, curve.PostInfinity );
}
/// <summary>
/// A entirely brand new curve has been provided (probably from external editing)
/// </summary>
private void SetRawCurve( ImmutableArray<Keyframe> keyframes, Extrapolation preInfinity, Extrapolation postInfinity )
{
// Clear selection state for any new curves coming in given rebuilding
_selectedIndicies.Clear();
SelectionState = new();
SelectedInterpolation = null;
_rawCurveKeyframes = keyframes.IsDefaultOrEmpty ? new() { new Keyframe() } : keyframes.ToList();
_extrapolationPreInfinity = preInfinity;
_extrapolationPostInfinity = postInfinity;
// Full rebuild of keyframes for our new curve data
RebuildKeyframeWidgets();
RebuildSanitizedCurve();
Update();
}
// Note: important that this is quick, it's called often (ie for each selected item during drag)
private void SetRawKeyframe( int index, in Keyframe keyframe )
{
if ( index < 0 || index >= _rawCurveKeyframes.Count )
throw new ArgumentOutOfRangeException( nameof( index ), "Keyframe index out of range" );
_rawCurveKeyframes[index] = keyframe;
RebuildSanitizedCurve();
// Update the widget handles
_keyWidgets[index].VisibleHandle.Keyframe = keyframe;
_keyWidgets[index].DragHandle.Keyframe = keyframe;
}
private void RebuildSanitizedCurve()
{
Assert.True( _keyWidgets.Count == _rawCurveKeyframes.Count, "_keyWidgets count mismatch" );
_sanitizedKeyframes = SanitizeKeyframes( _rawCurveKeyframes ).ToList();
// Update the automatic tangents with this new sanitized curve data, and feed it back to the raw keyframe data.
// Generating automatic tangents against the raw potentially invalid curve data will result in incorrect tangents.
// Build a map for translating between the raw and sanitized keyframe indicies
_rawToSanitizedIdMap = _sanitizedKeyframes
.Select( ( kf, index ) => new { kf, index } )
.ToDictionary( x => _rawCurveKeyframes.IndexOf( x.kf ), x => x.index );
_sanitizedToRawIdMap = _rawToSanitizedIdMap.ToDictionary( x => x.Value, x => x.Key );
// Perform the tangent updates
UpdateCurveAutoTangents( ref _sanitizedKeyframes, out var updatedSanitizedIds );
// Feed back the tangents
foreach ( var updatedId in updatedSanitizedIds )
{
// This should always be safe, because the sanitized keyframes are a subset of the raw keyframes
var rawId = _sanitizedToRawIdMap[updatedId];
// Intentionally directly setting rather than piping through setters
_rawCurveKeyframes[rawId] = _sanitizedKeyframes[updatedId];
_keyWidgets[rawId].VisibleHandle.Keyframe = _sanitizedKeyframes[updatedId];
}
_sanitizedCurve = new( _sanitizedKeyframes, _extrapolationPreInfinity, _extrapolationPostInfinity );
Update();
}
/// <summary>
/// Recreate the curve keyframe widgets, triggered after modifications to the curve that potentially add/remove keyframes
/// </summary>
private void RebuildKeyframeWidgets()
{
_draggingKeyframeIndex = -1;
foreach ( var keyframe in _keyWidgets )
{
keyframe.Destroy();
}
_keyWidgets.Clear();
// Create the keyframe widgets
var previousTimes = new HashSet<float>();
for ( int i = 0; i < _rawCurveKeyframes.Count; i++ )
{
var dragWidget = new DragHandle( i, _curveTransform, _rawCurveKeyframes[i], this )
{
ZIndex = 2
};
dragWidget.OnMouseDown += OnHandleClicked;
dragWidget.OnMouseUp += OnHandleMouseUp;
dragWidget.OnDragging += OnHandleDrag;
dragWidget.OnDragComplete += OnHandleDragComplete;
var visibleWidget = new KeyVisible( i, _rawCurveKeyframes.Count, _curveTransform, _rawCurveKeyframes[i], ViewConfig.TangentMode, this )
{
ZIndex = 3,
UserSelected = _selectedIndicies.Contains( i ),
InvalidKeyframe = previousTimes.Contains( _rawCurveKeyframes[i].Time )
};
// Tangent controls
visibleWidget.TangentIn.OnDragStart += () => { PushUndoState( "Dragging Tangent Handle" ); };
visibleWidget.TangentIn.OnUpdated += ( value ) =>
{
switch ( visibleWidget.Keyframe.TangentMode )
{
case TangentMode.Mirrored:
visibleWidget.Keyframe = visibleWidget.Keyframe with { TangentOut = value, TangentIn = value };
break;
case TangentMode.Split:
visibleWidget.Keyframe = visibleWidget.Keyframe with { TangentIn = value };
break;
case TangentMode.Automatic: // User edits to an auto tangent will switch to mirrored mode
visibleWidget.Keyframe = visibleWidget.Keyframe with { TangentOut = value, TangentIn = value, TangentMode = TangentMode.Mirrored };
CalculateSelectedInterpolation(); // We changed handle interpolation, refresh the state
break;
}
// Push the updated keyframe data into the raw curve
SetRawKeyframe( visibleWidget.Index, visibleWidget.Keyframe );
};
visibleWidget.TangentOut.OnDragStart += () => { PushUndoState( "Dragging Tangent Handle" ); };
visibleWidget.TangentOut.OnUpdated += ( value ) =>
{
switch ( visibleWidget.Keyframe.TangentMode )
{
case TangentMode.Mirrored:
visibleWidget.Keyframe = visibleWidget.Keyframe with { TangentOut = value, TangentIn = value };
break;
case TangentMode.Split:
visibleWidget.Keyframe = visibleWidget.Keyframe with { TangentOut = value };
break;
case TangentMode.Automatic: // User edits to an auto tangent will switch to mirrored mode
visibleWidget.Keyframe = visibleWidget.Keyframe with { TangentOut = value, TangentIn = value, TangentMode = TangentMode.Mirrored };
CalculateSelectedInterpolation(); // We changed handle interpolation, refresh the state
break;
}
// Push the updated keyframe data into the raw curve
SetRawKeyframe( visibleWidget.Index, visibleWidget.Keyframe );
};
_keyWidgets.Add( new()
{
DragHandle = dragWidget,
VisibleHandle = visibleWidget
} );
previousTimes.Add( _rawCurveKeyframes[i].Time );
}
BuildSelectionState(); // Update the state of selected keyframes after rebuild
CalculateSelectedInterpolation(); // Check for any changes to the interpolation settings of our selection
Update();
}
/// <summary>
/// Triggered each time the user-configurable view settings changes, this can alter things like tangent visiblity, info popup state etc.
/// </summary>
private void ViewConfigUpdated( in EditorViewConfig lastViewConfig )
{
// If we change tangent visibility mode, pass to keyframes
if ( lastViewConfig.TangentMode != ViewConfig.TangentMode )
{
foreach ( var pair in _keyWidgets )
{
pair.VisibleHandle.TangentMode = ViewConfig.TangentMode;
}
}
}
/// <summary>
/// Passed from the curve editor when focus is gained
/// </summary>
public void OnCurveFocus( FocusChangeReason _ )
{
// Forcefully release the alt key, there's an annoying bug with the alt snap override.
// OnKeyPressed gets called for the alt key, but OnKeyReleased does not after alt tabbing.
// This will forcefully reset the state when tabbing back in.
ForceDisableSnap = false;
}
public void OnCurveKeyPressed( KeyEvent e )
{
// Shift while in a drag operation will start monitoring drag direction, and then snap movement along that axis
switch ( e.Key )
{
case KeyCode.Shift when _draggingKeyframeIndex >= 0:
_dragDirectionSnap.Start();
break;
case KeyCode.Alt:
e.Accepted = true;
ForceDisableSnap = true;
break;
case KeyCode.Z when e.HasCtrl:
e.Accepted = true;
Undo();
break;
case KeyCode.Y when e.HasCtrl:
e.Accepted = true;
Redo();
break;
case KeyCode.Delete:
e.Accepted = true;
DeleteSelection();
break;
case KeyCode.A when e.HasCtrl:
e.Accepted = true;
SelectAll();
break;
case KeyCode.Z when !e.HasCtrl:
e.Accepted = true;
SnapTimeEnabled = !SnapTimeEnabled;
Update();
break;
case KeyCode.X when !e.HasCtrl:
e.Accepted = true;
SnapValueEnabled = !SnapValueEnabled;
Update();
break;
case KeyCode.Num1:
e.Accepted = true;
SetSelectionInterpolation( new( Interpolation.Cubic, TangentMode.Automatic ) );
break;
case KeyCode.Num2:
e.Accepted = true;
SetSelectionInterpolation( new( Interpolation.Cubic, TangentMode.Mirrored ) );
break;
case KeyCode.Num3:
e.Accepted = true;
SetSelectionInterpolation( new( Interpolation.Cubic, TangentMode.Split ) );
break;
case KeyCode.Num4:
e.Accepted = true;
SetSelectionInterpolation( new( Interpolation.Linear ) );
break;
case KeyCode.Num5:
e.Accepted = true;
SetSelectionInterpolation( new( Interpolation.Constant ) );
break;
case KeyCode.Num6:
e.Accepted = true;
SetSelectionTangentFlat();
break;
}
}
public void OnCurveKeyReleased( KeyEvent e )
{
switch ( e.Key )
{
case KeyCode.Shift:
// Shift while in a drag operation will start monitoring drag direction, and then snap movement along that axis
_dragDirectionSnap.Reset();
break;
case KeyCode.Alt:
// Force disable snapping when holding alt
e.Accepted = true;
ForceDisableSnap = false;
break;
}
}
public void OnCurveMouseMove( MouseEvent e )
{
var prevMousePos = _lastMousePos;
_lastMousePos = e.LocalPosition - Position;
var positionDelta = _lastMousePos - prevMousePos;
if ( _dragDirectionSnap.State == DragLatch.LatchState.Accumulating )
_dragDirectionSnap.Accumulate( positionDelta );
// Highlight the curve if we're not dragging (dragging keyframes will override this)
if ( !_draggingSelect )
PerformCurveHitDetection( e.LocalPosition );
// Push updates during mouse movement while dragging to correctly paint selection box
if ( _draggingSelect )
Update();
// Update on mouse movement when we're showing the hover window and we're hovering the curve itself.
if ( HoveringCurve && !HoveringKeyframe && !HoveringTangent && ShowCurveHoverInfo )
Update();
}
internal void OnCurveMousePress( MouseEvent e )
{
// Perform a single hit detection update when pressing down,
// we want the latest hover state and we might have been hovering
// a context menu instead of just constantly updating hover state
PerformCurveHitDetection( e.LocalPosition );
_lastMousePos = e.LocalPosition - Position;
if ( e.LeftMouseButton )
{
var canDrag = _hoveredKeyframe == null && !HoveringTangent;
if ( !_draggingSelect && canDrag )
{
_draggingSelect = true;
_dragStartPos = e.LocalPosition - Position;
}
}
else if ( e.MiddleMouseButton )
{
if ( HoveringCurveOrKeyframe )
{
CreateHoveredKeyframe( e.LocalPosition - Position );
}
}
Update();
}
internal void OnCurveMouseRelease( MouseEvent e )
{
var localMousePos = e.LocalPosition - Position;
if ( e.LeftMouseButton && !e.Accepted )
{
if ( _draggingSelect )
{
_draggingSelect = false;
BoxSelect( _dragStartPos, localMousePos, append: e.HasShift, toggle: e.HasCtrl, subtract: e.HasAlt );
}
}
else if ( e.RightMouseButton && !e.Accepted )
{
// Right-clicking on an unselected keyframe directly will also switch selection to it (or add it), similar to OnHandleClicked
if ( _hoveredKeyframe.HasValue )
{
var clickedIndex = _hoveredKeyframe.Value.Index;
if ( !_selectedIndicies.Contains( clickedIndex ) )
{
var clickedKeyframe = new HashSet<int>() { clickedIndex };
UpdateSelection( ref clickedKeyframe, append: e.HasShift, toggle: e.HasCtrl, subtract: e.HasAlt );
}
}
OpenContextMenu();
e.Accepted = true;
}
Update();
}
private void OnHandleClicked( int clickedIndex, bool shift, bool ctrl, bool alt )
{
// If the user is clicking and dragging an unselected keyframe, we want to update the selection to include it.
if ( !_selectedIndicies.Contains( clickedIndex ) )
{
var clickedKeyframe = new HashSet<int>() { clickedIndex };
UpdateSelection( ref clickedKeyframe, append: shift, toggle: ctrl, subtract: alt );
}
}
private void OnHandleMouseUp( int clickedIndex, bool shift, bool ctrl, bool alt )
{
_draggingKeyframeIndex = -1;
_dragDirectionSnap.Reset();
Update();
}
/// <summary>
/// Handle the keyframe drag events, snapping the associated curve keyframe when desired
/// </summary>
protected void OnHandleDrag( int dragIndex )
{
if ( _draggingKeyframeIndex == -1 )
{
// First drag event in a sequence, push an undo state
PushUndoState( $"Moved {_selectedIndicies.Count} keyframe{(_selectedIndicies.Count > 1 ? "s" : "")}" );
}
_draggingKeyframeIndex = dragIndex;
var draggingPair = _keyWidgets[dragIndex];
var unsnappedTime = draggingPair.DragHandle.Keyframe.Time;
var unsnappedValue = draggingPair.DragHandle.Keyframe.Value;
var timeChange = unsnappedTime - draggingPair.DragHandle.StartDragKeyframe.Time;
var valueChange = unsnappedValue - draggingPair.DragHandle.StartDragKeyframe.Value;
// If we're latched to a direction, factor out the movement in the opposite direction
if ( _dragDirectionSnap.State == DragLatch.LatchState.Latched )
{
if ( _dragDirectionSnap.LatchDirection == Vector2.Right )
{
valueChange = 0.0f;
unsnappedValue = draggingPair.DragHandle.StartDragKeyframe.Value;
}
else
{
timeChange = 0.0f;
unsnappedTime = draggingPair.DragHandle.StartDragKeyframe.Time;
}
}
// Snap the main dragged widget
var (snappedTime, snappedValue) = SnapTimeValueToGrid( unsnappedTime, unsnappedValue );
SetRawKeyframe( dragIndex, _rawCurveKeyframes[dragIndex] with { Time = snappedTime, Value = snappedValue } );
// And drag every other selected widget by the same amount that the dragged keyframe is moving (then snapped to grid)
foreach ( var idx in _selectedIndicies )
{
if ( idx == dragIndex )
continue;
// Noteworthy: During this drag we are only updating the visible widget of selected-but-not-dragged keyframes.
// When the drag completes we will run a pass updating all drag handles to the correct widget positions
var selectedWidgets = _keyWidgets[idx];
var selectedBaseKeyframe = selectedWidgets.DragHandle.Keyframe;
var (snappedOtherTime, snappedOtherValue) = SnapTimeValueToGrid( selectedBaseKeyframe.Time + timeChange, selectedBaseKeyframe.Value + valueChange );
SetRawKeyframe( idx, _rawCurveKeyframes[idx] with { Time = snappedOtherTime, Value = snappedOtherValue } );
}
// Update the validity state for each relevant handle, flagging as invalid if they're going to be culled (because they share a time).
// The first instance of each time is taken, so we know if we see a repeat time that the keyframe will be lost
var seenTimes = new HashSet<float>();
foreach ( var pair in _keyWidgets )
{
pair.VisibleHandle.InvalidKeyframe = seenTimes.Contains( pair.VisibleHandle.Keyframe.Time );
seenTimes.Add( pair.VisibleHandle.Keyframe.Time );
}
BuildSelectionState();
Update();
}
/// <summary>
/// Scan through a list of keyframes that represent a curve
/// Modify the tangents of auto-interpolating keyframes based on the surrounding keyframe slope.
/// </summary>
private static void UpdateCurveAutoTangents( ref List<Keyframe> alteredKeyframes, out List<int> alteredKeyframeIds )
{
alteredKeyframeIds = new();
for ( int i = 0; i < alteredKeyframes.Count; i++ )
{
var keyframe = alteredKeyframes[i];
if ( keyframe.Interpolation == Interpolation.Cubic && keyframe.TangentMode == TangentMode.Automatic )
{
// First-last keyframes always flatten the curve for a seamless cycle with itself
if ( i == 0 || i == alteredKeyframes.Count - 1 )
{
alteredKeyframes[i] = keyframe with { TangentIn = 0.0f, TangentOut = 0.0f };
alteredKeyframeIds.Add( i );
continue;
}
// Calculate the slope between the previous and next keyframes
var prevKeyframe = alteredKeyframes[i - 1];
var nextKeyframe = alteredKeyframes[i + 1];
var slope = (nextKeyframe.Value - prevKeyframe.Value) / (nextKeyframe.Time - prevKeyframe.Time);
alteredKeyframes[i] = keyframe with { TangentIn = slope, TangentOut = slope };
alteredKeyframeIds.Add( i );
}
}
}
/// <summary>
/// Called when the mouse is released after drag completion
/// </summary>
private void OnHandleDragComplete( int dragIndex )
{
// Snap all the other drag handles to their correct positions
_keyWidgets[dragIndex].DragHandle.Keyframe = _keyWidgets[dragIndex].VisibleHandle.Keyframe;
foreach ( var idx in _selectedIndicies )
{
if ( idx == dragIndex )
continue;
_keyWidgets[idx].DragHandle.Keyframe = _keyWidgets[idx].VisibleHandle.Keyframe;
}
// Update selection state with the drag position
BuildSelectionState();
Update();
_draggingKeyframeIndex = -1;
}
protected override void OnPaint()
{
float alpha = HoveringCurveOrKeyframe ? 1.0f : 0.8f;
float thickness = HoveringCurveOrKeyframe ? CurveThicknessHovered : CurveThickness;
Paint.SetPen( CurveColor.WithAlpha( alpha ), thickness, PenStyle.Solid );
_sanitizedCurve.DrawPartialCurve( _curveTransform, 1.0f );
// Paint a little point on the curve near our closest hover position
if ( HoveringCurve && !HoveringKeyframe && !HoveringTangent && ShowCurveHoverInfo )
{
var (hoverTime, hoverValue, _) = GetHoverInfo( _lastMousePos );
var hoverPos = _curveTransform.CurveToWidgetPosition( new( hoverTime, hoverValue ) );
Paint.SetPen( Color.White );
Paint.DrawCircle( hoverPos, 4.0f );
}
// Drag box
if ( _draggingSelect )
{
var dragMins = _dragStartPos.ComponentMin( _lastMousePos );
var dragMaxs = _dragStartPos.ComponentMax( _lastMousePos );
Paint.SetBrushAndPen( Color.FromRgba( 0xADD8E644 ), Color.FromRgb( 0X72BCD4 ), style: PenStyle.Dash );
Paint.DrawRect( Rect.FromPoints( dragMins, dragMaxs ), 4.0f );
}
//Paint.DrawText( new Vector2( 100.0f, 100.0f ), $"DragStart{_dragStartPos}, LastMouse{_lastMousePos}" );
//Paint.DrawText( new Vector2( 100.0f, 100.0f ), $"Selected IDS: {string.Join(',', _selectedIndicies)}" );
//Paint.DrawText( new Vector2( 100.0f, 100.0f ), $"Selected Type: {_selectedInterpolation}" );
}
/// <summary>
/// Box selection logic based on pixel start/end positions
/// </summary>
private void BoxSelect( Vector2 dragStartPos, Vector2 dragEndPos, bool append, bool toggle, bool subtract )
{
// Find the keyframes that are within the drag box
var searchMin = dragStartPos.ComponentMin( dragEndPos );
var searchMax = dragStartPos.ComponentMax( dragEndPos );
var curveTimeRangeMin = _curveTransform.WidgetToCurveX( searchMin.x );
var curveTimeRangeMax = _curveTransform.WidgetToCurveX( searchMax.x );
var curveValueRangeMax = _curveTransform.WidgetToCurveY( searchMin.y ); // Y curve/widget space inversion
var curveValueRangeMin = _curveTransform.WidgetToCurveY( searchMax.y );
var selectedIndicies = new HashSet<int>();
for ( int i = 0; i < _rawCurveKeyframes.Count; i++ )
{
var keyframe = _rawCurveKeyframes[i];
if ( keyframe.Time >= curveTimeRangeMin && keyframe.Time <= curveTimeRangeMax &&
keyframe.Value >= curveValueRangeMin && keyframe.Value <= curveValueRangeMax )
{
selectedIndicies.Add( i );
}
}
UpdateSelection( ref selectedIndicies, append, toggle, subtract );
}
/// <summary>
/// Update the selected keyframe set with the given selection. Support shift/ctrl/alt modifiers for adding/toggling/subtracting.
/// </summary>
private void UpdateSelection( ref HashSet<int> newSelection, bool append, bool toggle, bool subtract )
{
if ( toggle )
{
foreach ( var idx in newSelection )
{
if ( _selectedIndicies.Contains( idx ) )
{
_selectedIndicies.Remove( idx );
}
else
{
_selectedIndicies.Add( idx );
}
}
}
else if ( subtract )
{
foreach ( var idx in newSelection )
{
_selectedIndicies.Remove( idx );
}
}
else if ( append )
{
foreach ( var idx in newSelection )
{
_selectedIndicies.Add( idx );
}
}
else
{
_selectedIndicies = newSelection;
}
// Update visible handles with selection state
foreach ( var keyframe in _keyWidgets )
{
keyframe.VisibleHandle.UserSelected = _selectedIndicies.Contains( keyframe.Index );
}
CalculateSelectedInterpolation();
BuildSelectionState();
Update();
}
/// <summary>
/// Update our selection state, triggered when the selection set changes or selected keyframes are dragged.
/// Events bubble through to the toolbar.
/// </summary>
private void BuildSelectionState()
{
SelectionState = new()
{
SelectedKeyframes = _selectedIndicies.Count,
SelectedTime = _selectedIndicies.Count == 1 ? _rawCurveKeyframes[_selectedIndicies.First()].Time : 0.0f,
SelectedValue = _selectedIndicies.Count == 1 ? _rawCurveKeyframes[_selectedIndicies.First()].Value : 0.0f
};
}
private void PerformCurveHitDetection( Vector2 mousePos )
{
const float pixelHitRange = 8.0f;
var widgetRelativeMouse = mousePos - Position; // Mouse relative to the curve widget
// Keyframe hover detection, simple distance check to the widget-space keyframe positions
HoveringKeyframe = false;
foreach ( var keyPair in _keyWidgets )
{
var keyframeWidgetPos = _curveTransform.CurveToWidgetPosition( new( keyPair.DragHandle.Keyframe.Time, keyPair.DragHandle.Keyframe.Value ) );
if ( keyframeWidgetPos.Distance( widgetRelativeMouse ) < pixelHitRange )
{
_hoveredKeyframe = keyPair;
HoveringKeyframe = true;
break;
}
}
// If we're still not hovering a direct keyframe, do a second pass against the handles
HoveringTangent = false;
if ( !HoveringKeyframe )
{
foreach ( var keyPair in _keyWidgets )
{
if ( keyPair.VisibleHandle?.TangentIn.Hovered ?? false )
{
HoveringTangent = true;
break;
}
if ( keyPair.VisibleHandle?.TangentOut.Hovered ?? false )
{
HoveringTangent = true;
break;
}
}
}
if ( !HoveringKeyframe )
{
_hoveredKeyframe = null;
}
// Curve hit-test logic:
// Get the value of the curve pixelHitRange pixels to the left and right of the mouse position
// Convert these values to widget-space coordinates
// The line distance between the mouse and the line segment from pixelMin/pixelMax is compared against pixelHitRange.
// Hit test against the sanitized rendered curve, not the potentially invalid raw curve.
var minValue = _sanitizedCurve.Evaluate( _curveTransform.WidgetToCurveX( widgetRelativeMouse.x - pixelHitRange ) );
var maxValue = _sanitizedCurve.Evaluate( _curveTransform.WidgetToCurveX( widgetRelativeMouse.x + pixelHitRange ) );
var pixelMin = _curveTransform.CurveToWidgetY( minValue );
var pixelMax = _curveTransform.CurveToWidgetY( maxValue );
var lineDist = AltCurveUtils.DistanceToLineSegment(
widgetRelativeMouse.x - pixelHitRange, pixelMin,
widgetRelativeMouse.x + pixelHitRange, pixelMax,
widgetRelativeMouse.x, mousePos.y - Position.y
);
HoveringCurve = lineDist < pixelHitRange;
var wasHovering = HoveringCurveOrKeyframe;
HoveringCurveOrKeyframe = HoveringCurve || HoveringKeyframe || _draggingKeyframeIndex >= 0 || HoveringTangent; // Additionally consider dragging so it's illuminated, and hovering on a tangent
if ( wasHovering != HoveringCurveOrKeyframe )
{
Update();
if ( HoveringCurveOrKeyframe )
CurveStartHoverTime = Time.Now;
}
}
/// <summary>
/// Middle-mouse creation along a hovered curve
/// </summary>
private void CreateHoveredKeyframe( Vector2 cursorPos )
{
PushUndoState( "Create keyframe" );
var newKeyframeTime = _curveTransform.WidgetToCurveX( cursorPos.x );
// Find the source keyframe we want to copy
var sourceKeyframe = new Keyframe();
var (minTime, maxTime) = _sanitizedCurve.TimeRange;
if ( newKeyframeTime >= minTime && newKeyframeTime <= maxTime )
{
// We're within the time range, copy the prior keyframe
var priorKeyframes = _rawCurveKeyframes.Where( x => x.Time <= newKeyframeTime );
sourceKeyframe = priorKeyframes.Any() ? priorKeyframes.MaxBy( x => x.Time ) : new Keyframe();
}
else if ( newKeyframeTime < minTime && _rawCurveKeyframes.Any() )
{
// We're before the curve, first keyframe
sourceKeyframe = _rawCurveKeyframes.MinBy( x => x.Time );
}
else if ( newKeyframeTime > maxTime && _rawCurveKeyframes.Any() )
{
// We're after the curve, last keyframe
sourceKeyframe = _rawCurveKeyframes.MaxBy( x => x.Time );
}
// Just stick it on to the end of the list and rebuild widgets
_rawCurveKeyframes.Add( sourceKeyframe with { Time = newKeyframeTime, Value = _sanitizedCurve.Evaluate( newKeyframeTime ) } );
RebuildKeyframeWidgets();
RebuildSanitizedCurve();
var newSelectSet = new HashSet<int>() { _rawCurveKeyframes.Count - 1 };
UpdateSelection( ref newSelectSet, false, false, false );
}
/// <summary>
/// Get the time and value of the cursor in curve space, overridden when doing things like dragging
/// </summary>
internal CurveHoverInfo GetHoverInfo( Vector2 cursorLocalPos )
{
// Show the snapped value when we're dragging keyframes
if ( _draggingKeyframeIndex >= 0 )
{
var handle = _keyWidgets[_draggingKeyframeIndex].VisibleHandle;
return new( handle.Keyframe.Time, handle.Keyframe.Value, handle.InvalidKeyframe );
}
// Show the nearby keyframe if not dragging and hovering near one
if ( _hoveredKeyframe.HasValue )
{
var handle = _hoveredKeyframe.Value.VisibleHandle;
return new( handle.Keyframe.Time, handle.Keyframe.Value, handle.InvalidKeyframe );
}
// Evaluate against the rendered sanitized curve, not the raw underlying curve.
var time = _curveTransform.WidgetToCurveX( cursorLocalPos.x );
return new( time, _sanitizedCurve.Evaluate( time ), false );
}
/// <summary>
/// Magnetic time/value snapping
/// </summary>
private (float time, float value) SnapTimeValueToGrid( float time, float value )
{
if ( ForceDisableSnap )
return (time, value);
var snappedTime = time;
var snappedValue = value;
if ( SnapTimeEnabled )
{
switch ( SnapTimeMode )
{
case TimeSnapOptions.Hundredths:
snappedTime = MathF.Round( time * 100 ) / 100;
break;
case TimeSnapOptions.Tenths:
snappedTime = MathF.Round( time * 10 ) / 10;
break;
case TimeSnapOptions.Quarters:
snappedTime = MathF.Round( time * 4 ) / 4;
break;
case TimeSnapOptions.Halfs:
snappedTime = MathF.Round( time * 2 ) / 2;
break;
case TimeSnapOptions.Seconds:
snappedTime = MathF.Round( time );
break;
case TimeSnapOptions.TenSeconds:
snappedTime = MathF.Round( time / 10 ) * 10;
break;
case TimeSnapOptions.OneMinute:
snappedTime = MathF.Round( time / 60 ) * 60;
break;
case TimeSnapOptions.Gridlines:
if ( _gridBackground.GridStepX > 0.0f )
snappedTime = (float)(_gridBackground.GridBaseX + Math.Round( (time - _gridBackground.GridBaseX) / _gridBackground.GridStepX ) * _gridBackground.GridStepX);
break;
case TimeSnapOptions.Custom:
if ( SnapValueCustom > 0.0f )
snappedTime = MathF.Round( time / SnapTimeCustom ) * SnapTimeCustom;
break;
default:
throw new NotImplementedException( "SnapTimeMode not implemented" );
}
}
if ( SnapValueEnabled )
{
switch ( SnapValueMode )
{
case ValueSnapOptions.Tenth:
snappedValue = MathF.Round( value * 10 ) / 10;
break;
case ValueSnapOptions.Half:
snappedValue = MathF.Round( value * 2 ) / 2;
break;
case ValueSnapOptions.One:
snappedValue = MathF.Round( value );
break;
case ValueSnapOptions.Two:
snappedValue = MathF.Round( value / 2 ) * 2;
break;
case ValueSnapOptions.Five:
snappedValue = MathF.Round( value / 5 ) * 5;
break;
case ValueSnapOptions.Ten:
snappedValue = MathF.Round( value / 10 ) * 10;
break;
case ValueSnapOptions.Fifty:
snappedValue = MathF.Round( value / 50 ) * 50;
break;
case ValueSnapOptions.Hundred:
snappedValue = MathF.Round( value / 100 ) * 100;
break;
case ValueSnapOptions.Gridlines:
if ( _gridBackground.GridStepY > 0.0f )
snappedValue = (float)(_gridBackground.GridBaseY + Math.Round( (value - _gridBackground.GridBaseY) / _gridBackground.GridStepY ) * _gridBackground.GridStepY);
break;
case ValueSnapOptions.Custom:
if ( SnapValueCustom > 0.0f )
snappedValue = MathF.Round( value / SnapValueCustom ) * SnapValueCustom;
break;
default:
break;
}
}
return (snappedTime, snappedValue);
}
/// <summary>
/// Update selection to include all possible keyframes
/// </summary>
internal void SelectAll()
{
var allIndicies = new HashSet<int>();
for ( int i = 0; i < _keyWidgets.Count; i++ )
{
allIndicies.Add( i );
}
UpdateSelection( ref allIndicies, false, false, false );
}
/// <summary>
/// Delete key pressed, remove the selected keyframes
/// </summary>
internal void DeleteSelection()
{
if ( _selectedIndicies.Count == 0 )
return;
PushUndoState( $"Delete {_selectedIndicies.Count} keyframe{(_selectedIndicies.Count > 1 ? "s" : "")}" );
// Remove the keyframes in reverse order so we don't invalidate the indicies
foreach ( var idx in _selectedIndicies.OrderByDescending( x => x ) )
{
_rawCurveKeyframes.RemoveAt( idx );
}
// If we've removed all of the existing keyframes, add a default keyframe back in at 0,0
if ( !_rawCurveKeyframes.Any() )
{
_rawCurveKeyframes.Add( new() );
}
_selectedIndicies.Clear();
RebuildKeyframeWidgets(); // Full rebuild after direct raw curve keyframe removal
RebuildSanitizedCurve();
}
/// <summary>
/// Get the coordinate range that results in the current selected keyframes being visible on screen (when applied to the curve transform)
/// </summary>
internal CoordinateRange2D? GetCoordinateRangeForSelection()
{
// Nothing selected, focus on the entire curve.
var selectedKeyframes = _selectedIndicies.Count == 0 ? _sanitizedKeyframes : SelectedKeyframes;
if ( !selectedKeyframes.Any() )
{
return new( -1.0f, 1.0f, -1.0f, 1.0f ); // No selection and no keyframes to focus on.
}
else if ( selectedKeyframes.Count() == 1 )
{
var keyframe = selectedKeyframes.Single();
// Try preserve the zoom level of the current transform and just focus on moving it
var currentTimeRange = _curveTransform.CurveRange.MaxX - _curveTransform.CurveRange.MinX;
var currentValueRange = _curveTransform.CurveRange.MaxY - _curveTransform.CurveRange.MinY;
// Snap range back to default if we don't have any current time range (ie 1 keyframe)
if ( currentTimeRange <= float.Epsilon )
currentTimeRange = 2.0f;
if ( currentValueRange <= float.Epsilon )
currentValueRange = 2.0f;
return new( keyframe.Time - (currentTimeRange * 0.5f), keyframe.Time + (currentTimeRange * 0.5f), keyframe.Value - (currentValueRange * 0.5f), keyframe.Value + (currentValueRange * 0.5f) );
}
// Translate our selected raw keyframes indicies into sanitized keyframe indicies,
// we need to determine the evaluated value range of the rendered curve
var selectedKeyframeSanitized = new List<int>();
var invalidRawIndicies = new List<int>();
for ( int i = 0; i < _keyWidgets.Count; i++ )
{
if ( _selectedIndicies.Count > 0 && !_selectedIndicies.Contains( i ) )
continue;
if ( _rawToSanitizedIdMap.TryGetValue( i, out int sanitizedIndex ) )
{
selectedKeyframeSanitized.Add( sanitizedIndex );
}
else
{
invalidRawIndicies.Add( i );
}
}
float minTime = float.MaxValue;
float maxTime = float.MinValue;
float minValue = float.MaxValue;
float maxValue = float.MinValue;
// Sanitized full curve value lookup, ensures that curves extending beyond keyframes are included in the zoom.
if ( selectedKeyframeSanitized.Count > 0 )
{
var lowestIdx = selectedKeyframeSanitized.Min();
var highestIdx = selectedKeyframeSanitized.Max();
minTime = _sanitizedCurve.Keyframes[lowestIdx].Time;
maxTime = _sanitizedCurve.Keyframes[highestIdx].Time;
// If we only have one single selected keyframe then we don't care about the curve range
if ( selectedKeyframeSanitized.Count == 1 )
{
minValue = _sanitizedCurve.Keyframes[lowestIdx].Value;
maxValue = _sanitizedCurve.Keyframes[highestIdx].Value;
}
else
{
var valueRange = _sanitizedCurve.KeyframeValueRanges.Skip( lowestIdx ).Take( highestIdx - lowestIdx + 1 );
minValue = valueRange.Min( x => x.Min );
maxValue = valueRange.Max( x => x.Max );
}
}
// For any invalid selected keyframes, just ensure their keyframe is in view (because they have no sanitized curve to also fit)
if ( invalidRawIndicies.Count > 0 )
{
// Just focus on all selected directly, ignoring any curve evaluation
var allInvalidKeyframes = invalidRawIndicies.Select( x => _rawCurveKeyframes[x] );
minTime = Math.Min( minTime, allInvalidKeyframes.Min( x => x.Time ) );
maxTime = Math.Max( maxTime, allInvalidKeyframes.Max( x => x.Time ) );
minValue = Math.Min( minValue, allInvalidKeyframes.Min( x => x.Value ) );
maxValue = Math.Max( maxValue, allInvalidKeyframes.Max( x => x.Value ) );
}
return new CoordinateRange2D( minTime, maxTime, minValue, maxValue ).PadRange( 0.1f );
}
/// <summary>
/// Move all selected keyframes by time/value
/// </summary>
internal void TranslateSelection( float time, float value )
{
if ( _selectedIndicies.Count == 0 )
return;
// Unlike other methods we intentionally don't push an undo state, this is often
// called very frequently and we push the undo state when they start dragging operations
foreach ( var idx in _selectedIndicies )
{
var keyframe = _rawCurveKeyframes[idx];
SetRawKeyframe( idx, keyframe with { Time = keyframe.Time + time, Value = keyframe.Value + value } );
}
}
internal void SetSelectionTime( float newTime )
{
if ( _selectedIndicies.Count == 0 )
return;
PushUndoState( "Update keyframe time" );
foreach ( var idx in _selectedIndicies )
{
SetRawKeyframe( idx, _rawCurveKeyframes[idx] with { Time = newTime } );
}
}
internal void SetSelectionValue( float newValue )
{
if ( _selectedIndicies.Count == 0 )
return;
PushUndoState( "Update keyframe value" );
foreach ( var idx in _selectedIndicies )
{
SetRawKeyframe( idx, _rawCurveKeyframes[idx] with { Value = newValue } );
}
}
public void SetSelectionInterpolation( InterpTangentMode newMode )
{
if ( _selectedIndicies.Count == 0 )
return;
PushUndoState( "Update keyframe interpolation" );
foreach ( var idx in _selectedIndicies )
{
SetRawKeyframe( idx, _rawCurveKeyframes[idx] with
{
Interpolation = newMode.Interp,
TangentMode = newMode.Tangent
} );
}
SelectedInterpolation = newMode; // We know all selection must be this interp value now
}
/// <summary>
/// Update data based on the user selection, such as:
/// - Is the user selecting keyframes all of the same interpolation/tangent mode
/// </summary>
private void CalculateSelectedInterpolation()
{
SelectedInterpolation = null;
if ( SelectedKeyframes.Any() )
{
var firstInterp = SelectedKeyframes.First();
if ( SelectedKeyframes.All( x => x.Interpolation == firstInterp.Interpolation && x.TangentMode == firstInterp.TangentMode ) )
SelectedInterpolation = new( firstInterp.Interpolation, firstInterp.TangentMode );
}
}
/// <summary>
/// Flatten the tangents of selected keyframes
/// </summary>
public void SetSelectionTangentFlat()
{
if ( _selectedIndicies.Count == 0 )
return;
PushUndoState( "Flatten keyframes" );
foreach ( var idx in _selectedIndicies )
{
var keyframe = _rawCurveKeyframes[idx] with { TangentIn = 0.0f, TangentOut = 0.0f };
// Also disable auto tangent mode for any cubic keyframes
if ( keyframe.Interpolation == Interpolation.Cubic && keyframe.TangentMode == TangentMode.Automatic )
keyframe = keyframe with { TangentMode = TangentMode.Mirrored };
SetRawKeyframe( idx, keyframe );
}
CalculateSelectedInterpolation(); // We might have changed handle interpolation, so refresh the state
}
/// <summary>
/// Called after changes to push a new undo state onto the stack, the user can ctrl-z to roll back curve changes
/// </summary>
public void PushUndoState( string operation )
{
// Push a COPY of the keyframe onto the stack
_curveHistory.Push( new( operation, _rawCurveKeyframes.ToImmutableArray(), _extrapolationPreInfinity, _extrapolationPostInfinity ) );
_curveRedoHistory.Clear(); // A new undo-worthy change flushes the redo stack
}
/// <summary>
/// Pop a change from the undo state stack into the redo stack, applying the change
/// </summary>
internal void Undo()
{
if ( _curveHistory.Count == 0 )
return;
// Do not allow undo operations while mid-drag
if ( _draggingKeyframeIndex >= 0 )
{
Log.Warning( "Ignoring undo attempt mid-keyframe drag." );
return;
}
var undo = _curveHistory.Pop();
_curveRedoHistory.Push( new( undo.Operation, _rawCurveKeyframes.ToImmutableArray(), _extrapolationPreInfinity, _extrapolationPostInfinity ) );
OnUndoRedo.Invoke( false, undo.Operation );
SetRawCurve( undo.Keyframes, undo.PreInfinity, undo.PostInfinity );
}
/// <summary>
/// Pop a change from the redo stack, returning it to the undo stack
/// </summary>
internal void Redo()
{
if ( _curveRedoHistory.Count == 0 )
return;
// Do not allow undo operations while mid-drag
if ( _draggingKeyframeIndex >= 0 )
{
Log.Warning( "Ignoring redo attempt mid-keyframe drag." );
return;
}
var undo = _curveRedoHistory.Pop();
_curveHistory.Push( new( undo.Operation, _rawCurveKeyframes.ToImmutableArray(), _extrapolationPreInfinity, _extrapolationPostInfinity ) );
OnUndoRedo.Invoke( true, undo.Operation );
SetRawCurve( undo.Keyframes, undo.PreInfinity, undo.PostInfinity );
}
private void OpenContextMenu()
{
var m = new Menu()
{
DeleteOnClose = true
};
// We want the curve options if we have no selection, or we directly click on the curve (but not a keyframe!)
var showCurveOptions = _selectedIndicies.Count == 0 || (HoveringCurve && !HoveringKeyframe);
if ( showCurveOptions )
{
m.AddHeading( $"Curve Options:" );
m.AddOption( "Add Key (Middle-Mouse)", "add", () => CreateHoveredKeyframe( _lastMousePos ) );
}
else
{
m.AddHeading( $"{_selectedIndicies.Count} Selected Keyframe{(_selectedIndicies.Count == 1 ? "" : "s")}:" );
m.AddOption( $"Delete (Del)", "delete", () => DeleteSelection() );
m.AddOption( $"Flatten (6)", "horizontal_rule", () => SetSelectionTangentFlat() )
.Enabled = SelectedKeyframes.Any( x => x.Interpolation == Interpolation.Cubic ); // Only if we have at least 1 cubic key selected
// Interpolation
m.AddSeparator();
m.AddHeading( "Keyframe Interpolation:" );
m.AddOption( new Option( "Cubic - Auto (1)", null, () => SetSelectionInterpolation( new( Interpolation.Cubic, TangentMode.Automatic ) ) )
{
Checkable = true,
Checked = SelectedInterpolation.HasValue && SelectedInterpolation.Value == new InterpTangentMode( Interpolation.Cubic, TangentMode.Automatic ),
} ).SetIcon( CurveTextures.Instance.CurveCubicAutoPixmap );
m.AddOption( new Option( "Cubic - Mirror (2)", null, () => SetSelectionInterpolation( new( Interpolation.Cubic, TangentMode.Mirrored ) ) )
{
Checkable = true,
Checked = SelectedInterpolation.HasValue && SelectedInterpolation.Value == new InterpTangentMode( Interpolation.Cubic, TangentMode.Mirrored ),
} ).SetIcon( CurveTextures.Instance.CurveCubicMirrorPixmap );
m.AddOption( new Option( "Cubic - Split (3)", null, () => SetSelectionInterpolation( new( Interpolation.Cubic, TangentMode.Split ) ) )
{
Checkable = true,
Checked = SelectedInterpolation.HasValue && SelectedInterpolation.Value == new InterpTangentMode( Interpolation.Cubic, TangentMode.Split ),
} ).SetIcon( CurveTextures.Instance.CurveCubicBrokenPixmap );
m.AddOption( new Option( "Linear (4)", null, () => SetSelectionInterpolation( new( Interpolation.Linear ) ) )
{
Checkable = true,
Checked = SelectedInterpolation.HasValue && SelectedInterpolation.Value.Interp == Interpolation.Linear,
} ).SetIcon( CurveTextures.Instance.CurveLinearPixmap );
m.AddOption( new Option( "Stepped (5)", null, () => SetSelectionInterpolation( new( Interpolation.Constant ) ) )
{
Checkable = true,
Checked = SelectedInterpolation.HasValue && SelectedInterpolation.Value.Interp == Interpolation.Constant
} ).SetIcon( CurveTextures.Instance.CurveConstantPixmap );
}
// Always show pre/post infinity & select all:
m.AddSeparator();
AddExtrapolationMenu( m, "Pre-Infinity", "west", _extrapolationPreInfinity, ( value ) =>
{
PushUndoState( "Changed curve pre-infinity" );
_extrapolationPreInfinity = value;
RebuildSanitizedCurve();
} );
AddExtrapolationMenu( m, "Post-Infinity", "east", _extrapolationPostInfinity, ( value ) =>
{
PushUndoState( "Changed curve post-infinity" );
_extrapolationPostInfinity = value;
RebuildSanitizedCurve();
} );
m.AddSeparator();
m.AddOption( "Select All (Ctrl-A)", "select_all", () => SelectAll() );
// Slightly nudged down so we're not obscuring the title
m.OpenAt( Editor.Application.CursorPosition + (Vector2.Down * -10.0f) );
void AddExtrapolationMenu( Menu m, string title, string icon, AltCurve.Extrapolation currentExtrap, Action<AltCurve.Extrapolation> onSelectOption )
{
var subMenu = m.AddMenu( title, icon );
var linear = subMenu.AddOption( new Option( "Linear", null, () => onSelectOption.Invoke( Extrapolation.Linear ) )
{
Checkable = true,
Checked = currentExtrap == Extrapolation.Linear
} );
linear.SetIcon( CurveTextures.Instance.ExtrapLinearPixmap );
linear.ToolTip = "Linearly extrapolate";
// JMCB TODO: Why do tooltips not work in the context menus :(
linear.Enabled = true;
subMenu.AddOption( new Option( "Constant", null, () => onSelectOption.Invoke( Extrapolation.Constant ) )
{
Checkable = true,
Checked = currentExtrap == Extrapolation.Constant
} ).SetIcon( CurveTextures.Instance.ExtrapConstantPixmap );
subMenu.AddOption( new Option( "Cycle", null, () => onSelectOption.Invoke( Extrapolation.Cycle ) )
{
Checkable = true,
Checked = currentExtrap == Extrapolation.Cycle
} ).SetIcon( CurveTextures.Instance.ExtrapCyclePixmap );
subMenu.AddOption( new Option( "Cycle with Offset", null, () => onSelectOption.Invoke( Extrapolation.CycleOffset ) )
{
Checkable = true,
Checked = currentExtrap == Extrapolation.CycleOffset
} ).SetIcon( CurveTextures.Instance.ExtrapCycleOffsetPixmap );
subMenu.AddOption( new Option( "Oscillate", null, () => onSelectOption.Invoke( Extrapolation.Oscillate ) )
{
Checkable = true,
Checked = currentExtrap == Extrapolation.Oscillate
} ).SetIcon( CurveTextures.Instance.ExtrapOscillatePixmap );
}
}
}