From 5187f6520247ddb328504a1023a254e9fdcf1e69 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Thu, 2 Jul 2026 07:25:10 +0300 Subject: [PATCH] feat: update notation chord entry workflow --- CHANGELOG.md | 1 + JammLab/ContentView.swift | 5 + JammLab/DesignSystem/AppTheme.swift | 2 + JammLab/Models/NotationScoreModels.swift | 28 +++ JammLab/Utilities/AppHotkey.swift | 9 + .../AudioPlayerViewModel+Notes.swift | 73 ++++++++ .../AudioPlayerViewModel+Project.swift | 1 + .../AudioPlayerViewModel+UndoDirty.swift | 1 + JammLab/ViewModels/AudioPlayerViewModel.swift | 1 + JammLab/Views/MainWorkspacePanels.swift | 2 + .../NotationHarmonyInlineTextField.swift | 13 +- JammLab/Views/NotationTrackView.swift | 172 ++++++++++++++---- JammLab/Views/NotationWindowView.swift | 7 + JammLab/Views/WaveformTimelineView.swift | 4 + JammLabTests/TimelineProjectLogicTests.swift | 95 +++++++++- JammLabTests/ViewModelLifecycleTests.swift | 61 +++++++ 16 files changed, 430 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 317c134..c1a55c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ development artifact builds use `vMAJOR.MINOR.PATCH-dev.N`. ## Unreleased +- Changed notation harmony entry so beat slashes are selectable and Cmd+K opens chord input for the selected beat. - 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. - Improved notation measure range selection so adjacent selected measures draw as one continuous frame. - Added boxed Region start labels to Notation in the timeline track and Notation window. diff --git a/JammLab/ContentView.swift b/JammLab/ContentView.swift index 79b3cd5..3cf281b 100644 --- a/JammLab/ContentView.swift +++ b/JammLab/ContentView.swift @@ -185,6 +185,9 @@ struct ContentView: View { if !viewModel.hasSelectedNotationMeasures { hotkeys.remove(.clearNotationMeasureSelection) } + if !viewModel.canEditSelectedNotationBeat { + hotkeys.remove(.editHarmonyAtSelectedBeat) + } return hotkeys } @@ -234,6 +237,8 @@ struct ContentView: View { case .clearNotationMeasureSelection: viewModel.clearNotationMeasureSelection() return true + case .editHarmonyAtSelectedBeat: + return viewModel.requestEditSelectedNotationBeat() } } diff --git a/JammLab/DesignSystem/AppTheme.swift b/JammLab/DesignSystem/AppTheme.swift index 8d47982..788a00c 100644 --- a/JammLab/DesignSystem/AppTheme.swift +++ b/JammLab/DesignSystem/AppTheme.swift @@ -275,6 +275,8 @@ enum AppTheme { static let notationHarmonyAnchorLeadingOffset: CGFloat = AppTheme.Spacing.md static let notationHarmonySymbolWidth: CGFloat = 84 static let notationHarmonyEditorWidth: CGFloat = 64 + static let notationHarmonyEditorMinWidth: CGFloat = 38 + static let notationHarmonyEditorMaxWidth: CGFloat = 104 static let notationRegionLabelMaxWidth: CGFloat = 88 static let notationRegionLabelHeight: CGFloat = 16 static let notationRegionLabelFontSize: CGFloat = 10 diff --git a/JammLab/Models/NotationScoreModels.swift b/JammLab/Models/NotationScoreModels.swift index 397684d..cd407f0 100644 --- a/JammLab/Models/NotationScoreModels.swift +++ b/JammLab/Models/NotationScoreModels.swift @@ -79,6 +79,34 @@ struct NotationMeasureSelection: Equatable, Identifiable { } } +struct NotationBeatSelection: Equatable, Identifiable { + var measureNumber: Int + var measureStartTime: TimeInterval + var measureEndTime: TimeInterval + var attributes: MeasureAttributes + var offsetInQuarterNotes: Double + + init(measure: ScoreMeasure, offsetInQuarterNotes: Double) { + self.measureNumber = measure.number + self.measureStartTime = measure.startTime + self.measureEndTime = measure.endTime + self.attributes = measure.attributes + self.offsetInQuarterNotes = offsetInQuarterNotes + } + + var id: String { + "\(measureNumber)-\(measureStartTime)-\(measureEndTime)-\(offsetInQuarterNotes)" + } + + func matches(_ measure: ScoreMeasure, offsetInQuarterNotes offset: Double) -> Bool { + measureNumber == measure.number + && abs(measureStartTime - measure.startTime) < NotationMeasureTiming.timelineTolerance + && abs(measureEndTime - measure.endTime) < NotationMeasureTiming.timelineTolerance + && attributes == measure.attributes + && abs(offsetInQuarterNotes - offset) < NotationMeasureTiming.timelineTolerance + } +} + struct NotationMeasureClipboard: Equatable { var measures: [NotationMeasureClipboardMeasure] } diff --git a/JammLab/Utilities/AppHotkey.swift b/JammLab/Utilities/AppHotkey.swift index 1f6ad25..c5e30c6 100644 --- a/JammLab/Utilities/AppHotkey.swift +++ b/JammLab/Utilities/AppHotkey.swift @@ -16,6 +16,7 @@ enum AppHotkey: CaseIterable, Hashable { case copyMeasure case pasteMeasure case clearNotationMeasureSelection + case editHarmonyAtSelectedBeat // 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 @@ -30,6 +31,8 @@ enum AppHotkey: CaseIterable, Hashable { self = .copyMeasure case (9, [.command]): self = .pasteMeasure + case (40, [.command]): + self = .editHarmonyAtSelectedBeat case (1, [.option]): self = .toggleSnap case (9, [.option]): @@ -100,6 +103,8 @@ enum AppHotkey: CaseIterable, Hashable { return "Cmd+V" case .clearNotationMeasureSelection: return "Esc" + case .editHarmonyAtSelectedBeat: + return "Cmd+K" } } @@ -133,6 +138,8 @@ enum AppHotkey: CaseIterable, Hashable { return "Paste Measure" case .clearNotationMeasureSelection: return "Clear Measure Selection" + case .editHarmonyAtSelectedBeat: + return "Edit Harmony" } } @@ -166,6 +173,8 @@ enum AppHotkey: CaseIterable, Hashable { return "Replace the selected notation measure with the copied measure contents." case .clearNotationMeasureSelection: return "Clear the selected notation measure or measure range." + case .editHarmonyAtSelectedBeat: + return "Open harmony entry for the selected notation beat." } } } diff --git a/JammLab/ViewModels/AudioPlayerViewModel+Notes.swift b/JammLab/ViewModels/AudioPlayerViewModel+Notes.swift index fb83ab7..dd621a5 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel+Notes.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel+Notes.swift @@ -49,10 +49,49 @@ extension AudioPlayerViewModel { pendingHarmonyEditorRequest = HarmonyEditorRequest(time: placement.time) } + @discardableResult + func requestEditSelectedNotationBeat() -> Bool { + guard duration > 0, + let selection = selectedNotationBeat, + let placement = harmonyPlacement(for: selection) + else { + clearNotationBeatSelection() + return false + } + + selectedNotationBeat = NotationBeatSelection( + measure: placement.measure, + offsetInQuarterNotes: placement.harmonyPlacement.offsetInQuarterNotes + ) + selectedHarmonySymbolID = harmonySymbolID(at: placement.harmonyPlacement.time) + pendingHarmonyEditorRequest = HarmonyEditorRequest(time: placement.harmonyPlacement.time) + return true + } + func selectHarmonySymbol(id: HarmonySymbol.ID?) { selectedHarmonySymbolID = availableHarmonySymbolID(id) } + func selectNotationBeat(_ selection: NotationBeatSelection?) { + guard let selection else { + clearNotationBeatSelection() + return + } + + selectedNotationBeat = selection + selectedNotationMeasures = [] + notationMeasureSelectionAnchor = nil + if let placement = harmonyPlacement(for: selection) { + selectedHarmonySymbolID = harmonySymbolID(at: placement.harmonyPlacement.time) + } else { + selectedHarmonySymbolID = nil + } + } + + func clearNotationBeatSelection() { + selectedNotationBeat = nil + } + var canCopySelectedNotationMeasure: Bool { !currentSelectedNotationMeasures().isEmpty } @@ -66,6 +105,10 @@ extension AudioPlayerViewModel { !selectedNotationMeasures.isEmpty } + var canEditSelectedNotationBeat: Bool { + duration > 0 && selectedNotationBeat != nil + } + func selectNotationMeasure(_ measure: ScoreMeasure?, extendingSelection: Bool = false) { guard let measure else { clearNotationMeasureSelection() @@ -87,12 +130,14 @@ extension AudioPlayerViewModel { notationMeasureSelectionAnchor = NotationMeasureSelection(measure: measure) } selectedHarmonySymbolID = nil + selectedNotationBeat = nil } func clearNotationMeasureSelection() { selectedNotationMeasures = [] notationMeasureSelectionAnchor = nil selectedHarmonySymbolID = nil + selectedNotationBeat = nil } func clearNotationMeasureSelectionAndClipboard() { @@ -136,6 +181,7 @@ extension AudioPlayerViewModel { guard currentItemsByMeasure != pastedItemsByMeasure else { selectedHarmonySymbolID = nil + selectedNotationBeat = nil selectedNotationMeasures = targetMeasures.map(NotationMeasureSelection.init) notationMeasureSelectionAnchor = selectedNotationMeasures.first return true @@ -164,6 +210,7 @@ extension AudioPlayerViewModel { harmonySymbols = ProjectStateNormalizer.normalizedHarmonySymbols(harmonySymbols, duration: duration) selectedHarmonySymbolID = nil + selectedNotationBeat = nil selectedNotationMeasures = targetMeasures.map(NotationMeasureSelection.init) notationMeasureSelectionAnchor = selectedNotationMeasures.first } @@ -515,6 +562,32 @@ extension AudioPlayerViewModel { ) } + private func harmonyPlacement( + for selection: NotationBeatSelection + ) -> (measure: ScoreMeasure, harmonyPlacement: HarmonyPlacement)? { + guard let measure = currentNotationScoreMeasures().first(where: { measure in + selection.matches(measure, offsetInQuarterNotes: selection.offsetInQuarterNotes) + }) else { + return nil + } + guard NotationMeasureTiming.isValidHarmonyOffset( + selection.offsetInQuarterNotes, + in: measure.attributes.timeSignature + ) else { + return nil + } + + let time = NotationMeasureTiming.time(forQuarterOffset: selection.offsetInQuarterNotes, in: measure) + return ( + measure, + HarmonyPlacement( + time: max(0, min(time, max(0, duration.nextDown))), + measureNumber: measure.number, + offsetInQuarterNotes: selection.offsetInQuarterNotes + ) + ) + } + private func harmonySymbolID(at time: TimeInterval) -> HarmonySymbol.ID? { harmonySymbols.first { sameHarmonyPosition($0.time, time) }?.id } diff --git a/JammLab/ViewModels/AudioPlayerViewModel+Project.swift b/JammLab/ViewModels/AudioPlayerViewModel+Project.swift index 874e1f6..ba6f683 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel+Project.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel+Project.swift @@ -501,6 +501,7 @@ extension AudioPlayerViewModel { beatGridSettings.firstBeatTime = 0 beatGridSettings.alignmentSource = .automatic beatGridSettings.lastChangedAt = Date() + clearNotationMeasureSelection() applyTempoMapToPlaybackEngine() playbackEngine.setClickEnabled(isClickEnabled && beatGridSettings.bpm != nil) if !isProjectModified { diff --git a/JammLab/ViewModels/AudioPlayerViewModel+UndoDirty.swift b/JammLab/ViewModels/AudioPlayerViewModel+UndoDirty.swift index 2a73b7d..c5ac2f1 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel+UndoDirty.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel+UndoDirty.swift @@ -76,6 +76,7 @@ extension AudioPlayerViewModel { projectKeySelection = state.projectKeySelection selectedRegionID = availableRegionID(state.selectedRegionID) selectedHarmonySymbolID = availableHarmonySymbolID(state.selectedHarmonySymbolID) + selectedNotationBeat = nil activeLoopRegionID = availableRegionID(state.activeLoopRegionID) loopRegion = state.loopRegion.clamped(to: duration, minimumLength: activeRangeMinimumLength) stemMixState = state.stemMixState diff --git a/JammLab/ViewModels/AudioPlayerViewModel.swift b/JammLab/ViewModels/AudioPlayerViewModel.swift index 2590a17..fd5d3bb 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel.swift @@ -41,6 +41,7 @@ final class AudioPlayerViewModel: ObservableObject { @Published var selectedRegionID: TimecodedNote.ID? @Published var selectedHarmonySymbolID: HarmonySymbol.ID? @Published var selectedNotationMeasures: [NotationMeasureSelection] = [] + @Published var selectedNotationBeat: NotationBeatSelection? @Published var notationMeasureClipboard: NotationMeasureClipboard? @Published var harmonyInputResolutionDenominator = HarmonyInputResolution.defaultDenominator @Published var pendingHarmonyEditorRequest: HarmonyEditorRequest? diff --git a/JammLab/Views/MainWorkspacePanels.swift b/JammLab/Views/MainWorkspacePanels.swift index f85d11e..dfb52f9 100644 --- a/JammLab/Views/MainWorkspacePanels.swift +++ b/JammLab/Views/MainWorkspacePanels.swift @@ -131,6 +131,7 @@ extension ContentView { notes: viewModel.notes, selectedHarmonySymbolID: viewModel.selectedHarmonySymbolID, selectedNotationMeasures: viewModel.selectedNotationMeasures, + selectedNotationBeat: viewModel.selectedNotationBeat, harmonyInputResolutionDenominator: viewModel.harmonyInputResolutionDenominator, pendingHarmonyEditorRequest: viewModel.pendingHarmonyEditorRequest, selectedRegionID: viewModel.selectedRegionID, @@ -186,6 +187,7 @@ extension ContentView { harmonyInputResolutionChanged: { viewModel.setHarmonyInputResolutionDenominator($0) }, selectHarmony: { viewModel.selectHarmonySymbol(id: $0) }, selectNotationMeasure: { viewModel.selectNotationMeasure($0, extendingSelection: $1) }, + selectNotationBeat: { viewModel.selectNotationBeat($0) }, saveHarmony: { viewModel.saveHarmonySymbol($0) }, deleteHarmony: { viewModel.deleteHarmonySymbol(id: $0) }, adjacentHarmonyPlacement: { viewModel.adjacentHarmonyPlacement(from: $0, direction: $1) }, diff --git a/JammLab/Views/NotationHarmonyInlineTextField.swift b/JammLab/Views/NotationHarmonyInlineTextField.swift index c7d8430..d0a6702 100644 --- a/JammLab/Views/NotationHarmonyInlineTextField.swift +++ b/JammLab/Views/NotationHarmonyInlineTextField.swift @@ -9,12 +9,16 @@ struct HarmonyInlineTextField: NSViewRepresentable { func makeNSView(context: Context) -> HarmonyInlineNSTextField { let textField = HarmonyInlineNSTextField(string: text) - textField.isBordered = true - textField.isBezeled = true - textField.bezelStyle = .roundedBezel - textField.focusRingType = .default + textField.isBordered = false + textField.isBezeled = false + textField.drawsBackground = false + textField.backgroundColor = .clear + textField.focusRingType = .none textField.delegate = context.coordinator textField.font = .systemFont(ofSize: 13, weight: .semibold) + textField.textColor = .controlAccentColor + textField.lineBreakMode = .byClipping + textField.cell?.isScrollable = true textField.onWindowAttached = { [weak coordinator = context.coordinator, weak textField] in guard let textField else { return } coordinator?.focusAndSelectIfNeeded(textField) @@ -27,6 +31,7 @@ struct HarmonyInlineTextField: NSViewRepresentable { if nsView.stringValue != text { nsView.stringValue = text } + nsView.invalidateIntrinsicContentSize() context.coordinator.focusAndSelectIfNeeded(nsView) } diff --git a/JammLab/Views/NotationTrackView.swift b/JammLab/Views/NotationTrackView.swift index 893a24b..347b162 100644 --- a/JammLab/Views/NotationTrackView.swift +++ b/JammLab/Views/NotationTrackView.swift @@ -4,6 +4,7 @@ import SwiftUI struct NotationTrackActions { var selectHarmony: (HarmonySymbol.ID?) -> Void var selectMeasure: (ScoreMeasure?, Bool) -> Void + var selectBeat: (NotationBeatSelection?) -> Void var saveHarmony: (HarmonySymbol) -> Void var deleteHarmony: (HarmonySymbol.ID) -> Void var adjacentHarmonyPlacement: (TimeInterval, HarmonyNavigationDirection) -> HarmonyPlacement? @@ -13,6 +14,7 @@ struct NotationTrackView: View { let state: NotationViewportState let selectedHarmonySymbolID: HarmonySymbol.ID? let selectedMeasures: [NotationMeasureSelection] + let selectedBeat: NotationBeatSelection? let pendingEditorRequest: HarmonyEditorRequest? let inputResolution: HarmonyInputResolution let actions: NotationTrackActions @@ -26,6 +28,7 @@ struct NotationTrackView: View { state: NotationViewportState, selectedHarmonySymbolID: HarmonySymbol.ID? = nil, selectedMeasures: [NotationMeasureSelection] = [], + selectedBeat: NotationBeatSelection? = nil, pendingEditorRequest: HarmonyEditorRequest? = nil, inputResolution: HarmonyInputResolution = HarmonyInputResolution(), actions: NotationTrackActions = .noop, @@ -34,6 +37,7 @@ struct NotationTrackView: View { self.state = state self.selectedHarmonySymbolID = selectedHarmonySymbolID self.selectedMeasures = selectedMeasures + self.selectedBeat = selectedBeat self.pendingEditorRequest = pendingEditorRequest self.inputResolution = inputResolution self.actions = actions @@ -60,6 +64,11 @@ struct NotationTrackView: View { height: proxy.size.height, attributeDisplays: attributeDisplays ) + beatSelectionHitLayer( + width: contentWidth, + height: proxy.size.height, + attributeDisplays: attributeDisplays + ) measureNumberLabels( width: contentWidth, height: proxy.size.height, @@ -94,12 +103,6 @@ struct NotationTrackView: View { .frame(width: contentWidth, height: proxy.size.height) .id(scrollResetIdentity) .contentShape(Rectangle()) - .simultaneousGesture( - SpatialTapGesture(count: 2) - .onEnded { value in - beginEditingHarmony(at: value.location, width: contentWidth) - } - ) .onTapGesture { isTrackFocused = true } @@ -248,31 +251,27 @@ struct NotationTrackView: View { lineJoin: .round ) - for index in state.visibleMeasures.indices { - guard geometries.indices.contains(index) else { continue } - - let measure = state.visibleMeasures[index] - let beatCenters = NotationMeasureLayout.slashBeatCenters( - geometry: geometries[index], - timeSignature: measure.attributes.timeSignature + for item in beatLayoutItems(geometries: geometries) { + let color = selectedBeat?.matches( + item.measure, + offsetInQuarterNotes: item.selection.offsetInQuarterNotes + ) == true + ? appColors.accent + : appColors.notationSymbolsAndLines + var path = Path() + path.move(to: CGPoint( + x: item.x - slashWidth / 2, + y: centerY + slashHeight / 2 + )) + path.addLine(to: CGPoint( + x: item.x + slashWidth / 2, + y: centerY - slashHeight / 2 + )) + context.stroke( + path, + with: .color(color), + style: style ) - - for x in beatCenters { - var path = Path() - path.move(to: CGPoint( - x: x - slashWidth / 2, - y: centerY + slashHeight / 2 - )) - path.addLine(to: CGPoint( - x: x + slashWidth / 2, - y: centerY - slashHeight / 2 - )) - context.stroke( - path, - with: .color(appColors.notationSymbolsAndLines), - style: style - ) - } } } @@ -404,6 +403,50 @@ struct NotationTrackView: View { } } + private func beatSelectionHitLayer( + width: CGFloat, + height: CGFloat, + attributeDisplays: [NotationAttributeDisplay] + ) -> some View { + let geometries = measureCanvasGeometries( + measureCount: renderedMeasureCount, + width: width, + attributeDisplays: attributeDisplays + ) + let staffTop = staffTop(in: height) + let hitY = max(0, staffTop - AppTheme.Spacing.sm) + let hitHeight = AppTheme.Timeline.notationStaffLineSpacing * 4 + AppTheme.Spacing.md + let hitWidth = max( + AppTheme.ControlSize.abletonNumberFieldHeight, + AppTheme.Timeline.notationSlashWidth + AppTheme.Spacing.lg + ) + + return ZStack(alignment: .topLeading) { + ForEach(beatLayoutItems(geometries: geometries), id: \.id) { item in + Rectangle() + .fill(Color.clear) + .contentShape(Rectangle()) + .frame(width: hitWidth, height: hitHeight) + .offset( + x: item.x - hitWidth / 2, + y: hitY + ) + .onTapGesture { + isTrackFocused = true + editingDraft = nil + actions.selectBeat(item.selection) + } + .accessibilityLabel("Beat \(item.beatNumber) in measure \(item.selection.measureNumber)") + .accessibilityValue( + selectedBeat?.matches( + item.measure, + offsetInQuarterNotes: item.selection.offsetInQuarterNotes + ) == true ? "Selected" : "" + ) + } + } + } + private func regionLabelsLayer( width: CGFloat, height: CGFloat, @@ -533,18 +576,14 @@ struct NotationTrackView: View { .contentShape(Rectangle()) .onTapGesture { isTrackFocused = true - if let measure = measure(containing: symbol) { - actions.selectMeasure(measure, isShiftClickActive) - } + actions.selectBeat(beatSelection(for: symbol)) if !isShiftClickActive { actions.selectHarmony(symbol.id) } } .onTapGesture(count: 2) { isTrackFocused = true - if let measure = measure(containing: symbol) { - actions.selectMeasure(measure, false) - } + actions.selectBeat(beatSelection(for: symbol)) beginEditingHarmony(symbol) } .accessibilityLabel("Harmony \(symbol.rawText)") @@ -575,7 +614,7 @@ struct NotationTrackView: View { onNavigate: { commitEditingDraft(navigation: $0) } ) .frame( - width: AppTheme.Timeline.notationHarmonyEditorWidth, + width: harmonyEditorWidth(for: editingDraft.text), height: AppTheme.ControlSize.abletonNumberFieldHeight ) .offset( @@ -585,6 +624,17 @@ struct NotationTrackView: View { } } + private func harmonyEditorWidth(for text: String) -> CGFloat { + let measuredText = text.isEmpty ? "M" : text + let font = NSFont.systemFont(ofSize: 13, weight: .semibold) + let textWidth = (measuredText as NSString).size(withAttributes: [.font: font]).width + let paddedWidth = ceil(textWidth) + AppTheme.Spacing.md + return min( + AppTheme.Timeline.notationHarmonyEditorMaxWidth, + max(AppTheme.Timeline.notationHarmonyEditorMinWidth, paddedWidth) + ) + } + private func measureAttributes( _ attributes: MeasureAttributes, display: NotationAttributeDisplay, @@ -776,6 +826,33 @@ struct NotationTrackView: View { } } + private func beatLayoutItems( + geometries: [NotationMeasureCanvasGeometry] + ) -> [BeatLayoutItem] { + state.visibleMeasures.indices.flatMap { index -> [BeatLayoutItem] in + guard geometries.indices.contains(index) else { return [] } + let measure = state.visibleMeasures[index] + let centers = NotationMeasureLayout.slashBeatCenters( + geometry: geometries[index], + timeSignature: measure.attributes.timeSignature + ) + let beatLength = 4.0 / Double(max(1, measure.attributes.timeSignature.beatUnit)) + + return centers.enumerated().map { beatIndex, x in + let offset = Double(beatIndex) * beatLength + return BeatLayoutItem( + measure: measure, + selection: NotationBeatSelection( + measure: measure, + offsetInQuarterNotes: offset + ), + beatNumber: beatIndex + 1, + x: x + ) + } + } + } + private func harmonyLayoutItem( for time: TimeInterval, width: CGFloat, @@ -911,6 +988,15 @@ struct NotationTrackView: View { } } + private func beatSelection(for symbol: HarmonySymbol) -> NotationBeatSelection? { + guard let measure = measure(containing: symbol) else { return nil } + + return NotationBeatSelection( + measure: measure, + offsetInQuarterNotes: symbol.offsetInQuarterNotes + ) + } + private func harmonyPlacement(for time: TimeInterval) -> NotationHarmonyPlacement? { guard let measureIndex = state.visibleMeasures.indices.first(where: { index in let measure = state.visibleMeasures[index] @@ -1073,6 +1159,17 @@ private struct HarmonyLayoutItem: Equatable { var x: CGFloat } +private struct BeatLayoutItem: Equatable, Identifiable { + var measure: ScoreMeasure + var selection: NotationBeatSelection + var beatNumber: Int + var x: CGFloat + + var id: String { + selection.id + } +} + private struct NotationHarmonyPlacement: Equatable { var measureIndex: Int var time: TimeInterval @@ -1092,6 +1189,7 @@ private extension NotationTrackActions { static let noop = NotationTrackActions( selectHarmony: { _ in }, selectMeasure: { _, _ in }, + selectBeat: { _ in }, saveHarmony: { _ in }, deleteHarmony: { _ in }, adjacentHarmonyPlacement: { _, _ in nil } diff --git a/JammLab/Views/NotationWindowView.swift b/JammLab/Views/NotationWindowView.swift index 3656082..624f7b4 100644 --- a/JammLab/Views/NotationWindowView.swift +++ b/JammLab/Views/NotationWindowView.swift @@ -103,6 +103,7 @@ struct NotationWindowView: View { state: system.viewportState, selectedHarmonySymbolID: viewModel.selectedHarmonySymbolID, selectedMeasures: viewModel.selectedNotationMeasures, + selectedBeat: viewModel.selectedNotationBeat, pendingEditorRequest: viewModel.pendingHarmonyEditorRequest, inputResolution: HarmonyInputResolution( denominator: viewModel.harmonyInputResolutionDenominator @@ -165,6 +166,7 @@ struct NotationWindowView: View { NotationTrackActions( selectHarmony: { viewModel.selectHarmonySymbol(id: $0) }, selectMeasure: { viewModel.selectNotationMeasure($0, extendingSelection: $1) }, + selectBeat: { viewModel.selectNotationBeat($0) }, saveHarmony: { viewModel.saveHarmonySymbol($0) }, deleteHarmony: { viewModel.deleteHarmonySymbol(id: $0) }, adjacentHarmonyPlacement: { viewModel.adjacentHarmonyPlacement(from: $0, direction: $1) } @@ -182,6 +184,9 @@ struct NotationWindowView: View { if viewModel.hasSelectedNotationMeasures { hotkeys.insert(.clearNotationMeasureSelection) } + if viewModel.canEditSelectedNotationBeat { + hotkeys.insert(.editHarmonyAtSelectedBeat) + } return hotkeys } @@ -198,6 +203,8 @@ struct NotationWindowView: View { case .clearNotationMeasureSelection: viewModel.clearNotationMeasureSelection() return true + case .editHarmonyAtSelectedBeat: + return viewModel.requestEditSelectedNotationBeat() default: return false } diff --git a/JammLab/Views/WaveformTimelineView.swift b/JammLab/Views/WaveformTimelineView.swift index 5063220..9647b6b 100644 --- a/JammLab/Views/WaveformTimelineView.swift +++ b/JammLab/Views/WaveformTimelineView.swift @@ -26,6 +26,7 @@ struct TimelineViewState: Equatable { var notes: [TimecodedNote] var selectedHarmonySymbolID: HarmonySymbol.ID? var selectedNotationMeasures: [NotationMeasureSelection] + var selectedNotationBeat: NotationBeatSelection? var harmonyInputResolutionDenominator: Int var pendingHarmonyEditorRequest: HarmonyEditorRequest? var selectedRegionID: TimecodedNote.ID? @@ -46,6 +47,7 @@ struct TimelineViewActions { var harmonyInputResolutionChanged: (Int) -> Void var selectHarmony: (HarmonySymbol.ID?) -> Void var selectNotationMeasure: (ScoreMeasure?, Bool) -> Void + var selectNotationBeat: (NotationBeatSelection?) -> Void var saveHarmony: (HarmonySymbol) -> Void var deleteHarmony: (HarmonySymbol.ID) -> Void var adjacentHarmonyPlacement: (TimeInterval, HarmonyNavigationDirection) -> HarmonyPlacement? @@ -238,11 +240,13 @@ struct WaveformTimelineView: View { state: state.notationViewport, selectedHarmonySymbolID: state.selectedHarmonySymbolID, selectedMeasures: state.selectedNotationMeasures, + selectedBeat: state.selectedNotationBeat, pendingEditorRequest: state.pendingHarmonyEditorRequest, inputResolution: HarmonyInputResolution(denominator: state.harmonyInputResolutionDenominator), actions: NotationTrackActions( selectHarmony: actions.selectHarmony, selectMeasure: actions.selectNotationMeasure, + selectBeat: actions.selectNotationBeat, saveHarmony: actions.saveHarmony, deleteHarmony: actions.deleteHarmony, adjacentHarmonyPlacement: actions.adjacentHarmonyPlacement diff --git a/JammLabTests/TimelineProjectLogicTests.swift b/JammLabTests/TimelineProjectLogicTests.swift index d3d1af9..3b25d82 100644 --- a/JammLabTests/TimelineProjectLogicTests.swift +++ b/JammLabTests/TimelineProjectLogicTests.swift @@ -610,12 +610,17 @@ final class TimelineProjectLogicTests: XCTestCase { ) } - func testAppHotkeyDoesNotExposeHarmonyShortcutMetadata() { - XCTAssertFalse(AppHotkey.allCases.contains { $0.key == "A" }) - XCTAssertFalse(AppHotkey.allCases.contains { $0.title == "Add Harmony" }) + func testAppHotkeyExposesSelectedBeatHarmonyShortcutMetadata() { + XCTAssertTrue(AppHotkey.allCases.contains(.editHarmonyAtSelectedBeat)) + XCTAssertEqual(AppHotkey.editHarmonyAtSelectedBeat.key, "Cmd+K") + XCTAssertEqual(AppHotkey.editHarmonyAtSelectedBeat.title, "Edit Harmony") + XCTAssertEqual( + AppHotkey.editHarmonyAtSelectedBeat.detail, + "Open harmony entry for the selected notation beat." + ) } - func testAppHotkeyDoesNotRecognizeAOrHForHarmony() throws { + func testAppHotkeyRecognizesCmdKButNotOldHarmonyKeys() throws { let aEvent = try XCTUnwrap(NSEvent.keyEvent( with: .keyDown, location: .zero, @@ -652,6 +657,18 @@ final class TimelineProjectLogicTests: XCTestCase { isARepeat: false, keyCode: 0 )) + let commandKEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: "k", + charactersIgnoringModifiers: "k", + isARepeat: false, + keyCode: 40 + )) let shiftAEvent = try XCTUnwrap(NSEvent.keyEvent( with: .keyDown, location: .zero, @@ -668,6 +685,7 @@ final class TimelineProjectLogicTests: XCTestCase { XCTAssertNil(AppHotkey(event: aEvent)) XCTAssertNil(AppHotkey(event: hEvent)) XCTAssertNil(AppHotkey(event: commandAEvent)) + XCTAssertEqual(AppHotkey(event: commandKEvent), .editHarmonyAtSelectedBeat) XCTAssertNil(AppHotkey(event: shiftAEvent)) } @@ -864,4 +882,73 @@ final class TimelineProjectLogicTests: XCTestCase { ) } + func testAppHotkeyEventFilterDoesNotStealEditHarmonyFromTextRespondersOrUnavailableScopes() throws { + let editHarmonyEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: 0, + windowNumber: 42, + context: nil, + characters: "k", + charactersIgnoringModifiers: "k", + isARepeat: false, + keyCode: 40 + )) + let repeatEditHarmonyEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: 0, + windowNumber: 42, + context: nil, + characters: "k", + charactersIgnoringModifiers: "k", + isARepeat: true, + keyCode: 40 + )) + + XCTAssertEqual( + AppHotkeyEventFilter.hotkey( + for: editHarmonyEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: [.editHarmonyAtSelectedBeat] + ), + .editHarmonyAtSelectedBeat + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: editHarmonyEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: [.playPause] + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: editHarmonyEvent, + attachedWindowNumber: 42, + firstResponder: NSTextView(), + allowedHotkeys: [.editHarmonyAtSelectedBeat] + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: editHarmonyEvent, + attachedWindowNumber: 42, + firstResponder: AbletonNumberFieldNSView(), + allowedHotkeys: [.editHarmonyAtSelectedBeat] + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: repeatEditHarmonyEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: [.editHarmonyAtSelectedBeat] + ) + ) + } + } diff --git a/JammLabTests/ViewModelLifecycleTests.swift b/JammLabTests/ViewModelLifecycleTests.swift index 0131f0e..b7c7f4a 100644 --- a/JammLabTests/ViewModelLifecycleTests.swift +++ b/JammLabTests/ViewModelLifecycleTests.swift @@ -1530,6 +1530,67 @@ final class ViewModelLifecycleTests: XCTestCase { XCTAssertFalse(viewModel.isProjectModified) } + @MainActor + func testSelectingNotationBeatDoesNotMarkProjectModifiedAndClearsMeasureSelection() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let measure = try notationMeasure(1, in: viewModel) + + viewModel.selectNotationMeasure(measure) + viewModel.selectNotationBeat(NotationBeatSelection(measure: measure, offsetInQuarterNotes: 2)) + + XCTAssertTrue(viewModel.selectedNotationMeasures.isEmpty) + XCTAssertEqual(viewModel.selectedNotationBeat?.measureNumber, 1) + XCTAssertEqual(viewModel.selectedNotationBeat?.offsetInQuarterNotes, 2) + XCTAssertFalse(viewModel.isProjectModified) + } + + @MainActor + func testRequestEditSelectedNotationBeatUsesExactBeatOffsetAndExistingHarmony() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let measure = try notationMeasure(1, in: viewModel) + let harmony = HarmonySymbol( + time: NotationMeasureTiming.time(forQuarterOffset: 1, in: measure), + measureNumber: measure.number, + offsetInQuarterNotes: 1, + rawText: "Fmaj7" + ) + viewModel.harmonySymbols = [harmony] + viewModel.setHarmonyInputResolutionDenominator(1) + viewModel.markProjectClean() + + viewModel.selectNotationBeat(NotationBeatSelection(measure: measure, offsetInQuarterNotes: 1)) + + XCTAssertTrue(viewModel.requestEditSelectedNotationBeat()) + let request = try XCTUnwrap(viewModel.pendingHarmonyEditorRequest) + XCTAssertEqual(request.time, harmony.time, accuracy: 0.0001) + XCTAssertEqual(viewModel.selectedHarmonySymbolID, harmony.id) + XCTAssertFalse(viewModel.isProjectModified) + } + + @MainActor + func testNotationBeatSelectionClearsForTempoMapAndUndoChanges() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let firstMeasure = try notationMeasure(1, in: viewModel) + + viewModel.selectNotationBeat(NotationBeatSelection(measure: firstMeasure, offsetInQuarterNotes: 1)) + viewModel.setTimeSignature(beatsPerBar: 3, beatUnit: 4) + + XCTAssertNil(viewModel.selectedNotationBeat) + + let undoManager = UndoManager() + viewModel.undoManager = undoManager + let updatedMeasure = try notationMeasure(1, in: viewModel) + viewModel.markProjectClean() + viewModel.selectNotationBeat(NotationBeatSelection(measure: updatedMeasure, offsetInQuarterNotes: 1)) + viewModel.addNote(at: 0.5) + + XCTAssertNotNil(viewModel.selectedNotationBeat) + + viewModel.undoLastEdit() + + XCTAssertNil(viewModel.selectedNotationBeat) + } + @MainActor func testShiftSelectingNotationMeasuresBuildsContiguousRange() throws { let viewModel = try loadedNotationViewModel(duration: 8)