diff --git a/Source/Editor/GUI/ContextMenu/ContextMenuBase.cs b/Source/Editor/GUI/ContextMenu/ContextMenuBase.cs
index 3947430f9..8c89a38d0 100644
--- a/Source/Editor/GUI/ContextMenu/ContextMenuBase.cs
+++ b/Source/Editor/GUI/ContextMenu/ContextMenuBase.cs
@@ -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;
+ }
+
///
/// Gets a value indicating whether use automatic popup direction fix based on the screen dimensions.
///
@@ -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;
+ }
+ }
+
+ ///
+ /// Gets the popup window location in screen coordinates.
+ ///
+ protected Float2 WindowLocation => _window != null ? _window.ClientBounds.Location : Float2.Zero;
+
+ ///
+ /// Moves the popup window to the given screen-space location.
+ ///
+ /// The popup window location in screen coordinates.
+ protected void MoveWindowTo(Float2 screenLocation)
+ {
+ if (_window != null)
+ {
+ var bounds = _window.ClientBounds;
+ if (!IsValidWindowBounds(screenLocation, bounds.Size))
+ return;
+ bounds.Location = screenLocation;
+ _window.ClientBounds = bounds;
}
}
diff --git a/Source/Editor/GUI/CurveEditor.Contents.cs b/Source/Editor/GUI/CurveEditor.Contents.cs
index 75f37d457..80db7fb27 100644
--- a/Source/Editor/GUI/CurveEditor.Contents.cs
+++ b/Source/Editor/GUI/CurveEditor.Contents.cs
@@ -19,6 +19,16 @@ namespace FlaxEditor.GUI
///
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 _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;
}
///
@@ -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
diff --git a/Source/Editor/GUI/CurveEditor.cs b/Source/Editor/GUI/CurveEditor.cs
index 4fb727ea1..7d7bb93a2 100644
--- a/Source/Editor/GUI/CurveEditor.cs
+++ b/Source/Editor/GUI/CurveEditor.cs
@@ -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 _editor;
private List _keyframeIndices;
+ private Panel _panel;
private bool _isDirty;
+ private bool _isDragging;
+ private Float2 _dragStartScreenPos;
+ private Float2 _dragStartWindowPos;
public Popup(CurveEditor editor, object[] selection, List 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();
}
+ ///
+ 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);
+ }
+
+ ///
+ protected override void OnSizeChanged()
+ {
+ base.OnSizeChanged();
+
+ UpdateContentBounds();
+ }
+
///
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();
}
+ ///
+ 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);
+ }
+
+ ///
+ 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);
+ }
+
+ ///
+ public override bool OnMouseUp(Float2 location, MouseButton button)
+ {
+ if (button == MouseButton.Left && _isDragging)
+ {
+ EndDragging();
+ return true;
+ }
+
+ return base.OnMouseUp(location, button);
+ }
+
+ ///
+ public override void OnMouseLeave()
+ {
+ if (!_isDragging)
+ Cursor = CursorType.Default;
+
+ base.OnMouseLeave();
+ }
+
+ ///
+ public override void OnLostFocus()
+ {
+ EndDragging();
+ Cursor = CursorType.Default;
+
+ base.OnLostFocus();
+ }
+
+ ///
+ public override void OnEndMouseCapture()
+ {
+ _isDragging = false;
+ Cursor = CursorType.Default;
+
+ base.OnEndMouseCapture();
+ }
+
///
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;
///
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.
}
///
@@ -436,12 +555,41 @@ namespace FlaxEditor.GUI
///
protected readonly TangentPoint[] _tangents = new TangentPoint[2];
+ private static bool IsFinite(float value)
+ {
+ return !float.IsNaN(value) && !float.IsInfinity(value);
+ }
+
+ ///
+ /// Returns the input value if finite, otherwise returns a finite fallback or zero.
+ ///
+ 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;
+ }
+
///
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;
+ }
}
///
@@ -705,6 +859,36 @@ namespace FlaxEditor.GUI
/// The proxy object.
protected abstract IAllKeyframesProxy GetAllKeyframesEditingProxy();
+ ///
+ /// Creates an editing proxy for a single keyframe.
+ ///
+ /// The keyframe index.
+ /// The keyframe.
+ /// The keyframe editing proxy.
+ protected virtual object CreateKeyframeEditingProxy(int index, object keyframe)
+ {
+ return keyframe;
+ }
+
+ ///
+ /// Gets keyframe data from its editing proxy.
+ ///
+ /// The keyframe editing proxy.
+ /// The keyframe data.
+ protected virtual object GetKeyframeFromEditingProxy(object proxy)
+ {
+ return proxy;
+ }
+
+ ///
+ /// Gets the maximum height for keyframe editing popup.
+ ///
+ /// The maximum popup height.
+ protected virtual float GetKeyframeEditingPopupHeight()
+ {
+ return 320.0f;
+ }
+
///
/// Interface for keyframes editing proxy objects.
///
@@ -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
/// The main panel client area used as a view bounds.
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();
+ }
+ }
+
///
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 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 editor, int index, LinearCurve.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.Keyframe ToKeyframe()
+ {
+ return new LinearCurve.Keyframe(GetValidTime(), Value);
+ }
+ }
+
sealed class AllKeyframesProxy : IAllKeyframesProxy
{
[HideInEditor, NoSerialize]
@@ -1489,6 +1820,24 @@ namespace FlaxEditor.GUI
};
}
+ ///
+ protected override object CreateKeyframeEditingProxy(int index, object keyframe)
+ {
+ return new KeyframeProxy(this, index, (LinearCurve.Keyframe)keyframe);
+ }
+
+ ///
+ protected override object GetKeyframeFromEditingProxy(object proxy)
+ {
+ return proxy is KeyframeProxy keyframeProxy ? keyframeProxy.ToKeyframe() : proxy;
+ }
+
+ ///
+ protected override float GetKeyframeEditingPopupHeight()
+ {
+ return 240.0f;
+ }
+
///
public override object[] GetKeyframes()
{
@@ -2213,6 +2562,95 @@ namespace FlaxEditor.GUI
}
}
+ sealed class KeyframeProxy
+ {
+ [HideInEditor, NoSerialize]
+ public BezierCurveEditor 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 editor, int index, BezierCurve.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.Keyframe ToKeyframe()
+ {
+ return new BezierCurve.Keyframe(GetValidTime(), Value, TangentIn, TangentOut);
+ }
+ }
+
sealed class AllKeyframesProxy : IAllKeyframesProxy
{
[HideInEditor, NoSerialize]
@@ -2237,6 +2675,24 @@ namespace FlaxEditor.GUI
};
}
+ ///
+ protected override object CreateKeyframeEditingProxy(int index, object keyframe)
+ {
+ return new KeyframeProxy(this, index, (BezierCurve.Keyframe)keyframe);
+ }
+
+ ///
+ protected override object GetKeyframeFromEditingProxy(object proxy)
+ {
+ return proxy is KeyframeProxy keyframeProxy ? keyframeProxy.ToKeyframe() : proxy;
+ }
+
+ ///
+ protected override float GetKeyframeEditingPopupHeight()
+ {
+ return 320.0f;
+ }
+
///
public override object[] GetKeyframes()
{
diff --git a/Source/Editor/GUI/Timeline/GUI/Background.cs b/Source/Editor/GUI/Timeline/GUI/Background.cs
index ed9b8150b..d15e307af 100644
--- a/Source/Editor/GUI/Timeline/GUI/Background.cs
+++ b/Source/Editor/GUI/Timeline/GUI/Background.cs
@@ -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);
+ }
+
///
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;
diff --git a/Source/Editor/GUI/Timeline/GUI/KeyframesEditor.cs b/Source/Editor/GUI/Timeline/GUI/KeyframesEditor.cs
index d6fa0e076..7b2500799 100644
--- a/Source/Editor/GUI/Timeline/GUI/KeyframesEditor.cs
+++ b/Source/Editor/GUI/Timeline/GUI/KeyframesEditor.cs
@@ -77,6 +77,13 @@ namespace FlaxEditor.GUI
///
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;
}
diff --git a/Source/Editor/GUI/Timeline/Tracks/CurvePropertyTrack.cs b/Source/Editor/GUI/Timeline/Tracks/CurvePropertyTrack.cs
index e6246f4c5..7e974b54f 100644
--- a/Source/Editor/GUI/Timeline/Tracks/CurvePropertyTrack.cs
+++ b/Source/Editor/GUI/Timeline/Tracks/CurvePropertyTrack.cs
@@ -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;
diff --git a/Source/Editor/Windows/Assets/SceneAnimationWindow.cs b/Source/Editor/Windows/Assets/SceneAnimationWindow.cs
index de384c60a..4dfa287a9 100644
--- a/Source/Editor/Windows/Assets/SceneAnimationWindow.cs
+++ b/Source/Editor/Windows/Assets/SceneAnimationWindow.cs
@@ -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;
}
///
@@ -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;
diff --git a/Source/Engine/UI/GUI/Panels/ScrollBar.cs b/Source/Engine/UI/GUI/Panels/ScrollBar.cs
index 756c698c5..0cf3bda95 100644
--- a/Source/Engine/UI/GUI/Panels/ScrollBar.cs
+++ b/Source/Engine/UI/GUI/Panels/ScrollBar.cs
@@ -463,6 +463,8 @@ namespace FlaxEngine.GUI
// Click change
Value = _value + (mousePosition < _thumbCenter ? -1 : 1) * _clickChange;
}
+
+ return true;
}
return base.OnMouseDown(location, button);