diff --git a/Source/Editor/GUI/ContextMenu/ContextMenuBase.cs b/Source/Editor/GUI/ContextMenu/ContextMenuBase.cs index aa1952525..16fe417b8 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; @@ -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; + } + } + + /// + /// 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);