#nullable enable
using ImGuiNET;
using T3.Core.Animation;
using T3.Core.DataTypes.Vector;
using T3.Core.Operator;
using T3.Editor.Gui.Interaction.Snapping;
using T3.Editor.Gui.Styling;
namespace T3.Editor.Gui.Windows.TimeLine;
///
/// Thin horizontal indicator drawn inside the timeline ruler showing the time range of
/// the current keyframe / clip selection. Start and end handles stretch the selection;
/// the middle section translates it. When nothing is selected, it falls back to the extent
/// of all visible keyframes so the user can stretch/translate everything without pre-selecting.
///
/// TimeWarp handles (Alt+click on the bar): small circular markers that subdivide the
/// selection range into segments. Dragging a handle retimes the two adjacent segments
/// piecewise-linearly around it. If inner handles exist, dragging the SRI start or end
/// retimes only the outer segment rather than stretching the whole selection; translating
/// via middle-drag carries the handles along.
///
/// The handle list and the piecewise-linear drag math live in
/// and ; this class orchestrates
/// drawing, hit-testing, and mode dispatch.
///
internal sealed class SelectionRangeIndicator : IValueSnapAttractor
{
public SelectionRangeIndicator(TimeLineCanvas canvas, ValueSnapHandler snapHandler)
{
_canvas = canvas;
_snapHandler = snapHandler;
_snapExclusions = [this];
_handles = new TimeWarpHandleSet();
_warpDrag = new TimeWarpDrag(canvas, _handles);
}
public void Draw(Instance composition, ImDrawListPtr drawList)
{
_lastComposition = composition;
if (!ComputeRange(out var rulerPos, out var rulerSize, out var scale))
return;
var xStart = _canvas.TransformX(_range.Start);
var xEnd = _canvas.TransformX(_range.End);
var lineY = rulerPos.Y + rulerSize.Y - 4 * scale;
var leftClamped = MathF.Max(xStart, rulerPos.X);
var rightClamped = MathF.Min(xEnd, rulerPos.X + rulerSize.X);
var handleSize = new Vector2(5 * scale, 5 * scale);
var hitY = lineY - 2 * scale;
var hitHeight = 8 * scale;
var altHeld = ImGui.GetIO().KeyAlt;
_handles.RebuildVisible(_range);
// Middle — emitted first; edges + warp handles emitted later steal overlap.
EmitMiddleButton(composition.Symbol.Id, xStart, xEnd, hitY, hitHeight, scale, handleSize.X,
altHeld, out var middleHovered, out var middleActive, out var middlePressed);
EmitEdgeButton(composition.Symbol.Id, xStart, hitY, hitHeight, handleSize.X, isStart: true,
out var startHovered, out var startActive);
EmitEdgeButton(composition.Symbol.Id, xEnd, hitY, hitHeight, handleSize.X, isStart: false,
out var endHovered, out var endActive);
var warpHandleRadius = 4 * scale;
var warpHandleHoveredIndex = EmitWarpHandleButtons(composition, hitY, hitHeight, warpHandleRadius);
HandleAltClickInsert(altHeld, middlePressed, middleActive, warpHandleHoveredIndex, scale);
DrawBar(drawList, leftClamped, rightClamped, lineY, handleSize,
new Vector2(xStart, lineY + 0.5f), new Vector2(xEnd, lineY + 0.5f),
altHeld, middleHovered, middleActive,
startHovered, startActive, endHovered, endActive);
DrawWarpHandles(drawList, lineY, warpHandleRadius, warpHandleHoveredIndex);
DrawAltHoverPreview(drawList, altHeld, middleHovered, warpHandleHoveredIndex, lineY, warpHandleRadius);
ApplyPendingRemoval();
}
public void ClearTimeWarpHandles() => _handles.Clear();
void IValueSnapAttractor.CheckForSnap(ref SnapResult snapResult)
{
if (!_range.IsValid || _range.Duration <= 0)
return;
snapResult.TryToImproveWithAnchorValue(_range.Start);
snapResult.TryToImproveWithAnchorValue(_range.End);
}
//
// Range computation ------------------------------------------------------
//
private bool ComputeRange(out Vector2 rulerPos, out Vector2 rulerSize, out float scale)
{
rulerPos = ImGui.GetWindowPos();
rulerSize = ImGui.GetWindowSize();
scale = T3Ui.UiScaleFactor;
var editors = _canvas.KeyframeEditors;
var layers = _canvas.ClipArea;
// Keyframes: aggregated across all active editors — selection if it covers a positive range,
// else fall back to all keyframes. Clips: only selected clips contribute.
var keyframeRange = editors.GetSelectionTimeRange();
_autoSelectKeyframesOnDrag = !keyframeRange.IsValid || keyframeRange.Duration <= 0;
if (_autoSelectKeyframesOnDrag)
keyframeRange = editors.GetAllKeyframesTimeRange();
var clipRange = layers.GetSelectionTimeRange();
_range = TimeRange.Undefined;
if (keyframeRange.IsValid)
{
_range.Unite(keyframeRange.Start);
_range.Unite(keyframeRange.End);
}
if (clipRange.IsValid)
{
_range.Unite(clipRange.Start);
_range.Unite(clipRange.End);
}
// Detect selection-just-became-empty → drop handles.
var hasKeyframeSelectionNow = !_autoSelectKeyframesOnDrag;
if (_lastHadKeyframeSelection && !hasKeyframeSelectionNow)
_handles.Clear();
_lastHadKeyframeSelection = hasKeyframeSelectionNow;
return _range.IsValid && _range.Duration > 0;
}
//
// Hit-test emission ------------------------------------------------------
//
private void EmitMiddleButton(in Guid compositionSymbolId,
float xStart, float xEnd, float hitY, float hitHeight,
float scale, float edgeHalfWidth, bool altHeld,
out bool hovered, out bool active, out bool pressed)
{
hovered = false;
active = false;
pressed = false;
if (xEnd - xStart <= edgeHalfWidth * 2)
return;
var middleStart = xStart + edgeHalfWidth;
var middleEnd = xEnd - edgeHalfWidth;
ImGui.SetCursorScreenPos(new Vector2(middleStart, hitY));
ImGui.SetNextItemAllowOverlap();
ImGui.InvisibleButton("##SriMiddle", new Vector2(middleEnd - middleStart, hitHeight));
hovered = ImGui.IsItemHovered();
active = ImGui.IsItemActive();
pressed = ImGui.IsItemActivated();
if (hovered)
ImGui.SetMouseCursor(ImGuiMouseCursor.Hand);
// Translate drag only when Alt is NOT held (Alt reserved for marker insert);
// continue driving an in-flight drag even if Alt state changes mid-drag.
if (!altHeld || _currentDragMode == DragMode.Middle || _currentDragMode == DragMode.MiddleCustom)
HandleMiddleDrag(compositionSymbolId);
}
private void EmitEdgeButton(in Guid compositionSymbolId, float edgeX, float hitY, float hitHeight,
float edgeHalfWidth, bool isStart,
out bool hovered, out bool active)
{
ImGui.SetCursorScreenPos(new Vector2(edgeX - edgeHalfWidth, hitY));
ImGui.SetNextItemAllowOverlap();
ImGui.InvisibleButton(isStart ? "##SriStart" : "##SriEnd", new Vector2(edgeHalfWidth * 2, hitHeight));
hovered = ImGui.IsItemHovered();
active = ImGui.IsItemActive();
if (hovered)
ImGui.SetMouseCursor(ImGuiMouseCursor.Hand);
HandleEdgeDrag(compositionSymbolId, isStart);
}
private int EmitWarpHandleButtons(Instance composition, float hitY, float hitHeight, float handleRadius)
{
var warpHitSize = new Vector2(handleRadius * 2 + 2, hitHeight);
var hoveredIdx = -1;
for (var vi = 0; vi < _handles.VisibleIndices.Count; vi++)
{
var handleIdx = _handles.VisibleIndices[vi];
var hx = _canvas.TransformX((float)_handles[handleIdx]);
ImGui.SetCursorScreenPos(new Vector2(hx - warpHitSize.X * 0.5f, hitY));
ImGui.InvisibleButton($"##SriWarp{handleIdx}", warpHitSize);
if (ImGui.IsItemHovered())
{
hoveredIdx = handleIdx;
ImGui.SetMouseCursor(ImGuiMouseCursor.Hand);
}
HandleWarpHandleDrag(composition, handleIdx);
}
return hoveredIdx;
}
private void HandleAltClickInsert(bool altHeld, bool middlePressed, bool middleActive,
int warpHandleHoveredIndex, float scale)
{
// Alt+click on middle (no existing handle under cursor) inserts a handle.
// Alt+click on an existing handle is handled inside HandleWarpHandleDrag (toggle).
if (altHeld && middlePressed && warpHandleHoveredIndex < 0 && _currentDragMode == DragMode.None)
{
_pendingAltInsertU = _canvas.InverseTransformX(ImGui.GetIO().MousePos.X);
_pendingAltInsertActive = true;
}
if (_pendingAltInsertActive && middleActive && ImGui.IsMouseDragging(ImGuiMouseButton.Left, 2f * scale))
{
// Alt+drag is reserved for another interaction — cancel the pending insert.
_pendingAltInsertActive = false;
}
if (_pendingAltInsertActive && !middleActive)
{
_handles.Insert(_pendingAltInsertU, _range);
_pendingAltInsertActive = false;
}
}
//
// Rendering --------------------------------------------------------------
//
private void DrawBar(ImDrawListPtr drawList, float leftClamped, float rightClamped, float lineY,
Vector2 handleSize, Vector2 startCenter, Vector2 endCenter,
bool altHeld, bool middleHovered, bool middleActive,
bool startHovered, bool startActive, bool endHovered, bool endActive)
{
var baseOpacity = _autoSelectKeyframesOnDrag ? 0.3f : 0.75f;
var lineColor = (altHeld && (middleHovered || middleActive))
? UiColors.StatusAutomated
: UiColors.ForegroundFull.Fade((middleHovered || middleActive) ? 1.0f : baseOpacity);
var startColor = UiColors.ForegroundFull.Fade((startHovered || startActive) ? 1.0f : baseOpacity);
var endColor = UiColors.ForegroundFull.Fade((endHovered || endActive) ? 1.0f : baseOpacity);
if (rightClamped > leftClamped)
{
drawList.AddRectFilled(new Vector2(leftClamped, lineY),
new Vector2(rightClamped, lineY + 1),
lineColor);
}
DrawEdgeHandle(drawList, startCenter, handleSize, startColor);
DrawEdgeHandle(drawList, endCenter, handleSize, endColor);
}
private static void DrawEdgeHandle(ImDrawListPtr drawList, Vector2 center, Vector2 size, Color color)
{
var hx = size.X * 0.5f;
var hy = size.Y * 0.5f;
drawList.AddRectFilled(new Vector2(center.X - hx, center.Y - hy),
new Vector2(center.X + hx, center.Y + hy),
color);
}
private void DrawWarpHandles(ImDrawListPtr drawList, float lineY, float handleRadius, int hoveredIdx)
{
for (var vi = 0; vi < _handles.VisibleIndices.Count; vi++)
{
var handleIdx = _handles.VisibleIndices[vi];
if (handleIdx == _pendingRemoveHandleIndex || handleIdx >= _handles.Count)
continue;
var hx = _canvas.TransformX((float)_handles[handleIdx]);
var center = new Vector2(hx, lineY + 0.5f);
var hovered = handleIdx == hoveredIdx || handleIdx == _draggedWarpHandleIndex;
var color = hovered ? UiColors.StatusAutomated : UiColors.ForegroundFull;
drawList.AddCircleFilled(center, handleRadius, color);
}
}
private void DrawAltHoverPreview(ImDrawListPtr drawList, bool altHeld, bool middleHovered,
int warpHandleHoveredIndex, float lineY, float handleRadius)
{
if (!altHeld || !middleHovered || _currentDragMode != DragMode.None)
return;
float previewX;
if (warpHandleHoveredIndex >= 0 && warpHandleHoveredIndex < _handles.Count
&& warpHandleHoveredIndex != _pendingRemoveHandleIndex)
{
previewX = _canvas.TransformX((float)_handles[warpHandleHoveredIndex]);
}
else
{
previewX = ImGui.GetIO().MousePos.X;
}
drawList.AddCircle(new Vector2(previewX, lineY + 0.5f), handleRadius + 1,
UiColors.StatusAutomated, 12, 1.2f);
}
private void ApplyPendingRemoval()
{
if (_pendingRemoveHandleIndex >= 0)
_handles.RemoveAt(_pendingRemoveHandleIndex);
_pendingRemoveHandleIndex = -1;
}
//
// Drag handlers ----------------------------------------------------------
//
private void HandleEdgeDrag(in Guid compositionSymbolId, bool isStart)
{
var originalU = isStart ? _range.Start : _range.End;
var origin = isStart ? _range.End : _range.Start;
if (ImGui.IsItemActive() && ImGui.IsMouseDragging(ImGuiMouseButton.Left))
{
FrameStats.Current.OpenedPopupCapturedMouse = true;
var u = _canvas.InverseTransformX(ImGui.GetIO().MousePos.X);
if (_currentDragMode == DragMode.None)
StartEdgeDrag(compositionSymbolId, isStart, originalU);
if (_currentDragMode is DragMode.EdgeStartCustom or DragMode.EdgeEndCustom)
{
_warpDrag.Update(u);
_lastDragU = u;
return;
}
if (_currentDragMode != (isStart ? DragMode.EdgeStart : DragMode.EdgeEnd))
return;
if (!ImGui.GetIO().KeyShift
&& _snapHandler.TryCheckForSnapping(u, out var snappedValue, _canvas.Scale.X, _snapExclusions))
{
u = (float)snappedValue;
}
var denom = _lastDragU - origin;
if (Math.Abs(denom) < 1e-6)
return;
var dScale = (u - origin) / denom;
_canvas.UpdateDragStretchCommand(scaleU: dScale, scaleV: 1, originU: origin, originV: 0);
_lastDragU = u;
}
else if (ImGui.IsItemDeactivated())
{
var wantsStart = isStart ? DragMode.EdgeStart : DragMode.EdgeEnd;
var wantsStartCustom = isStart ? DragMode.EdgeStartCustom : DragMode.EdgeEndCustom;
if (_currentDragMode == wantsStart)
{
_canvas.CompleteDragCommand();
_currentDragMode = DragMode.None;
}
else if (_currentDragMode == wantsStartCustom)
{
_warpDrag.Complete();
_currentDragMode = DragMode.None;
}
}
}
private void StartEdgeDrag(in Guid compositionSymbolId, bool isStart, double originalU)
{
if (_autoSelectKeyframesOnDrag)
_canvas.KeyframeEditors.SelectAllKeyframes();
if (_handles.HasVisible)
{
// Handles present → retime only the outer segment (start to first handle, or last to end).
var origEdgeU = isStart ? (double)_range.Start : _range.End;
var boundary = isStart ? _handles.GetFirstVisibleU() : _handles.GetLastVisibleU();
_warpDrag.Begin(_lastComposition!, origHandleU: origEdgeU,
prevBoundary: isStart ? double.NegativeInfinity : boundary,
nextBoundary: isStart ? boundary : double.PositiveInfinity,
singleSegment: true,
segmentLeftU: isStart ? Math.Min(origEdgeU, boundary) : boundary,
segmentRightU: isStart ? boundary : Math.Max(origEdgeU, boundary),
pureTranslation: false,
trackHandlePositions: false,
useAllKeyframes: _autoSelectKeyframesOnDrag);
_currentDragMode = isStart ? DragMode.EdgeStartCustom : DragMode.EdgeEndCustom;
}
else
{
_canvas.StartDragCommand(compositionSymbolId);
_currentDragMode = isStart ? DragMode.EdgeStart : DragMode.EdgeEnd;
}
_lastDragU = originalU;
}
private void HandleMiddleDrag(in Guid compositionSymbolId)
{
if (ImGui.IsItemActive() && ImGui.IsMouseDragging(ImGuiMouseButton.Left))
{
FrameStats.Current.OpenedPopupCapturedMouse = true;
var u = _canvas.InverseTransformX(ImGui.GetIO().MousePos.X);
if (_currentDragMode == DragMode.None)
{
StartMiddleDrag(compositionSymbolId, u);
return;
}
if (_currentDragMode != DragMode.Middle && _currentDragMode != DragMode.MiddleCustom)
return;
// Snap the SRI's start or end to nearby anchors — whichever gets a stronger snap wins.
var rawDu = u - _middleOrigPressU;
var correctedDu = SnapMiddleTranslation(rawDu);
if (_currentDragMode == DragMode.MiddleCustom)
{
_warpDrag.Update(_middleOrigPressU + correctedDu);
_middleLastAppliedDu = correctedDu;
_lastDragU = u;
return;
}
var frameDu = correctedDu - _middleLastAppliedDu;
if (frameDu == 0)
return;
_canvas.UpdateDragCommand(frameDu, 0);
_middleLastAppliedDu = correctedDu;
_lastDragU = u;
}
else if (ImGui.IsItemDeactivated())
{
if (_currentDragMode == DragMode.Middle)
{
_canvas.CompleteDragCommand();
_currentDragMode = DragMode.None;
}
else if (_currentDragMode == DragMode.MiddleCustom)
{
_warpDrag.Complete();
_currentDragMode = DragMode.None;
}
}
}
private void StartMiddleDrag(in Guid compositionSymbolId, double pressU)
{
if (_autoSelectKeyframesOnDrag)
_canvas.KeyframeEditors.SelectAllKeyframes();
_middleOrigPressU = pressU;
_middleOrigStart = _range.Start;
_middleOrigEnd = _range.End;
_middleLastAppliedDu = 0;
if (_handles.Count > 0)
{
_warpDrag.Begin(_lastComposition!, origHandleU: pressU,
prevBoundary: 0, nextBoundary: 0,
singleSegment: false,
segmentLeftU: 0, segmentRightU: 0,
pureTranslation: true,
trackHandlePositions: true,
useAllKeyframes: _autoSelectKeyframesOnDrag);
_currentDragMode = DragMode.MiddleCustom;
}
else
{
_canvas.StartDragCommand(compositionSymbolId);
_currentDragMode = DragMode.Middle;
}
_lastDragU = pressU;
}
///
/// Adjust the requested translation so that the SRI's start or end snaps to a nearby anchor.
/// Whichever edge has the stronger snap (smaller delta) wins; Shift bypasses snapping.
///
private double SnapMiddleTranslation(double rawDu)
{
if (ImGui.GetIO().KeyShift)
return rawDu;
var candidateStart = _middleOrigStart + rawDu;
var candidateEnd = _middleOrigEnd + rawDu;
var bestDelta = 0.0;
var hasSnap = false;
if (_snapHandler.TryCheckForSnapping(candidateStart, out var snappedStart, _canvas.Scale.X, _snapExclusions))
{
bestDelta = snappedStart - candidateStart;
hasSnap = true;
}
if (_snapHandler.TryCheckForSnapping(candidateEnd, out var snappedEnd, _canvas.Scale.X, _snapExclusions))
{
var d = snappedEnd - candidateEnd;
if (!hasSnap || Math.Abs(d) < Math.Abs(bestDelta))
{
bestDelta = d;
hasSnap = true;
}
}
return hasSnap ? rawDu + bestDelta : rawDu;
}
private void HandleWarpHandleDrag(Instance composition, int handleIdx)
{
_lastComposition = composition;
var altHeld = ImGui.GetIO().KeyAlt;
if (ImGui.IsItemActivated())
{
if (altHeld)
{
// Alt+press on a handle: candidate for toggle-removal on clean release.
_pendingAltToggleIndex = handleIdx;
return;
}
StartWarpHandleDrag(composition, handleIdx);
}
// Alt+press followed by drag → cancel the toggle and promote to a warp drag.
if (_pendingAltToggleIndex == handleIdx && ImGui.IsItemActive()
&& ImGui.IsMouseDragging(ImGuiMouseButton.Left, 2f))
{
_pendingAltToggleIndex = -1;
StartWarpHandleDrag(composition, handleIdx);
}
if (_currentDragMode == DragMode.WarpHandle && _draggedWarpHandleIndex == handleIdx
&& ImGui.IsItemActive() && ImGui.IsMouseDragging(ImGuiMouseButton.Left))
{
FrameStats.Current.OpenedPopupCapturedMouse = true;
var rawU = _canvas.InverseTransformX(ImGui.GetIO().MousePos.X);
var (prev, next) = _handles.GetNeighbors(handleIdx, _range);
const double minGap = 1e-4;
var clamped = Math.Clamp(rawU, prev + minGap, next - minGap);
_handles.SetValueUnsorted(handleIdx, clamped);
_warpDrag.Update(clamped);
}
if (ImGui.IsItemDeactivated())
{
if (_pendingAltToggleIndex == handleIdx)
{
// Toggle on release-without-drag; defer the removal until after all per-frame
// iterations so indices stay valid for the rest of this frame.
_pendingRemoveHandleIndex = handleIdx;
_pendingAltToggleIndex = -1;
return;
}
if (_currentDragMode == DragMode.WarpHandle && _draggedWarpHandleIndex == handleIdx)
{
_warpDrag.Complete();
_draggedWarpHandleIndex = -1;
_currentDragMode = DragMode.None;
}
}
}
private void StartWarpHandleDrag(Instance composition, int handleIdx)
{
var (prev, next) = _handles.GetNeighbors(handleIdx, _range);
_warpDrag.Begin(composition, origHandleU: _handles[handleIdx],
prevBoundary: prev, nextBoundary: next,
singleSegment: false,
segmentLeftU: 0, segmentRightU: 0,
pureTranslation: false,
trackHandlePositions: true,
useAllKeyframes: _autoSelectKeyframesOnDrag);
_draggedWarpHandleIndex = handleIdx;
_currentDragMode = DragMode.WarpHandle;
}
//
// State ------------------------------------------------------------------
//
private enum DragMode
{
None,
Middle,
MiddleCustom, // middle-translate when inner handles exist (tracks handle positions for undo)
EdgeStart,
EdgeEnd,
EdgeStartCustom, // edge drag limited to the outer segment (inner handles exist)
EdgeEndCustom,
WarpHandle,
}
private readonly TimeLineCanvas _canvas;
private readonly ValueSnapHandler _snapHandler;
private readonly IValueSnapAttractor[] _snapExclusions;
private readonly TimeWarpHandleSet _handles;
private readonly TimeWarpDrag _warpDrag;
private DragMode _currentDragMode = DragMode.None;
private bool _autoSelectKeyframesOnDrag;
private bool _lastHadKeyframeSelection;
private double _lastDragU;
private TimeRange _range;
private Instance? _lastComposition;
private int _draggedWarpHandleIndex = -1;
private int _pendingAltToggleIndex = -1;
private int _pendingRemoveHandleIndex = -1;
private double _pendingAltInsertU;
private bool _pendingAltInsertActive;
// Middle-drag snap state (captured once at drag-start).
private double _middleOrigPressU;
private double _middleOrigStart;
private double _middleOrigEnd;
private double _middleLastAppliedDu;
}