Improve Animation Scene curve keyframe editing

Improves Animation Scene timeline curve editing, including vector keyframe selection/editing, tangent interaction, draggable edit popup, and several related editor UI fixes.
This commit is contained in:
Andrei Gagua
2026-06-12 16:26:35 +03:00
parent 057e5684e9
commit 752dc644e5
8 changed files with 785 additions and 83 deletions
@@ -55,6 +55,21 @@ namespace FlaxEditor.GUI.ContextMenu
private Window _window;
private Control _previouslyFocused;
private static bool IsFinite(float value)
{
return !float.IsNaN(value) && !float.IsInfinity(value);
}
private static bool IsValidWindowBounds(Float2 location, Float2 size)
{
return IsFinite(location.X) &&
IsFinite(location.Y) &&
IsFinite(size.X) &&
IsFinite(size.Y) &&
size.X > 0.0f &&
size.Y > 0.0f;
}
/// <summary>
/// Gets a value indicating whether use automatic popup direction fix based on the screen dimensions.
/// </summary>
@@ -190,6 +205,11 @@ namespace FlaxEditor.GUI.ContextMenu
var dpiSize = Size * dpiScale;
var locationWS = parent.PointToWindow(location);
var locationSS = parentWin.PointToScreen(locationWS);
if (!IsValidWindowBounds(locationSS, dpiSize))
{
Hide();
return;
}
var monitorBounds = Platform.GetMonitorBounds(locationSS);
var rightBottomLocationSS = locationSS + dpiSize;
bool isUp = false, isLeft = false;
@@ -403,7 +423,31 @@ namespace FlaxEditor.GUI.ContextMenu
{
if (_window != null)
{
_window.ClientSize = Size * _window.DpiScale;
var size = Size * _window.DpiScale;
if (!IsValidWindowBounds(_window.ClientBounds.Location, size))
return;
_window.ClientSize = size;
}
}
/// <summary>
/// Gets the popup window location in screen coordinates.
/// </summary>
protected Float2 WindowLocation => _window != null ? _window.ClientBounds.Location : Float2.Zero;
/// <summary>
/// Moves the popup window to the given screen-space location.
/// </summary>
/// <param name="screenLocation">The popup window location in screen coordinates.</param>
protected void MoveWindowTo(Float2 screenLocation)
{
if (_window != null)
{
var bounds = _window.ClientBounds;
if (!IsValidWindowBounds(screenLocation, bounds.Size))
return;
bounds.Location = screenLocation;
_window.ClientBounds = bounds;
}
}
+202 -50
View File
@@ -19,6 +19,16 @@ namespace FlaxEditor.GUI
/// <seealso cref="FlaxEngine.GUI.ContainerControl" />
protected class ContentsBase : ContainerControl
{
private const float DragStartDistance = 1.5f;
private const float DragStartDistanceSquared = DragStartDistance * DragStartDistance;
private enum SelectionMode
{
Replace,
Add,
Remove,
}
private readonly CurveEditor<T> _editor;
internal bool _leftMouseDown;
private bool _rightMouseDown;
@@ -46,6 +56,89 @@ namespace FlaxEditor.GUI
_editor = editor;
}
private KeyframePoint GetKeyframePointAt(Float2 location, bool cycle)
{
return GetKeyframePointAt(location, cycle, out _);
}
private KeyframePoint GetKeyframePointAt(Float2 location, bool cycle, out bool isStacked)
{
KeyframePoint firstHit = null;
KeyframePoint selectedHit = null;
KeyframePoint nextHitAfterSelected = null;
int hitsCount = 0;
for (int i = 0; i < _editor._points.Count; i++)
{
var point = _editor._points[i];
if (!point.Visible || !point.Bounds.Contains(ref location))
continue;
if (hitsCount == 0)
firstHit = point;
if (selectedHit != null && nextHitAfterSelected == null)
nextHitAfterSelected = point;
if (selectedHit == null && point.IsSelected)
selectedHit = point;
hitsCount++;
}
isStacked = hitsCount > 1;
if (hitsCount == 0)
return null;
if (hitsCount == 1)
return firstHit;
if (selectedHit != null)
return cycle ? nextHitAfterSelected ?? firstHit : selectedHit;
return firstHit;
}
private static string GetComponentName(int component)
{
switch (component)
{
case 0: return "X";
case 1: return "Y";
case 2: return "Z";
case 3: return "W";
default: return (component + 1).ToString();
}
}
private void SelectKeyframePoint(KeyframePoint keyframe, bool addToSelection)
{
if (!addToSelection)
{
if (_editor.KeyframesEditorContext != null)
_editor.KeyframesEditorContext.OnKeyframesDeselect(_editor);
else
_editor.ClearSelection();
}
keyframe.IsSelected = true;
if (_editor.ShowCollapsed)
{
for (int i = 0; i < _editor._points.Count; i++)
{
var point = _editor._points[i];
if (point.Index == keyframe.Index)
point.IsSelected = true;
}
}
_editor.UpdateTangents();
}
private void SelectKeyframeComponent(int keyframeIndex, int component)
{
if (_editor.KeyframesEditorContext != null)
_editor.KeyframesEditorContext.OnKeyframesDeselect(_editor);
else
_editor.ClearSelection();
for (int i = 0; i < _editor._points.Count; i++)
{
var point = _editor._points[i];
point.IsSelected = point.Index == keyframeIndex && point.Component == component;
}
_editor.UpdateTangents();
}
private void UpdateSelectionRectangle()
{
var selectionRect = Rectangle.FromPoints(_leftMouseDownPos, _mousePos);
@@ -55,14 +148,41 @@ namespace FlaxEditor.GUI
UpdateSelection(ref selectionRect);
}
private SelectionMode GetSelectionMode()
{
if (Root.GetKey(KeyboardKeys.Alt))
return SelectionMode.Remove;
if (Root.GetKey(KeyboardKeys.Shift))
return SelectionMode.Add;
return SelectionMode.Replace;
}
internal void UpdateSelection(ref Rectangle selectionRect)
{
var mode = GetSelectionMode();
// Find controls to select
var children = _children;
for (int i = 0; i < children.Count; i++)
{
if (children[i] is KeyframePoint p)
p.IsSelected = p.Bounds.Intersects(ref selectionRect);
{
var intersects = p.Bounds.Intersects(ref selectionRect);
switch (mode)
{
case SelectionMode.Replace:
p.IsSelected = intersects;
break;
case SelectionMode.Add:
if (intersects)
p.IsSelected = true;
break;
case SelectionMode.Remove:
if (intersects)
p.IsSelected = false;
break;
}
}
}
_editor.UpdateTangents();
}
@@ -82,19 +202,15 @@ namespace FlaxEditor.GUI
_editor.OnEditingStart();
}
internal void OnMove(Float2 location)
private bool MoveSelectedKeyframes(Float2 location)
{
// Skip updating keyframes until move actual starts to be meaningful
if (Float2.Distance(ref _movingSelectionStartPosLock, ref location) < 1.5f)
return;
_movingSelectionStartPosLock = Float2.Minimum;
var viewRect = _editor._mainPanel.GetClientArea();
var locationKeyframes = PointToKeyframes(location, ref viewRect);
var accessor = _editor.Accessor;
var components = accessor.GetCurveComponents();
var snapEnabled = Root.GetKey(KeyboardKeys.Control);
var snapGrid = snapEnabled ? _editor.GetGridSnap() : Float2.One;
var moved = false;
for (var i = 0; i < _editor._points.Count; i++)
{
var p = _editor._points[i];
@@ -154,7 +270,23 @@ namespace FlaxEditor.GUI
}
_editor.SetKeyframeInternal(p.Index, time, value, p.Component);
moved = true;
}
}
return moved;
}
internal void OnMove(Float2 location)
{
// Skip updating keyframes until move actual starts to be meaningful
if (Float2.Distance(ref _movingSelectionStartPosLock, ref location) < 1.5f)
return;
_movingSelectionStartPosLock = Float2.Minimum;
var moved = MoveSelectedKeyframes(location);
if (moved)
{
_editor.UpdateKeyframes();
_editor.UpdateTooltips();
if (_editor.EnablePanning == UseMode.On)
@@ -168,13 +300,15 @@ namespace FlaxEditor.GUI
internal void OnMoveEnd(Float2 location)
{
_isMovingSelection = false;
if (_movedKeyframes)
{
_editor.OnEdited();
_editor.OnEditingEnd();
_editor.UpdateKeyframes();
_editor.UpdateTooltips();
_movedKeyframes = false;
}
_isMovingSelection = false;
}
/// <inheritdoc />
@@ -199,26 +333,32 @@ namespace FlaxEditor.GUI
_mousePos = location;
// Start moving selection if movement started from the keyframe
if (_leftMouseDown && !_isMovingSelection && GetChildAt(_leftMouseDownPos) is KeyframePoint)
var leftMouseDownOverKeyframe = _leftMouseDown && GetKeyframePointAt(_leftMouseDownPos, false) != null;
if (leftMouseDownOverKeyframe && !_isMovingSelection)
{
if (Float2.DistanceSquared(ref _leftMouseDownPos, ref location) < DragStartDistanceSquared)
return;
if (_editor.KeyframesEditorContext != null)
_editor.KeyframesEditorContext.OnKeyframesMove(_editor, this, location, true, false);
_editor.KeyframesEditorContext.OnKeyframesMove(_editor, this, _leftMouseDownPos, true, false);
else
OnMoveStart(location);
OnMoveStart(_leftMouseDownPos);
}
// Moving view
if (_rightMouseDown)
{
var movingViewPos = Parent.PointToParent(PointToParent(location));
var delta = movingViewPos - _movingViewLastPos;
if (_editor.CustomViewPanning != null)
delta = _editor.CustomViewPanning(delta);
delta *= GetUseModeMask(_editor.EnablePanning);
if (delta.LengthSquared > 0.01f)
var mousePosition = Root.MousePosition;
var mouseDelta = mousePosition - _movingViewLastPos;
if (mouseDelta.LengthSquared > 0.01f)
{
_editor._mainPanel.ViewOffset += delta;
_movingViewLastPos = movingViewPos;
var delta = mouseDelta;
if (_editor.CustomViewPanning != null)
delta = _editor.CustomViewPanning(delta);
var viewDelta = delta * GetUseModeMask(_editor.EnablePanning);
if (viewDelta.LengthSquared > 0.0f)
_editor.ViewOffset += viewDelta;
_movingViewLastPos = mousePosition;
_movedView = true;
if (_editor.CustomViewPanning != null)
{
@@ -257,7 +397,7 @@ namespace FlaxEditor.GUI
var tangent = PointToKeyframes(location, ref viewRect).Y - value;
if (Root.GetKey(KeyboardKeys.Control))
tangent = Float2.SnapToGrid(new Float2(0, tangent), _editor.GetGridSnap()).Y; // Snap tangent over Y axis
tangent = tangent * _editor.ViewScale.X * 2;
tangent = tangent * UnitsPerSecond / _movingTangent.TangentOffset;
_movingTangent.TangentValue = tangent;
_editor.UpdateTangents();
Cursor = CursorType.SizeNS;
@@ -265,7 +405,7 @@ namespace FlaxEditor.GUI
return;
}
// Selecting
else if (_leftMouseDown)
else if (_leftMouseDown && !leftMouseDownOverKeyframe)
{
UpdateSelectionRectangle();
return;
@@ -321,12 +461,13 @@ namespace FlaxEditor.GUI
_rightMouseDown = true;
_rightMouseDownPos = location;
_movedView = false;
_movingViewLastPos = Parent.PointToParent(PointToParent(location));
_movingViewLastPos = Root.MousePosition;
}
// Check if any node is under the mouse
var underMouse = GetChildAt(location);
if (underMouse is KeyframePoint keyframe)
var keyframe = underMouse is KeyframePoint ? GetKeyframePointAt(location, false) : null;
if (keyframe != null)
{
if (_leftMouseDown)
{
@@ -355,12 +496,7 @@ namespace FlaxEditor.GUI
{
// Select node
if (!Root.GetKey(KeyboardKeys.Control))
{
if (_editor.KeyframesEditorContext != null)
_editor.KeyframesEditorContext.OnKeyframesDeselect(_editor);
else
_editor.ClearSelection();
}
SelectKeyframePoint(keyframe, false);
_toggledSelection = true;
keyframe.IsSelected = true;
_editor.UpdateTangents();
@@ -402,11 +538,14 @@ namespace FlaxEditor.GUI
{
// Start selecting
StartMouseCapture();
if (_editor.KeyframesEditorContext != null)
_editor.KeyframesEditorContext.OnKeyframesDeselect(_editor);
else
_editor.ClearSelection();
_editor.UpdateTangents();
if (GetSelectionMode() == SelectionMode.Replace)
{
if (_editor.KeyframesEditorContext != null)
_editor.KeyframesEditorContext.OnKeyframesDeselect(_editor);
else
_editor.ClearSelection();
_editor.UpdateTangents();
}
Focus();
return true;
}
@@ -453,11 +592,21 @@ namespace FlaxEditor.GUI
OnMoveEnd(location);
}
// Toggle selection
else if (!_toggledSelection && Root.GetKey(KeyboardKeys.Control) && GetChildAt(location) is KeyframePoint keyframe)
else if (!_toggledSelection && Root.GetKey(KeyboardKeys.Control) && GetKeyframePointAt(location, false) is KeyframePoint keyframe)
{
keyframe.IsSelected = !keyframe.IsSelected;
_editor.UpdateTangents();
}
// Select next stacked keyframe component only after a click has completed, not before a possible drag.
else if (!_toggledSelection && !Root.GetKey(KeyboardKeys.Control) && !Root.GetKey(KeyboardKeys.Shift) &&
Float2.DistanceSquared(ref _leftMouseDownPos, ref location) < DragStartDistanceSquared &&
GetKeyframePointAt(_leftMouseDownPos, false, out var mouseDownStacked) != null &&
mouseDownStacked &&
GetKeyframePointAt(location, true, out var mouseUpStacked) is KeyframePoint clickedKeyframe &&
mouseUpStacked)
{
SelectKeyframePoint(clickedKeyframe, false);
}
_isMovingSelection = false;
_isMovingTangent = false;
@@ -473,22 +622,12 @@ namespace FlaxEditor.GUI
if (!_movedView)
{
var selectionCount = _editor.SelectionCount;
var point = GetChildAt(location) as KeyframePoint;
if (selectionCount == 0 && point != null)
var point = GetKeyframePointAt(location, false);
if (point != null && (selectionCount == 0 || !point.IsSelected))
{
// Select node
selectionCount = 1;
point.IsSelected = true;
if (_editor.ShowCollapsed)
{
for (int i = 0; i < _editor._points.Count; i++)
{
var p = _editor._points[i];
if (p.Index == point.Index)
p.IsSelected = point.IsSelected;
}
}
_editor.UpdateTangents();
SelectKeyframePoint(point, false);
}
var viewRect = _editor._mainPanel.GetClientArea();
@@ -500,6 +639,16 @@ namespace FlaxEditor.GUI
{
cm.AddButton(selectionCount == 1 ? "Edit keyframe" : "Edit keyframes", () => _editor.EditKeyframes(this, location));
}
var components = _editor.Accessor.GetCurveComponents();
if (point != null && !_editor.ShowCollapsed && components > 1)
{
var componentMenu = cm.AddChildMenu("Select component");
for (int i = 0; i < components; i++)
{
var component = i;
componentMenu.ContextMenu.AddButton(GetComponentName(component), () => SelectKeyframeComponent(point.Index, component));
}
}
var totalSelectionCount = _editor.KeyframesEditorContext?.OnKeyframesSelectionCount() ?? selectionCount;
if (totalSelectionCount > 0)
{
@@ -556,7 +705,7 @@ namespace FlaxEditor.GUI
// Add keyframe on double click
var child = GetChildAt(location);
if (child is not KeyframePoint &&
if (GetKeyframePointAt(location, false) == null &&
child is not TangentPoint &&
_editor.KeyframesCount < _editor.MaxKeyframes)
{
@@ -588,7 +737,10 @@ namespace FlaxEditor.GUI
// Scale relative to the curve size
var scale = new Float2(delta * 0.1f);
_editor._mainPanel.GetDesireClientArea(out var mainPanelArea);
var curveScale = mainPanelArea.Size / _editor._contents.Size;
var contentsSize = _editor._contents.Size;
var curveScale = new Float2(
GetSafeZoomRatio(mainPanelArea.Width, contentsSize.X),
GetSafeZoomRatio(mainPanelArea.Height, contentsSize.Y));
scale *= curveScale;
if (zoomAlt)
scale.X = 0; // Scale Y axis only
+479 -23
View File
@@ -63,17 +63,29 @@ namespace FlaxEditor.GUI
private class Popup : ContextMenuBase
{
private const float HeaderHeight = 12.0f;
private const float MinContentHeight = 120.0f;
private CustomEditorPresenter _presenter;
private CurveEditor<T> _editor;
private List<int> _keyframeIndices;
private Panel _panel;
private bool _isDirty;
private bool _isDragging;
private Float2 _dragStartScreenPos;
private Float2 _dragStartWindowPos;
public Popup(CurveEditor<T> editor, object[] selection, List<int> keyframeIndices = null, float maxHeight = 140.0f)
: this(editor, maxHeight)
{
_presenter.Select(selection);
_presenter.OpenAllGroups();
Size = new Float2(Size.X, Mathf.Min(_presenter.ContainerControl.Size.Y, maxHeight));
var maxContentHeight = Mathf.Max(maxHeight - HeaderHeight, 1.0f);
var desiredHeight = _presenter.ContainerControl.Size.Y;
if (desiredHeight <= 1.0f)
desiredHeight = maxContentHeight;
Size = new Float2(Size.X, HeaderHeight + Mathf.Min(Mathf.Max(desiredHeight, MinContentHeight), maxContentHeight));
UpdateContentBounds();
_keyframeIndices = keyframeIndices;
if (keyframeIndices != null && selection.Length != keyframeIndices.Count)
throw new Exception();
@@ -84,18 +96,39 @@ namespace FlaxEditor.GUI
_editor = editor;
const float width = 340.0f;
Size = new Float2(width, height);
var panel1 = new Panel(ScrollBars.Vertical)
_panel = new Panel(ScrollBars.Vertical)
{
Bounds = new Rectangle(0, 0.0f, width, height),
Bounds = new Rectangle(0, HeaderHeight, width, Mathf.Max(height - HeaderHeight, 1.0f)),
Parent = this
};
_presenter = new CustomEditorPresenter(null);
_presenter.Panel.AnchorPreset = AnchorPresets.HorizontalStretchTop;
_presenter.Panel.IsScrollable = true;
_presenter.Panel.Parent = panel1;
_presenter.Panel.Parent = _panel;
_presenter.Modified += OnModified;
}
private void UpdateContentBounds()
{
if (_panel != null)
_panel.Bounds = new Rectangle(0, HeaderHeight, Width, Mathf.Max(Height - HeaderHeight, 1.0f));
}
private bool IsOverHeader(ref Float2 location)
{
return location.X >= 0.0f && location.X <= Width && location.Y >= 0.0f && location.Y <= HeaderHeight;
}
private void EndDragging()
{
if (_isDragging)
{
_isDragging = false;
Cursor = CursorType.Default;
EndMouseCapture();
}
}
private void OnModified()
{
if (!_isDirty)
@@ -108,7 +141,7 @@ namespace FlaxEditor.GUI
{
for (int i = 0; i < _presenter.SelectionCount; i++)
{
_editor.SetKeyframeInternal(_keyframeIndices[i], _presenter.Selection[i]);
_editor.SetKeyframeInternal(_keyframeIndices[i], _editor.GetKeyframeFromEditingProxy(_presenter.Selection[i]));
}
}
else if (_presenter.Selection[0] is IAllKeyframesProxy proxy)
@@ -129,12 +162,31 @@ namespace FlaxEditor.GUI
base.OnShow();
}
/// <inheritdoc />
public override void Draw()
{
base.Draw();
var style = Style.Current;
Render2D.FillRectangle(new Rectangle(0, 0, Width, HeaderHeight), style.BackgroundHighlighted);
Render2D.FillRectangle(new Rectangle(0, HeaderHeight - 1.0f, Width, 1.0f), style.Background);
}
/// <inheritdoc />
protected override void OnSizeChanged()
{
base.OnSizeChanged();
UpdateContentBounds();
}
/// <inheritdoc />
public override void Hide()
{
if (!Visible)
return;
EndDragging();
Focus(null);
if (_isDirty)
@@ -145,13 +197,87 @@ namespace FlaxEditor.GUI
if (_editor._popup == this)
_editor._popup = null;
_presenter.Modified -= OnModified;
_presenter = null;
_editor = null;
_keyframeIndices = null;
_panel = null;
base.Hide();
}
/// <inheritdoc />
public override bool OnMouseDown(Float2 location, MouseButton button)
{
if (button == MouseButton.Left && IsOverHeader(ref location))
{
_isDragging = true;
_dragStartScreenPos = FlaxEngine.Input.MouseScreenPosition;
_dragStartWindowPos = WindowLocation;
Cursor = CursorType.SizeAll;
StartMouseCapture();
Focus();
return true;
}
return base.OnMouseDown(location, button);
}
/// <inheritdoc />
public override void OnMouseMove(Float2 location)
{
if (_isDragging)
{
var screenPos = FlaxEngine.Input.MouseScreenPosition;
MoveWindowTo(_dragStartWindowPos + screenPos - _dragStartScreenPos);
Cursor = CursorType.SizeAll;
return;
}
Cursor = IsOverHeader(ref location) ? CursorType.SizeAll : CursorType.Default;
base.OnMouseMove(location);
}
/// <inheritdoc />
public override bool OnMouseUp(Float2 location, MouseButton button)
{
if (button == MouseButton.Left && _isDragging)
{
EndDragging();
return true;
}
return base.OnMouseUp(location, button);
}
/// <inheritdoc />
public override void OnMouseLeave()
{
if (!_isDragging)
Cursor = CursorType.Default;
base.OnMouseLeave();
}
/// <inheritdoc />
public override void OnLostFocus()
{
EndDragging();
Cursor = CursorType.Default;
base.OnLostFocus();
}
/// <inheritdoc />
public override void OnEndMouseCapture()
{
_isDragging = false;
Cursor = CursorType.Default;
base.OnEndMouseCapture();
}
/// <inheritdoc />
public override bool OnKeyDown(KeyboardKeys key)
{
@@ -208,7 +334,8 @@ namespace FlaxEditor.GUI
{
var style = Style.Current;
var rect = new Rectangle(Float2.Zero, Size);
var color = Editor.ShowCollapsed ? style.ForegroundDisabled : Editor.Colors[Component];
var axisColor = Editor.ShowCollapsed ? style.ForegroundDisabled : Editor.Colors[Component];
var color = axisColor;
if (IsSelected)
color = Editor.ContainsFocus ? style.SelectionBorder : Color.Lerp(style.ForegroundDisabled, style.SelectionBorder, 0.4f);
if (IsMouseOver)
@@ -288,22 +415,14 @@ namespace FlaxEditor.GUI
set => Editor.SetKeyframeTangentInternal(Index, IsIn, Component, value);
}
internal float TangentOffset => 50.0f / Editor.ViewScale.X;
private const float TangentVisualOffset = 50.0f;
internal float TangentOffset => TangentVisualOffset / Editor.ViewScale.X;
/// <inheritdoc />
public override void Draw()
{
var style = Style.Current;
var thickness = 6.0f / Mathf.Max(Editor.ViewScale.X, 1.0f);
var size = Size;
var pointPos = PointFromParent(Point.Center);
Render2D.DrawLine(size * 0.5f, pointPos, style.ForegroundDisabled, thickness);
var rect = new Rectangle(Float2.Zero, size);
var color = style.BorderSelected;
if (IsMouseOver)
color *= 1.1f;
Render2D.FillRectangle(rect, color);
// Drawn by the editor overlay to keep a constant screen-space size.
}
/// <summary>
@@ -436,12 +555,41 @@ namespace FlaxEditor.GUI
/// </summary>
protected readonly TangentPoint[] _tangents = new TangentPoint[2];
private static bool IsFinite(float value)
{
return !float.IsNaN(value) && !float.IsInfinity(value);
}
/// <summary>
/// Returns the input value if finite, otherwise returns a finite fallback or zero.
/// </summary>
protected static float SanitizeFinite(float value, float fallback)
{
if (IsFinite(value))
return value;
return IsFinite(fallback) ? fallback : 0.0f;
}
private static float SanitizeViewScale(float value, float fallback)
{
if (!IsFinite(value))
value = IsFinite(fallback) ? fallback : 1.0f;
return Mathf.Clamp(value, 0.0001f, 1000.0f);
}
private static float GetSafeZoomRatio(float viewSize, float contentsSize)
{
return IsFinite(viewSize) && IsFinite(contentsSize) && viewSize > Mathf.Epsilon && contentsSize > Mathf.Epsilon ? viewSize / contentsSize : 0.0f;
}
/// <inheritdoc />
public override Float2 ViewOffset
{
get => _mainPanel.ViewOffset;
set
{
value.X = SanitizeFinite(value.X, _mainPanel.ViewOffset.X);
value.Y = SanitizeFinite(value.Y, _mainPanel.ViewOffset.Y);
_mainPanel.ViewOffset = value;
_mainPanel.FastScroll();
}
@@ -451,7 +599,13 @@ namespace FlaxEditor.GUI
public override Float2 ViewScale
{
get => _contents.Scale;
set => _contents.Scale = Float2.Clamp(value, new Float2(0.0001f), new Float2(1000.0f));
set
{
var scale = _contents.Scale;
value.X = SanitizeViewScale(value.X, scale.X);
value.Y = SanitizeViewScale(value.Y, scale.Y);
_contents.Scale = value;
}
}
/// <summary>
@@ -705,6 +859,36 @@ namespace FlaxEditor.GUI
/// <returns>The proxy object.</returns>
protected abstract IAllKeyframesProxy GetAllKeyframesEditingProxy();
/// <summary>
/// Creates an editing proxy for a single keyframe.
/// </summary>
/// <param name="index">The keyframe index.</param>
/// <param name="keyframe">The keyframe.</param>
/// <returns>The keyframe editing proxy.</returns>
protected virtual object CreateKeyframeEditingProxy(int index, object keyframe)
{
return keyframe;
}
/// <summary>
/// Gets keyframe data from its editing proxy.
/// </summary>
/// <param name="proxy">The keyframe editing proxy.</param>
/// <returns>The keyframe data.</returns>
protected virtual object GetKeyframeFromEditingProxy(object proxy)
{
return proxy;
}
/// <summary>
/// Gets the maximum height for keyframe editing popup.
/// </summary>
/// <returns>The maximum popup height.</returns>
protected virtual float GetKeyframeEditingPopupHeight()
{
return 320.0f;
}
/// <summary>
/// Interface for keyframes editing proxy objects.
/// </summary>
@@ -740,8 +924,8 @@ namespace FlaxEditor.GUI
var selection = new object[keyframeIndices.Count];
var keyframes = GetKeyframes();
for (int i = 0; i < keyframeIndices.Count; i++)
selection[i] = keyframes[keyframeIndices[i]];
_popup = new Popup(this, selection, keyframeIndices);
selection[i] = CreateKeyframeEditingProxy(keyframeIndices[i], keyframes[keyframeIndices[i]]);
_popup = new Popup(this, selection, keyframeIndices, GetKeyframeEditingPopupHeight());
_popup.Show(control, pos);
}
@@ -1049,6 +1233,70 @@ namespace FlaxEditor.GUI
/// <param name="viewRect">The main panel client area used as a view bounds.</param>
protected abstract void DrawCurve(ref Rectangle viewRect);
private Float2 ContentsToEditor(Float2 location)
{
location = _contents.PointToParent(location);
return _mainPanel.PointToParent(location);
}
private Float2 GetControlCenterInEditor(Control control)
{
var center = control.PointToParent(control.Size * 0.5f);
return ContentsToEditor(center);
}
private void DrawTangentHandles()
{
var style = Style.Current;
for (int i = 0; i < _tangents.Length; i++)
{
var tangent = _tangents[i];
if (!tangent.Visible || tangent.Point == null || !tangent.Point.Visible)
continue;
var tangentCenter = GetControlCenterInEditor(tangent);
var keyframeCenter = GetControlCenterInEditor(tangent.Point);
Render2D.DrawLine(tangentCenter, keyframeCenter, style.ForegroundDisabled, 2.0f);
var rect = new Rectangle(tangentCenter - KeyframesSize * 0.5f, KeyframesSize);
var color = style.BorderSelected;
if (tangent.IsMouseOver)
color *= 1.1f;
Render2D.FillRectangle(rect, color);
}
}
private void DrawSelectedKeyframeLabels()
{
if (ShowCollapsed)
return;
var style = Style.Current;
for (int i = 0; i < _points.Count; i++)
{
var point = _points[i];
if (!point.Visible || !point.IsSelected)
continue;
var center = GetControlCenterInEditor(point);
var label = GetComponentLabel(point.Component);
var labelRect = new Rectangle(center.X + 12.0f, center.Y - 22.0f, 34.0f, 28.0f);
Render2D.DrawText(style.FontMedium, label, labelRect, Colors[point.Component], TextAlignment.Near, TextAlignment.Center, TextWrapping.NoWrap, 1.0f, 1.25f);
}
}
private static string GetComponentLabel(int component)
{
switch (component)
{
case 0: return "x";
case 1: return "y";
case 2: return "z";
case 3: return "w";
default: return (component + 1).ToString();
}
}
/// <inheritdoc />
public override void Draw()
{
@@ -1097,14 +1345,18 @@ namespace FlaxEditor.GUI
{
var selectionRect = Rectangle.FromPoints
(
_mainPanel.PointToParent(_contents.PointToParent(_contents._leftMouseDownPos)),
_mainPanel.PointToParent(_contents.PointToParent(_contents._mousePos))
ContentsToEditor(_contents._leftMouseDownPos),
ContentsToEditor(_contents._mousePos)
);
Render2D.FillRectangle(selectionRect, style.Selection);
Render2D.DrawRectangle(selectionRect, style.SelectionBorder);
}
base.Draw();
Render2D.PushClip(ref viewRect);
DrawTangentHandles();
DrawSelectedKeyframeLabels();
Render2D.PopClip();
// Draw border
if (ContainsFocus)
@@ -1465,6 +1717,85 @@ namespace FlaxEditor.GUI
}
}
sealed class KeyframeProxy
{
[HideInEditor, NoSerialize]
public LinearCurveEditor<T> Editor;
[HideInEditor, NoSerialize]
public int Index;
private float _time;
private readonly float _originalTime;
[EditorDisplay("Time"), EditorOrder(0), VisibleIf(nameof(HasFPS))]
[Tooltip("The keyframe frame number.")]
public int Frame
{
get => HasFPS ? Mathf.FloorToInt(SanitizeFinite(_time, _originalTime) * Editor.FPS.Value) : 0;
set
{
if (Editor?.FPS.HasValue == true && Editor.FPS.Value > Mathf.Epsilon)
_time = value / Editor.FPS.Value;
}
}
[EditorDisplay("Time"), EditorOrder(1), Limit(float.MinValue, float.MaxValue, 0.01f)]
[Tooltip("The time of the keyframe.")]
public float Time
{
get => _time;
set => _time = SanitizeFinite(value, _time);
}
[EditorDisplay("Value"), EditorOrder(10), Limit(float.MinValue, float.MaxValue, 0.01f)]
[Tooltip("The value of the curve at keyframe.")]
public T Value;
private bool HasFPS => Editor?.FPS.HasValue == true && Editor.FPS.Value > Mathf.Epsilon;
public KeyframeProxy(LinearCurveEditor<T> editor, int index, LinearCurve<T>.Keyframe keyframe)
{
Editor = editor;
Index = index;
_time = _originalTime = keyframe.Time;
Value = keyframe.Value;
}
private float GetValidTime()
{
if (Editor == null)
return SanitizeFinite(_time, _originalTime);
if (Editor.FPS.HasValue)
{
var fps = Editor.FPS.Value;
if (fps <= Mathf.Epsilon)
return SanitizeFinite(_time, _originalTime);
var frame = Mathf.FloorToInt(SanitizeFinite(_time, _originalTime) * fps);
var minFrame = int.MinValue;
var maxFrame = int.MaxValue;
if (Index > 0)
minFrame = Mathf.FloorToInt(Editor._keyframes[Index - 1].Time * fps) + 1;
if (Index < Editor._keyframes.Count - 1)
maxFrame = Mathf.FloorToInt(Editor._keyframes[Index + 1].Time * fps) - 1;
if (minFrame > maxFrame)
return _originalTime;
frame = Mathf.Clamp(frame, minFrame, maxFrame);
return frame / fps;
}
var minTime = Index > 0 ? Editor._keyframes[Index - 1].Time + Mathf.Epsilon : float.MinValue;
var maxTime = Index < Editor._keyframes.Count - 1 ? Editor._keyframes[Index + 1].Time - Mathf.Epsilon : float.MaxValue;
if (minTime > maxTime)
return _originalTime;
return Mathf.Clamp(SanitizeFinite(_time, _originalTime), minTime, maxTime);
}
public LinearCurve<T>.Keyframe ToKeyframe()
{
return new LinearCurve<T>.Keyframe(GetValidTime(), Value);
}
}
sealed class AllKeyframesProxy : IAllKeyframesProxy
{
[HideInEditor, NoSerialize]
@@ -1489,6 +1820,24 @@ namespace FlaxEditor.GUI
};
}
/// <inheritdoc />
protected override object CreateKeyframeEditingProxy(int index, object keyframe)
{
return new KeyframeProxy(this, index, (LinearCurve<T>.Keyframe)keyframe);
}
/// <inheritdoc />
protected override object GetKeyframeFromEditingProxy(object proxy)
{
return proxy is KeyframeProxy keyframeProxy ? keyframeProxy.ToKeyframe() : proxy;
}
/// <inheritdoc />
protected override float GetKeyframeEditingPopupHeight()
{
return 240.0f;
}
/// <inheritdoc />
public override object[] GetKeyframes()
{
@@ -2213,6 +2562,95 @@ namespace FlaxEditor.GUI
}
}
sealed class KeyframeProxy
{
[HideInEditor, NoSerialize]
public BezierCurveEditor<T> Editor;
[HideInEditor, NoSerialize]
public int Index;
private float _time;
private readonly float _originalTime;
[EditorDisplay("Time"), EditorOrder(0), VisibleIf(nameof(HasFPS))]
[Tooltip("The keyframe frame number.")]
public int Frame
{
get => HasFPS ? Mathf.FloorToInt(SanitizeFinite(_time, _originalTime) * Editor.FPS.Value) : 0;
set
{
if (Editor?.FPS.HasValue == true && Editor.FPS.Value > Mathf.Epsilon)
_time = value / Editor.FPS.Value;
}
}
[EditorDisplay("Time"), EditorOrder(1), Limit(float.MinValue, float.MaxValue, 0.01f)]
[Tooltip("The time of the keyframe.")]
public float Time
{
get => _time;
set => _time = SanitizeFinite(value, _time);
}
[EditorDisplay("Value"), EditorOrder(10), Limit(float.MinValue, float.MaxValue, 0.01f)]
[Tooltip("The value of the curve at keyframe.")]
public T Value;
[EditorDisplay("Tangents", "Tangent In"), EditorOrder(20), Limit(float.MinValue, float.MaxValue, 0.01f)]
[Tooltip("The input tangent (going from the previous key to this one) of the key.")]
public T TangentIn;
[EditorDisplay("Tangents", "Tangent Out"), EditorOrder(21), Limit(float.MinValue, float.MaxValue, 0.01f)]
[Tooltip("The output tangent (going from this key to next one) of the key.")]
public T TangentOut;
private bool HasFPS => Editor?.FPS.HasValue == true && Editor.FPS.Value > Mathf.Epsilon;
public KeyframeProxy(BezierCurveEditor<T> editor, int index, BezierCurve<T>.Keyframe keyframe)
{
Editor = editor;
Index = index;
_time = _originalTime = keyframe.Time;
Value = keyframe.Value;
TangentIn = keyframe.TangentIn;
TangentOut = keyframe.TangentOut;
}
private float GetValidTime()
{
if (Editor == null)
return SanitizeFinite(_time, _originalTime);
if (Editor.FPS.HasValue)
{
var fps = Editor.FPS.Value;
if (fps <= Mathf.Epsilon)
return SanitizeFinite(_time, _originalTime);
var frame = Mathf.FloorToInt(SanitizeFinite(_time, _originalTime) * fps);
var minFrame = int.MinValue;
var maxFrame = int.MaxValue;
if (Index > 0)
minFrame = Mathf.FloorToInt(Editor._keyframes[Index - 1].Time * fps) + 1;
if (Index < Editor._keyframes.Count - 1)
maxFrame = Mathf.FloorToInt(Editor._keyframes[Index + 1].Time * fps) - 1;
if (minFrame > maxFrame)
return _originalTime;
frame = Mathf.Clamp(frame, minFrame, maxFrame);
return frame / fps;
}
var minTime = Index > 0 ? Editor._keyframes[Index - 1].Time + Mathf.Epsilon : float.MinValue;
var maxTime = Index < Editor._keyframes.Count - 1 ? Editor._keyframes[Index + 1].Time - Mathf.Epsilon : float.MaxValue;
if (minTime > maxTime)
return _originalTime;
return Mathf.Clamp(SanitizeFinite(_time, _originalTime), minTime, maxTime);
}
public BezierCurve<T>.Keyframe ToKeyframe()
{
return new BezierCurve<T>.Keyframe(GetValidTime(), Value, TangentIn, TangentOut);
}
}
sealed class AllKeyframesProxy : IAllKeyframesProxy
{
[HideInEditor, NoSerialize]
@@ -2237,6 +2675,24 @@ namespace FlaxEditor.GUI
};
}
/// <inheritdoc />
protected override object CreateKeyframeEditingProxy(int index, object keyframe)
{
return new KeyframeProxy(this, index, (BezierCurve<T>.Keyframe)keyframe);
}
/// <inheritdoc />
protected override object GetKeyframeFromEditingProxy(object proxy)
{
return proxy is KeyframeProxy keyframeProxy ? keyframeProxy.ToKeyframe() : proxy;
}
/// <inheritdoc />
protected override float GetKeyframeEditingPopupHeight()
{
return 320.0f;
}
/// <inheritdoc />
public override object[] GetKeyframes()
{
+7 -1
View File
@@ -36,6 +36,11 @@ namespace FlaxEditor.GUI.Timeline.GUI
_timeline.OnKeyframesSelection(null, this, selectionRect);
}
private bool HasSelectionModifier()
{
return Root.GetKey(KeyboardKeys.Shift) || Root.GetKey(KeyboardKeys.Alt);
}
/// <inheritdoc />
public override bool OnMouseDown(Float2 location, MouseButton button)
{
@@ -48,7 +53,8 @@ namespace FlaxEditor.GUI.Timeline.GUI
// Start selecting
_isSelecting = true;
_selectingStartPos = location;
_timeline.OnKeyframesDeselect(null);
if (!HasSelectionModifier())
_timeline.OnKeyframesDeselect(null);
Focus();
StartMouseCapture();
return true;
@@ -77,6 +77,13 @@ namespace FlaxEditor.GUI
/// <seealso cref="FlaxEngine.GUI.ContainerControl" />
private class Contents : ContainerControl
{
private enum SelectionMode
{
Replace,
Add,
Remove,
}
private readonly KeyframesEditor _editor;
internal bool _leftMouseDown;
private bool _rightMouseDown;
@@ -108,14 +115,39 @@ namespace FlaxEditor.GUI
UpdateSelection(ref selectionRect);
}
private SelectionMode GetSelectionMode()
{
if (Root.GetKey(KeyboardKeys.Alt))
return SelectionMode.Remove;
if (Root.GetKey(KeyboardKeys.Shift))
return SelectionMode.Add;
return SelectionMode.Replace;
}
internal void UpdateSelection(ref Rectangle selectionRect)
{
var mode = GetSelectionMode();
// Find controls to select
for (int i = 0; i < Children.Count; i++)
{
if (Children[i] is KeyframePoint p)
{
p.IsSelected = p.Bounds.Intersects(ref selectionRect);
var intersects = p.Bounds.Intersects(ref selectionRect);
switch (mode)
{
case SelectionMode.Replace:
p.IsSelected = intersects;
break;
case SelectionMode.Add:
if (intersects)
p.IsSelected = true;
break;
case SelectionMode.Remove:
if (intersects)
p.IsSelected = false;
break;
}
}
}
}
@@ -138,6 +170,7 @@ namespace FlaxEditor.GUI
{
var viewRect = _editor._mainPanel.GetClientArea();
var locationKeyframes = PointToKeyframes(location, ref viewRect);
var moved = false;
for (var i = 0; i < _editor._points.Count; i++)
{
var p = _editor._points[i];
@@ -160,7 +193,12 @@ namespace FlaxEditor.GUI
// TODO: snapping keyframes to grid when moving
_editor._keyframes[p.Index] = k;
moved = true;
}
}
if (moved)
{
_editor.UpdateKeyframes();
if (_editor.EnablePanning)
{
@@ -351,10 +389,13 @@ namespace FlaxEditor.GUI
{
// Start selecting
StartMouseCapture();
if (_editor.KeyframesEditorContext != null)
_editor.KeyframesEditorContext.OnKeyframesDeselect(_editor);
else
_editor.ClearSelection();
if (GetSelectionMode() == SelectionMode.Replace)
{
if (_editor.KeyframesEditorContext != null)
_editor.KeyframesEditorContext.OnKeyframesDeselect(_editor);
else
_editor.ClearSelection();
}
Focus();
return true;
}
@@ -180,7 +180,7 @@ namespace FlaxEditor.GUI.Timeline.Tracks
private void OnSplitterMoved(Float2 location)
{
var height = Mathf.Clamp(PointToParent(location).Y, 40.0f, 1000.0f);
var height = Mathf.Clamp(_splitter.PointToParent(location).Y + Height - Curve.Height, 40.0f, 1000.0f);
if (!Mathf.NearEqual(height, _expandedHeight))
{
Height = _expandedHeight = height;
@@ -755,6 +755,7 @@ namespace FlaxEditor.Windows.Assets
}
_previewPlayerPicker.Parent.Visible = !_previewButton.Checked;
_timeline.CanPlayPause = _previewButton.Checked || Editor.IsPlayMode;
_timeline.CanPlayStop = _previewButton.Checked || Editor.IsPlayMode;
}
private void OnRenderButtonClicked()
@@ -927,7 +928,7 @@ namespace FlaxEditor.Windows.Assets
UpdateToolstrip();
_timeline.CanPlayPause = _previewButton.Checked;
_timeline.CanPlayStop = false;
_timeline.CanPlayStop = _previewButton.Checked;
}
/// <inheritdoc />
@@ -982,7 +983,7 @@ namespace FlaxEditor.Windows.Assets
// Preview is playing
_previewPlayer.Tick(Time.UnscaledDeltaTime);
}
else if (Mathf.NearEqual(_previewPlayer.Time, _timeline.CurrentFrame))
else if (Mathf.NearEqual(_previewPlayer.Time, _timeline.CurrentTime))
{
// Preview is paused
_previewPlayer.Time = _timeline.CurrentTime;
+2
View File
@@ -463,6 +463,8 @@ namespace FlaxEngine.GUI
// Click change
Value = _value + (mousePosition < _thumbCenter ? -1 : 1) * _clickChange;
}
return true;
}
return base.OnMouseDown(location, button);