From 752dc644e5a8fadb401fe25db49cace0c26b3707 Mon Sep 17 00:00:00 2001 From: Andrei Gagua Date: Fri, 12 Jun 2026 16:26:35 +0300 Subject: [PATCH 1/4] Improve Animation Scene curve keyframe editing Improves Animation Scene timeline curve editing, including vector keyframe selection/editing, tangent interaction, draggable edit popup, and several related editor UI fixes. --- .../Editor/GUI/ContextMenu/ContextMenuBase.cs | 46 +- Source/Editor/GUI/CurveEditor.Contents.cs | 252 +++++++-- Source/Editor/GUI/CurveEditor.cs | 502 +++++++++++++++++- Source/Editor/GUI/Timeline/GUI/Background.cs | 8 +- .../GUI/Timeline/GUI/KeyframesEditor.cs | 51 +- .../GUI/Timeline/Tracks/CurvePropertyTrack.cs | 2 +- .../Windows/Assets/SceneAnimationWindow.cs | 5 +- Source/Engine/UI/GUI/Panels/ScrollBar.cs | 2 + 8 files changed, 785 insertions(+), 83 deletions(-) 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); From 223d16b6849e8ee151be86586fbcc4add394d280 Mon Sep 17 00:00:00 2001 From: Wojtek Figat Date: Sun, 20 Sep 2026 22:34:49 +0200 Subject: [PATCH 2/4] Hide various Actor functions from scene animations --- Source/Engine/Level/Actor.h | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/Source/Engine/Level/Actor.h b/Source/Engine/Level/Actor.h index 4271393ce..f7eb7d36e 100644 --- a/Source/Engine/Level/Actor.h +++ b/Source/Engine/Level/Actor.h @@ -788,12 +788,12 @@ public: /// /// Draws debug shapes for the actor and all child scripts. /// - API_FUNCTION() virtual void OnDebugDraw(); + API_FUNCTION(Attributes="NoAnimate") virtual void OnDebugDraw(); /// /// Draws debug shapes for the selected actor and all child scripts. /// - 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. /// /// The Json container with serialized actor data. - API_FUNCTION() String ToJson(); + API_FUNCTION(Attributes="NoAnimate") String ToJson(); /// /// Deserializes the actor object to the Json string. Deserialized are only this actor properties but no child actors nor scripts. /// /// The serialized actor data (state). - API_FUNCTION() void FromJson(const StringAnsiView& json); + API_FUNCTION(Attributes="NoAnimate") void FromJson(const StringAnsiView& json); /// /// Clones actor including all scripts and any child actors (whole scene tree). Objects are duplicated via serialization (any transient/non-saved state is ignored). /// - API_FUNCTION() Actor* Clone(); + API_FUNCTION(Attributes="NoAnimate") Actor* Clone(); public: /// /// 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). /// - API_FUNCTION() virtual void OnEnable(); + API_FUNCTION(Attributes="NoAnimate") virtual void OnEnable(); /// /// 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). /// - API_FUNCTION() virtual void OnDisable(); + API_FUNCTION(Attributes="NoAnimate") virtual void OnDisable(); /// /// Called when actor parent gets changed. /// - API_FUNCTION() virtual void OnParentChanged(); + API_FUNCTION(Attributes="NoAnimate") virtual void OnParentChanged(); /// /// Called when actor transform gets changed. /// - API_FUNCTION() virtual void OnTransformChanged(); + API_FUNCTION(Attributes="NoAnimate") virtual void OnTransformChanged(); /// /// Called when actor active state gets changed. /// - API_FUNCTION() virtual void OnActiveChanged(); + API_FUNCTION(Attributes="NoAnimate") virtual void OnActiveChanged(); /// /// Called when actor active in tree state gets changed. /// - API_FUNCTION() virtual void OnActiveInTreeChanged(); + API_FUNCTION(Attributes="NoAnimate") virtual void OnActiveInTreeChanged(); /// /// Called when order in parent children array gets changed. /// - API_FUNCTION() virtual void OnOrderInParentChanged(); + API_FUNCTION(Attributes="NoAnimate") virtual void OnOrderInParentChanged(); /// /// Called when actor static flag gets changed. /// - API_FUNCTION() virtual void OnStaticFlagsChanged(); + API_FUNCTION(Attributes="NoAnimate") virtual void OnStaticFlagsChanged(); /// /// Called when layer gets changed. /// - API_FUNCTION() virtual void OnLayerChanged(); + API_FUNCTION(Attributes="NoAnimate") virtual void OnLayerChanged(); /// /// Called when adding object to the game. From 7d1d222d636cd72a27cf9a8544737187cccbd3b4 Mon Sep 17 00:00:00 2001 From: Wojtek Figat Date: Sun, 20 Sep 2026 22:50:34 +0200 Subject: [PATCH 3/4] Improve component labels for colors #4153 --- Source/Editor/GUI/CurveEditor.Base.cs | 22 ++++++++++++++++++++++ Source/Editor/GUI/CurveEditor.Contents.cs | 14 +------------- Source/Editor/GUI/CurveEditor.cs | 14 +------------- 3 files changed, 24 insertions(+), 26 deletions(-) diff --git a/Source/Editor/GUI/CurveEditor.Base.cs b/Source/Editor/GUI/CurveEditor.Base.cs index fc9abc08e..04b4b2f72 100644 --- a/Source/Editor/GUI/CurveEditor.Base.cs +++ b/Source/Editor/GUI/CurveEditor.Base.cs @@ -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(); + } + } + /// /// Evaluates the animation curve value at the specified time. /// diff --git a/Source/Editor/GUI/CurveEditor.Contents.cs b/Source/Editor/GUI/CurveEditor.Contents.cs index 80db7fb27..104863c40 100644 --- a/Source/Editor/GUI/CurveEditor.Contents.cs +++ b/Source/Editor/GUI/CurveEditor.Contents.cs @@ -91,18 +91,6 @@ namespace FlaxEditor.GUI 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) @@ -646,7 +634,7 @@ namespace FlaxEditor.GUI for (int i = 0; i < components; i++) { var component = i; - componentMenu.ContextMenu.AddButton(GetComponentName(component), () => SelectKeyframeComponent(point.Index, component)); + componentMenu.ContextMenu.AddButton(GetComponentLabel(component, typeof(T)), () => SelectKeyframeComponent(point.Index, component)); } } var totalSelectionCount = _editor.KeyframesEditorContext?.OnKeyframesSelectionCount() ?? selectionCount; diff --git a/Source/Editor/GUI/CurveEditor.cs b/Source/Editor/GUI/CurveEditor.cs index 7d7bb93a2..092cda707 100644 --- a/Source/Editor/GUI/CurveEditor.cs +++ b/Source/Editor/GUI/CurveEditor.cs @@ -1279,24 +1279,12 @@ namespace FlaxEditor.GUI continue; var center = GetControlCenterInEditor(point); - var label = GetComponentLabel(point.Component); + 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); } } - 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() { From ebcf315e87f662e7f31e5a4cd3430846d5a55385 Mon Sep 17 00:00:00 2001 From: Wojtek Figat Date: Sun, 20 Sep 2026 22:50:46 +0200 Subject: [PATCH 4/4] Add object track type tooltip --- Source/Editor/GUI/Timeline/Tracks/ActorTrack.cs | 1 - Source/Editor/GUI/Timeline/Tracks/ObjectTrack.cs | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/Editor/GUI/Timeline/Tracks/ActorTrack.cs b/Source/Editor/GUI/Timeline/Tracks/ActorTrack.cs index 4a0993cb9..109bf5ccd 100644 --- a/Source/Editor/GUI/Timeline/Tracks/ActorTrack.cs +++ b/Source/Editor/GUI/Timeline/Tracks/ActorTrack.cs @@ -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; diff --git a/Source/Editor/GUI/Timeline/Tracks/ObjectTrack.cs b/Source/Editor/GUI/Timeline/Tracks/ObjectTrack.cs index 1042e3014..f0de3584b 100644 --- a/Source/Editor/GUI/Timeline/Tracks/ObjectTrack.cs +++ b/Source/Editor/GUI/Timeline/Tracks/ObjectTrack.cs @@ -100,6 +100,7 @@ namespace FlaxEditor.GUI.Timeline.Tracks /// The object. protected virtual void OnObjectExistenceChanged(object obj) { + TooltipText = obj != null ? Editor.Instance.CodeDocs.GetTooltip(obj.GetType()) : "null"; } ///