Merge branch 'ifromstone-dev/AnimationTimelinesWindowImprovements'

This commit is contained in:
2026-09-20 22:57:09 +02:00
12 changed files with 798 additions and 98 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;
@@ -405,7 +425,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;
}
}
+22
View File
@@ -179,6 +179,28 @@ namespace FlaxEditor.GUI
UpdateKeyframes();
}
internal static string GetComponentLabel(int component, Type valueType)
{
if (valueType == typeof(Color) || valueType == typeof(Color32))
{
switch (component)
{
case 0: return "r";
case 1: return "g";
case 2: return "b";
case 3: return "a";
}
}
switch (component)
{
case 0: return "x";
case 1: return "y";
case 2: return "z";
case 3: return "w";
default: return (component + 1).ToString();
}
}
/// <summary>
/// Evaluates the animation curve value at the specified time.
/// </summary>
+190 -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,77 @@ 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 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 +136,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 +190,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 +258,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 +288,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 +321,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 +385,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 +393,7 @@ namespace FlaxEditor.GUI
return;
}
// Selecting
else if (_leftMouseDown)
else if (_leftMouseDown && !leftMouseDownOverKeyframe)
{
UpdateSelectionRectangle();
return;
@@ -321,12 +449,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 +484,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 +526,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 +580,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 +610,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 +627,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(GetComponentLabel(component, typeof(T)), () => SelectKeyframeComponent(point.Index, component));
}
}
var totalSelectionCount = _editor.KeyframesEditorContext?.OnKeyframesSelectionCount() ?? selectionCount;
if (totalSelectionCount > 0)
{
@@ -556,7 +693,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 +725,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
+467 -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,58 @@ 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, ValueType);
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);
}
}
/// <inheritdoc />
public override void Draw()
{
@@ -1097,14 +1333,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 +1705,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 +1808,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 +2550,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 +2663,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;
}
@@ -3,7 +3,6 @@
using System;
using System.IO;
using System.Linq;
using FlaxEditor.CustomEditors;
using FlaxEditor.GUI.ContextMenu;
using FlaxEditor.GUI.Timeline.Undo;
using FlaxEditor.SceneGraph;
@@ -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;
@@ -100,6 +100,7 @@ namespace FlaxEditor.GUI.Timeline.Tracks
/// <param name="obj">The object.</param>
protected virtual void OnObjectExistenceChanged(object obj)
{
TooltipText = obj != null ? Editor.Instance.CodeDocs.GetTooltip(obj.GetType()) : "null";
}
/// <summary>
@@ -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;
+14 -14
View File
@@ -788,12 +788,12 @@ public:
/// <summary>
/// Draws debug shapes for the actor and all child scripts.
/// </summary>
API_FUNCTION() virtual void OnDebugDraw();
API_FUNCTION(Attributes="NoAnimate") virtual void OnDebugDraw();
/// <summary>
/// Draws debug shapes for the selected actor and all child scripts.
/// </summary>
API_FUNCTION() virtual void OnDebugDrawSelected();
API_FUNCTION(Attributes="NoAnimate") virtual void OnDebugDrawSelected();
#endif
public:
@@ -1056,64 +1056,64 @@ public:
/// Serializes the actor object to the Json string. Serialized are only this actor properties but no child actors nor scripts. Serializes references to the other objects in a proper way using IDs.
/// </summary>
/// <returns>The Json container with serialized actor data.</returns>
API_FUNCTION() String ToJson();
API_FUNCTION(Attributes="NoAnimate") String ToJson();
/// <summary>
/// Deserializes the actor object to the Json string. Deserialized are only this actor properties but no child actors nor scripts.
/// </summary>
/// <param name="json">The serialized actor data (state).</param>
API_FUNCTION() void FromJson(const StringAnsiView& json);
API_FUNCTION(Attributes="NoAnimate") void FromJson(const StringAnsiView& json);
/// <summary>
/// Clones actor including all scripts and any child actors (whole scene tree). Objects are duplicated via serialization (any transient/non-saved state is ignored).
/// </summary>
API_FUNCTION() Actor* Clone();
API_FUNCTION(Attributes="NoAnimate") Actor* Clone();
public:
/// <summary>
/// Called when actor gets added to game systems. Occurs on BeginPlay event or when actor gets activated in hierarchy. Use this event to register object to other game system (eg. audio).
/// </summary>
API_FUNCTION() virtual void OnEnable();
API_FUNCTION(Attributes="NoAnimate") virtual void OnEnable();
/// <summary>
/// Called when actor gets removed from game systems. Occurs on EndPlay event or when actor gets inactivated in hierarchy. Use this event to unregister object from other game system (eg. audio).
/// </summary>
API_FUNCTION() virtual void OnDisable();
API_FUNCTION(Attributes="NoAnimate") virtual void OnDisable();
/// <summary>
/// Called when actor parent gets changed.
/// </summary>
API_FUNCTION() virtual void OnParentChanged();
API_FUNCTION(Attributes="NoAnimate") virtual void OnParentChanged();
/// <summary>
/// Called when actor transform gets changed.
/// </summary>
API_FUNCTION() virtual void OnTransformChanged();
API_FUNCTION(Attributes="NoAnimate") virtual void OnTransformChanged();
/// <summary>
/// Called when actor active state gets changed.
/// </summary>
API_FUNCTION() virtual void OnActiveChanged();
API_FUNCTION(Attributes="NoAnimate") virtual void OnActiveChanged();
/// <summary>
/// Called when actor active in tree state gets changed.
/// </summary>
API_FUNCTION() virtual void OnActiveInTreeChanged();
API_FUNCTION(Attributes="NoAnimate") virtual void OnActiveInTreeChanged();
/// <summary>
/// Called when order in parent children array gets changed.
/// </summary>
API_FUNCTION() virtual void OnOrderInParentChanged();
API_FUNCTION(Attributes="NoAnimate") virtual void OnOrderInParentChanged();
/// <summary>
/// Called when actor static flag gets changed.
/// </summary>
API_FUNCTION() virtual void OnStaticFlagsChanged();
API_FUNCTION(Attributes="NoAnimate") virtual void OnStaticFlagsChanged();
/// <summary>
/// Called when layer gets changed.
/// </summary>
API_FUNCTION() virtual void OnLayerChanged();
API_FUNCTION(Attributes="NoAnimate") virtual void OnLayerChanged();
/// <summary>
/// Called when adding object to the game.
+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);