#nullable enable using System.Diagnostics; using ImGuiNET; using T3.Core.Animation; using T3.Core.DataTypes; using T3.Core.DataTypes.Vector; using T3.Core.Operator; using T3.Core.Utils; using T3.Editor.Gui.InputUi.VectorInputs; using T3.Editor.Gui.Interaction; using T3.Editor.Gui.Interaction.Animation; using T3.Editor.Gui.Interaction.Keyboard; using T3.Editor.Gui.Interaction.Snapping; using T3.Editor.Gui.Styling; using T3.Editor.Gui.UiHelpers; using T3.Editor.UiModel; using T3.Editor.UiModel.Commands; using T3.Editor.UiModel.Commands.Animation; using T3.Editor.UiModel.InputsAndTypes; using T3.Editor.UiModel.ProjectHandling; using T3.Editor.UiModel.Selection; namespace T3.Editor.Gui.Windows.TimeLine; [HelpUiID("DopeSheet")] internal sealed class DopeSheetArea : AnimationParameterEditing, ITimeObjectManipulation, IValueSnapAttractor { public DopeSheetArea(ValueSnapHandler snapHandler, TimeLineCanvas timeLineCanvas) : base(timeLineCanvas.SharedSelectedKeyframes) { _snapHandler = snapHandler; TimeLineCanvas = timeLineCanvas; } private TimeLineCanvas.AnimationParameter? _currentAnimationParameter; public void Draw(Instance compositionOp, List animationParameters) { MouseClickChangedSelection = false; var symbolUi = compositionOp.GetSymbolUi(); var drawList = ImGui.GetWindowDrawList(); AnimationParameters = animationParameters; if (ImGui.IsMouseClicked(ImGuiMouseButton.Left)) { _selectionCountBeforeClick = SelectedKeyframes.Count; } ImGui.BeginGroup(); { if (UserActions.FocusSelection.Triggered()) { ViewAllOrSelectedKeys(alsoChangeTimeRange: true); } if (UserActions.Duplicate.Triggered()) { symbolUi.FlagAsModified(); DuplicateSelectedKeyframes(); } if (UserActions.InsertKeyframe.Triggered()) { symbolUi.FlagAsModified(); foreach (var p in AnimationParameters) { InsertNewKeyframe(p, (float)TimeLineCanvas.Playback.TimeInBars); } } if (UserActions.InsertKeyframeWithIncrement.Triggered()) { symbolUi.FlagAsModified(); SelectedKeyframes.Clear(); foreach (var p in AnimationParameters) { InsertNewKeyframe(p, (float)TimeLineCanvas.Playback.TimeInBars, false, 1); } } ImGui.Dummy(new Vector2(1, 3)); // top padding (was SetCursorPos+3, but ImGui 1.91 needs an item) _minScreenPos = ImGui.GetCursorScreenPos(); var compositionSymbolChildId = compositionOp.SymbolChildId; // Draw Top boundary { var min = ImGui.GetCursorScreenPos(); var max = min + new Vector2(ImGui.GetContentRegionAvail().X, LayerHeight); drawList.AddRectFilled(new Vector2(min.X, max.Y), new Vector2(max.X, max.Y + 1), UiColors.BackgroundFull.Fade(0.3f)); } for (var index = 0; index < animationParameters.Count; index++) { var parameter = animationParameters[index]; _currentAnimationParameter = parameter; ImGui.PushID(index); DrawProperty(parameter, compositionSymbolChildId, drawList, compositionOp); ImGui.PopID(); } DrawContextMenu(compositionOp); } ImGuiUtils.ResetCursorForExtentCheck(); ImGui.EndGroup(); } private void DrawProperty(TimeLineCanvas.AnimationParameter parameter, Guid compositionSymbolChildId, ImDrawListPtr drawList, Instance compositionOp) { Debug.Assert(TimeLineCanvas.Current != null); var min = ImGui.GetCursorScreenPos(); var max = min + new Vector2(ImGui.GetContentRegionAvail().X, LayerHeight); drawList.AddRectFilled(new Vector2(min.X, max.Y), new Vector2(max.X, max.Y + 1), UiColors.BackgroundFull.Fade(0.3f)); var mousePos = ImGui.GetMousePos(); var mouseTime = TimeLineCanvas.InverseTransformX(mousePos.X); var layerArea = new ImRect(min, max); var layerHovered = ImGui.IsWindowHovered() && layerArea.Contains(mousePos); var isCurrentSelected = TimeLineCanvas.NodeSelection.GetSelectedInstanceWithoutComposition()?.SymbolChildId == parameter.Input.Parent.SymbolChildId; if (FrameStats.IsIdHovered(parameter.Input.Parent.SymbolChildId) || isCurrentSelected || layerHovered) { var fade = isCurrentSelected ? 0.05f : 0.02f; drawList.AddRectFilled(new Vector2(min.X, min.Y), new Vector2(max.X, max.Y), UiColors.ForegroundFull.Fade(fade)); } // Layer-row hover publishes the hovered parameter so CEA curves of other params fade. // Keyframe / component-toggle hovers (rendered later below) overwrite with more-specific // values — more-specific wins because it writes last. if (layerHovered) { TimeLineCanvas.HoveredParameterHash = parameter.Hash; TimeLineCanvas.HoveredComponentBit = 0; } drawList.ChannelsSplit(2); drawList.ChannelsSetCurrent(1); // Three-segment parameter header: pin | curve-expand | name { var hash = parameter.Hash; ImGui.PushID(hash); var isEditedInCurveArea = TimeLineCanvas.CurveEditingParamHashes.Contains(hash); ImGui.PushFont((isCurrentSelected || isEditedInCurveArea) ? Fonts.FontBold : Fonts.FontNormal); var label = $"{parameter.ChildUi.SymbolChild.ReadableName}.{parameter.Input.Input.Name}"; var opLabelSize = ImGui.CalcTextSize(label); var iconSize = 15 * T3Ui.UiScaleFactor; var iconSpacing = 2 * T3Ui.UiScaleFactor; var iconBlockWidth = iconSize + iconSpacing; var rowHeight = LayerHeight; var headerMin = ImGui.GetCursorScreenPos(); var reservedWidth = iconBlockWidth * 2 + opLabelSize.X + 8 * T3Ui.UiScaleFactor; var isPinned = PinnedParametersHashes.Contains(hash); if (UserSettings.Config.AutoPinAllAnimations) { PinnedParametersHashes.Add(hash); isPinned = true; } // Backdrop so keyframes drawn on channel 0 don't bleed through the header drawList.AddRectFilled(headerMin, headerMin + new Vector2(reservedWidth, rowHeight), UiColors.BackgroundFull.Fade(0.8f)); // --- Pin toggle --- ImGui.SetCursorScreenPos(headerMin); if (ImGui.InvisibleButton("pin", new Vector2(iconBlockWidth, rowHeight)) && !UserSettings.Config.AutoPinAllAnimations) { if (isPinned) PinnedParametersHashes.Remove(hash); else PinnedParametersHashes.Add(hash); } var pinIconColor = isPinned ? UiColors.StatusActivated : UiColors.ForegroundFull.Fade(0.3f); pinIconColor = pinIconColor.Fade(ImGui.IsItemHovered() ? 1 : 0.8f); Icons.DrawIconAtScreenPosition(isPinned ? Icon.Pin : Icon.PinOutline, headerMin + new Vector2(2, 5) * T3Ui.UiScaleFactor, drawList, pinIconColor); // --- Curve-expand toggle --- var curveBtnPos = headerMin + new Vector2(iconBlockWidth, 0); ImGui.SetCursorScreenPos(curveBtnPos); if (ImGui.InvisibleButton("curve", new Vector2(iconBlockWidth, rowHeight))) { if (isEditedInCurveArea) { TimeLineCanvas.CurveEditingParamHashes.Remove(hash); TimeLineCanvas.VisibleComponentMask.Remove(hash); isEditedInCurveArea = false; } else { TimeLineCanvas.CurveEditingParamHashes.Add(hash); isEditedInCurveArea = true; } } var curveIconColor = isEditedInCurveArea ? UiColors.StatusActivated : UiColors.ForegroundFull.Fade(0.3f); curveIconColor = curveIconColor.Fade(ImGui.IsItemHovered() ? 1 : 0.8f); Icons.DrawIconAtScreenPosition(Icon.InterpolateBrokenTangents, curveBtnPos + new Vector2(0, 5) * T3Ui.UiScaleFactor, drawList, curveIconColor); // --- Name button: replace / shift-add / ctrl-remove on parameter's keyframes --- var namePos = curveBtnPos + new Vector2(iconBlockWidth + 4 * T3Ui.UiScaleFactor, 0); ImGui.SetCursorScreenPos(namePos); var nameSize = new Vector2(opLabelSize.X + 4 * T3Ui.UiScaleFactor, rowHeight); if (ImGui.InvisibleButton("name", nameSize)) { var io = ImGui.GetIO(); if (io.KeyCtrl) { foreach (var curve in parameter.Curves) SelectedKeyframes.ExceptWith(curve.GetVDefinitions()); } else if (io.KeyShift) { foreach (var curve in parameter.Curves) SelectedKeyframes.UnionWith(curve.GetVDefinitions()); } else { SelectedKeyframes.Clear(); foreach (var curve in parameter.Curves) SelectedKeyframes.UnionWith(curve.GetVDefinitions()); } MouseClickChangedSelection = true; } var nameHovered = ImGui.IsItemHovered(); // Opacity rule: 60% default, +20% when any of this parameter's keyframes is selected, // +15% on hover (layer / operator in graph / cross-view curve-line hover). var anyKeySelected = false; foreach (var c in parameter.Curves) { foreach (var v in c.GetVDefinitions()) { if (!SelectedKeyframes.Contains(v)) continue; anyKeySelected = true; break; } if (anyKeySelected) break; } var isRelatedHovered = layerHovered || nameHovered || FrameStats.IsIdHovered(parameter.Input.Parent.SymbolChildId) || TimeLineCanvas.HoveredParameterHash == hash; var labelOpacity = 0.60f; if (anyKeySelected) labelOpacity += 0.20f; if (isRelatedHovered) labelOpacity += 0.15f; if (labelOpacity > 1f) labelOpacity = 1f; var labelColor = isEditedInCurveArea ? UiColors.StatusActivated : UiColors.ForegroundFull.Fade(labelOpacity); drawList.AddText(namePos + new Vector2(0, 3) * T3Ui.UiScaleFactor, labelColor, label); // --- Component visibility toggles (X/Y/Z/W or R/G/B/A) for expanded vector params --- // One small button per curve; click toggles its bit in VisibleComponentMask. Hover // drives the cross-view fade via TimeLineCanvas.Hovered{Parameter,Component}{Bit,Hash}. if (isEditedInCurveArea && parameter.Curves.Length > 1) { var componentNames = parameter.Input.ValueType == typeof(System.Numerics.Vector4) ? ColorCurveNames : CurveNames; var compSize = new Vector2(14 * T3Ui.UiScaleFactor, rowHeight); var compGap = 1 * T3Ui.UiScaleFactor; var compStart = namePos + new Vector2(opLabelSize.X + 6 * T3Ui.UiScaleFactor, 0); for (var i = 0; i < parameter.Curves.Length && i < componentNames.Length; i++) { var bit = 1 << i; var isVisible = IsComponentVisible(hash, bit, parameter.Curves.Length); var pos = compStart + new Vector2(i * (compSize.X + compGap), 0); ImGui.PushID(i); ImGui.SetCursorScreenPos(pos); if (ImGui.InvisibleButton("comp", compSize)) ToggleComponentVisibility(hash, bit, parameter.Curves.Length); var compHovered = ImGui.IsItemHovered(); if (compHovered) { TimeLineCanvas.HoveredParameterHash = hash; TimeLineCanvas.HoveredComponentBit = bit; } ImGui.PopID(); var baseColor = CurveColors[i % CurveColors.Length]; var alpha = isVisible ? 1f : 0.25f; if (compHovered) alpha = 1f; var color = baseColor.Fade(alpha); var letter = componentNames[i]; var textSize = ImGui.CalcTextSize(letter); var textPos = pos + (compSize - textSize) * 0.5f; drawList.AddText(textPos, color, letter); } } ImGui.PopFont(); ImGui.PopID(); } drawList.ChannelsSetCurrent(0); //if (layerHovered && mousePos.X > ImGui.GetItemRectMax().X) if (layerHovered) { drawList.AddRectFilled(new Vector2(mousePos.X, min.Y), new Vector2(mousePos.X + 1, max.Y), UiColors.StatusAnimated.Fade(0.4f)); ImGui.BeginTooltip(); ImGui.PushFont(Fonts.FontSmall); ImGui.TextUnformatted(parameter.Input.Input.Name); //@pixtur: Make sure this works //FrameStats.AddHoveredId(parameter.Input.Parent.SymbolChildId); FrameStats.AddHoveredId(parameter.Input.Parent.SymbolChildId); foreach (var curve in parameter.Curves) { var v = curve.GetSampledValue(mouseTime); ImGui.TextUnformatted($"{v:0.00}"); } ImGui.PopFont(); ImGui.EndTooltip(); var isAnyKeysSelected = _selectionCountBeforeClick > 0; var wasMouseDragging = ImGui.GetMouseDragDelta(ImGuiMouseButton.Left, 0).Length() > 2; var isMouseReleased = ImGui.IsMouseReleased(ImGuiMouseButton.Left); var isLayerBackgroundClicked = !ImGui.IsAnyItemHovered() && isMouseReleased && !wasMouseDragging; if (!isAnyKeysSelected && ImGui.IsMouseDoubleClicked(ImGuiMouseButton.Left)) { if (compositionOp.Children.TryGetChildInstance(parameter.ChildUi.SymbolChild.Id, out _)) { ProjectView.Focused?.NodeSelection.Clear(); ProjectView.Focused?.NodeSelection.TrySelectCompositionChild(compositionOp, parameter.ChildUi.Id); FitViewToSelectionHandling.FitViewToSelection(); } } // Select and focus parameter if no keyframes are selected if (isLayerBackgroundClicked) { if (ImGui.GetIO().KeyShift) { var someKeysNotVisible = false; foreach (var curve in parameter.Curves) { foreach (var k in curve.GetVDefinitions()) { var x = TimeLineCanvas.Current.TransformX((float)k.U); var isNotVisible = x < min.X || x > max.X; if (isNotVisible) { someKeysNotVisible = true; break; } } SelectedKeyframes.UnionWith(curve.GetVDefinitions()); } if (someKeysNotVisible) { ViewAllOrSelectedKeys(); } MouseClickChangedSelection = true; } else if (ImGui.GetIO().KeyCtrl) { foreach (var curve in parameter.Curves) { // remove keys from selection SelectedKeyframes.ExceptWith(curve.GetVDefinitions()); } MouseClickChangedSelection = true; } else if (isAnyKeysSelected) { SelectedKeyframes.Clear(); MouseClickChangedSelection = true; } } } // Draw curves and gradients... if (parameter.Curves.Length == 4) { DrawCurveGradient(parameter, layerArea, drawList); } else { DrawCurveLines(parameter, layerArea, drawList); } HandleCreateNewKeyframes(parameter, layerArea); foreach (var curve in parameter.Curves) { var list = curve.GetVDefinitions(); for (var index = 0; index < list.Count; index++) { var vDef = list[index]; var nextVDef = index < list.Count - 1 ? list[index + 1] : null; DrawKeyframe(compositionSymbolChildId, vDef, layerArea, parameter, nextVDef, drawList); } } ImGui.SetCursorScreenPos(min + new Vector2(0, LayerHeight)); // Next Line drawList.ChannelsMerge(); } public readonly HashSet PinnedParametersHashes = new(); private bool HandleCreateNewKeyframes(TimeLineCanvas.AnimationParameter parameter, ImRect layerArea) { Debug.Assert(TimeLineCanvas.Current != null); var hoverNewKeyframe = !ImGui.IsAnyItemActive() && ImGui.IsWindowHovered() && ImGui.GetIO().KeyAlt && layerArea.Contains(ImGui.GetMousePos()); if (!hoverNewKeyframe) return false; var hoverTime = TimeLineCanvas.Current.InverseTransformX(ImGui.GetIO().MousePos.X); if (_snapHandler.TryCheckForSnapping(hoverTime, out var snappedValue, TimeLineCanvas.Current.Scale.X)) { hoverTime = (float)snappedValue; } var changed = false; if (ImGui.IsMouseReleased(0)) { var dragDistance = ImGui.GetIO().MouseDragMaxDistanceAbs[0].Length(); if (dragDistance < 2) { TimeLineCanvas.Current.ClearSelection(); InsertNewKeyframe(parameter, hoverTime, setPlaybackTime: true); changed = true; } } else { var posOnScreen = new Vector2( TimeLineCanvas.Current.TransformX(hoverTime) - KeyframeIconWidth / 2 + 1, layerArea.Min.Y); Icons.DrawIconAtScreenPosition(Icon.DopeSheetKeyframeLinear, posOnScreen); } ImGui.SetMouseCursor(ImGuiMouseCursor.Hand); return changed; } private void InsertNewKeyframe(TimeLineCanvas.AnimationParameter parameter, float time, bool setPlaybackTime = false, float increment = 0) { Debug.Assert(TimeLineCanvas.Current != null); var curves = parameter.Curves; var newKeyframes = AnimationOperations.InsertKeyframeToCurves(curves, time, increment); foreach (var k in newKeyframes) { SelectedKeyframes.Add(k); } if (setPlaybackTime) TimeLineCanvas.Current.Playback.TimeInBars = time; } internal static readonly Color GrayCurveColor = new(0.8f, 1f, 1.0f, 0.3f); /// /// Is component of the parameter (hash) currently visible? /// Missing entry in the mask means "all components visible" (the default on expand). /// private bool IsComponentVisible(int hash, int bit, int curveCount) { if (!TimeLineCanvas.VisibleComponentMask.TryGetValue(hash, out var mask)) return true; return (mask & bit) != 0; } /// /// Isolate-on-first-click semantics: /// • No mask entry (all visible) → isolate this component (only it stays visible). /// • Mask entry already present → toggle this bit; if the result is "all visible" or /// "none visible", drop the entry so we're back to the default "all visible". /// private void ToggleComponentVisibility(int hash, int bit, int curveCount) { var allOn = (1 << curveCount) - 1; if (!TimeLineCanvas.VisibleComponentMask.TryGetValue(hash, out var mask)) { // All currently visible — isolate this component. if (bit == allOn) return; // only one component; nothing to isolate to TimeLineCanvas.VisibleComponentMask[hash] = bit; return; } var next = mask ^ bit; if (next == 0 || next == allOn) TimeLineCanvas.VisibleComponentMask.Remove(hash); // restore default (all visible) else TimeLineCanvas.VisibleComponentMask[hash] = next; } /// /// Cross-view alpha: full when no hover is set, dimmed when the hover picks a different /// parameter or (within the same parameter) a different component. /// internal static float HoverFadeAlpha(int paramHash, int bit) { var canvas = TimeLineCanvas.Current; if (canvas == null || canvas.HoveredParameterHash is not { } hoveredParam) return 1f; if (hoveredParam != paramHash) return 0.4f; if (canvas.HoveredComponentBit != 0 && canvas.HoveredComponentBit != bit) return 0.4f; return 1f; } internal static readonly Color[] CurveColors = { new(1f, 0.2f, 0.2f, 0.4f), new(0.1f, 1f, 0.2f, 0.4f), new(0.1f, 0.4f, 1.0f, 0.6f), GrayCurveColor, }; internal static readonly string[] CurveNames = [ "X", "Y", "Z", "W", "5", "6", "7", "8", "9", "10", "11", "12" ]; internal static readonly string[] ColorCurveNames = [ "R", "G", "B", "A" ]; private static void DrawCurveLines(TimeLineCanvas.AnimationParameter parameter, ImRect layerArea, ImDrawListPtr drawList) { Debug.Assert(TimeLineCanvas.Current != null); const float padding = 2; var curveIndex = 0; var canvas = TimeLineCanvas.Current; var visibleStartU = canvas.InverseTransformPositionFloat(canvas.WindowPos).X; var visibleEndU = canvas.InverseTransformPositionFloat(canvas.WindowPos + new Vector2(canvas.WindowSize.X, 0)).X; var screenScaleX = (double)canvas.Scale.X; var minValue = float.PositiveInfinity; var maxValue = float.NegativeInfinity; // Component visibility (expanded vector params can hide individual curves via the // dope-row toggle buttons); missing mask entry means "all visible". TimeLineCanvas.Current.VisibleComponentMask.TryGetValue(parameter.Hash, out var componentMask); var hasComponentMask = componentMask != 0; foreach (var curve in parameter.Curves) { var bit = 1 << curveIndex; if (hasComponentMask && (componentMask & bit) == 0) { curveIndex++; continue; } if (curve.Table.Count == 0) continue; curve.SampleCache.Update(curve, visibleStartU, visibleEndU, screenScaleX); var cache = curve.SampleCache; var firstKeyU = cache.FirstKeyU; var lastKeyU = cache.LastKeyU; if (double.IsNaN(firstKeyU)) { curveIndex++; continue; } // Track min/max from ALL visible cached samples (including pre/post regions) var allVisiblePoints = cache.GetPointsInRange(visibleStartU, visibleEndU); for (var i = 0; i < allVisiblePoints.Length; i++) { var value = allVisiblePoints[i].Y; if (value < minValue) minValue = value; if (value > maxValue) maxValue = value; } var fade = HoverFadeAlpha(parameter.Hash, bit); var bodyColor = (parameter.Curves.Length > 1 ? CurveColors[curveIndex % 4] : GrayCurveColor).Fade(fade); var outsideColor = bodyColor.Fade(0.3f); // Always draw 3 segments: dimmed pre, full body, dimmed post // Use -/+Infinity for outer bounds to include all cached pre/post points beyond visible edges DrawDopeSheetPolyline(cache.GetPointsInRange(double.NegativeInfinity, firstKeyU), canvas, drawList, parameter, layerArea, padding, outsideColor); DrawDopeSheetPolyline(cache.GetPointsInRange(firstKeyU, lastKeyU), canvas, drawList, parameter, layerArea, padding, bodyColor); DrawDopeSheetPolyline(cache.GetPointsInRange(lastKeyU, double.PositiveInfinity), canvas, drawList, parameter, layerArea, padding, outsideColor); curveIndex++; } minValue = parameter.DampedMinValue.DampTowards(minValue); maxValue = parameter.DampedMaxValue.DampTowards(maxValue); } private static void DrawDopeSheetPolyline(ReadOnlySpan points, TimeLineCanvas canvas, ImDrawListPtr drawList, TimeLineCanvas.AnimationParameter parameter, ImRect layerArea, float padding, Color color) { var pointCount = Math.Min(points.Length, TimelineCurveEditor.MaxPolylinePoints); if (pointCount < 2) return; var buf = TimelineCurveEditor._polylineBuffer; var valueRange = parameter.DampedMaxValue - parameter.DampedMinValue; var centerY = (layerArea.Min.Y + layerArea.Max.Y) * 0.5f; var isFlatRange = Math.Abs(valueRange) < 1e-6f; for (var i = 0; i < pointCount; i++) { var p = points[i]; var screenX = canvas.TransformX(p.X); var screenY = isFlatRange ? centerY : ((float)p.Y).RemapAndClamp(parameter.DampedMaxValue, parameter.DampedMinValue, layerArea.Min.Y + padding, layerArea.Max.Y - padding); buf[i] = new Vector2(screenX, screenY); } drawList.AddPolyline(ref buf[0], pointCount, color, ImDrawFlags.None, 0.5f); } private static void DrawCurveGradient(TimeLineCanvas.AnimationParameter parameter, ImRect layerArea, ImDrawListPtr drawList) { Debug.Assert(TimeLineCanvas.Current != null); if (parameter.Curves.Length != 4) return; var curve = parameter.Curves[0]; const float padding = 2; var points = curve.GetVDefinitions(); var times = new float[points.Count]; var colors = new Color[points.Count]; var curves = parameter.Curves; var index = 0; foreach (var vDef in points) { times[index] = TimeLineCanvas.Current.TransformX((float)vDef.U); colors[index] = new Color( (float)vDef.Value, (float)curves[1].GetSampledValue(vDef.U), (float)curves[2].GetSampledValue(vDef.U), (float)curves[3].GetSampledValue(vDef.U) ); index++; } for (var index2 = 0; index2 < times.Length - 1; index2++) { drawList.AddRectFilledMultiColor(new Vector2(times[index2], layerArea.Min.Y + padding), new Vector2(times[index2 + 1], layerArea.Max.Y - padding), colors[index2], colors[index2 + 1], colors[index2 + 1], colors[index2]); } } private void DrawKeyframe(in Guid compositionSymbolId, VDefinition vDef, ImRect layerArea, TimeLineCanvas.AnimationParameter parameter, VDefinition? nextVDef, ImDrawListPtr drawList) { Debug.Assert(TimeLineCanvas.Current != null); Debug.Assert(_currentAnimationParameter != null); var vDefU = (float)vDef.U; if (vDefU < Playback.Current.TimeInBars) { FrameStats.Current.HasKeyframesBeforeCurrentTime = true; } if (vDefU > Playback.Current.TimeInBars) { FrameStats.Current.HasKeyframesAfterCurrentTime = true; } var posOnScreen = new Vector2( TimeLineCanvas.Current.TransformX(vDefU) - KeyframeIconWidth * T3Ui.UiScaleFactor / 2 + 1, layerArea.Min.Y); if (vDef.OutInterpolation == VDefinition.KeyInterpolation.Constant) { var availableSpace = nextVDef != null ? TimeLineCanvas.Current.TransformX((float)nextVDef.U) - posOnScreen.X : 9999; if (availableSpace > 30) { var labelPos = new Vector2(posOnScreen.X + KeyframeIconWidth / 2 + 6, layerArea.Min.Y + 3); var color = UiColors.StatusAnimated.Fade(availableSpace.RemapAndClamp(30, 50, 0, 1).Clamp(0, 1)); ImGui.PushFont(Fonts.FontSmall); drawList.AddText(labelPos, color, $"{vDef.Value:G3}"); ImGui.PopFont(); } } var keyHash = vDef.GetHashCode(); ImGui.PushID(keyHash); { ImGui.PushStyleColor(ImGuiCol.Text, Color.White.Rgba); var isSelected = SelectedKeyframes.Contains(vDef); if (vDef.OutInterpolation == VDefinition.KeyInterpolation.Constant) { Icons.DrawIconAtScreenPosition(isSelected ? Icon.ConstantKeyframeSelected : Icon.ConstantKeyframe, posOnScreen); } else if (vDef.OutInterpolation == VDefinition.KeyInterpolation.Horizontal) { Icons.DrawIconAtScreenPosition(isSelected ? Icon.DopeSheetKeyframeHorizontalSelected : Icon.DopeSheetKeyframeHorizontal, posOnScreen); } else if (vDef.OutInterpolation == VDefinition.KeyInterpolation.Cubic) { Icons.DrawIconAtScreenPosition(isSelected ? Icon.DopeSheetKeyframeCubicSelected : Icon.DopeSheetKeyframeCubic, posOnScreen); } else if (vDef.OutInterpolation == VDefinition.KeyInterpolation.Smooth) { Icons.DrawIconAtScreenPosition(isSelected ? Icon.DopeSheetKeyframeSmoothSelected : Icon.DopeSheetKeyframeSmooth, posOnScreen); } else { Icons.DrawIconAtScreenPosition(isSelected ? Icon.DopeSheetKeyframeLinearSelected : Icon.DopeSheetKeyframeLinear, posOnScreen); } ImGui.PopStyleColor(); ImGui.SetCursorScreenPos(posOnScreen); // Click released var keyframeSize = new Vector2(10, 24); if (ImGui.InvisibleButton("##key", keyframeSize)) { var justClicked = ImGui.GetMouseDragDelta().Length() < UserSettings.Config.ClickThreshold; if (justClicked) { UpdateSelectionOnClickOrDrag(vDef, isSelected); if (_clickedKeyframeHash == keyHash) { _activeKeyframeHash = keyHash; if (ProjectView.Focused?.CompositionInstance != null && ProjectView.Focused.NodeSelection.TrySelectCompositionChild( ProjectView.Focused.CompositionInstance, parameter.ChildUi.Id, false)) { FitViewToSelectionHandling.FitViewToSelection(); } } else { _activeKeyframeHash = -1; } _clickedKeyframeHash = keyHash; if (Math.Abs(TimeLineCanvas.Playback.PlaybackSpeed) < 0.001f) { TimeLineCanvas.Current.Playback.TimeInBars = vDef.U; } } if (_changeKeyframesCommand != null) TimeLineCanvas.Current.CompleteDragCommand(); } // Keyframe hover — set shared hover state so the curve pane can outline the // matching per-component keyframes, and draw an outline here if CEA/another // DSA key just set the hovered UniqueId. if (ImGui.IsItemHovered()) { TimeLineCanvas.HoveredParameterHash = parameter.Hash; TimeLineCanvas.HoveredComponentBit = 0; TimeLineCanvas.HoveredKeyframeUniqueId = vDef.UniqueId; } else if (TimeLineCanvas.HoveredKeyframeUniqueId == vDef.UniqueId) { drawList.AddRect(posOnScreen - Vector2.One, posOnScreen + keyframeSize + Vector2.One, UiColors.ForegroundFull, rounding: 1f, ImDrawFlags.None, 1f); } HandleCurvePointDragging(compositionSymbolId, vDef, isSelected); // Draw value input var valueInputVisible = isSelected && keyHash == _activeKeyframeHash; if (valueInputVisible) { var symbolUi = parameter.ChildUi.SymbolChild.Symbol.GetSymbolUi(); var inputUi = symbolUi.InputUis[parameter.Input.Id]; if (inputUi is FloatInputUi floatInputUi) { var size = new Vector2(60, 25); ImGui.SetCursorScreenPos(posOnScreen + new Vector2(-size.X / 2, keyframeSize.Y - 5)); ImGui.BeginChild($"##kf{keyHash}", size, ImGuiChildFlags.FrameStyle, ImGuiWindowFlags.NoScrollbar); ImGui.PushFont(Fonts.FontSmall); var tmp = (float)vDef.Value; var result = floatInputUi.DrawEditControl(ref tmp); if (result == InputEditStateFlags.Started) { _changeKeyframesCommand = new ChangeKeyframesCommand(SelectedKeyframes, _currentAnimationParameter.Curves); } if ((result & InputEditStateFlags.Modified) == InputEditStateFlags.Modified) { foreach (var k in SelectedKeyframes) { k.Value = tmp; } } if ((result & InputEditStateFlags.Finished) == InputEditStateFlags.Finished && _changeKeyframesCommand != null) { _changeKeyframesCommand.StoreCurrentValues(); UndoRedoStack.AddAndExecute(_changeKeyframesCommand); _changeKeyframesCommand = null; } //vDef.Value = tmp; ImGui.PopFont(); ImGui.EndChild(); } else if (inputUi is IntInputUi intInputUi) { var size = new Vector2(60, 25); ImGui.SetCursorScreenPos(posOnScreen + new Vector2(-size.X / 2, keyframeSize.Y - 5)); ImGui.BeginChild($"##kf{keyHash}", size, ImGuiChildFlags.FrameStyle, ImGuiWindowFlags.NoScrollbar); ImGui.PushFont(Fonts.FontSmall); var tmp = (int)vDef.Value; var result = intInputUi.DrawEditControl(ref tmp); if (result == InputEditStateFlags.Started) { _changeKeyframesCommand = new ChangeKeyframesCommand(SelectedKeyframes, _currentAnimationParameter.Curves); } if ((result & InputEditStateFlags.Modified) == InputEditStateFlags.Modified) { foreach (var k in SelectedKeyframes) { k.Value = tmp; } } if ((result & InputEditStateFlags.Finished) == InputEditStateFlags.Finished && _changeKeyframesCommand != null) { _changeKeyframesCommand.StoreCurrentValues(); UndoRedoStack.AddAndExecute(_changeKeyframesCommand); _changeKeyframesCommand = null; } //vDef.Value = tmp; ImGui.PopFont(); ImGui.EndChild(); } } ImGui.PopID(); } } private int _clickedKeyframeHash; private int _activeKeyframeHash; protected internal override void HandleCurvePointDragging(in Guid compositionSymbolId, VDefinition vDef, bool isSelected) { Debug.Assert(TimeLineCanvas.Current != null); if (ImGui.IsItemHovered()) { ImGui.SetMouseCursor(ImGuiMouseCursor.ResizeEW); } if (!ImGui.IsItemActive() || !ImGui.IsMouseDragging(0, 1f)) { _draggedKeyframe = null; return; } _draggedKeyframe = vDef; if (UpdateSelectionOnClickOrDrag(vDef, isSelected)) return; if (_changeKeyframesCommand == null) { TimeLineCanvas.Current.StartDragCommand(compositionSymbolId); } var newDragTime = TimeLineCanvas.Current.InverseTransformX(ImGui.GetIO().MousePos.X); if (!ImGui.GetIO().KeyShift) { if (_snapHandler.TryCheckForSnapping(newDragTime, out var snappedValue, TimeLineCanvas.Current.Scale.X, TimeLineCanvas.Current.SelectionDragSnapExclusions)) { newDragTime = (float)snappedValue; } } TimeLineCanvas.Current.UpdateDragCommand(newDragTime - vDef.U, 0); } private bool UpdateSelectionOnClickOrDrag(VDefinition vDef, bool isSelected) { if (TimeLineCanvas.Current == null) return false; // Deselect if (ImGui.GetIO().KeyCtrl) { if (!isSelected) return true; foreach (var k in FindParameterKeysAtPosition(vDef.U)) { SelectedKeyframes.Remove(k); } return true; } if (!isSelected) { if (!ImGui.GetIO().KeyShift) { TimeLineCanvas.Current.ClearSelection(); } foreach (var k in FindParameterKeysAtPosition(vDef.U)) { SelectedKeyframes.Add(k); } } return false; } private IEnumerable FindParameterKeysAtPosition(double u) { if(_currentAnimationParameter == null) yield break; foreach (var curve in _currentAnimationParameter.Curves) { var matchingKey = curve.GetVDefinitions().FirstOrDefault(vDef2 => Math.Abs(vDef2.U - u) < 1 / 120f); if (matchingKey != null) yield return matchingKey; } } #region implement interface -------------------------------------------- void ITimeObjectManipulation.ClearSelection() { SelectedKeyframes.Clear(); } public void UpdateSelectionForArea(ImRect screenArea, SelectionFence.SelectModes selectMode) { if (TimeLineCanvas.Current == null) return; if (selectMode == SelectionFence.SelectModes.Replace) { SelectedKeyframes.Clear(); _clickedKeyframeHash = 0; } var startTime = TimeLineCanvas.Current.InverseTransformX(screenArea.Min.X); var endTime = TimeLineCanvas.Current.InverseTransformX(screenArea.Max.X); var layerMinIndex = (screenArea.Min.Y - _minScreenPos.Y) / LayerHeight - 1; var layerMaxIndex = (screenArea.Max.Y - _minScreenPos.Y) / LayerHeight; var index = 0; foreach (var parameter in AnimationParameters) { if (index >= layerMinIndex && index <= layerMaxIndex) { foreach (var c in parameter.Curves) { var keysCount = c.Keys.Count; if (keysCount == 0) continue; var keyIndex = c.FindIndexBefore(startTime)+1; while (keyIndex != -1 && keyIndex < keysCount) { var key = c.Keys[keyIndex]; if (key.U > endTime) break; switch (selectMode) { case SelectionFence.SelectModes.Add: case SelectionFence.SelectModes.Replace: SelectedKeyframes.Add(key); break; case SelectionFence.SelectModes.Remove: SelectedKeyframes.Remove(key); break; } keyIndex++; } } } index++; } } ICommand ITimeObjectManipulation.StartDragCommand(in Guid compositionSymbolId) { _changeKeyframesCommand = new ChangeKeyframesCommand(SelectedKeyframes, GetAllCurves()); return _changeKeyframesCommand; } void ITimeObjectManipulation.UpdateDragCommand(double dt, double dv) { foreach (var vDefinition in SelectedKeyframes) { vDefinition.U += dt; } RebuildCurveTables(); } void ITimeObjectManipulation.UpdateDragAtStartPointCommand(double dt, double dv) { } void ITimeObjectManipulation.UpdateDragAtEndPointCommand(double dt, double dv) { } void ITimeObjectManipulation.CompleteDragCommand() { if (_changeKeyframesCommand == null) return; // Update reference in Macro-command _changeKeyframesCommand.StoreCurrentValues(); _changeKeyframesCommand = null; } void ITimeObjectManipulation.DeleteSelectedElements(Instance compositionOp) { AnimationOperations .DeleteSelectedKeyframesFromAnimationParameters(SelectedKeyframes, AnimationParameters, compositionOp); RebuildCurveTables(); } #endregion /// /// Snap to all non-selected Clips /// void IValueSnapAttractor.CheckForSnap(ref SnapResult snapResult) { foreach (var vDefinition in GetAllKeyframes()) { if (SelectedKeyframes.Contains(vDefinition)) continue; if (_draggedKeyframe == vDefinition) continue; if (ExternalDragSnapExclusions != null && ContainsRef(ExternalDragSnapExclusions, vDefinition)) continue; snapResult.TryToImproveWithAnchorValue(vDefinition.U); } } private static bool ContainsRef(IReadOnlyList list, VDefinition target) { for (var i = 0; i < list.Count; i++) { if (ReferenceEquals(list[i], target)) return true; } return false; } /// /// Keyframes for an external drag (e.g. a bucket drag) that /// should not contribute snap anchors. Generalises for callers /// that move several keys at once. Caller sets on drag start, clears on drag end. /// internal IReadOnlyList? ExternalDragSnapExclusions { get; set; } private VDefinition? _draggedKeyframe; // ignore snapping to self private const float KeyframeIconWidth = 10; private Vector2 _minScreenPos; private static ChangeKeyframesCommand? _changeKeyframesCommand; public static int LayerHeight => (int)(25f * T3Ui.UiScaleFactor); private readonly ValueSnapHandler _snapHandler; private int _selectionCountBeforeClick; public bool MouseClickChangedSelection; }