#nullable enable using System.Diagnostics; using ImGuiNET; using T3.Core.Animation; using T3.Core.DataTypes.Vector; using T3.Core.Operator; using T3.Core.Operator.Slots; using T3.Core.Utils; using T3.Editor.Gui.Input; using T3.Editor.Gui.Interaction; using T3.Editor.Gui.Interaction.Keyboard; using T3.Editor.Gui.Interaction.Snapping; using T3.Editor.Gui.MagGraph.Model; using T3.Editor.Gui.UiHelpers; using T3.Editor.UiModel; using T3.Editor.UiModel.Commands; using T3.Editor.UiModel.Commands.Animation; using T3.Editor.UiModel.Commands.Graph; using T3.Editor.UiModel.ProjectHandling; using T3.Editor.UiModel.Selection; namespace T3.Editor.Gui.Windows.TimeLine.TimeClips; /// /// All interaction concerns for op-backed s in the timeline-clip /// area: selection, drag command lifecycle, delete-with-children, split-at-time, snap. /// /// Per-clip rendering + handle drag lives in ; this class owns /// the cross-frame drag command state and the operations that span multiple clips /// (delete / split / select-all / get-bounds). /// internal sealed class TimeClipInteractions { public TimeClipInteractions(ClipArea.LayerContext context, Func getCompositionOp) { _context = context; _getCompositionOp = getCompositionOp; } public void SetPlayback(Playback? playback) => _playback = playback; public void DrawClips(Instance compositionOp, ImRect layerRect, int minLayerIndex, ImDrawListPtr drawList) { var clips = _context.ClipSelection.CompositionTimeClips.Values; if (clips.Count == 0) return; var compositionSymbolUi = compositionOp.GetSymbolUi(); var drawAttributes = new TimeClipItem.ClipDrawingAttributes( _context, layerRect, minLayerIndex, compositionOp, compositionSymbolUi, _moveClipsCommand, drawList); // Cleanup on changes if (compositionSymbolUi.VersionCounter != _lastOpVersion) { _lastOpVersion = compositionSymbolUi.VersionCounter; // Avoid overlaps of selected clips (probably newly created or duplicated) first foreach (var clip in clips) { if (!_context.ClipSelection.SelectedClipsIds.Contains(clip.Id)) continue; while (clip.IsClipOverlappingOthers(clips)) { clip.LayerIndex--; } } foreach (var clip in clips) { if (clip.MakeConform()) Log.Debug($"Corrected malformed timing for {clip.Id}"); } } foreach (var clip in clips) TimeClipItem.DrawClip(clip, ref drawAttributes); } public void DrawContextMenuItems(Instance compositionOp) { Debug.Assert(_playback != null); if (!_contextMenuIsOpen && !ImGui.IsWindowHovered()) return; if (_context.ClipSelection.Count == 0) return; if (!_contextMenuIsOpen && !UiHelpers.UiHelpers.WasRightMouseClick()) return; ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, new Vector2(6, 6)); ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, new Vector2(6, 6)); if (ImGui.BeginPopupContextWindow("windows_context_menu")) { _contextMenuIsOpen = true; if (ImGui.MenuItem("Delete", UserActions.DeleteSelection.ListShortcuts(), false, _context.ClipSelection.Count > 0)) { DeleteSelectedClips(compositionOp); } if (ImGui.MenuItem("Clear Time Stretch", null, false, _context.ClipSelection.Count > 0)) { var selectedClips = _context.ClipSelection.GetAllOrSelectedClips().ToList(); var moveTimeClipCommand = new MoveTimeClipsCommand(compositionOp, selectedClips); // Reset stretch only — keep the user's source-side trim. The source slice // continues to start at its existing SourceRange.Start (so the event sitting // at the trimmed-in edge stays put), and the End is pulled to match the // timeline duration so the rate becomes 1. Pinning SourceRange.Start to 0 // here would silently undo the trim and snap content the user had pushed // off-screen back into view. foreach (var clip in selectedClips) clip.SourceRange.End = clip.SourceRange.Start + clip.TimeRange.Duration; moveTimeClipCommand.StoreCurrentValues(); UndoRedoStack.AddAndExecute(moveTimeClipCommand); // Keep the selection — the user is mid-edit on these clips and the next // action (Edit clip times, Cut, drag) is almost always still on them. } if (ImGui.MenuItem("Edit clip times", null, false, _context.ClipSelection.Count > 0)) { ClipTimingEditor.TimeClipEditorRequested = true; } if (ImGui.MenuItem("Cut at time", UserActions.SplitSelectedOrHoveredClips.ListShortcuts())) { SplitClipsAtTime(compositionOp); } // Only offered when the selection includes a DataClip op - the inline pane has nothing to show otherwise. if (InlineDataClipArea.HasSelectedDataClipInstance(_context.TimeCanvas, compositionOp)) { var showClipData = _context.TimeCanvas.InlineDataClipEditEnabled; if (ImGui.MenuItem("Show Clip Data", null, showClipData)) { _context.TimeCanvas.InlineDataClipEditEnabled = !showClipData; } } ImGui.Separator(); ImGui.EndPopup(); } else { _contextMenuIsOpen = false; } ImGui.PopStyleVar(2); } public void DeleteSelectedClips(Instance compositionOp) { var compositionSymbolUi = compositionOp.GetSymbolUi(); List selectedChildren = []; foreach (var id in _context.ClipSelection.SelectedClipsIds) { if (!compositionSymbolUi.ChildUis.TryGetValue(id, out var child)) continue; selectedChildren.Add(child); } if (selectedChildren.Count == 0) return; UndoRedoStack.AddAndExecute(new DeleteSymbolChildrenCommand(compositionSymbolUi, selectedChildren)); _context.TimeCanvas.NodeSelection.Clear(); compositionSymbolUi.FlagAsModified(); ProjectView.Focused?.FlagChanges(ProjectView.ChangeTypes.Children); } public void SplitClipsAtTime(Instance compositionOp) { Debug.Assert(_playback != null); var timeInBars = _playback.TimeInBars; var newClips = new List(); var commands = new List(); foreach (var clip in _context.ClipSelection.GetAllOrSelectedClips()) { if (!clip.TimeRange.Contains(timeInBars)) return; if (timeInBars - clip.TimeRange.Start < 0.01 || clip.TimeRange.End - timeInBars < 0.01) { Log.Debug("This clip would be too small..."); continue; } var compositionSymbolUi = compositionOp.GetSymbolUi(); var symbolChildUi = compositionSymbolUi.ChildUis[clip.Id]; var originalName = symbolChildUi.SymbolChild.ReadableName; var newPos = symbolChildUi.PosOnCanvas; newPos.Y += MagGraphItem.GridSize.Y; // Pass an empty section list (not null) so the command does not fall back // to cloning every section in the composition. var cmd = new CopySymbolChildrenCommand(compositionSymbolUi, [symbolChildUi], [], compositionSymbolUi, newPos); commands.Add(cmd); cmd.Do(); // Set new end to the original time clip var orgTimeRangeEnd = clip.TimeRange.End; var originalSourceDuration = clip.SourceRange.Duration; var normalizedCutPosition = ((float)_playback.TimeInBars - clip.TimeRange.Start) / clip.TimeRange.Duration; // Apply new time range to newly added instance var newChildId = cmd.OldToNewChildIds[clip.Id]; var newInstance = compositionOp.Children[newChildId]; var newTimeClip = newInstance.Outputs.OfType().Single().TimeClip; var newSymbolUiChild = compositionSymbolUi.ChildUis[newChildId]; var newName = originalName.AppendOrIncrementVersionNumber(); var renameCommand = new ChangeSymbolChildNameCommand(newSymbolUiChild, compositionSymbolUi.Symbol) { NewName = newName }; renameCommand.Do(); commands.Add(renameCommand); // Capture the new clip's just-copied TimeRange/SourceRange as the undo state, then mutate to // the "second half" ranges and store those as the redo state. var adjustNewClipCommand = new MoveTimeClipsCommand(compositionOp, [newTimeClip]); newTimeClip.TimeRange = new TimeRange((float)_playback.TimeInBars, orgTimeRangeEnd); newTimeClip.SourceRange.Start = newTimeClip.SourceRange.Start + originalSourceDuration * normalizedCutPosition; newTimeClip.SourceRange.End = clip.SourceRange.End; adjustNewClipCommand.StoreCurrentValues(); commands.Add(adjustNewClipCommand); newClips.Add(newTimeClip); // Adjust first clip end time var adjustFirstClipCommand = new MoveTimeClipsCommand(compositionOp, [clip]); clip.TimeRange.End = (float)_playback.TimeInBars; clip.SourceRange.Duration = originalSourceDuration * normalizedCutPosition; adjustFirstClipCommand.StoreCurrentValues(); commands.Add(adjustFirstClipCommand); // Copy connection of original clip { Symbol.Child.Output? timeClipOutput = null; foreach (var o in symbolChildUi.SymbolChild.Outputs.Values) { if (o.OutputData is TimeClip tc && tc == clip) { timeClipOutput = o; break; } } if (timeClipOutput == null) { Log.Warning($"Can't find timeclip output for {symbolChildUi}?"); continue; } var connections = compositionOp.Symbol.Connections .Where(c => c.SourceParentOrChildId == symbolChildUi.Id && c.SourceSlotId == timeClipOutput.OutputDefinition.Id) .ToList(); foreach (var c in connections) { if (!compositionOp.Symbol.Children.TryGetValue(c.TargetParentOrChildId, out var targetOp)) continue; if (!targetOp.Inputs.TryGetValue(c.TargetSlotId, out var targetInput) || !targetInput.IsMultiInput) continue; var addConnectionCommand = new AddConnectionCommand(compositionOp.Symbol, new Symbol.Connection(newInstance.SymbolChildId, c.SourceSlotId, c.TargetParentOrChildId, c.TargetSlotId), compositionOp.Symbol.GetMultiInputIndexFor(c) + 1); addConnectionCommand.Do(); commands.Add(addConnectionCommand); } } } if (commands.Count == 0) { Log.Debug($"There are no time clips to split at current time {timeInBars:0.0}"); return; } var macroCommands = new MacroCommand("split clip", commands); UndoRedoStack.Add(macroCommands); _context.ClipSelection.Clear(); foreach (var t in newClips) _context.ClipSelection.Select(t); ProjectView.Focused?.FlagChanges(ProjectView.ChangeTypes.Children | ProjectView.ChangeTypes.Connections); } // --------------------------------------------------------------------------- // Drag command lifecycle (ITimeObjectManipulation forwarded from ClipArea) // --------------------------------------------------------------------------- public ICommand StartDragCommand() { var composition = _getCompositionOp(); var selection = _context.ClipSelection.SelectedClipsIds.Count > 0 ? _context.ClipSelection.GetSelectedClips().ToList() : []; _moveClipsCommand = new MoveTimeClipsCommand(composition, selection); _layerIndexOnDragStart = 0; return _moveClipsCommand; } public void UpdateDragCommand(double dt, double dy) { var io = ImGui.GetIO(); var toggleLinkMode = io.KeyAlt; var dragInside = io.KeyCtrl && io.KeyAlt; var lockTime = io.KeyCtrl && !io.KeyAlt; var indexDelta = _layerIndexOnDragStart - (int)(dy / ClipArea.LayerHeight); if (indexDelta != 0) _layerIndexOnDragStart -= indexDelta; foreach (var clipId in _context.ClipSelection.SelectedClipsIds) { var clip = _context.ClipSelection.CompositionTimeClips[clipId]; clip.LayerIndex += indexDelta; if (lockTime) continue; if (dragInside) { clip.SourceRange.Start += (float)dt; clip.SourceRange.End += (float)dt; } else if (clip.UsedForRegionMapping ^ toggleLinkMode) { clip.TimeRange.Start += (float)dt; clip.TimeRange.End += (float)dt; } else { clip.TimeRange.Start += (float)dt; clip.TimeRange.End += (float)dt; clip.SourceRange.Start += (float)dt; clip.SourceRange.End += (float)dt; } } } public void UpdateDragAtStartPointCommand(double dt, double dv) { var trim = !ImGui.GetIO().KeyAlt; foreach (var clipId in _context.ClipSelection.SelectedClipsIds) { var clip = _context.ClipSelection.CompositionTimeClips[clipId]; // Capture the stretch rate BEFORE mutation so trim preserves it. Without // this, a clip at e.g. 50% speed gradually equalises toward 100% over a few // frames of trim drag because both ends move by the same delta — events // inside the clip appear to scale until the rate flattens. var rate = ComputeRate(clip); var org = clip.TimeRange.Start; clip.TimeRange.Start = (float)Math.Min(clip.TimeRange.Start + dt, clip.TimeRange.End - MinDuration); var d = clip.TimeRange.Start - org; if (trim) clip.SourceRange.Start += (float)(d * rate); } } public void UpdateDragAtEndPointCommand(double dt, double dv) { var trim = !ImGui.GetIO().KeyAlt; foreach (var clipId in _context.ClipSelection.SelectedClipsIds) { var clip = _context.ClipSelection.CompositionTimeClips[clipId]; var rate = ComputeRate(clip); var org = clip.TimeRange.End; clip.TimeRange.End = (float)Math.Max(clip.TimeRange.End + dt, clip.TimeRange.Start + MinDuration); var d = clip.TimeRange.End - org; if (trim) clip.SourceRange.End += (float)(d * rate); } } /// /// Source-bars-per-timeline-bar ratio. 1.0 for a clip whose source plays at native /// speed; < 1 when the source is stretched (plays slower than the timeline) and /// > 1 when compressed (plays faster). Falls back to 1 for the zero-duration edge /// case so a fresh clip with TimeRange.Start == TimeRange.End behaves like the /// non-stretched path before any drag samples a duration. /// private static double ComputeRate(TimeClip clip) { var timelineDuration = clip.TimeRange.Duration; if (Math.Abs(timelineDuration) < 0.0001) return 1.0; return clip.SourceRange.Duration / (double)timelineDuration; } public void UpdateDragStretchCommand(double scaleU, double scaleV, double originU, double originV) { foreach (var clipId in _context.ClipSelection.SelectedClipsIds) { var clip = _context.ClipSelection.CompositionTimeClips[clipId]; clip.TimeRange.Start = (float)(originU + (clip.TimeRange.Start - originU) * scaleU); clip.TimeRange.End = (float)Math.Max(originU + (clip.TimeRange.End - originU) * scaleU, clip.TimeRange.Start + MinDuration); } } public void CompleteDragCommand() { if (_moveClipsCommand == null) return; _moveClipsCommand.StoreCurrentValues(); _moveClipsCommand = null; } // --------------------------------------------------------------------------- // Selection-rectangle + snap // --------------------------------------------------------------------------- public void UpdateSelectionForArea(ImRect screenArea, SelectionFence.SelectModes selectMode, Vector2 minScreenPos, int minLayerIndex) { var compositionOp = _getCompositionOp(); if (selectMode == SelectionFence.SelectModes.Replace) _context.ClipSelection.Clear(); var startTime = _context.TimeCanvas.InverseTransformX(screenArea.Min.X); var endTime = _context.TimeCanvas.InverseTransformX(screenArea.Max.X); var layerMinIndex = (screenArea.Min.Y - minScreenPos.Y - ClipArea.LayerHeight * 0.5f) / ClipArea.LayerHeight + minLayerIndex; var layerMaxIndex = (screenArea.Max.Y - minScreenPos.Y - ClipArea.LayerHeight * 0.5f) / ClipArea.LayerHeight + minLayerIndex; foreach (var clip in Structure.GetAllTimeClips(compositionOp)) { var matches = clip.TimeRange.Start <= endTime && clip.TimeRange.End >= startTime && clip.LayerIndex <= layerMaxIndex && clip.LayerIndex >= layerMinIndex - 1; if (!matches) continue; switch (selectMode) { case SelectionFence.SelectModes.Add: case SelectionFence.SelectModes.Replace: _context.ClipSelection.AddSelection(clip); break; case SelectionFence.SelectModes.Remove: _context.ClipSelection.Deselect(clip); break; } } } /// Snap to all non-selected clips. public void CheckForSnap(ref SnapResult snapResult) { var currentComp = _getCompositionOp(); var allClips = Structure.GetAllTimeClips(currentComp); foreach (var clip in allClips) { if (_context.ClipSelection.Contains(clip)) continue; snapResult.TryToImproveWithAnchorValue(clip.TimeRange.Start); snapResult.TryToImproveWithAnchorValue(clip.TimeRange.End); } } // --------------------------------------------------------------------------- // Selection queries // --------------------------------------------------------------------------- public void ClearSelection() => _context.ClipSelection.Clear(); public bool HasSelectedClips => _context.ClipSelection.Count > 0; public bool HasAnyClips => _context.ClipSelection.CompositionTimeClips.Count > 0; public IEnumerable EnumerateSelectedClips() => _context.ClipSelection.GetSelectedClips(); public void SelectAllClips() { foreach (var clip in _context.ClipSelection.CompositionTimeClips.Values) _context.ClipSelection.AddSelection(clip); } public TimeRange GetSelectionTimeRange() { var timeRange = TimeRange.Undefined; foreach (var id in _context.ClipSelection.SelectedClipsIds) { // Selection can transiently reference clips no longer in the composition // (e.g. immediately after a split, before ClipSelection re-syncs). if (!_context.ClipSelection.CompositionTimeClips.TryGetValue(id, out var s)) continue; // Defensive: heal broken time ranges. FIXME: prevent these upstream. if (s.TimeRange.Duration <= 0 || float.IsNaN(s.TimeRange.Start) || float.IsNaN(s.TimeRange.End)) { s.TimeRange.Start = 0; s.TimeRange.End = s.TimeRange.Start + 1; } timeRange.Unite(s.TimeRange.Start); timeRange.Unite(s.TimeRange.End); } return timeRange; } public TimeRange GetAllClipsTimeRange() { var timeRange = TimeRange.Undefined; foreach (var clip in _context.ClipSelection.CompositionTimeClips.Values) { timeRange.Unite(clip.TimeRange.Start); timeRange.Unite(clip.TimeRange.End); } return timeRange; } public bool TryGetBounds(out ImRect bounds, bool useAllIfNonSelected) { var isFirst = true; bounds = new ImRect(); var range = useAllIfNonSelected ? _context.ClipSelection.GetAllOrSelectedClips() : _context.ClipSelection.GetSelectedClips(); foreach (var c in range) { var clipBound = new ImRect(new Vector2(c.TimeRange.Start, c.LayerIndex * ClipArea.LayerHeight), new Vector2(c.TimeRange.End, (c.LayerIndex + 1) * ClipArea.LayerHeight)); if (isFirst) { bounds = clipBound; isFirst = false; } else { bounds.Add(clipBound); } } return !isFirst; } public ClipSelection Selection => _context.ClipSelection; private const float MinDuration = 1 / 60f; // In bars private readonly ClipArea.LayerContext _context; private readonly Func _getCompositionOp; private Playback? _playback; private int _layerIndexOnDragStart; private bool _contextMenuIsOpen; private int _lastOpVersion = -1; private static MoveTimeClipsCommand? _moveClipsCommand; }