Files
2026-07-13 13:13:17 +08:00

982 lines
40 KiB
C#

#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;
/// <summary>
/// Combines multiple <see cref="ITimeObjectManipulation"/>s into a single consistent
/// timeline that allows dragging selected time elements of various types.
/// </summary>
[HelpUiID("Timeline")]
internal sealed class TimeLineCanvas : AnimationCanvas
{
public TimeLineCanvas(NodeSelection nodeSelection, Func<Instance> getCompositionOp, Func<Guid, bool> 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!;
}
/// <summary>
/// Snap-attractors to skip while dragging selected keyframes or keyset-indicator clusters.
/// The <see cref="SelectionRangeIndicator"/> 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.
/// </summary>
internal IValueSnapAttractor[] SelectionDragSnapExclusions => _selectionDragSnapExclusions;
/// <summary>
/// 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 <see cref="KeyframeEditors"/> so they don't need to pick one.
/// </summary>
public readonly KeyframeEditorGroup KeyframeEditors;
/// <summary>
/// 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.
/// </summary>
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();
}
}
}
}
/// <summary>
/// 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 <see cref="AnimationCanvas.TimeObjectManipulators"/>: that list dispatches
/// drag / selection mutations to every entry, and both editors share the same
/// <c>SelectedKeyframes</c> set — so double-registering makes every <c>UpdateDragCommand</c>
/// 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.
/// </summary>
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;
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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<AnimationParameter> 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<int> 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<int, int> 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;
}
/// <summary>
/// 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
/// <c>DataClip</c> output is selected, the inline area renders below the dope sheet.
/// </summary>
internal bool InlineDataClipEditEnabled;
/// <summary>
/// User-controlled height of the inline details pane (curve editor OR DataClip
/// editor — both share one pane). Persisted with <see cref="TimelineState"/> 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.
/// </summary>
internal float DetailsAreaHeight = 200f;
/// <summary>Forwards a mouse-wheel zoom from an embedded pane to this canvas's X axis.</summary>
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 _);
}
/// <summary>
/// Pans this canvas's X scroll by a screen-pixel delta measured from a drag-start
/// snapshot. Embedded panes capture <see cref="Scroll"/>.X on RMB-down then call
/// this each frame so cumulative drag deltas don't over-accumulate.
/// </summary>
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<AnimationParameter> 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<AnimationParameter> _tmpAnimationParameters = [];
private List<AnimationParameter> _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<AnimationParameterEditing> _activeKeyframeEditors = new(2);
private readonly NodeSelection _nodeSelection;
private double _lastPlaybackSpeed;
private readonly List<AnimationParameter> _pinnedParams = new(20);
private readonly List<AnimationParameter> _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,
}