diff --git a/Sources/PalmierPro/Editor/ViewModel/EditorViewModel+Ripple.swift b/Sources/PalmierPro/Editor/ViewModel/EditorViewModel+Ripple.swift index 6f1a5a958..bcd63edaf 100644 --- a/Sources/PalmierPro/Editor/ViewModel/EditorViewModel+Ripple.swift +++ b/Sources/PalmierPro/Editor/ViewModel/EditorViewModel+Ripple.swift @@ -478,6 +478,83 @@ extension EditorViewModel { return created } + func rippleMoveClips(_ moves: [(clipId: String, toTrack: Int, toFrame: Int)]) { + var infos: [(clip: Clip, toTrack: Int, toFrame: Int)] = [] + for m in moves { + guard let loc = findClip(id: m.clipId), + timeline.tracks.indices.contains(m.toTrack) else { continue } + let clip = timeline.tracks[loc.trackIndex].clips[loc.clipIndex] + guard timeline.tracks[m.toTrack].type.isCompatible(with: timeline.tracks[loc.trackIndex].type) else { continue } + infos.append((clip, m.toTrack, max(0, m.toFrame))) + } + guard !infos.isEmpty else { return } + + let insertFrame = infos.map(\.toFrame).min()! + let pushAmount = infos.map { $0.toFrame + $0.clip.durationFrames }.max()! - insertFrame + guard pushAmount > 0 else { return } + + if let reason = multicamMoveViolation(moves: infos.map { ($0.clip.id, $0.toTrack, $0.toFrame) }) { + refuseWithToast(reason) + return + } + let destTracks = Set(infos.map(\.toTrack)) + let pushTrackIndexes = timeline.tracks.indices.filter { destTracks.contains($0) || timeline.tracks[$0].syncLocked } + if let reason = multicamManualRippleViolation( + shiftingTrackIds: Set(pushTrackIndexes.map { timeline.tracks[$0].id }), + atFrame: insertFrame + ) { + refuseRipple(reason: reason) + return + } + + withTimelineSwap(actionName: infos.count == 1 ? "Ripple Move Clip" : "Ripple Move Clips") { + let toTrackIds = infos.map { timeline.tracks[$0.toTrack].id } + let pushTrackIds = pushTrackIndexes.map { timeline.tracks[$0].id } + + for info in infos { + if let loc = findClip(id: info.clip.id) { + timeline.tracks[loc.trackIndex].clips.remove(at: loc.clipIndex) + } + } + + var splitRightIds: [String] = [] + for tid in pushTrackIds { + guard let ti = timeline.tracks.firstIndex(where: { $0.id == tid }) else { continue } + if let straddler = timeline.tracks[ti].clips.first(where: { + $0.startFrame < insertFrame && insertFrame < $0.endFrame + }) { + splitRightIds += splitClip(clipId: straddler.id, atFrame: insertFrame) + } + } + + for tid in pushTrackIds { + guard let ti = timeline.tracks.firstIndex(where: { $0.id == tid }) else { continue } + applyShifts(RippleEngine.computeRipplePush( + clips: timeline.tracks[ti].clips, + insertFrame: insertFrame, + pushAmount: pushAmount + )) + } + + // splitClip also splits linked partners; push their right halves on tracks outside the push set. + let pushTrackIdSet = Set(pushTrackIds) + for id in splitRightIds { + guard let loc = findClip(id: id), + !pushTrackIdSet.contains(timeline.tracks[loc.trackIndex].id) else { continue } + timeline.tracks[loc.trackIndex].clips[loc.clipIndex].startFrame += pushAmount + } + + for (i, info) in infos.enumerated() { + guard let idx = timeline.tracks.firstIndex(where: { $0.id == toTrackIds[i] }) else { continue } + var clip = info.clip + clip.startFrame = info.toFrame + timeline.tracks[idx].clips.append(clip) + } + for i in timeline.tracks.indices { sortClips(trackIndex: i) } + pruneEmptyTracks() + } + } + // MARK: - Internal fileprivate func trimClipInternal(clipId: String, trimStartFrame: Int, trimEndFrame: Int, protecting: Set = []) { diff --git a/Sources/PalmierPro/Help/ShortcutsPane.swift b/Sources/PalmierPro/Help/ShortcutsPane.swift index 6871424f9..acd829873 100644 --- a/Sources/PalmierPro/Help/ShortcutsPane.swift +++ b/Sources/PalmierPro/Help/ShortcutsPane.swift @@ -22,7 +22,7 @@ struct ShortcutsPane: View { ("Backspace", "Delete"), ("Shift + Backspace", "Ripple Delete"), ("Shift + Drag Edge", "Ripple Trim"), - ("Cmd + Drag Media", "Ripple Insert"), + ("Cmd + Drag", "Ripple Insert"), ("Opt + Drag", "Duplicate Clip"), ]), ShortcutGroup(title: "Timeline", shortcuts: [ diff --git a/Sources/PalmierPro/Timeline/DragState.swift b/Sources/PalmierPro/Timeline/DragState.swift index 3b6fa0129..277856411 100644 --- a/Sources/PalmierPro/Timeline/DragState.swift +++ b/Sources/PalmierPro/Timeline/DragState.swift @@ -44,6 +44,7 @@ enum DragState { var deltaFrames: Int = 0 var dropTarget: TrackDropTarget let isDuplicate: Bool + var isRipple: Bool = false var all: [Participant] { [lead] + companions } diff --git a/Sources/PalmierPro/Timeline/TimelineInputController.swift b/Sources/PalmierPro/Timeline/TimelineInputController.swift index ddc992528..3e9d5f509 100644 --- a/Sources/PalmierPro/Timeline/TimelineInputController.swift +++ b/Sources/PalmierPro/Timeline/TimelineInputController.swift @@ -221,7 +221,8 @@ final class TimelineInputController { companions: companions, grabOffsetFrames: grabFrame - clip.startFrame, dropTarget: .existingTrack(hit.trackIndex), - isDuplicate: isOption + isDuplicate: isOption, + isRipple: isCommand && !isOption )) } } else { @@ -278,6 +279,7 @@ final class TimelineInputController { editor.setTimelineRange(startFrame: drag.anchorFrame, endFrame: rangeEndFrame) case .moveClip(var drag): + drag.isRipple = event.modifierFlags.contains(.command) && !drag.isDuplicate let candidateFrame = frame - drag.grabOffsetFrames let allDraggedIds = Set(drag.all.map(\.clipId)) let targets = SnapEngine.collectTargets( @@ -441,40 +443,43 @@ final class TimelineInputController { view.needsDisplay = true } + // MARK: - Flags changed + + func flagsChanged(with event: NSEvent) { + guard case .moveClip(var drag) = dragState else { return } + let isRipple = event.modifierFlags.contains(.command) && !drag.isDuplicate + guard isRipple != drag.isRipple else { return } + drag.isRipple = isRipple + dragState = .moveClip(drag) + view.needsDisplay = true + } + // MARK: - Mouse up func mouseUp(with event: NSEvent, geometry: TimelineGeometry) { stopPlayheadAutoScroll() switch dragState { - case .moveClip(let drag): + case .moveClip(var drag): + drag.isRipple = event.modifierFlags.contains(.command) && !drag.isDuplicate if case .existingTrack(let idx) = drag.dropTarget, idx == drag.lead.originalTrack, drag.deltaFrames == 0 { break } - let resolved = resolvedMoveParticipants(for: drag) - guard let resolvedLead = resolved.first(where: { $0.participant.clipId == drag.lead.clipId }) else { - break - } - let minOrigFrame = resolved.map { $0.frame }.min()! - let frameDelta = max(-minOrigFrame, drag.deltaFrames) - let pinned = pinnedCompanionIds(for: drag) - let leadTrack = resolvedLead.trackIndex - switch drag.dropTarget { case .existingTrack: - // Rigid translation: non-pinned shift by trackDelta; pinned hold their row. - let delta = drag.dropTargetTrackIndex.map { $0 - leadTrack } ?? 0 - let moves = resolved.map { item in - let p = item.participant - let toTrack = pinned.contains(p.clipId) ? item.trackIndex : item.trackIndex + delta - return (clipId: p.clipId, toTrack: toTrack, toFrame: item.frame + frameDelta) - } - commitMoves(moves, isDuplicate: drag.isDuplicate) + guard let moves = existingTrackMoves(for: drag) else { break } + commitMoves(moves, isDuplicate: drag.isDuplicate, isRipple: drag.isRipple) case .newTrackAt(let insertIndex): - guard let leadTrackType = resolvedLeadTrackType(for: drag) else { break } + let resolved = resolvedMoveParticipants(for: drag) + guard let resolvedLead = resolved.first(where: { $0.participant.clipId == drag.lead.clipId }), + let leadTrackType = resolvedLeadTrackType(for: drag) else { break } + let minOrigFrame = resolved.map { $0.frame }.min()! + let frameDelta = max(-minOrigFrame, drag.deltaFrames) + let pinned = pinnedCompanionIds(for: drag) + let leadTrack = resolvedLead.trackIndex if !drag.isDuplicate, let reason = editor.multicamMoveViolation(moves: resolved.map { (clipId: $0.participant.clipId, toTrack: $0.trackIndex, toFrame: $0.frame + frameDelta) @@ -985,14 +990,40 @@ final class TimelineInputController { editor.seekToFrame(frame, mode: .interactiveScrub) } - private func commitMoves(_ moves: [(clipId: String, toTrack: Int, toFrame: Int)], isDuplicate: Bool) { + private func commitMoves(_ moves: [(clipId: String, toTrack: Int, toFrame: Int)], isDuplicate: Bool, isRipple: Bool = false) { if isDuplicate { editor.duplicateClipsToPositions(moves) + } else if isRipple { + editor.rippleMoveClips(moves) } else { editor.moveClips(moves) } } + func existingTrackMoves(for drag: DragState.MoveClipDrag) -> [(clipId: String, toTrack: Int, toFrame: Int)]? { + let resolved = resolvedMoveParticipants(for: drag) + guard let resolvedLead = resolved.first(where: { $0.participant.clipId == drag.lead.clipId }) else { + return nil + } + let minOrigFrame = resolved.map { $0.frame }.min()! + let frameDelta = max(-minOrigFrame, drag.deltaFrames) + let pinned = pinnedCompanionIds(for: drag) + let delta = drag.dropTargetTrackIndex.map { $0 - resolvedLead.trackIndex } ?? 0 + return resolved.map { item in + let p = item.participant + let toTrack = pinned.contains(p.clipId) ? item.trackIndex : item.trackIndex + delta + return (clipId: p.clipId, toTrack: toTrack, toFrame: item.frame + frameDelta) + } + } + + func rippleMoveInsertFrame() -> Int? { + guard case .moveClip(let drag) = dragState, + drag.isRipple, !drag.isDuplicate, + case .existingTrack(let idx) = drag.dropTarget, + !(idx == drag.lead.originalTrack && drag.deltaFrames == 0) else { return nil } + return existingTrackMoves(for: drag)?.map(\.toFrame).min() + } + private func resolvedMoveParticipants( for drag: DragState.MoveClipDrag ) -> [(participant: DragState.Participant, trackIndex: Int, frame: Int)] { diff --git a/Sources/PalmierPro/Timeline/TimelineView.swift b/Sources/PalmierPro/Timeline/TimelineView.swift index d0a5d47a4..448d7ae59 100644 --- a/Sources/PalmierPro/Timeline/TimelineView.swift +++ b/Sources/PalmierPro/Timeline/TimelineView.swift @@ -36,6 +36,7 @@ final class TimelineView: NSView { required init?(coder: NSCoder) { fatalError() } override var isFlipped: Bool { true } + override var acceptsFirstResponder: Bool { true } // MARK: - Viewport canvas @@ -229,6 +230,8 @@ final class TimelineView: NSView { drawRippleInsertIndicator(atFrame: externalDragFrame, geometry: geo, context: ctx) drawRippleInsertBadge(atFrame: externalDragFrame, geometry: geo, scrollOffset: scrollOffset, visibleWidth: visibleWidth, context: ctx) } + } else if let insertFrame = inputController.rippleMoveInsertFrame() { + drawRippleInsertIndicator(atFrame: insertFrame, geometry: geo, context: ctx) } if case .marquee(let marq) = inputController.dragState, @@ -400,12 +403,18 @@ final class TimelineView: NSView { } clipDisplayRects[clip.id] = ghostRect if ghostRect.intersects(dirtyRect) { - ClipRenderer.draw(ghostClip, type: clip.mediaType, in: ghostRect, - isSelected: true, opacity: 0.7, context: ctx, - cache: editor.mediaVisualCache, - displayName: editor.clipDisplayLabel(for: clip), - multicamAngleLabel: angleLabel(clip), - fps: editor.timeline.fps, isMissing: clipMissing, isGenerating: clipGenerating) + let chip = angleLabel(clip) + let cache = editor.mediaVisualCache + let name = editor.clipDisplayLabel(for: clip) + let fps = editor.timeline.fps + // Ghosts draw after all clips so they stay on top in either drag direction. + deferredDraws.append { + ClipRenderer.draw(ghostClip, type: clip.mediaType, in: ghostRect, + isSelected: true, opacity: 0.7, context: ctx, + cache: cache, displayName: name, + multicamAngleLabel: chip, + fps: fps, isMissing: clipMissing, isGenerating: clipGenerating) + } } continue } @@ -670,7 +679,6 @@ final class TimelineView: NSView { let assets = externalDragAssets, !assets.isEmpty, let target = externalDropTarget else { return nil } - let plan = editor.resolveDropPlan(cursor: target, assets: assets, atFrame: externalDragFrame, segments: externalDragSegments) return editor.planRippleInsertPreview(dropPlan: plan, atFrame: externalDragFrame) } @@ -799,6 +807,7 @@ final class TimelineView: NSView { // MARK: - Input forwarding override func mouseDown(with event: NSEvent) { + window?.makeFirstResponder(self) inputController.mouseDown(with: event, geometry: geometry) } @@ -810,6 +819,10 @@ final class TimelineView: NSView { inputController.mouseUp(with: event, geometry: geometry) } + override func flagsChanged(with event: NSEvent) { + inputController.flagsChanged(with: event) + } + override func mouseMoved(with event: NSEvent) { inputController.mouseMoved(with: event, geometry: geometry) } diff --git a/Tests/PalmierProTests/Timeline/RippleMoveTests.swift b/Tests/PalmierProTests/Timeline/RippleMoveTests.swift new file mode 100644 index 000000000..fee1aad6b --- /dev/null +++ b/Tests/PalmierProTests/Timeline/RippleMoveTests.swift @@ -0,0 +1,178 @@ +import Foundation +import Testing +@testable import PalmierPro + +@MainActor +private func editor(_ tracks: [Track]) -> EditorViewModel { + let e = EditorViewModel() + e.timeline = Fixtures.timeline(tracks: tracks) + return e +} + +@MainActor +private func clips(_ e: EditorViewModel, track: Int) -> [(id: String, start: Int, duration: Int)] { + e.timeline.tracks[track].clips.map { ($0.id, $0.startFrame, $0.durationFrames) } +} + +@Suite("EditorViewModel - ripple move") +@MainActor +struct RippleMoveTests { + + @Test func moveLaterOnSameTrackSplitsStraddlerAndPushesDownstream() { + let e = editor([Fixtures.videoTrack(clips: [ + Fixtures.clip(id: "a", start: 0, duration: 10), + Fixtures.clip(id: "b", start: 10, duration: 10), + Fixtures.clip(id: "c", start: 20, duration: 10), + ])]) + + e.rippleMoveClips([(clipId: "a", toTrack: 0, toFrame: 12)]) + + let result = clips(e, track: 0) + #expect(result.map(\.start) == [10, 12, 22, 30]) + #expect(result[0].id == "b") + #expect(result[0].duration == 2) + #expect(result[1].id == "a") + #expect(result[2].duration == 8) + #expect(result[3].id == "c") + } + + @Test func moveEarlierOnSameTrackInsertsWithoutOverlap() { + let e = editor([Fixtures.videoTrack(clips: [ + Fixtures.clip(id: "a", start: 0, duration: 10), + Fixtures.clip(id: "b", start: 20, duration: 10), + ])]) + + e.rippleMoveClips([(clipId: "b", toTrack: 0, toFrame: 5)]) + + let result = clips(e, track: 0) + #expect(result.map(\.start) == [0, 5, 15]) + #expect(result[0].duration == 5) + #expect(result[1].id == "b") + #expect(result[2].duration == 5) + for i in 1..= result[i - 1].start + result[i - 1].duration) + } + } + + @Test func movedClipLandsExactlyAtDropFrame() { + let e = editor([Fixtures.videoTrack(clips: [ + Fixtures.clip(id: "a", start: 0, duration: 10), + Fixtures.clip(id: "b", start: 10, duration: 10), + ])]) + + e.rippleMoveClips([(clipId: "a", toTrack: 0, toFrame: 3)]) + + let result = clips(e, track: 0) + #expect(result.first { $0.id == "a" }?.start == 3) + #expect(result.first { $0.id == "b" }?.start == 20) + } + + @Test func moveBeyondFollowerLeavesSourceGap() { + let e = editor([Fixtures.videoTrack(clips: [ + Fixtures.clip(id: "a", start: 0, duration: 10), + Fixtures.clip(id: "b", start: 30, duration: 10), + ])]) + + e.rippleMoveClips([(clipId: "a", toTrack: 0, toFrame: 45)]) + + let result = clips(e, track: 0) + #expect(result.first { $0.id == "b" }?.start == 30) + #expect(result.first { $0.id == "a" }?.start == 45) + } + + @Test func syncLockedTrackPushesWithDestination() { + var video = Fixtures.videoTrack(clips: [ + Fixtures.clip(id: "v1", start: 0, duration: 10), + Fixtures.clip(id: "v2", start: 50, duration: 10), + ]) + video.syncLocked = false + var locked = Fixtures.audioTrack(clips: [ + Fixtures.clip(id: "s1", mediaType: .audio, start: 30, duration: 10), + ]) + locked.syncLocked = true + let e = editor([video, locked]) + + e.rippleMoveClips([(clipId: "v1", toTrack: 0, toFrame: 20)]) + + #expect(clips(e, track: 0).first { $0.id == "v1" }?.start == 20) + #expect(clips(e, track: 0).first { $0.id == "v2" }?.start == 60) + #expect(clips(e, track: 1).first { $0.id == "s1" }?.start == 40) + } + + @Test func unlockedOtherTrackStaysPut() { + var video = Fixtures.videoTrack(clips: [ + Fixtures.clip(id: "v1", start: 0, duration: 10), + Fixtures.clip(id: "v2", start: 50, duration: 10), + ]) + video.syncLocked = false + var other = Fixtures.audioTrack(clips: [ + Fixtures.clip(id: "o1", mediaType: .audio, start: 30, duration: 10), + ]) + other.syncLocked = false + let e = editor([video, other]) + + e.rippleMoveClips([(clipId: "v1", toTrack: 0, toFrame: 20)]) + + #expect(clips(e, track: 1).first { $0.id == "o1" }?.start == 30) + } + + @Test func crossTrackMovePushesDestinationOnly() { + var t0 = Fixtures.videoTrack(clips: [ + Fixtures.clip(id: "mover", start: 0, duration: 10), + Fixtures.clip(id: "tail", start: 10, duration: 10), + ]) + t0.syncLocked = false + var t1 = Fixtures.videoTrack(clips: [ + Fixtures.clip(id: "d1", start: 20, duration: 10), + ]) + t1.syncLocked = false + let e = editor([t0, t1]) + + e.rippleMoveClips([(clipId: "mover", toTrack: 1, toFrame: 20)]) + + let source = e.timeline.tracks.first { $0.clips.contains { $0.id == "tail" } }! + let dest = e.timeline.tracks.first { $0.clips.contains { $0.id == "d1" } }! + #expect(source.clips.first { $0.id == "tail" }?.startFrame == 10) + #expect(dest.clips.first { $0.id == "mover" }?.startFrame == 20) + #expect(dest.clips.first { $0.id == "d1" }?.startFrame == 30) + } + + @Test func linkedPartnerOfSplitStraddlerRidesThePush() { + var v = Fixtures.clip(id: "v", start: 20, duration: 20) + v.linkGroupId = "g" + var a = Fixtures.clip(id: "a", mediaType: .audio, start: 20, duration: 20) + a.linkGroupId = "g" + var video = Fixtures.videoTrack(clips: [ + Fixtures.clip(id: "mover", start: 0, duration: 10), + v, + ]) + video.syncLocked = false + var audio = Fixtures.audioTrack(clips: [a]) + audio.syncLocked = false + let e = editor([video, audio]) + + e.rippleMoveClips([(clipId: "mover", toTrack: 0, toFrame: 30)]) + + let videoStarts = clips(e, track: 0).map(\.start).sorted() + let audioStarts = clips(e, track: 1).map(\.start).sorted() + #expect(videoStarts == [20, 30, 40]) + #expect(audioStarts == [20, 40]) + } + + @Test func rippleMoveIsUndoable() { + let e = editor([Fixtures.videoTrack(clips: [ + Fixtures.clip(id: "a", start: 0, duration: 10), + Fixtures.clip(id: "b", start: 10, duration: 10), + ])]) + let undo = UndoManager() + e.undo.attach(undo) + + e.rippleMoveClips([(clipId: "a", toTrack: 0, toFrame: 15)]) + #expect(clips(e, track: 0).first { $0.id == "a" }?.start == 15) + + undo.undo() + let result = clips(e, track: 0) + #expect(result.first { $0.id == "a" }?.start == 0) + #expect(result.first { $0.id == "b" }?.start == 10) + } +}