#nullable enable using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using ImGuiNET; using T3.Core.Animation; using T3.Core.DataTypes; using T3.Core.Operator; using T3.Core.Operator.Slots; using T3.Core.Utils; using T3.Editor.Gui.Interaction; using T3.Editor.Gui.Interaction.Keyboard; using T3.Editor.Gui.Interaction.Snapping; using T3.Editor.Gui.Interaction.Timing; using T3.Editor.Gui.Interaction.WithCurves; using T3.Editor.Gui.Styling; using T3.Editor.Gui.UiHelpers; using T3.Editor.Gui.Windows.TimeLine.Raster; using T3.Editor.Gui.Windows.TimeLine.TimeClips; using T3.Editor.UiModel; using T3.Editor.UiModel.ProjectHandling; using T3.Editor.UiModel.Selection; // ReSharper disable ForeachCanBePartlyConvertedToQueryUsingAnotherGetEnumerator namespace T3.Editor.Gui.Windows.TimeLine; /// /// Combines multiple s into a single consistent /// timeline that allows dragging selected time elements of various types. /// [HelpUiID("Timeline")] internal sealed class TimeLineCanvas : AnimationCanvas { public TimeLineCanvas(NodeSelection nodeSelection, Func getCompositionOp, Func requestChildCompositionFunc) { _nodeSelection = nodeSelection; DopeSheetArea = new DopeSheetArea(SnapHandlerForU, this); _timelineCurveEditArea = new TimelineCurveEditor(this, SnapHandlerForU, SnapHandlerForV); _timeSelectionRange = new TimeSelectionRange(this, SnapHandlerForU); _selectionRangeIndicator = new SelectionRangeIndicator(this, SnapHandlerForU); _timeSelectionArea = new TimeSelectionArea(this); ClipArea = new ClipArea(this, getCompositionOp, requestChildCompositionFunc, SnapHandlerForU); _curveEditCanvas = new InlineCurveArea(this, _timelineCurveEditArea, _horizontalRaster); _inlineDataClipArea = new InlineDataClipArea(this); _detailsArea = new TimelineDetailsArea(this, _curveEditCanvas, _inlineDataClipArea); SnapHandlerForV.AddSnapAttractor(_horizontalRaster); SnapHandlerForU.AddSnapAttractor(_clipRange); SnapHandlerForU.AddSnapAttractor(_loopRange); SnapHandlerForU.AddSnapAttractor(_timeRasterSwitcher); SnapHandlerForU.AddSnapAttractor(_currentTimeMarker); SnapHandlerForU.AddSnapAttractor(ClipArea); SnapHandlerForU.AddSnapAttractor(_selectionRangeIndicator); _selectionDragSnapExclusions = [_selectionRangeIndicator]; KeyframeEditors = new KeyframeEditorGroup(_activeKeyframeEditors); FoldingHeight = new TimelineHeight(this); Playback = null!; } /// /// Snap-attractors to skip while dragging selected keyframes or keyset-indicator clusters. /// The anchors at the first/last selected-keyframe U, /// so snapping to it during such a drag forms a feedback loop that stutters the boundary keys. /// internal IValueSnapAttractor[] SelectionDragSnapExclusions => _selectionDragSnapExclusions; /// /// Every currently-active animation-parameter editor on the timeline. Today this is always /// a single editor (DopeSheetArea in DopeView mode, TimelineCurveEditor in CurveEditor mode); /// future split-view will expose both simultaneously. Cross-mode components (SRI, TimeSelectionArea, /// TimeWarpDrag) aggregate through so they don't need to pick one. /// public readonly KeyframeEditorGroup KeyframeEditors; /// /// Invoked when the keyframe selection is explicitly replaced (not merely added to / removed from). /// Lets the SRI drop its TimeWarp handles since they were configured for the previous selection. /// internal void OnKeyframeSelectionReplaced() => _selectionRangeIndicator.ClearTimeWarpHandles(); public NodeSelection NodeSelection => _nodeSelection; private int RulerHeight => (int)(28 * T3Ui.UiScaleFactor); private int SummaryHeight => (int)(11 * T3Ui.UiScaleFactor); public void Draw(ProjectView projectView, Playback playback) { Debug.Assert(projectView.CompositionInstance != null); var compositionOp = projectView.CompositionInstance; Current = this; Playback = playback; SyncStateWithComposition(compositionOp); _selectedAnimationParameters = GetAnimationParametersForSelectedNodes(compositionOp); PruneExpandedForMissingParams(); // Very ugly hack to prevent scaling the output above window size var keepScale = T3Ui.UiScaleFactor; ScrollToTimeAfterStopped(); var modeChanged = UpdateMode(); SyncInlineCurveEditorRegistration(); // Details pane (curve editor OR DataClip editor) takes over the bottom strip of // the timeline body when it's active. The outer canvas suppresses its fence in // that case so clicks inside the details pane don't bubble up and clear the // clip / keyframe selection that drove the pane open in the first place; a // dope-local fence inside the top pane covers the rubber-band-select use case. var detailsActive = _detailsArea.IsActive(compositionOp); var outerFlags = T3Ui.EditingFlags.AllowHoveredChildWindows; if (detailsActive && CurveEditAreaScreenRect.HasValue && CurveEditAreaScreenRect.Value.Contains(ImGui.GetMousePos())) { // Curve editor's sub-canvas owns wheel / RMB while the cursor is inside it; // ceding here keeps zoom / pan from double-processing on the outer canvas. outerFlags |= T3Ui.EditingFlags.PreventZoomWithMouseWheel | T3Ui.EditingFlags.PreventPanningWithMouse; } DrawAnimationCanvas(drawAdditionalCanvasContent: DrawCanvasContent, detailsActive ? null : _selectionFence, 0, outerFlags, drawVSnapIndicator: !detailsActive); Current = null; T3Ui.UiScaleFactor = keepScale; return; void DrawCanvasContent(InteractionState interactionState) { // Cross-view hover state is rebuilt each frame by whichever renderer's hit-test // sets it. Stale values would leak hover fade to the next frame on mouse-exit. HoveredParameterHash = null; HoveredComponentBit = 0; HoveredKeyframeUniqueId = null; ImGui.SetCursorPosY(ImGui.GetCursorPosY() ); if (PlaybackUtils.TryFindingSoundtrack(out var soundtrack, out var composition)) { TimeLineImage.Draw(Drawlist, soundtrack); } _timeRasterSwitcher.Draw(this); // Ruler { ImGui.BeginChild("##ruler", new Vector2(0,RulerHeight), ImGuiChildFlags.None, ImGuiWindowFlags.NoScrollbar); DrawTimeRuler(interactionState.MouseState.Position.X); _selectionRangeIndicator.Draw(compositionOp, ImGui.GetWindowDrawList()); ImGui.EndChild(); } // Selection Area (summary strip below ruler) { ImGui.PushStyleColor(ImGuiCol.ChildBg, UiColors.GridLines.Fade(0.20f).Rgba); ImGui.BeginChild("##selectionArea", new Vector2(0,SummaryHeight)); _timeSelectionArea.Draw(compositionOp, _selectedAnimationParameters, KeyframeEditors, ImGui.GetWindowDrawList()); ImGui.EndChild(); ImGui.PopStyleColor(); } // SRI and SA may have mutated keyframe U values via TimeWarpDrag / the SA's ApplyKeyframeTimeOffset. // Rebuild each active editor's curve tables once, centrally, so downstream editor draws see sorted curves. KeyframeEditors.ProcessPendingCurveTableRebuild(); HandleDeferredActions(); ImGui.BeginChild(ImGuiTitle, new Vector2(0, 0), ImGuiChildFlags.Borders, ImGuiWindowFlags.NoMove | ImGuiWindowFlags.NoBackground | ImGuiWindowFlags.NoScrollWithMouse); { if (KeyActionHandling.Triggered(UserActions.DeleteSelection)) DeleteSelectedElements(compositionOp); // Layout: top pane (dope + clips) | resize handle | details pane. // The 8 px handle gap sits outside any child's hit rect so ImGui routes // clicks to the handle; the two ItemSpacing.Y values close out the math // so the three stacked items consume exactly rawAvailHeight. var detailsActive = _detailsArea.IsActive(compositionOp); var rawAvailHeight = ImGui.GetContentRegionAvail().Y; if (detailsActive) DetailsAreaHeight = MathF.Min(MathF.Max(DetailsAreaHeight, 80f), MathF.Max(80f, rawAvailHeight - 80f)); const float splitterReserve = 8f; var splitterGap = detailsActive ? splitterReserve : 0f; var spacingReserve = detailsActive ? 2f * ImGui.GetStyle().ItemSpacing.Y : 0f; var detailsHeight = detailsActive ? DetailsAreaHeight : 0f; var topHeight = MathF.Max(60f, rawAvailHeight - detailsHeight - splitterGap - spacingReserve); switch (Mode) { case Modes.DopeView: if (detailsActive) { ImGui.BeginChild("##timelineTopPane", new Vector2(0, topHeight), ImGuiChildFlags.None, ImGuiWindowFlags.NoBackground | ImGuiWindowFlags.NoScrollWithMouse); var dopeContentStartY = ImGui.GetCursorScreenPos().Y; ClipArea.Draw(compositionOp, Playback, SnapHandlerForU); DopeSheetArea.Draw(compositionOp, _selectedAnimationParameters); // Dope-local fence — outer fence is suppressed while the // details pane is up so its clicks don't bubble out and // wipe the selection that opened the pane. Delegate to the // shared UpdateSelectionForArea so background clicks clear // every manipulator (keyframes AND clips), matching the // outer-fence behaviour the user expects. if (!T3Ui.IsAnyPopupOpen) { switch (_dopeFence.UpdateAndDraw(out var selectMode)) { case SelectionFence.States.Updated: case SelectionFence.States.CompletedAsClick: UpdateSelectionForArea(_dopeFence.BoundsInScreen, selectMode); break; } } CustomComponents.HandleDragScrolling(this); _lastDopeContentHeight = MathF.Max(0f, ImGui.GetCursorScreenPos().Y - dopeContentStartY); ImGui.EndChild(); _detailsArea.DrawResizeHandle(splitterReserve); ImGui.PushStyleColor(ImGuiCol.ChildBg, UiColors.BackgroundFull.Fade(0.2f).Rgba); _detailsArea.Draw(compositionOp, _selectedAnimationParameters, detailsHeight, detailsHeight, modeChanged); ImGui.PopStyleColor(); } else { ClipArea.Draw(compositionOp, Playback, SnapHandlerForU); DopeSheetArea.Draw(compositionOp, _selectedAnimationParameters); } break; case Modes.CurveEditor: { _horizontalRaster.Draw(this); var heightChanged = Math.Abs(ImGui.GetWindowHeight() - _lastCurveEditorHeight) > 1f; _lastCurveEditorHeight = ImGui.GetWindowHeight(); var selectionHash = ComputeSelectionHash(); var selectionChanged = selectionHash != _lastSelectionHash; _lastSelectionHash = selectionHash; _timelineCurveEditArea.Draw(compositionOp, _selectedAnimationParameters, fitVerticalOnly: modeChanged || selectionChanged || heightChanged); break; } } var compositionTimeClip = Structure.GetCompositionTimeClip(compositionOp); if (Playback.IsLooping) { _loopRange.Draw(this, Playback, Drawlist, SnapHandlerForU); } else if (compositionTimeClip != null) { _clipRange.Draw(this, compositionTimeClip, Drawlist, SnapHandlerForU); } // When the details pane is up, the top-pane child already handles its // own drag-scroll; outer call would hit ImGuiTitle (no overflow). if (!detailsActive) CustomComponents.HandleDragScrolling(this); } ImGui.EndChild(); _currentTimeMarker.Draw(Playback.TimeInBars, this); _timeSelectionRange.Draw(compositionOp, Drawlist); if (_selectionFence.State == SelectionFence.States.CompletedAsClick) { var newTime = InverseTransformPositionFloat(ImGui.GetMousePos()).X; Playback.TimeInBars = newTime; // Background click clears keyframe selection (both panes). if (SharedSelectedKeyframes.Count > 0) { SharedSelectedKeyframes.Clear(); OnKeyframeSelectionReplaced(); } } } } /// /// Keeps TimelineCurveEditor registered with the snap handler and keyframe-editor group /// while the inline curve pane is visible (DopeView + any expanded parameter). We do NOT /// add it to : that list dispatches /// drag / selection mutations to every entry, and both editors share the same /// SelectedKeyframes set — so double-registering makes every UpdateDragCommand /// apply U += dt twice per frame (oscillating keyframe jitter). DopeSheetArea is the single /// drag dispatcher; curve-pane visuals pick up changes via the shared selection set. /// private void SyncInlineCurveEditorRegistration() { var shouldBeActive = Mode == Modes.DopeView && CurveEditingParamHashes.Count > 0; if (shouldBeActive && !_curveEditorRegisteredInline) { SnapHandlerForU.AddSnapAttractor(_timelineCurveEditArea); _activeKeyframeEditors.Add(_timelineCurveEditArea); _curveEditorRegisteredInline = true; } else if (!shouldBeActive && _curveEditorRegisteredInline) { SnapHandlerForU.RemoveSnapAttractor(_timelineCurveEditArea); _activeKeyframeEditors.Remove(_timelineCurveEditArea); _curveEditorRegisteredInline = false; } } private bool _curveEditorRegisteredInline; /// /// In the inline curve-edit layout the timeline canvas becomes the U-axis authority and /// the curve-edit sub-canvas owns V. Constrain wheel zoom to U here so DSA wheel-zoom /// doesn't incidentally scale V (which outer canvas doesn't visibly render anyway, but we /// don't want stray ScrollTarget.Y updates either). Outside the inline layout, fall back /// to the base behavior. /// protected override void ApplyZoomDelta(Vector2 position, float zoomDelta, out bool zoomed) { if (Mode != Modes.DopeView || CurveEditingParamHashes.Count == 0) { base.ApplyZoomDelta(position, zoomDelta, out zoomed); return; } zoomed = false; if (Math.Abs(zoomDelta - 1) < 0.001f) return; var clamped = ClampScaleToValidRange(ScaleTarget * zoomDelta); if (clamped == ScaleTarget) return; var zoom = new Vector2(zoomDelta, 1f); ScaleTarget *= zoom; if (Math.Abs(zoomDelta) > 0.1f) zoomed = true; var focus = InverseTransformPositionFloat(position); ScrollTarget += (focus - ScrollTarget) * (zoom - Vector2.One) / zoom; } /// /// Drops any curve-editing hash whose parameter is no longer among the current /// selection's animated parameters. If the selection no longer includes an expanded /// param (e.g. the user selected a different node in the graph), the inline curve /// area would otherwise linger empty; pruning closes it automatically when the last /// expanded param disappears. /// private void PruneExpandedForMissingParams() { if (CurveEditingParamHashes.Count == 0) return; CurveEditingParamHashes.RemoveWhere(hash => !ContainsHash(_selectedAnimationParameters, hash)); // Drop orphaned component-mask entries (small alloc only when a hash was actually removed). if (VisibleComponentMask.Count > 0) { foreach (var h in VisibleComponentMask.Keys.ToList()) { if (!CurveEditingParamHashes.Contains(h)) VisibleComponentMask.Remove(h); } } } private static bool ContainsHash(List list, int hash) { for (var i = 0; i < list.Count; i++) { if (list[i].Hash == hash) return true; } return false; } // // #region handle nested timelines ---------------------------------- // public override void UpdateScaleAndTranslation(Instance compositionOp, ScalableCanvas.Transition transition) // { // if (transition == ScalableCanvas.Transition.Instant) // return; // // // remember the old scroll state // var oldScale = Scale; // var oldScroll = Scroll; // // var clip = Structure.GetCompositionTimeClip(compositionOp); // if (clip == null) return; // // // determine scaling factor // TimeRange sourceRange, targetRange; // if (transition == ScalableCanvas.Transition.JumpIn) // { // sourceRange = clip.TimeRange; // targetRange = clip.SourceRange; // } // else // { // sourceRange = clip.SourceRange; // targetRange = clip.TimeRange; // } // // float scale = targetRange.Duration / sourceRange.Duration; // // // remove scrolling, then determine where the time clip is centered // Scroll = new Vector2(0, Scroll.Y); // var centerOfSourceRange = (sourceRange.Start + sourceRange.End) * 0.5f; // var originalScreenPos = TransformX(centerOfSourceRange); // // // now apply scaling and determine where the source clip is centered // Scale /= scale; // var centerOfTargetRange = (targetRange.Start + targetRange.End) * 0.5f; // var newScreenPos = TransformX(centerOfTargetRange); // // // set final scale and "undo" the movement of the position // ScaleTarget.X = Scale.X; // var positionDelta = new Vector2(newScreenPos - originalScreenPos, 0f); // ScrollTarget.X = oldScroll.X * scale + InverseTransformDirection(positionDelta).X; // // // restore the old scale and scroll state // Scale = oldScale; // Scroll = oldScroll; // } // #endregion private void HandleDeferredActions() { if (UserActionRegistry.WasActionQueued(UserActions.PlaybackJumpToNextKeyframe)) { var bestNextTime = double.PositiveInfinity; var foundNext = false; var time = Playback.TimeInBars + 0.001f; foreach (var next in _selectedAnimationParameters) { foreach (var curve in next.Curves) { if (!curve.TryGetNextKey(time, out var key) || key.U > bestNextTime) continue; foundNext = true; bestNextTime = key.U; } } if (foundNext) Playback.TimeInBars = bestNextTime; } if (UserActionRegistry.WasActionQueued(UserActions.PlaybackJumpToPreviousKeyframe)) { var bestPreviousTime = double.NegativeInfinity; var foundNext = false; var time = Playback.TimeInBars - 0.001f; foreach (var next in _selectedAnimationParameters) { foreach (var curve in next.Curves) { if (!curve.TryGetPreviousKey(time, out var key) || key.U < bestPreviousTime) continue; foundNext = true; bestPreviousTime = key.U; } } if (foundNext) Playback.TimeInBars = bestPreviousTime; } } private void DrawTimeRuler(float mouseX) { var max = ImGui.GetWindowSize(); var clampedSize = max; clampedSize.Y = Math.Min(TimeLineDragHeight, max.Y - 1); ImGui.SetCursorPos(new Vector2(0, max.Y - clampedSize.Y)); // Allow the SelectionRangeIndicator (emitted later in the same ruler child) to steal hover/press in its overlapping area. ImGui.SetNextItemAllowOverlap(); ImGui.InvisibleButton("##TimeDrag", clampedSize); if (ImGui.IsItemHovered()) { ImGui.SetMouseCursor(ImGuiMouseCursor.ResizeEW); } if (ImGui.IsItemActive() && ImGui.IsMouseDragging(ImGuiMouseButton.Left) || ImGui.IsItemClicked()) { var draggedTime = InverseTransformX(mouseX); if (ImGui.GetIO().KeyShift) { if (SnapHandlerForU.TryCheckForSnapping(draggedTime, out var snappedValue, Scale.X, [_currentTimeMarker])) { draggedTime = (float)snappedValue; } } Playback.TimeInBars = draggedTime; } ImGui.SetCursorPos(Vector2.Zero); } private void ScrollToTimeAfterStopped() { var isPlaying = Math.Abs(Playback.PlaybackSpeed) > 0.01f; var wasPlaying = Math.Abs(_lastPlaybackSpeed) > 0.01f; if (!isPlaying && wasPlaying) { if (!IsCurrentTimeVisible()) { // assume we are not scrolling, what screen position would the playhead be at? var oldScroll = Scroll; Scroll = new Vector2(0, Scroll.Y); var posScreen = TransformX((float)Playback.TimeInBars); // position that playhead in the center of the window ScrollTarget.X = InverseTransformX(posScreen - WindowSize.X * 0.5f); // restore old state of scrolling Scroll = oldScroll; } } _lastPlaybackSpeed = Playback.PlaybackSpeed; } private bool IsCurrentTimeVisible() { var timePosInScreen = TransformPosition(new Vector2((float)this.Playback.TimeInBars, 0)); var timelineArea = ImRect.RectWithSize(WindowPos, WindowSize); timePosInScreen.Y = timelineArea.GetCenter().Y; // Adjust potential vertical scrolling of timeline area return timelineArea.Contains(timePosInScreen); } #region view modes private bool UpdateMode() { if (Mode == _lastMode) return false; // Tear down previous mode (skip on first call when _lastMode is null) switch (_lastMode) { case Modes.DopeView: TimeObjectManipulators.Remove(DopeSheetArea); TimeObjectManipulators.Remove(ClipArea); SnapHandlerForU.RemoveSnapAttractor(DopeSheetArea); _activeKeyframeEditors.Remove(DopeSheetArea); break; case Modes.CurveEditor: TimeObjectManipulators.Remove(_timelineCurveEditArea); SnapHandlerForU.RemoveSnapAttractor(_timelineCurveEditArea); _activeKeyframeEditors.Remove(_timelineCurveEditArea); break; } switch (Mode) { case Modes.DopeView: TimeObjectManipulators.Add(DopeSheetArea); TimeObjectManipulators.Add(ClipArea); SnapHandlerForU.AddSnapAttractor(DopeSheetArea); _activeKeyframeEditors.Add(DopeSheetArea); break; case Modes.CurveEditor: TimeObjectManipulators.Add(_timelineCurveEditArea); SnapHandlerForU.AddSnapAttractor(_timelineCurveEditArea); _activeKeyframeEditors.Add(_timelineCurveEditArea); break; case Modes.Undefined: Mode = Modes.DopeView; goto case Modes.DopeView; } _lastMode = Mode; return true; } public enum Modes { Undefined, DopeView, CurveEditor, } public Modes Mode = Modes.DopeView; private Modes _lastMode = Modes.Undefined; private float _lastCurveEditorHeight; private int _lastSelectionHash; // Per-parameter curve-expand state, populated by the curve-toggle icon on each dope-sheet row. // When non-empty in DopeView mode, the timeline body splits into dope-sheet (top) + curve-editor (below). internal readonly HashSet CurveEditingParamHashes = new(); // paramHash -> visible-component bitmask; missing entry = "all components visible". // Cleared when a parameter is un-expanded so re-expanding resets to all-on. internal readonly Dictionary VisibleComponentMask = new(); // Cross-view hover link (Phase 3). Written by whichever view the mouse is over; // both views read these to render matching emphasis. internal int? HoveredParameterHash; internal int HoveredComponentBit; internal int? HoveredKeyframeUniqueId; internal bool NormalizeCurveView; // Published by InlineCurveArea each frame it draws; null when the pane isn't visible. // TimeLineCanvas.Draw reads this at the top of the next frame to cede wheel/pan to the // curve-area sub-canvas when the mouse is inside it. internal ImRect? CurveEditAreaScreenRect; // Shared keyframe selection — DopeSheetArea and TimelineCurveEditor both receive this // via CurveEditing's shared-selection ctor, so edits in one view reflect in the other // (and in the SRI / SelectionArea aggregators). internal readonly VersionedKeyframeSet SharedSelectedKeyframes = new(); private int ComputeSelectionHash() { var hash = _selectedAnimationParameters.Count; for (var i = 0; i < _selectedAnimationParameters.Count; i++) { hash = hash * 397 ^ _selectedAnimationParameters[i].Hash; } return hash; } private void SyncStateWithComposition(Instance compositionOp) { var symbolId = compositionOp.Symbol.Id; if (symbolId == _lastSyncedSymbolId) { // Sync copy fields every frame so saves capture current values var currentSymbolUi = compositionOp.Symbol.GetSymbolUi(); if (currentSymbolUi != null) { currentSymbolUi.TimelineState ??= new TimelineState(); SaveStateTo(currentSymbolUi.TimelineState); } return; } // Save to previous composition. Read-only symbols keep the state in memory but must not be // flagged as modified — merely viewing them would prompt to save them as a copy. if (_lastSyncedSymbolUi != null) { _lastSyncedSymbolUi.TimelineState ??= new TimelineState(); SaveStateTo(_lastSyncedSymbolUi.TimelineState); if (!_lastSyncedSymbolUi.ReadOnly) { _lastSyncedSymbolUi.FlagAsModified(); } } _lastSyncedSymbolId = symbolId; _lastSyncedSymbolUi = compositionOp.Symbol.GetSymbolUi(); // Load from new composition if (_lastSyncedSymbolUi?.TimelineState is { } state) { LoadStateFrom(state); } } private Guid _lastSyncedSymbolId; private SymbolUi? _lastSyncedSymbolUi; internal void SaveStateTo(TimelineState state) { state.ScaleX = Scale.X; state.ScrollX = Scroll.X; state.Mode = Mode; state.TimelineHeight = FoldingHeight._customTimeLineHeight; state.InlineDataClipEditEnabled = InlineDataClipEditEnabled; state.DetailsAreaHeight = DetailsAreaHeight; } internal void LoadStateFrom(TimelineState state) { ScaleTarget = new Vector2(state.ScaleX, ScaleTarget.Y); Scale = new Vector2(state.ScaleX, Scale.Y); ScrollTarget = new Vector2(state.ScrollX, ScrollTarget.Y); Scroll = new Vector2(state.ScrollX, Scroll.Y); Mode = state.Mode; FoldingHeight._customTimeLineHeight = state.TimelineHeight; InlineDataClipEditEnabled = state.InlineDataClipEditEnabled; DetailsAreaHeight = state.DetailsAreaHeight; } /// /// Toggle for the inline DataClip edit area. Driven by the AudioFile icon button next /// to the Record toggle on the timeline toolbar. When on AND a TimeClip with a /// DataClip output is selected, the inline area renders below the dope sheet. /// internal bool InlineDataClipEditEnabled; /// /// User-controlled height of the inline details pane (curve editor OR DataClip /// editor — both share one pane). Persisted with and /// resized via the splitter shown above the pane. Clamped at layout time so a value /// saved from a taller window can't starve the dope sheet on a smaller monitor. /// internal float DetailsAreaHeight = 200f; /// Forwards a mouse-wheel zoom from an embedded pane to this canvas's X axis. internal void ZoomXFromEmbedded(float wheelDelta, Vector2 mouseScreenPos) { if (Math.Abs(wheelDelta) < 0.0001f) return; var mouseState = new MouseState(mouseScreenPos, ImGui.GetIO().MouseDelta, wheelDelta); ZoomWithMouseWheel(mouseState, out _); } /// /// Pans this canvas's X scroll by a screen-pixel delta measured from a drag-start /// snapshot. Embedded panes capture .X on RMB-down then call /// this each frame so cumulative drag deltas don't over-accumulate. /// internal void PanXFromEmbedded(float startScrollX, float screenDeltaX) { if (Scale.X <= 0) return; var canvasDelta = screenDeltaX / Scale.X; Scroll = new Vector2(startScrollX - canvasDelta, Scroll.Y); ScrollTarget = new Vector2(startScrollX - canvasDelta, ScrollTarget.Y); } #endregion // TODO: this is horrible and should be refactored private List GetAnimationParametersForSelectedNodes(Instance compositionOp) { var symbolUi = compositionOp.GetSymbolUi(); var animator = symbolUi.Symbol.Animator; // Copy objects to reuse if possible _tmpAnimationParameters.Clear(); _tmpAnimationParameters.AddRange(_pinnedParams); _pinnedParams.Clear(); _curvesForSelection.Clear(); var children = compositionOp.Children.Values; foreach (var child in children) { var isChildSelected = Selected.Unknown; for (var inputIndex = 0; inputIndex < child.Inputs.Count; inputIndex++) { var input = child.Inputs[inputIndex]; if (!animator.IsInputSlotAnimated(input)) continue; // Test if child is selected only once and only if required if (isChildSelected == Selected.Unknown) { isChildSelected = Selected.No; foreach (var s in _nodeSelection.Selection) { if (s.Id != child.SymbolChildId) continue; isChildSelected = Selected.Yes; break; } } var paramHash = input.GetChildSlotHash(); var isPinned = false; // Collect pinned foreach (var pinnedInputSlot in DopeSheetArea.PinnedParametersHashes) { if (pinnedInputSlot != input.GetChildSlotHash()) continue; if (!TryGetParamFromSpan(paramHash, out var param)) { if (!animator.TryGetCurvesForInputSlot(input, out var curves)) { Log.Warning("Can't find curves for animated parameter?"); continue; } param = new AnimationParameter { Instance = child, Input = input, Curves = curves, ChildUi = symbolUi.ChildUis[child.SymbolChildId], Hash = paramHash, }; } _pinnedParams.Add(param); isPinned = true; } if (isPinned || isChildSelected != Selected.Yes) continue; { if (!TryGetParamFromSpan(paramHash, out var param)) { if (!animator.TryGetCurvesForInputSlot(input, out var curves)) { Log.Warning("Can't find curves for animated parameter?"); continue; } param = new AnimationParameter { Instance = child, Input = input, Curves = curves, ChildUi = symbolUi.ChildUis[child.SymbolChildId], Hash = paramHash, }; } _curvesForSelection.Add(param); } } } // Merge collections _pinnedParams.AddRange(_curvesForSelection.FindAll(sp => _pinnedParams.All(pp => pp.Input != sp.Input))); return _pinnedParams; } private static bool TryGetParamFromSpan(int paramHash, [NotNullWhen(true)] out AnimationParameter? parameter) { parameter = null; foreach (var p in _tmpAnimationParameters) { if (p.Hash != paramHash) continue; parameter = p; return true; } return false; } internal Playback Playback; public readonly DopeSheetArea DopeSheetArea; public readonly ClipArea ClipArea; public static TimeLineCanvas? Current; private enum Selected { Yes, No, Unknown, } /** Only used to avoid allocations */ private static readonly List _tmpAnimationParameters = []; private List _selectedAnimationParameters = []; private readonly TimeRasterSwitcher _timeRasterSwitcher = new(); private readonly HorizontalRaster _horizontalRaster = new(); private readonly ClipRange _clipRange = new(); private readonly LoopRange _loopRange = new(); private readonly TimelineCurveEditor _timelineCurveEditArea; private readonly InlineCurveArea _curveEditCanvas; private readonly InlineDataClipArea _inlineDataClipArea; private readonly TimelineDetailsArea _detailsArea; private readonly CurrentTimeMarker _currentTimeMarker = new(); private readonly TimeSelectionRange _timeSelectionRange; private readonly SelectionRangeIndicator _selectionRangeIndicator; private readonly TimeSelectionArea _timeSelectionArea; private readonly IValueSnapAttractor[] _selectionDragSnapExclusions; private readonly List _activeKeyframeEditors = new(2); private readonly NodeSelection _nodeSelection; private double _lastPlaybackSpeed; private readonly List _pinnedParams = new(20); private readonly List _curvesForSelection = new(64); private readonly SelectionFence _selectionFence = new(); private readonly SelectionFence _dopeFence = new(); // Measured dope-pane content height from the previous frame (ClipArea + DSA rows + any // other interactive content drawn in that child). Drives the inline-layout split so the // dope pane takes only what it needs, capped at 50% of available height. private float _lastDopeContentHeight = 120f; // Styling private const float TimeLineDragHeight = 30; internal readonly TimelineHeight FoldingHeight; public sealed class AnimationParameter { public required Curve[] Curves; public required IInputSlot Input; public required Instance Instance; public required SymbolUi.Child ChildUi; public required int Hash; public float DampedMinValue; public float DampedMaxValue; } internal sealed class TimelineHeight { public TimelineHeight(TimeLineCanvas timeline) { _timeline = timeline; } public void DrawSplit(out int newContentHeight) { var currentTimelineHeight = _timeline.FoldingHeight.CurrentHeight; if (CustomComponents.SplitFromBottom(ref currentTimelineHeight)) { _customTimeLineHeight = (int)currentTimelineHeight; } newContentHeight = (int)ImGui.GetWindowHeight() - (int)currentTimelineHeight - 4; // Hack that also depends on when a window-title is being rendered } private const int UseComputedHeight = -1; internal int _customTimeLineHeight = UseComputedHeight; private readonly TimeLineCanvas _timeline; public bool UsingCustomTimelineHeight => _customTimeLineHeight > UseComputedHeight; private float CurrentHeight => UsingCustomTimelineHeight ? _customTimeLineHeight : ComputedTimelineHeight; private float ComputedTimelineHeight => MathF.Min( _timeline._selectedAnimationParameters.Count * DopeSheetArea.LayerHeight + _timeline.ClipArea.LastHeight + TimeLineDragHeight + 10, 200 * T3Ui.UiScaleFactor); public void Toggle() { _customTimeLineHeight = UsingCustomTimelineHeight ? UseComputedHeight : 200; } } } public enum FrameStepAmount { FrameAt60Fps, FrameAt30Fps, FrameAt15Fps, Bar, Beat, Tick, }