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

500 lines
20 KiB
C#

#nullable enable
using ImGuiNET;
using T3.Core.Animation;
using T3.Core.DataTypes;
using T3.Core.DataTypes.Vector;
using T3.Editor.Gui.UiHelpers;
using T3.Core.Operator;
using T3.Editor.Gui.Styling;
using T3.Editor.UiModel.Commands;
using T3.Editor.UiModel.Commands.Animation;
using T3.Editor.UiModel.Selection;
namespace T3.Editor.Gui.Windows.TimeLine;
/// <summary>
/// Thin strip below the timeline ruler that shows a baked icon for every keyframe
/// (or cluster of keyframes within sub-pixel distance) across the visible parameters.
///
/// Click: replace the keyframe selection with the clicked cluster's keys.
/// Drag: move the cluster's keys through time without touching the selection.
/// The broader "move whole selection" interaction lives in <see cref="SelectionRangeIndicator"/>.
///
/// To make the drag survive bucket merges/splits (e.g. dragging a cluster past another),
/// hover + press is detected via a single strip-wide InvisibleButton with a stable ID
/// rather than per-bucket buttons. ImGui's active-item tracking therefore stays valid
/// for the full duration of the drag, regardless of how clustering changes underneath.
///
/// The bucket layout itself is cached and only rebuilt when the view (scale/scroll), the
/// animation parameters, curve contents, or the keyframe selection change.
/// </summary>
internal sealed class TimeSelectionArea
{
public TimeSelectionArea(TimeLineCanvas canvas)
{
_canvas = canvas;
}
public void Draw(
Instance composition,
List<TimeLineCanvas.AnimationParameter> animationParameters,
KeyframeEditorGroup editors,
ImDrawListPtr drawList)
{
var windowPos = ImGui.GetWindowPos();
var windowSize = ImGui.GetWindowSize();
var scaleX = _canvas.Scale.X;
var scrollX = _canvas.Scroll.X;
var hash = ComputeStateHash(animationParameters, editors, scaleX, scrollX, windowPos, windowSize);
if (hash != _cachedStateHash)
{
RebuildBuckets(animationParameters, editors, windowPos, windowSize);
_cachedStateHash = hash;
}
var scale = T3Ui.UiScaleFactor;
var iconSize = 7f * scale;
var iconY = windowPos.Y + (windowSize.Y - iconSize) * 0.5f;
// A single strip-wide InvisibleButton claims the mouse interaction for the whole SA.
// Stable ID means ImGui's active-item tracking persists across bucket rebuilds, so a
// drag that crosses another cluster's position doesn't get cancelled.
ImGui.SetCursorScreenPos(windowPos);
ImGui.InvisibleButton("##saStrip", windowSize);
var isItemHovered = ImGui.IsItemHovered();
var isItemActive = ImGui.IsItemActive();
var isItemActivated = ImGui.IsItemActivated();
var isItemDeactivated = ImGui.IsItemDeactivated();
// Locate bucket under mouse (for hover + press bucket identification).
var hoveredIndex = -1;
if ((isItemHovered || isItemActive) && _cachedBuckets.Count > 0)
{
var mouseX = ImGui.GetMousePos().X;
var hitThreshold = iconSize * 0.5f + 1 * scale;
var bestDist = float.MaxValue;
for (var i = 0; i < _cachedBuckets.Count; i++)
{
var d = MathF.Abs(_cachedBuckets[i].CenterX - mouseX);
if (d < hitThreshold && d < bestDist)
{
bestDist = d;
hoveredIndex = i;
}
}
}
if (isItemHovered || isItemActive)
ImGui.SetMouseCursor(ImGuiMouseCursor.Hand);
if (isItemActivated)
{
if (hoveredIndex >= 0)
{
_pressedBucketStableId = _cachedBuckets[hoveredIndex].StableId;
_isPressed = true;
}
else
{
// Pressed on SA background: click = clear selection; drag = fence-select buckets.
_isBackgroundPressed = true;
_fenceStartScreen = ImGui.GetMousePos();
// Snapshot the pre-fence selection so Add/Remove modes are relative to it every frame,
// not accumulated across frames as the fence moves.
editors.CopyAllSelectedKeyframesTo(_preFenceSnapshot);
}
}
if (_isPressed && isItemActive && !_isDragging
&& ImGui.IsMouseDragging(ImGuiMouseButton.Left, 2f * scale))
{
var pressedIndex = FindBucketByStableId(_pressedBucketStableId);
if (pressedIndex >= 0)
{
CaptureDragSet(pressedIndex);
_dragCommand = new ChangeKeyframesCommand(_draggingKeys, _draggingCurves);
// The anchor key (first in _draggingKeys) tracks with the mouse using this fixed grab offset.
// Snapping targets the anchor's would-be U, matching the dope sheet's behavior.
var mouseU = _canvas.InverseTransformX(ImGui.GetMousePos().X);
_dragGrabOffset = mouseU - _draggingKeys[0].U;
_isDragging = true;
// Tell DSA's snap attractor to skip the bucket's own keys for this drag — the
// bucket carries the whole set, not just the selected ones, so unselected siblings
// would otherwise pull the dragged anchor onto themselves each frame.
_canvas.DopeSheetArea.ExternalDragSnapExclusions = _draggingKeys;
}
}
// Background fence: activate once the user actually drags past the click threshold.
if (_isBackgroundPressed && isItemActive && !_isFencing
&& ImGui.IsMouseDragging(ImGuiMouseButton.Left, 2f * scale))
{
_isFencing = true;
}
var liveFenceRect = default(ImRect);
var liveFenceMode = SelectionFence.SelectModes.Replace;
if (_isFencing && isItemActive)
{
FrameStats.Current.OpenedPopupCapturedMouse = true;
liveFenceRect = BuildFenceRect(ImGui.GetMousePos(), windowPos, windowSize);
liveFenceMode = GetSelectMode(ImGui.GetIO());
drawList.AddRectFilled(liveFenceRect.Min, liveFenceRect.Max, UiColors.Selection.Fade(0.15f));
drawList.AddRect(liveFenceRect.Min, liveFenceRect.Max, UiColors.Selection.Fade(0.5f));
// Apply the fence live (from the pre-fence snapshot) so the DopeSheet keyframes reflect
// the selection while dragging — not just after release. Add/Remove modes stay coherent
// because we rebase from the snapshot each frame instead of accumulating.
editors.ReplaceKeyframeSelection(_preFenceSnapshot);
ApplyFenceSelection(liveFenceRect, liveFenceMode, editors);
}
if (_isDragging && isItemActive && _draggingKeys.Count > 0)
{
FrameStats.Current.OpenedPopupCapturedMouse = true;
var mouseU = _canvas.InverseTransformX(ImGui.GetMousePos().X);
var desiredAnchorU = mouseU - _dragGrabOffset;
if (!ImGui.GetIO().KeyShift
&& _canvas.SnapHandlerForU.TryCheckForSnapping(desiredAnchorU, out var snappedValue, _canvas.Scale.X,
_canvas.SelectionDragSnapExclusions))
{
desiredAnchorU = (float)snappedValue;
}
var du = desiredAnchorU - _draggingKeys[0].U;
if (du != 0)
editors.ApplyKeyframeTimeOffset(_draggingKeys, du);
}
if (isItemDeactivated)
{
if (_isDragging && _dragCommand != null)
{
_dragCommand.StoreCurrentValues();
UndoRedoStack.AddAndExecute(_dragCommand);
_dragCommand = null;
_isDragging = false;
}
else if (_isPressed)
{
// Click without drag: replace the keyframe selection with this bucket's keys.
var pressedIndex = FindBucketByStableId(_pressedBucketStableId);
if (pressedIndex >= 0)
{
_tempBucketKeys.Clear();
var b = _cachedBuckets[pressedIndex];
for (var i = 0; i < b.KeyCount; i++)
_tempBucketKeys.Add(_rawKeys[b.FirstKeyIndex + i].Def);
editors.ReplaceKeyframeSelection(_tempBucketKeys);
}
}
else if (_isFencing)
{
// Re-apply using final mouse position (the live-apply branch is gated on isItemActive
// which is false on the deactivation frame, so the committed state could otherwise lag
// the user's last motion by one frame).
var fenceRect = BuildFenceRect(ImGui.GetMousePos(), windowPos, windowSize);
editors.ReplaceKeyframeSelection(_preFenceSnapshot);
ApplyFenceSelection(fenceRect, GetSelectMode(ImGui.GetIO()), editors);
}
else if (_isBackgroundPressed)
{
// Click on SA background without drag: clear keyframe selection.
_tempBucketKeys.Clear();
editors.ReplaceKeyframeSelection(_tempBucketKeys);
}
_isPressed = false;
_isBackgroundPressed = false;
_isFencing = false;
_canvas.DopeSheetArea.ExternalDragSnapExclusions = null;
_draggingKeys.Clear();
_draggingCurves.Clear();
}
// Icon render
if (_cachedBuckets.Count == 0)
return;
var highlightedIndex = _isDragging
? FindBucketByStableId(_pressedBucketStableId)
: hoveredIndex;
var defaultColor = UiColors.ForegroundFull.Fade(0.7f);
var hoverColor = UiColors.ForegroundFull;
for (var i = 0; i < _cachedBuckets.Count; i++)
{
var b = _cachedBuckets[i];
// While fencing, preview the effective selection so icons update before release.
var effectiveSelected = b.Selected;
if (_isFencing)
{
var inside = b.CenterX >= liveFenceRect.Min.X && b.CenterX <= liveFenceRect.Max.X;
effectiveSelected = liveFenceMode switch
{
SelectionFence.SelectModes.Replace => inside ? b.Total : 0,
SelectionFence.SelectModes.Add => inside ? b.Total : b.Selected,
SelectionFence.SelectModes.Remove => inside ? 0 : b.Selected,
_ => b.Selected,
};
}
var icon = effectiveSelected == 0
? Icon.KeyIndicator
: effectiveSelected < b.Total
? Icon.KeyIndicatorSelectedPartially
: Icon.KeyIndicatorSelected;
var pos = new Vector2(MathF.Floor(b.CenterX - iconSize * 0.5f) +1, MathF.Floor(iconY));
var color = i == highlightedIndex ? hoverColor : defaultColor;
Icons.DrawIconAtScreenPosition(icon, pos, drawList, color);
}
}
private ImRect BuildFenceRect(Vector2 mousePos, Vector2 windowPos, Vector2 windowSize)
{
// Selection-area buckets sit on a single row, so the fence visual extends the full strip height.
var minX = MathF.Min(_fenceStartScreen.X, mousePos.X);
var maxX = MathF.Max(_fenceStartScreen.X, mousePos.X);
return new ImRect(new Vector2(minX, windowPos.Y),
new Vector2(maxX, windowPos.Y + windowSize.Y));
}
private void ApplyFenceSelection(ImRect fenceRect, SelectionFence.SelectModes mode, KeyframeEditorGroup editors)
{
// Buckets are drawn on a single Y-row; fence selection is effectively a 1D X-range test.
_tempBucketKeys.Clear();
for (var i = 0; i < _cachedBuckets.Count; i++)
{
var b = _cachedBuckets[i];
if (b.CenterX < fenceRect.Min.X || b.CenterX > fenceRect.Max.X)
continue;
for (var k = 0; k < b.KeyCount; k++)
_tempBucketKeys.Add(_rawKeys[b.FirstKeyIndex + k].Def);
}
switch (mode)
{
case SelectionFence.SelectModes.Replace:
editors.ReplaceKeyframeSelection(_tempBucketKeys);
break;
case SelectionFence.SelectModes.Add:
editors.AddToKeyframeSelection(_tempBucketKeys);
break;
case SelectionFence.SelectModes.Remove:
editors.RemoveFromKeyframeSelection(_tempBucketKeys);
break;
}
}
private static SelectionFence.SelectModes GetSelectMode(in ImGuiIOPtr io)
{
if (io.KeyShift) return SelectionFence.SelectModes.Add;
if (io.KeyCtrl) return SelectionFence.SelectModes.Remove;
return SelectionFence.SelectModes.Replace;
}
private int FindBucketByStableId(int stableId)
{
for (var i = 0; i < _cachedBuckets.Count; i++)
if (_cachedBuckets[i].StableId == stableId)
return i;
// The original "first key" may now live inside a merged bucket — search raw keys instead.
for (var i = 0; i < _rawKeys.Count; i++)
{
if (_rawKeys[i].Def.UniqueId != stableId)
continue;
for (var b = 0; b < _cachedBuckets.Count; b++)
{
var bucket = _cachedBuckets[b];
if (i >= bucket.FirstKeyIndex && i < bucket.FirstKeyIndex + bucket.KeyCount)
return b;
}
}
return -1;
}
private void CaptureDragSet(int bucketIndex)
{
_draggingKeys.Clear();
_draggingCurves.Clear();
var bucket = _cachedBuckets[bucketIndex];
for (var i = 0; i < bucket.KeyCount; i++)
{
var rk = _rawKeys[bucket.FirstKeyIndex + i];
_draggingKeys.Add(rk.Def);
var already = false;
for (var c = 0; c < _draggingCurves.Count; c++)
{
if (_draggingCurves[c] == rk.Curve) { already = true; break; }
}
if (!already)
_draggingCurves.Add(rk.Curve);
}
}
private int ComputeStateHash(
List<TimeLineCanvas.AnimationParameter> parameters,
KeyframeEditorGroup editors,
float scaleX,
float scrollX,
Vector2 windowPos,
Vector2 windowSize)
{
var hash = 17;
hash = hash * 31 + scaleX.GetHashCode();
hash = hash * 31 + scrollX.GetHashCode();
hash = hash * 31 + windowPos.X.GetHashCode();
hash = hash * 31 + windowSize.X.GetHashCode();
hash = hash * 31 + parameters.Count;
for (var pi = 0; pi < parameters.Count; pi++)
{
var p = parameters[pi];
hash = hash * 31 + p.Hash;
for (var ci = 0; ci < p.Curves.Length; ci++)
hash = hash * 31 + p.Curves[ci].ChangeCount;
}
hash = hash * 31 + editors.AggregateSelectionChangeCounter;
return hash;
}
private void RebuildBuckets(
List<TimeLineCanvas.AnimationParameter> animationParameters,
KeyframeEditorGroup editors,
Vector2 windowPos,
Vector2 windowSize)
{
_cachedBuckets.Clear();
_rawKeys.Clear();
var minScreenX = windowPos.X;
var maxScreenX = windowPos.X + windowSize.X;
var cullPad = 8 * T3Ui.UiScaleFactor;
for (var pi = 0; pi < animationParameters.Count; pi++)
{
var param = animationParameters[pi];
for (var ci = 0; ci < param.Curves.Length; ci++)
{
var curve = param.Curves[ci];
var defs = curve.GetVDefinitions();
for (var ki = 0; ki < defs.Count; ki++)
{
var def = defs[ki];
var x = _canvas.TransformX((float)def.U);
if (x < minScreenX - cullPad || x > maxScreenX + cullPad)
continue;
_rawKeys.Add(new RawKey
{
ScreenX = x,
IsSelected = editors.IsKeyframeSelectedInAny(def),
Def = def,
Curve = curve,
});
}
}
}
if (_rawKeys.Count == 0)
return;
_rawKeys.Sort(_compareByX);
var mergeThreshold = 2f * T3Ui.UiScaleFactor;
var runStartIndex = 0;
var runStartX = _rawKeys[0].ScreenX;
var runEndX = runStartX;
var runTotal = 0;
var runSelected = 0;
for (var i = 0; i < _rawKeys.Count; i++)
{
var k = _rawKeys[i];
var wouldExtend = k.ScreenX - runStartX <= mergeThreshold;
if (i == 0 || wouldExtend)
{
if (i == 0)
{
runStartIndex = 0;
runStartX = k.ScreenX;
}
runEndX = k.ScreenX;
runTotal++;
if (k.IsSelected) runSelected++;
continue;
}
EmitBucket(runStartIndex, runStartX, runEndX, runTotal, runSelected);
runStartIndex = i;
runStartX = k.ScreenX;
runEndX = k.ScreenX;
runTotal = 1;
runSelected = k.IsSelected ? 1 : 0;
}
EmitBucket(runStartIndex, runStartX, runEndX, runTotal, runSelected);
}
private void EmitBucket(int firstIndex, float startX, float endX, int total, int selected)
{
_cachedBuckets.Add(new Bucket
{
CenterX = (startX + endX) * 0.5f,
Total = total,
Selected = selected,
FirstKeyIndex = firstIndex,
KeyCount = total,
StableId = _rawKeys[firstIndex].Def.UniqueId,
});
}
private struct RawKey
{
public float ScreenX;
public bool IsSelected;
public VDefinition Def;
public Curve Curve;
}
private struct Bucket
{
public float CenterX;
public int Total;
public int Selected;
public int FirstKeyIndex;
public int KeyCount;
public int StableId;
}
private static readonly Comparison<RawKey> _compareByX =
static (a, b) => a.ScreenX.CompareTo(b.ScreenX);
private readonly List<RawKey> _rawKeys = new(256);
private readonly List<Bucket> _cachedBuckets = new(256);
private readonly List<VDefinition> _tempBucketKeys = new(32);
private readonly List<VDefinition> _preFenceSnapshot = new(64);
private readonly List<VDefinition> _draggingKeys = new(32);
private readonly List<Curve> _draggingCurves = new(8);
private int _cachedStateHash;
private bool _isPressed;
private bool _isDragging;
private bool _isBackgroundPressed;
private bool _isFencing;
private Vector2 _fenceStartScreen;
private int _pressedBucketStableId;
private double _dragGrabOffset;
private ChangeKeyframesCommand? _dragCommand;
private readonly TimeLineCanvas _canvas;
}