From 1f6b023fd6d8d4de8cc6915a81b4a639ce3d649a Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Wed, 1 Jul 2026 12:14:27 +0300 Subject: [PATCH] feat: add notation measure copy paste --- CHANGELOG.md | 1 + JammLab/ContentView.swift | 39 +- JammLab/Models/NotationScoreModels.swift | 72 ++++ JammLab/Services/NotationMeasureLayout.swift | 7 +- .../Services/NotationViewportFactory.swift | 28 +- JammLab/Utilities/AppHotkey.swift | 27 ++ .../AudioPlayerViewModel+Notes.swift | 232 ++++++++++++ .../AudioPlayerViewModel+Project.swift | 3 + .../AudioPlayerViewModel+Timeline.swift | 5 + JammLab/ViewModels/AudioPlayerViewModel.swift | 3 + .../Components/AppHotkeyMonitorView.swift | 26 +- JammLab/Views/MainWorkspacePanels.swift | 2 + JammLab/Views/NotationTrackView.swift | 123 ++++++- JammLab/Views/NotationWindowView.swift | 33 +- JammLab/Views/WaveformTimelineView.swift | 4 + JammLabTests/AudioTimingLogicTests.swift | 33 ++ JammLabTests/TimelineProjectLogicTests.swift | 151 ++++++++ JammLabTests/ViewModelLifecycleTests.swift | 340 ++++++++++++++++++ 18 files changed, 1090 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff01a06..acd79c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ development artifact builds use `vMAJOR.MINOR.PATCH-dev.N`. ## Unreleased +- Added notation measure range selection with Shift-click, Cmd+C/Cmd+V measure copy and replace-paste for harmony contents, and Esc to clear the selection. - Added boxed Region start labels to Notation in the timeline track and Notation window. - Added beat slash notation to Notation measures in the timeline track and Notation window. - Added a synced Notation window that opens from the View menu or the Notation track context menu, showing the full chart in a notebook-style layout while sharing harmony editing with the timeline track. diff --git a/JammLab/ContentView.swift b/JammLab/ContentView.swift index 84d3002..79b3cd5 100644 --- a/JammLab/ContentView.swift +++ b/JammLab/ContentView.swift @@ -41,8 +41,8 @@ struct ContentView: View { .background(WindowCloseGuard()) .background( AppHotkeyMonitorView( - allowedHotkeys: Set(AppHotkey.allCases), - onHotkey: handleHotkey + allowedHotkeys: allowedHotkeys, + onHotkeyShouldConsume: handleHotkey ) ) .task { @@ -174,33 +174,66 @@ struct ContentView: View { } } - private func handleHotkey(_ hotkey: AppHotkey) { + private var allowedHotkeys: Set { + var hotkeys = Set(AppHotkey.allCases) + if !viewModel.canCopySelectedNotationMeasure { + hotkeys.remove(.copyMeasure) + } + if !viewModel.canPasteNotationMeasureClipboard { + hotkeys.remove(.pasteMeasure) + } + if !viewModel.hasSelectedNotationMeasures { + hotkeys.remove(.clearNotationMeasureSelection) + } + return hotkeys + } + + @discardableResult + private func handleHotkey(_ hotkey: AppHotkey) -> Bool { // AppHotkey is the single source of truth for handled shortcuts. // Add new hotkeys there first so Help > Keyboard Shortcuts updates // together with this dispatch switch. switch hotkey { case .playPause: viewModel.togglePlayStop() + return true case .toggleLoop: viewModel.toggleLooping() + return true case .setLoopStart: viewModel.setLoopStartAtCurrentTime() + return true case .setLoopEnd: viewModel.setLoopEndAtCurrentTime() + return true case .addNote: viewModel.addNoteAtCurrentTime() + return true case .addTempoTimeSignatureMarker: beginAddingTempoTimeSignatureMarker(at: viewModel.currentTime) + return true case .setBeatOne: viewModel.setCurrentTimeAsBeatOne() + return true case .toggleClick: viewModel.toggleClick() + return true case .toggleSnap: viewModel.toggleSnap() + return true case .togglePlaybackMode: viewModel.togglePlaybackMode() + return true case .toggleVideoWindow: viewModel.toggleVideoWindow() + return true + case .copyMeasure: + return viewModel.copySelectedNotationMeasure() + case .pasteMeasure: + return viewModel.pasteNotationMeasureClipboard() + case .clearNotationMeasureSelection: + viewModel.clearNotationMeasureSelection() + return true } } diff --git a/JammLab/Models/NotationScoreModels.swift b/JammLab/Models/NotationScoreModels.swift index 7aadce4..397684d 100644 --- a/JammLab/Models/NotationScoreModels.swift +++ b/JammLab/Models/NotationScoreModels.swift @@ -55,6 +55,78 @@ struct NotationRegionLabel: Identifiable, Equatable { } } +struct NotationMeasureSelection: Equatable, Identifiable { + var number: Int + var startTime: TimeInterval + var endTime: TimeInterval + var attributes: MeasureAttributes + + init(measure: ScoreMeasure) { + self.number = measure.number + self.startTime = measure.startTime + self.endTime = measure.endTime + self.attributes = measure.attributes + } + + var id: String { + "\(number)-\(startTime)-\(endTime)" + } + + func matches(_ measure: ScoreMeasure) -> Bool { + number == measure.number + && abs(startTime - measure.startTime) < NotationMeasureTiming.timelineTolerance + && abs(endTime - measure.endTime) < NotationMeasureTiming.timelineTolerance + } +} + +struct NotationMeasureClipboard: Equatable { + var measures: [NotationMeasureClipboardMeasure] +} + +struct NotationMeasureClipboardMeasure: Equatable { + var items: [NotationMeasureClipboardItem] +} + +struct NotationMeasureClipboardItem: Equatable { + var offsetInQuarterNotes: Double + var rawText: String +} + +enum NotationMeasureTiming { + static let timelineTolerance: TimeInterval = 0.000_001 + + static func containsEventTime(_ time: TimeInterval, in measure: ScoreMeasure) -> Bool { + time >= measure.startTime - timelineTolerance + && ( + time < measure.endTime - timelineTolerance + || abs(time - measure.startTime) < timelineTolerance + ) + } + + static func quarterOffset(for time: TimeInterval, in measure: ScoreMeasure) -> Double { + let length = quarterLength(for: measure.attributes.timeSignature) + guard measure.duration > 0, length > 0 else { return 0 } + let progress = max(0, min((time - measure.startTime) / measure.duration, 1)) + return progress * length + } + + static func time(forQuarterOffset offset: Double, in measure: ScoreMeasure) -> TimeInterval { + let length = quarterLength(for: measure.attributes.timeSignature) + guard measure.duration > 0, length > 0 else { return measure.startTime } + let progress = max(0, min(offset / length, 1)) + return measure.startTime + progress * measure.duration + } + + static func isValidHarmonyOffset(_ offset: Double, in timeSignature: TimeSignature) -> Bool { + let length = quarterLength(for: timeSignature) + return offset >= -timelineTolerance && offset < length - timelineTolerance + } + + static func quarterLength(for timeSignature: TimeSignature) -> Double { + Double(timeSignature.beatsPerBar) * 4.0 / Double(max(1, timeSignature.beatUnit)) + } +} + struct HarmonySymbol: Identifiable, Codable, Equatable { var id: UUID var time: TimeInterval diff --git a/JammLab/Services/NotationMeasureLayout.swift b/JammLab/Services/NotationMeasureLayout.swift index ff0b6b8..3e8d62b 100644 --- a/JammLab/Services/NotationMeasureLayout.swift +++ b/JammLab/Services/NotationMeasureLayout.swift @@ -486,14 +486,11 @@ struct NotationMeasureLayout { } static func time(forHarmonyOffset offset: Double, in measure: ScoreMeasure) -> TimeInterval { - let quarterLength = quarterLength(for: measure.attributes.timeSignature) - guard measure.duration > 0, quarterLength > 0 else { return measure.startTime } - let progress = max(0, min(offset / quarterLength, 1)) - return measure.startTime + progress * measure.duration + NotationMeasureTiming.time(forQuarterOffset: offset, in: measure) } static func quarterLength(for timeSignature: TimeSignature) -> Double { - Double(timeSignature.beatsPerBar) * 4.0 / Double(max(1, timeSignature.beatUnit)) + NotationMeasureTiming.quarterLength(for: timeSignature) } static func canvasGeometry( diff --git a/JammLab/Services/NotationViewportFactory.swift b/JammLab/Services/NotationViewportFactory.swift index 55fa066..3788e18 100644 --- a/JammLab/Services/NotationViewportFactory.swift +++ b/JammLab/Services/NotationViewportFactory.swift @@ -374,13 +374,13 @@ struct NotationViewportFactory { private func harmonies(for measure: ScoreMeasure, from harmonySymbols: [HarmonySymbol]) -> [HarmonySymbol] { harmonySymbols .compactMap { symbol -> HarmonySymbol? in - guard Self.isNotationEventTime(symbol.time, within: measure) else { + guard NotationMeasureTiming.containsEventTime(symbol.time, in: measure) else { return nil } return symbol.withPosition( measureNumber: measure.number, - offsetInQuarterNotes: quarterOffset(for: symbol.time, in: measure) + offsetInQuarterNotes: NotationMeasureTiming.quarterOffset(for: symbol.time, in: measure) ) } .sorted { @@ -396,7 +396,7 @@ struct NotationViewportFactory { private func regionLabels(for measure: ScoreMeasure, from regionNotes: [TimecodedNote]) -> [NotationRegionLabel] { regionNotes .compactMap { note -> NotationRegionLabel? in - guard Self.isNotationEventTime(note.time, within: measure) else { + guard NotationMeasureTiming.containsEventTime(note.time, in: measure) else { return nil } @@ -404,7 +404,7 @@ struct NotationViewportFactory { id: note.id, time: note.time, measureNumber: measure.number, - offsetInQuarterNotes: quarterOffset(for: note.time, in: measure), + offsetInQuarterNotes: NotationMeasureTiming.quarterOffset(for: note.time, in: measure), title: Self.regionLabelTitle(for: note) ) } @@ -435,14 +435,6 @@ struct NotationViewportFactory { return trimmedTitle.isEmpty ? "Region" : trimmedTitle } - private static func isNotationEventTime(_ time: TimeInterval, within measure: ScoreMeasure) -> Bool { - time >= measure.startTime - timelineTolerance - && ( - time < measure.endTime - timelineTolerance - || abs(time - measure.startTime) < timelineTolerance - ) - } - private static func isOrderedByNotationPosition( lhsOffset: Double, lhsID: UUID, @@ -457,17 +449,11 @@ struct NotationViewportFactory { } private func quarterOffset(for time: TimeInterval, in measure: ScoreMeasure) -> Double { - let length = quarterLength(for: measure.attributes.timeSignature) - guard measure.duration > 0, length > 0 else { return 0 } - let progress = max(0, min((time - measure.startTime) / measure.duration, 1)) - return progress * length + NotationMeasureTiming.quarterOffset(for: time, in: measure) } private func timeForQuarterOffset(_ offset: Double, in measure: ScoreMeasure) -> TimeInterval { - let length = quarterLength(for: measure.attributes.timeSignature) - guard measure.duration > 0, length > 0 else { return measure.startTime } - let progress = max(0, min(offset / length, 1)) - return measure.startTime + progress * measure.duration + NotationMeasureTiming.time(forQuarterOffset: offset, in: measure) } private func snappedOffset( @@ -494,7 +480,7 @@ struct NotationViewportFactory { } private func quarterLength(for timeSignature: TimeSignature) -> Double { - Double(timeSignature.beatsPerBar) * 4.0 / Double(max(1, timeSignature.beatUnit)) + NotationMeasureTiming.quarterLength(for: timeSignature) } private static let maximumMeasureTraversalCount = 100_000 diff --git a/JammLab/Utilities/AppHotkey.swift b/JammLab/Utilities/AppHotkey.swift index f12a218..1f6ad25 100644 --- a/JammLab/Utilities/AppHotkey.swift +++ b/JammLab/Utilities/AppHotkey.swift @@ -13,6 +13,9 @@ enum AppHotkey: CaseIterable, Hashable { case toggleSnap case togglePlaybackMode case toggleVideoWindow + case copyMeasure + case pasteMeasure + case clearNotationMeasureSelection // Keep this enum as the single source of truth for keyboard shortcuts. // When adding a new handled hotkey, add a case here with its help metadata @@ -23,6 +26,10 @@ enum AppHotkey: CaseIterable, Hashable { let relevantModifiers = modifierFlags.intersection(shortcutModifiers) switch (event.keyCode, relevantModifiers) { + case (8, [.command]): + self = .copyMeasure + case (9, [.command]): + self = .pasteMeasure case (1, [.option]): self = .toggleSnap case (9, [.option]): @@ -56,6 +63,8 @@ enum AppHotkey: CaseIterable, Hashable { self = .setBeatOne case 8: self = .toggleClick + case 53: + self = .clearNotationMeasureSelection default: return nil } @@ -85,6 +94,12 @@ enum AppHotkey: CaseIterable, Hashable { return "Tab" case .toggleVideoWindow: return "Opt+V" + case .copyMeasure: + return "Cmd+C" + case .pasteMeasure: + return "Cmd+V" + case .clearNotationMeasureSelection: + return "Esc" } } @@ -112,6 +127,12 @@ enum AppHotkey: CaseIterable, Hashable { return "Original / Stems" case .toggleVideoWindow: return "Video Window" + case .copyMeasure: + return "Copy Measure" + case .pasteMeasure: + return "Paste Measure" + case .clearNotationMeasureSelection: + return "Clear Measure Selection" } } @@ -139,6 +160,12 @@ enum AppHotkey: CaseIterable, Hashable { return "Switch between original playback and stems playback when stems are available." case .toggleVideoWindow: return "Open or close the sidecar video window for the current video project." + case .copyMeasure: + return "Copy the selected notation measure." + case .pasteMeasure: + return "Replace the selected notation measure with the copied measure contents." + case .clearNotationMeasureSelection: + return "Clear the selected notation measure or measure range." } } } diff --git a/JammLab/ViewModels/AudioPlayerViewModel+Notes.swift b/JammLab/ViewModels/AudioPlayerViewModel+Notes.swift index f4b9b66..fb83ab7 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel+Notes.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel+Notes.swift @@ -53,6 +53,124 @@ extension AudioPlayerViewModel { selectedHarmonySymbolID = availableHarmonySymbolID(id) } + var canCopySelectedNotationMeasure: Bool { + !currentSelectedNotationMeasures().isEmpty + } + + var canPasteNotationMeasureClipboard: Bool { + guard let clipboard = notationMeasureClipboard, !clipboard.measures.isEmpty else { return false } + return currentPasteTargetMeasures(forClipboardMeasureCount: clipboard.measures.count) != nil + } + + var hasSelectedNotationMeasures: Bool { + !selectedNotationMeasures.isEmpty + } + + func selectNotationMeasure(_ measure: ScoreMeasure?, extendingSelection: Bool = false) { + guard let measure else { + clearNotationMeasureSelection() + return + } + + if extendingSelection, let anchor = notationMeasureSelectionAnchor { + let scoreMeasures = currentNotationScoreMeasures() + if let anchorIndex = scoreMeasures.firstIndex(where: anchor.matches), + let measureIndex = scoreMeasures.firstIndex(where: { NotationMeasureSelection(measure: measure).matches($0) }) { + let range = min(anchorIndex, measureIndex)...max(anchorIndex, measureIndex) + selectedNotationMeasures = range.map { NotationMeasureSelection(measure: scoreMeasures[$0]) } + } else { + selectedNotationMeasures = [NotationMeasureSelection(measure: measure)] + notationMeasureSelectionAnchor = NotationMeasureSelection(measure: measure) + } + } else { + selectedNotationMeasures = [NotationMeasureSelection(measure: measure)] + notationMeasureSelectionAnchor = NotationMeasureSelection(measure: measure) + } + selectedHarmonySymbolID = nil + } + + func clearNotationMeasureSelection() { + selectedNotationMeasures = [] + notationMeasureSelectionAnchor = nil + selectedHarmonySymbolID = nil + } + + func clearNotationMeasureSelectionAndClipboard() { + clearNotationMeasureSelection() + notationMeasureClipboard = nil + } + + @discardableResult + func copySelectedNotationMeasure() -> Bool { + guard let measures = validatedSelectedNotationMeasures() else { return false } + + notationMeasureClipboard = NotationMeasureClipboard( + measures: measures.map { measure in + NotationMeasureClipboardMeasure( + items: notationClipboardItems(in: measure) + ) + } + ) + return true + } + + @discardableResult + func pasteNotationMeasureClipboard() -> Bool { + guard let clipboard = notationMeasureClipboard, + let targetMeasures = validatedPasteTargetMeasures(forClipboardMeasureCount: clipboard.measures.count) + else { + return false + } + + let pastedItemsByMeasure = zip(targetMeasures, clipboard.measures).map { targetMeasure, sourceMeasure in + sourceMeasure.items + .filter { + NotationMeasureTiming.isValidHarmonyOffset( + $0.offsetInQuarterNotes, + in: targetMeasure.attributes.timeSignature + ) + } + .sorted(by: notationClipboardItemSort) + } + let currentItemsByMeasure = targetMeasures.map { notationClipboardItems(in: $0) } + + guard currentItemsByMeasure != pastedItemsByMeasure else { + selectedHarmonySymbolID = nil + selectedNotationMeasures = targetMeasures.map(NotationMeasureSelection.init) + notationMeasureSelectionAnchor = selectedNotationMeasures.first + return true + } + + performUndoableEdit(targetMeasures.count == 1 ? "Paste Measure" : "Paste Measures") { + harmonySymbols.removeAll { symbol in + targetMeasures.contains { targetMeasure in + NotationMeasureTiming.containsEventTime(symbol.time, in: targetMeasure) + } + } + + for (targetMeasure, pastedItems) in zip(targetMeasures, pastedItemsByMeasure) { + harmonySymbols.append(contentsOf: pastedItems.map { item in + HarmonySymbol( + time: NotationMeasureTiming.time( + forQuarterOffset: item.offsetInQuarterNotes, + in: targetMeasure + ), + measureNumber: targetMeasure.number, + offsetInQuarterNotes: item.offsetInQuarterNotes, + rawText: item.rawText + ) + }) + } + + harmonySymbols = ProjectStateNormalizer.normalizedHarmonySymbols(harmonySymbols, duration: duration) + selectedHarmonySymbolID = nil + selectedNotationMeasures = targetMeasures.map(NotationMeasureSelection.init) + notationMeasureSelectionAnchor = selectedNotationMeasures.first + } + + return true + } + func saveHarmonySymbol(_ symbol: HarmonySymbol) { guard duration > 0, let placement = harmonyPlacement(for: symbol.time, resolution: nil) @@ -156,6 +274,7 @@ extension AudioPlayerViewModel { ) notes.append(note) notes.sort { $0.time < $1.time } + clearNotationMeasureSelection() applyTempoMapToPlaybackEngine() } } @@ -284,6 +403,7 @@ extension AudioPlayerViewModel { excluding: id ) else { notes.remove(at: index) + clearNotationMeasureSelection() applyTempoMapToPlaybackEngine() return } @@ -291,6 +411,7 @@ extension AudioPlayerViewModel { notes[index].metadata = payload.metadata notes[index].title = payload.title notes.sort { $0.time < $1.time } + clearNotationMeasureSelection() applyTempoMapToPlaybackEngine() } } @@ -321,6 +442,7 @@ extension AudioPlayerViewModel { notes[index].time = snappedTimelineTime(time) notes.sort { $0.time < $1.time } if notes.contains(where: { $0.id == id && $0.isTempoTimeSignatureMarker }) { + clearNotationMeasureSelection() applyTempoMapToPlaybackEngine() } } @@ -328,6 +450,7 @@ extension AudioPlayerViewModel { func deleteNote(id: TimecodedNote.ID) { performUndoableEdit("Delete Marker") { + let deletesTempoMapMarker = notes.contains { $0.id == id && $0.isTempoTimeSignatureMarker } notes.removeAll { $0.id == id } if selectedRegionID == id { @@ -339,6 +462,9 @@ extension AudioPlayerViewModel { applyLoopConfiguration() } + if deletesTempoMapMarker { + clearNotationMeasureSelection() + } applyTempoMapToPlaybackEngine() } } @@ -397,6 +523,112 @@ extension AudioPlayerViewModel { abs(lhs - rhs) < 0.000_001 } + private func notationClipboardItemSort( + _ lhs: NotationMeasureClipboardItem, + _ rhs: NotationMeasureClipboardItem + ) -> Bool { + if abs(lhs.offsetInQuarterNotes - rhs.offsetInQuarterNotes) > NotationMeasureTiming.timelineTolerance { + return lhs.offsetInQuarterNotes < rhs.offsetInQuarterNotes + } + + return lhs.rawText < rhs.rawText + } + + private func notationClipboardItems(in measure: ScoreMeasure) -> [NotationMeasureClipboardItem] { + harmonySymbols + .compactMap { symbol -> (HarmonySymbol, Double)? in + guard NotationMeasureTiming.containsEventTime(symbol.time, in: measure) else { + return nil + } + + return (symbol, NotationMeasureTiming.quarterOffset(for: symbol.time, in: measure)) + } + .sorted { + if abs($0.1 - $1.1) > NotationMeasureTiming.timelineTolerance { + return $0.1 < $1.1 + } + + return $0.0.id.uuidString < $1.0.id.uuidString + } + .map { symbol, offset in + NotationMeasureClipboardItem( + offsetInQuarterNotes: offset, + rawText: symbol.rawText + ) + } + } + + private func validatedSelectedNotationMeasures() -> [ScoreMeasure]? { + let measures = currentSelectedNotationMeasures() + guard !measures.isEmpty else { + clearNotationMeasureSelection() + return nil + } + + selectedNotationMeasures = measures.map(NotationMeasureSelection.init) + if let anchor = notationMeasureSelectionAnchor, + measures.contains(where: anchor.matches) { + notationMeasureSelectionAnchor = anchor + } else { + notationMeasureSelectionAnchor = selectedNotationMeasures.first + } + return measures + } + + private func validatedPasteTargetMeasures(forClipboardMeasureCount clipboardMeasureCount: Int) -> [ScoreMeasure]? { + guard let targetMeasures = currentPasteTargetMeasures(forClipboardMeasureCount: clipboardMeasureCount) else { + clearNotationMeasureSelection() + return nil + } + + selectedNotationMeasures = targetMeasures.map(NotationMeasureSelection.init) + notationMeasureSelectionAnchor = selectedNotationMeasures.first + return targetMeasures + } + + private func currentSelectedNotationMeasures() -> [ScoreMeasure] { + guard !selectedNotationMeasures.isEmpty else { return [] } + + let scoreMeasures = currentNotationScoreMeasures() + guard let firstIndex = scoreMeasures.firstIndex(where: selectedNotationMeasures[0].matches) else { return [] } + let expectedRange = firstIndex..<(firstIndex + selectedNotationMeasures.count) + guard expectedRange.upperBound <= scoreMeasures.endIndex else { return [] } + let expectedMeasures = expectedRange.map { scoreMeasures[$0] } + + return zip(expectedMeasures, selectedNotationMeasures).allSatisfy { measure, selection in + selection.matches(measure) + } ? expectedMeasures : [] + } + + private func currentPasteTargetMeasures(forClipboardMeasureCount clipboardMeasureCount: Int) -> [ScoreMeasure]? { + guard clipboardMeasureCount > 0 else { return nil } + let selectedMeasures = currentSelectedNotationMeasures() + guard let firstSelectedMeasure = selectedMeasures.first else { return nil } + + let scoreMeasures = currentNotationScoreMeasures() + let firstSelection = NotationMeasureSelection(measure: firstSelectedMeasure) + guard let startIndex = scoreMeasures.firstIndex(where: { firstSelection.matches($0) }) else { + return nil + } + + let targetCount = min(clipboardMeasureCount, scoreMeasures.count - startIndex) + guard targetCount > 0 else { return nil } + return (startIndex..<(startIndex + targetCount)).map { scoreMeasures[$0] } + } + + private func currentNotationScoreMeasures() -> [ScoreMeasure] { + NotationViewportFactory().scoreState( + tempoMap: tempoMap, + duration: duration, + currentTime: currentTime, + playbackMarkerTime: playbackMarkerTime, + isPlaying: playbackState == .playing, + keyName: effectiveKeyName, + harmonySymbols: harmonySymbols, + notes: notes + ).measures + } + func updateLoopStart(_ start: TimeInterval) { performUndoableEdit("Edit Loop") { selectedRegionID = nil diff --git a/JammLab/ViewModels/AudioPlayerViewModel+Project.swift b/JammLab/ViewModels/AudioPlayerViewModel+Project.swift index 353f86a..874e1f6 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel+Project.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel+Project.swift @@ -115,6 +115,7 @@ extension AudioPlayerViewModel { projectKeySelection = nil selectedRegionID = nil selectedHarmonySymbolID = nil + clearNotationMeasureSelectionAndClipboard() pendingHarmonyEditorRequest = nil activeLoopRegionID = nil harmonyInputResolutionDenominator = HarmonyInputResolution.defaultDenominator @@ -175,6 +176,7 @@ extension AudioPlayerViewModel { projectKeySelection = nil selectedRegionID = nil selectedHarmonySymbolID = nil + clearNotationMeasureSelectionAndClipboard() pendingHarmonyEditorRequest = nil harmonyInputResolutionDenominator = HarmonyInputResolution.defaultDenominator activeLoopRegionID = nil @@ -250,6 +252,7 @@ extension AudioPlayerViewModel { projectKeySelection = project.projectKeySelection selectedRegionID = nil selectedHarmonySymbolID = nil + clearNotationMeasureSelectionAndClipboard() pendingHarmonyEditorRequest = nil harmonyInputResolutionDenominator = HarmonyInputResolution.defaultDenominator activeLoopRegionID = nil diff --git a/JammLab/ViewModels/AudioPlayerViewModel+Timeline.swift b/JammLab/ViewModels/AudioPlayerViewModel+Timeline.swift index e7fa1c1..328c03e 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel+Timeline.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel+Timeline.swift @@ -29,6 +29,7 @@ extension AudioPlayerViewModel { beatGridSettings.bpm = tempoBPM beatGridSettings.lastChangedAt = Date() shouldAcceptAnalyzedTempo = false + clearNotationMeasureSelection() applyTempoMapToPlaybackEngine() } } @@ -38,24 +39,28 @@ extension AudioPlayerViewModel { beatGridSettings.timeSignature = TimeSignature(beatsPerBar: beatsPerBar, beatUnit: beatUnit) beatGridSettings.lastChangedAt = Date() shouldAcceptAnalyzedTempo = false + clearNotationMeasureSelection() applyTempoMapToPlaybackEngine() } } func setCurrentTimeAsBeatOne() { performUndoableEdit("Set Beat 1") { + clearNotationMeasureSelection() setFirstBeatTime(currentTime, source: .manual) } } func resetBeatGridAlignment() { performUndoableEdit("Reset Beat Grid") { + clearNotationMeasureSelection() setFirstBeatTime(beatGridSettings.automaticFirstBeatTime, source: .automatic) } } func nudgeBeatGrid(by delta: TimeInterval) { performUndoableEdit("Nudge Beat Grid") { + clearNotationMeasureSelection() setFirstBeatTime(beatGridSettings.firstBeatTime + delta, source: .manual) } } diff --git a/JammLab/ViewModels/AudioPlayerViewModel.swift b/JammLab/ViewModels/AudioPlayerViewModel.swift index e156134..2590a17 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel.swift @@ -40,6 +40,8 @@ final class AudioPlayerViewModel: ObservableObject { @Published var projectKeySelection: ProjectKeySelection? @Published var selectedRegionID: TimecodedNote.ID? @Published var selectedHarmonySymbolID: HarmonySymbol.ID? + @Published var selectedNotationMeasures: [NotationMeasureSelection] = [] + @Published var notationMeasureClipboard: NotationMeasureClipboard? @Published var harmonyInputResolutionDenominator = HarmonyInputResolution.defaultDenominator @Published var pendingHarmonyEditorRequest: HarmonyEditorRequest? @Published var activeLoopRegionID: TimecodedNote.ID? @@ -86,6 +88,7 @@ final class AudioPlayerViewModel: ObservableObject { var clockTask: Task? var analysisTask: Task? var waveformTask: Task? + var notationMeasureSelectionAnchor: NotationMeasureSelection? var stemSeparationTask: Task? var stemPeakformTask: Task? var stemCacheMetadata: StemCacheMetadata? diff --git a/JammLab/Views/Components/AppHotkeyMonitorView.swift b/JammLab/Views/Components/AppHotkeyMonitorView.swift index 9cb4c8c..368d39b 100644 --- a/JammLab/Views/Components/AppHotkeyMonitorView.swift +++ b/JammLab/Views/Components/AppHotkeyMonitorView.swift @@ -3,7 +3,26 @@ import SwiftUI struct AppHotkeyMonitorView: NSViewRepresentable { let allowedHotkeys: Set - let onHotkey: (AppHotkey) -> Void + let onHotkey: (AppHotkey) -> Bool + + init( + allowedHotkeys: Set, + onHotkey: @escaping (AppHotkey) -> Void + ) { + self.allowedHotkeys = allowedHotkeys + self.onHotkey = { hotkey in + onHotkey(hotkey) + return true + } + } + + init( + allowedHotkeys: Set, + onHotkeyShouldConsume: @escaping (AppHotkey) -> Bool + ) { + self.allowedHotkeys = allowedHotkeys + self.onHotkey = onHotkeyShouldConsume + } func makeNSView(context: Context) -> AppHotkeyMonitorNSView { let view = AppHotkeyMonitorNSView(frame: .zero) @@ -25,7 +44,7 @@ struct AppHotkeyMonitorView: NSViewRepresentable { final class AppHotkeyMonitorNSView: NSView { var allowedHotkeys: Set = [] - var onHotkey: ((AppHotkey) -> Void)? + var onHotkey: ((AppHotkey) -> Bool)? private var monitor: Any? @@ -51,8 +70,7 @@ final class AppHotkeyMonitorNSView: NSView { guard let self else { return event } guard let hotkey = self.hotkey(for: event) else { return event } - self.onHotkey?(hotkey) - return nil + return self.onHotkey?(hotkey) == true ? nil : event } } diff --git a/JammLab/Views/MainWorkspacePanels.swift b/JammLab/Views/MainWorkspacePanels.swift index 6a9b20c..f85d11e 100644 --- a/JammLab/Views/MainWorkspacePanels.swift +++ b/JammLab/Views/MainWorkspacePanels.swift @@ -130,6 +130,7 @@ extension ContentView { loopEnd: viewModel.loopRegion.end, notes: viewModel.notes, selectedHarmonySymbolID: viewModel.selectedHarmonySymbolID, + selectedNotationMeasures: viewModel.selectedNotationMeasures, harmonyInputResolutionDenominator: viewModel.harmonyInputResolutionDenominator, pendingHarmonyEditorRequest: viewModel.pendingHarmonyEditorRequest, selectedRegionID: viewModel.selectedRegionID, @@ -184,6 +185,7 @@ extension ContentView { addNote: { viewModel.addNote(at: $0) }, harmonyInputResolutionChanged: { viewModel.setHarmonyInputResolutionDenominator($0) }, selectHarmony: { viewModel.selectHarmonySymbol(id: $0) }, + selectNotationMeasure: { viewModel.selectNotationMeasure($0, extendingSelection: $1) }, saveHarmony: { viewModel.saveHarmonySymbol($0) }, deleteHarmony: { viewModel.deleteHarmonySymbol(id: $0) }, adjacentHarmonyPlacement: { viewModel.adjacentHarmonyPlacement(from: $0, direction: $1) }, diff --git a/JammLab/Views/NotationTrackView.swift b/JammLab/Views/NotationTrackView.swift index abd5542..0074771 100644 --- a/JammLab/Views/NotationTrackView.swift +++ b/JammLab/Views/NotationTrackView.swift @@ -3,6 +3,7 @@ import SwiftUI struct NotationTrackActions { var selectHarmony: (HarmonySymbol.ID?) -> Void + var selectMeasure: (ScoreMeasure?, Bool) -> Void var saveHarmony: (HarmonySymbol) -> Void var deleteHarmony: (HarmonySymbol.ID) -> Void var adjacentHarmonyPlacement: (TimeInterval, HarmonyNavigationDirection) -> HarmonyPlacement? @@ -11,6 +12,7 @@ struct NotationTrackActions { struct NotationTrackView: View { let state: NotationViewportState let selectedHarmonySymbolID: HarmonySymbol.ID? + let selectedMeasures: [NotationMeasureSelection] let pendingEditorRequest: HarmonyEditorRequest? let inputResolution: HarmonyInputResolution let actions: NotationTrackActions @@ -23,6 +25,7 @@ struct NotationTrackView: View { init( state: NotationViewportState, selectedHarmonySymbolID: HarmonySymbol.ID? = nil, + selectedMeasures: [NotationMeasureSelection] = [], pendingEditorRequest: HarmonyEditorRequest? = nil, inputResolution: HarmonyInputResolution = HarmonyInputResolution(), actions: NotationTrackActions = .noop, @@ -30,6 +33,7 @@ struct NotationTrackView: View { ) { self.state = state self.selectedHarmonySymbolID = selectedHarmonySymbolID + self.selectedMeasures = selectedMeasures self.pendingEditorRequest = pendingEditorRequest self.inputResolution = inputResolution self.actions = actions @@ -46,6 +50,16 @@ struct NotationTrackView: View { measureCount: renderedMeasureCount, attributeDisplays: attributeDisplays ) + selectedMeasureOverlay( + width: contentWidth, + height: proxy.size.height, + attributeDisplays: attributeDisplays + ) + measureSelectionHitLayer( + width: contentWidth, + height: proxy.size.height, + attributeDisplays: attributeDisplays + ) measureNumberLabels( width: contentWidth, height: proxy.size.height, @@ -92,6 +106,7 @@ struct NotationTrackView: View { .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) .focusable() .focused($isTrackFocused) + .focusEffectDisabled(true) .onDeleteCommand { deleteSelectedHarmony() } @@ -312,6 +327,80 @@ struct NotationTrackView: View { } } + private func selectedMeasureOverlay( + width: CGFloat, + height: CGFloat, + attributeDisplays: [NotationAttributeDisplay] + ) -> some View { + let geometries = measureCanvasGeometries( + measureCount: renderedMeasureCount, + width: width, + attributeDisplays: attributeDisplays + ) + let staffTop = staffTop(in: height) + let overlayY = max(AppTheme.Spacing.xs, staffTop - AppTheme.Spacing.xxl) + let overlayBottom = staffTop + + AppTheme.Timeline.notationStaffLineSpacing * 4 + + AppTheme.Spacing.lg + let overlayHeight = max(1, overlayBottom - overlayY) + + return ZStack(alignment: .topLeading) { + ForEach(state.visibleMeasures.indices, id: \.self) { index in + if selectedMeasures.contains(where: { $0.matches(state.visibleMeasures[index]) }), + geometries.indices.contains(index) { + let geometry = geometries[index] + RoundedRectangle(cornerRadius: AppTheme.Radius.small) + .fill(appColors.accent.opacity(0.16)) + .overlay { + RoundedRectangle(cornerRadius: AppTheme.Radius.small) + .stroke(appColors.accent, lineWidth: AppTheme.Stroke.thick) + } + .frame( + width: max(1, geometry.cellEndX - geometry.cellStartX), + height: overlayHeight + ) + .offset(x: geometry.cellStartX, y: overlayY) + .allowsHitTesting(false) + .accessibilityHidden(true) + } + } + } + } + + private func measureSelectionHitLayer( + width: CGFloat, + height: CGFloat, + attributeDisplays: [NotationAttributeDisplay] + ) -> some View { + let geometries = measureCanvasGeometries( + measureCount: renderedMeasureCount, + width: width, + attributeDisplays: attributeDisplays + ) + + return ZStack(alignment: .topLeading) { + ForEach(state.visibleMeasures.indices, id: \.self) { index in + if geometries.indices.contains(index) { + let geometry = geometries[index] + Rectangle() + .fill(Color.clear) + .contentShape(Rectangle()) + .frame( + width: max(1, geometry.cellEndX - geometry.cellStartX), + height: height + ) + .offset(x: geometry.cellStartX, y: 0) + .onTapGesture { + isTrackFocused = true + editingDraft = nil + actions.selectMeasure(state.visibleMeasures[index], isShiftClickActive) + } + .accessibilityHidden(true) + } + } + } + } + private func regionLabelsLayer( width: CGFloat, height: CGFloat, @@ -441,10 +530,18 @@ struct NotationTrackView: View { .contentShape(Rectangle()) .onTapGesture { isTrackFocused = true - actions.selectHarmony(symbol.id) + if let measure = measure(containing: symbol) { + actions.selectMeasure(measure, isShiftClickActive) + } + if !isShiftClickActive { + actions.selectHarmony(symbol.id) + } } .onTapGesture(count: 2) { isTrackFocused = true + if let measure = measure(containing: symbol) { + actions.selectMeasure(measure, false) + } beginEditingHarmony(symbol) } .accessibilityLabel("Harmony \(symbol.rawText)") @@ -805,6 +902,12 @@ struct NotationTrackView: View { .first { abs($0.time - time) < 0.000_001 } } + private func measure(containing symbol: HarmonySymbol) -> ScoreMeasure? { + state.visibleMeasures.first { + NotationMeasureTiming.containsEventTime(symbol.time, in: $0) + } + } + private func harmonyPlacement(for time: TimeInterval) -> NotationHarmonyPlacement? { guard let measureIndex = state.visibleMeasures.indices.first(where: { index in let measure = state.visibleMeasures[index] @@ -882,7 +985,22 @@ struct NotationTrackView: View { return "Pending tempo" } - return "Measures \(first.number) through \(last.number), \(state.keySignature.displayName), \(state.timeSignature.displayText)" + let selectedMeasureText: String + if selectedMeasures.isEmpty { + selectedMeasureText = "" + } else if selectedMeasures.count == 1, let selectedMeasure = selectedMeasures.first { + selectedMeasureText = ", selected measure \(selectedMeasure.number)" + } else if let firstSelectedMeasure = selectedMeasures.first, + let lastSelectedMeasure = selectedMeasures.last { + selectedMeasureText = ", selected measures \(firstSelectedMeasure.number) through \(lastSelectedMeasure.number)" + } else { + selectedMeasureText = "" + } + return "Measures \(first.number) through \(last.number), \(state.keySignature.displayName), \(state.timeSignature.displayText)\(selectedMeasureText)" + } + + private var isShiftClickActive: Bool { + NSApp.currentEvent?.modifierFlags.contains(.shift) == true } private var scrollResetIdentity: String { @@ -970,6 +1088,7 @@ private struct NotationHarmonyPlacement: Equatable { private extension NotationTrackActions { static let noop = NotationTrackActions( selectHarmony: { _ in }, + selectMeasure: { _, _ in }, saveHarmony: { _ in }, deleteHarmony: { _ in }, adjacentHarmonyPlacement: { _, _ in nil } diff --git a/JammLab/Views/NotationWindowView.swift b/JammLab/Views/NotationWindowView.swift index 9babff8..3656082 100644 --- a/JammLab/Views/NotationWindowView.swift +++ b/JammLab/Views/NotationWindowView.swift @@ -38,8 +38,8 @@ struct NotationWindowView: View { ) .background( AppHotkeyMonitorView( - allowedHotkeys: [.playPause], - onHotkey: handleHotkey + allowedHotkeys: allowedHotkeys, + onHotkeyShouldConsume: handleHotkey ) ) } @@ -102,6 +102,7 @@ struct NotationWindowView: View { NotationTrackView( state: system.viewportState, selectedHarmonySymbolID: viewModel.selectedHarmonySymbolID, + selectedMeasures: viewModel.selectedNotationMeasures, pendingEditorRequest: viewModel.pendingHarmonyEditorRequest, inputResolution: HarmonyInputResolution( denominator: viewModel.harmonyInputResolutionDenominator @@ -163,18 +164,42 @@ struct NotationWindowView: View { private var notationActions: NotationTrackActions { NotationTrackActions( selectHarmony: { viewModel.selectHarmonySymbol(id: $0) }, + selectMeasure: { viewModel.selectNotationMeasure($0, extendingSelection: $1) }, saveHarmony: { viewModel.saveHarmonySymbol($0) }, deleteHarmony: { viewModel.deleteHarmonySymbol(id: $0) }, adjacentHarmonyPlacement: { viewModel.adjacentHarmonyPlacement(from: $0, direction: $1) } ) } - private func handleHotkey(_ hotkey: AppHotkey) { + private var allowedHotkeys: Set { + var hotkeys: Set = [.playPause] + if viewModel.canCopySelectedNotationMeasure { + hotkeys.insert(.copyMeasure) + } + if viewModel.canPasteNotationMeasureClipboard { + hotkeys.insert(.pasteMeasure) + } + if viewModel.hasSelectedNotationMeasures { + hotkeys.insert(.clearNotationMeasureSelection) + } + return hotkeys + } + + @discardableResult + private func handleHotkey(_ hotkey: AppHotkey) -> Bool { switch hotkey { case .playPause: viewModel.togglePlayStop() + return true + case .copyMeasure: + return viewModel.copySelectedNotationMeasure() + case .pasteMeasure: + return viewModel.pasteNotationMeasureClipboard() + case .clearNotationMeasureSelection: + viewModel.clearNotationMeasureSelection() + return true default: - break + return false } } diff --git a/JammLab/Views/WaveformTimelineView.swift b/JammLab/Views/WaveformTimelineView.swift index 6380745..5063220 100644 --- a/JammLab/Views/WaveformTimelineView.swift +++ b/JammLab/Views/WaveformTimelineView.swift @@ -25,6 +25,7 @@ struct TimelineViewState: Equatable { var loopEnd: TimeInterval var notes: [TimecodedNote] var selectedHarmonySymbolID: HarmonySymbol.ID? + var selectedNotationMeasures: [NotationMeasureSelection] var harmonyInputResolutionDenominator: Int var pendingHarmonyEditorRequest: HarmonyEditorRequest? var selectedRegionID: TimecodedNote.ID? @@ -44,6 +45,7 @@ struct TimelineViewActions { var addNote: (TimeInterval) -> Void var harmonyInputResolutionChanged: (Int) -> Void var selectHarmony: (HarmonySymbol.ID?) -> Void + var selectNotationMeasure: (ScoreMeasure?, Bool) -> Void var saveHarmony: (HarmonySymbol) -> Void var deleteHarmony: (HarmonySymbol.ID) -> Void var adjacentHarmonyPlacement: (TimeInterval, HarmonyNavigationDirection) -> HarmonyPlacement? @@ -235,10 +237,12 @@ struct WaveformTimelineView: View { NotationTrackView( state: state.notationViewport, selectedHarmonySymbolID: state.selectedHarmonySymbolID, + selectedMeasures: state.selectedNotationMeasures, pendingEditorRequest: state.pendingHarmonyEditorRequest, inputResolution: HarmonyInputResolution(denominator: state.harmonyInputResolutionDenominator), actions: NotationTrackActions( selectHarmony: actions.selectHarmony, + selectMeasure: actions.selectNotationMeasure, saveHarmony: actions.saveHarmony, deleteHarmony: actions.deleteHarmony, adjacentHarmonyPlacement: actions.adjacentHarmonyPlacement diff --git a/JammLabTests/AudioTimingLogicTests.swift b/JammLabTests/AudioTimingLogicTests.swift index 3e4ebaf..c0024e1 100644 --- a/JammLabTests/AudioTimingLogicTests.swift +++ b/JammLabTests/AudioTimingLogicTests.swift @@ -1530,6 +1530,39 @@ final class AudioTimingLogicTests: XCTestCase { XCTAssertEqual(outOfRangeX, geometry.contentEndX, accuracy: 0.0001) } + func testNotationMeasureTimingUsesHalfOpenMeasureBoundaries() { + let measure = ScoreMeasure( + number: 1, + startTime: 0, + endTime: 2, + attributes: .defaultTreble + ) + + XCTAssertTrue(NotationMeasureTiming.containsEventTime(0, in: measure)) + XCTAssertTrue(NotationMeasureTiming.containsEventTime(1.999, in: measure)) + XCTAssertFalse(NotationMeasureTiming.containsEventTime(2, in: measure)) + } + + func testNotationMeasureTimingRecomputesQuarterOffsetsFromMeasureTime() { + let measure = ScoreMeasure( + number: 1, + startTime: 2, + endTime: 4, + attributes: .defaultTreble + ) + + XCTAssertEqual( + NotationMeasureTiming.quarterOffset(for: 3, in: measure), + 2, + accuracy: 0.0001 + ) + XCTAssertEqual( + NotationMeasureTiming.time(forQuarterOffset: 2, in: measure), + 3, + accuracy: 0.0001 + ) + } + func testNotationMeasureLayoutMapsAnchorXBackToProgress() { let geometry = NotationMeasureCanvasGeometry( measureIndex: 0, diff --git a/JammLabTests/TimelineProjectLogicTests.swift b/JammLabTests/TimelineProjectLogicTests.swift index 1a2281c..d3d1af9 100644 --- a/JammLabTests/TimelineProjectLogicTests.swift +++ b/JammLabTests/TimelineProjectLogicTests.swift @@ -713,4 +713,155 @@ final class TimelineProjectLogicTests: XCTestCase { XCTAssertEqual(AppHotkey.addTempoTimeSignatureMarker.title, "Add Tempo / Time Signature Marker") } + func testAppHotkeyRecognizesCommandCAndVForNotationMeasureCopyPaste() throws { + let copyEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: "c", + charactersIgnoringModifiers: "c", + isARepeat: false, + keyCode: 8 + )) + let pasteEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: "v", + charactersIgnoringModifiers: "v", + isARepeat: false, + keyCode: 9 + )) + + XCTAssertEqual(AppHotkey(event: copyEvent), .copyMeasure) + XCTAssertEqual(AppHotkey.copyMeasure.key, "Cmd+C") + XCTAssertEqual(AppHotkey.copyMeasure.title, "Copy Measure") + XCTAssertEqual(AppHotkey(event: pasteEvent), .pasteMeasure) + XCTAssertEqual(AppHotkey.pasteMeasure.key, "Cmd+V") + XCTAssertEqual(AppHotkey.pasteMeasure.title, "Paste Measure") + } + + func testAppHotkeyRecognizesEscapeForNotationMeasureSelectionClear() throws { + let event = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: "\u{1b}", + charactersIgnoringModifiers: "\u{1b}", + isARepeat: false, + keyCode: 53 + )) + + XCTAssertEqual(AppHotkey(event: event), .clearNotationMeasureSelection) + XCTAssertEqual(AppHotkey.clearNotationMeasureSelection.key, "Esc") + XCTAssertEqual(AppHotkey.clearNotationMeasureSelection.title, "Clear Measure Selection") + } + + func testAppHotkeyEventFilterDoesNotStealMeasureCopyPasteFromTextRespondersOrUnavailableScopes() throws { + let copyEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: 0, + windowNumber: 42, + context: nil, + characters: "c", + charactersIgnoringModifiers: "c", + isARepeat: false, + keyCode: 8 + )) + + XCTAssertEqual( + AppHotkeyEventFilter.hotkey( + for: copyEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: [.copyMeasure] + ), + .copyMeasure + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: copyEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: [.playPause] + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: copyEvent, + attachedWindowNumber: 42, + firstResponder: NSTextView(), + allowedHotkeys: [.copyMeasure] + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: copyEvent, + attachedWindowNumber: 42, + firstResponder: AbletonNumberFieldNSView(), + allowedHotkeys: [.copyMeasure] + ) + ) + } + + func testAppHotkeyEventFilterDoesNotStealEscapeFromTextRespondersOrUnavailableScopes() throws { + let escapeEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 42, + context: nil, + characters: "\u{1b}", + charactersIgnoringModifiers: "\u{1b}", + isARepeat: false, + keyCode: 53 + )) + + XCTAssertEqual( + AppHotkeyEventFilter.hotkey( + for: escapeEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: [.clearNotationMeasureSelection] + ), + .clearNotationMeasureSelection + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: escapeEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: [.playPause] + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: escapeEvent, + attachedWindowNumber: 42, + firstResponder: NSTextView(), + allowedHotkeys: [.clearNotationMeasureSelection] + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: escapeEvent, + attachedWindowNumber: 42, + firstResponder: AbletonNumberFieldNSView(), + allowedHotkeys: [.clearNotationMeasureSelection] + ) + ) + } + } diff --git a/JammLabTests/ViewModelLifecycleTests.swift b/JammLabTests/ViewModelLifecycleTests.swift index a90756e..0131f0e 100644 --- a/JammLabTests/ViewModelLifecycleTests.swift +++ b/JammLabTests/ViewModelLifecycleTests.swift @@ -1518,6 +1518,314 @@ final class ViewModelLifecycleTests: XCTestCase { XCTAssertFalse(viewModel.isProjectModified) } + @MainActor + func testSelectingNotationMeasureDoesNotMarkProjectModified() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let measure = try notationMeasure(1, in: viewModel) + + viewModel.selectNotationMeasure(measure) + + XCTAssertEqual(viewModel.selectedNotationMeasures.map(\.number), [1]) + XCTAssertTrue(viewModel.canCopySelectedNotationMeasure) + XCTAssertFalse(viewModel.isProjectModified) + } + + @MainActor + func testShiftSelectingNotationMeasuresBuildsContiguousRange() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let firstMeasure = try notationMeasure(1, in: viewModel) + let thirdMeasure = try notationMeasure(3, in: viewModel) + + viewModel.selectNotationMeasure(firstMeasure) + viewModel.selectNotationMeasure(thirdMeasure, extendingSelection: true) + + XCTAssertEqual(viewModel.selectedNotationMeasures.map(\.number), [1, 2, 3]) + + viewModel.selectNotationMeasure(firstMeasure, extendingSelection: true) + + XCTAssertEqual(viewModel.selectedNotationMeasures.map(\.number), [1]) + } + + @MainActor + func testShiftSelectingNotationMeasureWithoutAnchorFallsBackToSingleSelection() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let secondMeasure = try notationMeasure(2, in: viewModel) + + viewModel.selectNotationMeasure(secondMeasure, extendingSelection: true) + + XCTAssertEqual(viewModel.selectedNotationMeasures.map(\.number), [2]) + } + + @MainActor + func testCopyNotationMeasureCopiesOnlyHarmonies() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let measure = try notationMeasure(1, in: viewModel) + viewModel.notes = [ + TimecodedNote(kind: .region, time: measure.startTime, duration: 1, title: "Intro") + ] + viewModel.harmonySymbols = [ + HarmonySymbol(time: 0.5, measureNumber: 99, offsetInQuarterNotes: 99, rawText: "F"), + HarmonySymbol(time: measure.endTime, measureNumber: 1, offsetInQuarterNotes: 4, rawText: "G") + ] + + viewModel.selectNotationMeasure(measure) + + XCTAssertTrue(viewModel.copySelectedNotationMeasure()) + XCTAssertEqual(viewModel.notationMeasureClipboard?.measures.map(\.items), [[ + NotationMeasureClipboardItem(offsetInQuarterNotes: 1, rawText: "F") + ]]) + } + + @MainActor + func testCopyNotationMeasureRangePreservesOrderAndEmptyMeasures() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let firstMeasure = try notationMeasure(1, in: viewModel) + let thirdMeasure = try notationMeasure(3, in: viewModel) + viewModel.harmonySymbols = [ + HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C"), + HarmonySymbol(time: 4, measureNumber: 3, offsetInQuarterNotes: 0, rawText: "Am") + ] + + viewModel.selectNotationMeasure(firstMeasure) + viewModel.selectNotationMeasure(thirdMeasure, extendingSelection: true) + + XCTAssertTrue(viewModel.copySelectedNotationMeasure()) + XCTAssertEqual(viewModel.notationMeasureClipboard?.measures.map(\.items), [ + [NotationMeasureClipboardItem(offsetInQuarterNotes: 0, rawText: "C")], + [], + [NotationMeasureClipboardItem(offsetInQuarterNotes: 0, rawText: "Am")] + ]) + } + + @MainActor + func testPasteNotationMeasureReplacesTargetAndSupportsUndoRedo() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let undoManager = UndoManager() + viewModel.undoManager = undoManager + let sourceMeasure = try notationMeasure(1, in: viewModel) + let targetMeasure = try notationMeasure(2, in: viewModel) + let sourceA = HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C") + let sourceB = HarmonySymbol(time: 1, measureNumber: 1, offsetInQuarterNotes: 2, rawText: "F") + let targetExisting = HarmonySymbol(time: 2.5, measureNumber: 2, offsetInQuarterNotes: 1, rawText: "G7") + viewModel.harmonySymbols = [sourceA, sourceB, targetExisting] + viewModel.markProjectClean() + + viewModel.selectNotationMeasure(sourceMeasure) + XCTAssertTrue(viewModel.copySelectedNotationMeasure()) + viewModel.selectNotationMeasure(targetMeasure) + let beforePaste = viewModel.harmonySymbols + + XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) + + let targetSymbols = viewModel.harmonySymbols.filter { + NotationMeasureTiming.containsEventTime($0.time, in: targetMeasure) + } + XCTAssertEqual(targetSymbols.map(\.rawText), ["C", "F"]) + XCTAssertEqual(targetSymbols.map(\.id).contains(sourceA.id), false) + XCTAssertEqual(targetSymbols.map(\.id).contains(sourceB.id), false) + XCTAssertEqual(targetSymbols[0].time, 2, accuracy: 0.0001) + XCTAssertEqual(targetSymbols[1].time, 3, accuracy: 0.0001) + XCTAssertNil(viewModel.selectedHarmonySymbolID) + XCTAssertEqual(viewModel.selectedNotationMeasures.map(\.number), [targetMeasure.number]) + XCTAssertTrue(viewModel.isProjectModified) + + viewModel.undoLastEdit() + + XCTAssertEqual(viewModel.harmonySymbols, beforePaste) + XCTAssertFalse(viewModel.isProjectModified) + + viewModel.redoLastEdit() + + let redoneTargetSymbols = viewModel.harmonySymbols.filter { + NotationMeasureTiming.containsEventTime($0.time, in: targetMeasure) + } + XCTAssertEqual(redoneTargetSymbols.map(\.rawText), ["C", "F"]) + XCTAssertTrue(viewModel.isProjectModified) + } + + @MainActor + func testPasteNotationMeasureRangeStartsAtFirstSelectedTarget() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let sourceMeasure = try notationMeasure(1, in: viewModel) + let secondMeasure = try notationMeasure(2, in: viewModel) + let targetMeasure = try notationMeasure(3, in: viewModel) + let fourthMeasure = try notationMeasure(4, in: viewModel) + viewModel.harmonySymbols = [ + HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C"), + HarmonySymbol(time: 2, measureNumber: 2, offsetInQuarterNotes: 0, rawText: "F"), + HarmonySymbol(time: 4, measureNumber: 3, offsetInQuarterNotes: 0, rawText: "G"), + HarmonySymbol(time: 6, measureNumber: 4, offsetInQuarterNotes: 0, rawText: "Am") + ] + + viewModel.selectNotationMeasure(sourceMeasure) + viewModel.selectNotationMeasure(secondMeasure, extendingSelection: true) + XCTAssertTrue(viewModel.copySelectedNotationMeasure()) + viewModel.selectNotationMeasure(targetMeasure) + + XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) + + let thirdMeasureSymbols = viewModel.harmonySymbols.filter { + NotationMeasureTiming.containsEventTime($0.time, in: targetMeasure) + } + let fourthMeasureSymbols = viewModel.harmonySymbols.filter { + NotationMeasureTiming.containsEventTime($0.time, in: fourthMeasure) + } + XCTAssertEqual(thirdMeasureSymbols.map(\.rawText), ["C"]) + XCTAssertEqual(fourthMeasureSymbols.map(\.rawText), ["F"]) + XCTAssertEqual(viewModel.selectedNotationMeasures.map(\.number), [3, 4]) + } + + @MainActor + func testPastingEmptyNotationMeasureClearsTarget() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let emptyMeasure = try notationMeasure(3, in: viewModel) + let targetMeasure = try notationMeasure(2, in: viewModel) + viewModel.harmonySymbols = [ + HarmonySymbol(time: 2.5, measureNumber: 2, offsetInQuarterNotes: 1, rawText: "G7") + ] + + viewModel.selectNotationMeasure(emptyMeasure) + XCTAssertTrue(viewModel.copySelectedNotationMeasure()) + viewModel.selectNotationMeasure(targetMeasure) + + XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) + + XCTAssertFalse(viewModel.harmonySymbols.contains { + NotationMeasureTiming.containsEventTime($0.time, in: targetMeasure) + }) + } + + @MainActor + func testPastingNotationMeasureRangePreservesEmptyMeasuresByClearingTargets() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let sourceMeasure = try notationMeasure(1, in: viewModel) + let secondMeasure = try notationMeasure(2, in: viewModel) + let targetMeasure = try notationMeasure(3, in: viewModel) + let fourthMeasure = try notationMeasure(4, in: viewModel) + viewModel.harmonySymbols = [ + HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C"), + HarmonySymbol(time: 4, measureNumber: 3, offsetInQuarterNotes: 0, rawText: "G"), + HarmonySymbol(time: 6, measureNumber: 4, offsetInQuarterNotes: 0, rawText: "Am") + ] + + viewModel.selectNotationMeasure(sourceMeasure) + viewModel.selectNotationMeasure(secondMeasure, extendingSelection: true) + XCTAssertTrue(viewModel.copySelectedNotationMeasure()) + viewModel.selectNotationMeasure(targetMeasure) + + XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) + + XCTAssertEqual(viewModel.harmonySymbols.filter { + NotationMeasureTiming.containsEventTime($0.time, in: targetMeasure) + }.map(\.rawText), ["C"]) + XCTAssertFalse(viewModel.harmonySymbols.contains { + NotationMeasureTiming.containsEventTime($0.time, in: fourthMeasure) + }) + } + + @MainActor + func testPastingNotationMeasureRangeIgnoresOverflowBeyondAvailableTargets() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let sourceMeasure = try notationMeasure(1, in: viewModel) + let thirdMeasure = try notationMeasure(3, in: viewModel) + let fourthMeasure = try notationMeasure(4, in: viewModel) + viewModel.harmonySymbols = [ + HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C"), + HarmonySymbol(time: 2, measureNumber: 2, offsetInQuarterNotes: 0, rawText: "F"), + HarmonySymbol(time: 4, measureNumber: 3, offsetInQuarterNotes: 0, rawText: "G"), + HarmonySymbol(time: 6, measureNumber: 4, offsetInQuarterNotes: 0, rawText: "Am") + ] + + viewModel.selectNotationMeasure(sourceMeasure) + viewModel.selectNotationMeasure(thirdMeasure, extendingSelection: true) + XCTAssertTrue(viewModel.copySelectedNotationMeasure()) + viewModel.selectNotationMeasure(fourthMeasure) + + XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) + + let fourthMeasureSymbols = viewModel.harmonySymbols.filter { + NotationMeasureTiming.containsEventTime($0.time, in: fourthMeasure) + } + XCTAssertEqual(fourthMeasureSymbols.map(\.rawText), ["C"]) + XCTAssertEqual(viewModel.selectedNotationMeasures.map(\.number), [4]) + } + + @MainActor + func testPasteNotationMeasureSkipsOffsetsOutsideTargetTimeSignature() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + viewModel.addTempoTimeSignatureMarker(at: 2, bpm: 120, beatsPerBar: 3) + viewModel.markProjectClean() + let sourceMeasure = try notationMeasure(1, in: viewModel) + let targetMeasure = try notationMeasure(2, in: viewModel) + viewModel.harmonySymbols = [ + HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C"), + HarmonySymbol(time: 1.5, measureNumber: 1, offsetInQuarterNotes: 3, rawText: "D") + ] + + viewModel.selectNotationMeasure(sourceMeasure) + XCTAssertTrue(viewModel.copySelectedNotationMeasure()) + viewModel.selectNotationMeasure(targetMeasure) + + XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) + + let targetSymbols = viewModel.harmonySymbols.filter { + NotationMeasureTiming.containsEventTime($0.time, in: targetMeasure) + } + XCTAssertEqual(targetSymbols.map(\.rawText), ["C"]) + XCTAssertEqual(try XCTUnwrap(targetSymbols.first).time, 2, accuracy: 0.0001) + } + + @MainActor + func testTempoMapChangesClearSelectedNotationMeasure() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let measure = try notationMeasure(1, in: viewModel) + + viewModel.selectNotationMeasure(measure) + viewModel.setTimeSignature(beatsPerBar: 3, beatUnit: 4) + + XCTAssertTrue(viewModel.selectedNotationMeasures.isEmpty) + } + + @MainActor + func testCopyRejectsPartialStaleNotationMeasureSelection() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let firstMeasure = try notationMeasure(1, in: viewModel) + let secondMeasure = try notationMeasure(2, in: viewModel) + + viewModel.selectedNotationMeasures = [ + NotationMeasureSelection(measure: firstMeasure), + NotationMeasureSelection( + measure: ScoreMeasure( + number: secondMeasure.number, + startTime: secondMeasure.startTime, + endTime: secondMeasure.endTime + 0.25, + attributes: secondMeasure.attributes + ) + ) + ] + + XCTAssertFalse(viewModel.copySelectedNotationMeasure()) + XCTAssertTrue(viewModel.selectedNotationMeasures.isEmpty) + } + + @MainActor + func testClearingNotationMeasureSelectionDoesNotClearClipboardOrMarkDirty() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let measure = try notationMeasure(1, in: viewModel) + viewModel.harmonySymbols = [ + HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C") + ] + viewModel.selectNotationMeasure(measure) + XCTAssertTrue(viewModel.copySelectedNotationMeasure()) + viewModel.markProjectClean() + + viewModel.clearNotationMeasureSelection() + + XCTAssertTrue(viewModel.selectedNotationMeasures.isEmpty) + XCTAssertNotNil(viewModel.notationMeasureClipboard) + XCTAssertFalse(viewModel.isProjectModified) + } + @MainActor func testLocateRegionStartSelectsRegionAndMovesPlaybackMarkerWithoutActivatingLoop() throws { let audioURL = try temporaryAudioFile(duration: 6) @@ -1550,6 +1858,38 @@ final class ViewModelLifecycleTests: XCTestCase { XCTAssertTrue(viewModel.isProjectModified) } + @MainActor + private func loadedNotationViewModel(duration: TimeInterval) throws -> AudioPlayerViewModel { + let audioURL = try temporaryAudioFile(duration: duration) + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: MockPlaybackEngine() + ) + let media = ImportedAudioFile(url: audioURL, displayName: "notation.wav", duration: duration) + try viewModel.loadImportedAudio(media) + viewModel.beatGridSettings = BeatGridSettings(bpm: 120, timeSignature: .fourFour) + viewModel.tempoBPM = 120 + viewModel.applyTempoMapToPlaybackEngine() + viewModel.markProjectClean() + return viewModel + } + + @MainActor + private func notationMeasure(_ number: Int, in viewModel: AudioPlayerViewModel) throws -> ScoreMeasure { + let score = NotationViewportFactory().scoreState( + tempoMap: viewModel.tempoMap, + duration: viewModel.duration, + currentTime: viewModel.currentTime, + playbackMarkerTime: viewModel.playbackMarkerTime, + isPlaying: viewModel.playbackState == .playing, + keyName: viewModel.effectiveKeyName, + harmonySymbols: viewModel.harmonySymbols, + notes: viewModel.notes + ) + return try XCTUnwrap(score.measures.first { $0.number == number }) + } + @MainActor func testActivateRegionAsLoopWithoutSeekingPreservesPlaybackPosition() throws { let audioURL = try temporaryAudioFile(duration: 6)