Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions JammLab/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,9 @@ struct ContentView: View {
if !viewModel.hasSelectedNotationMeasures {
hotkeys.remove(.clearNotationMeasureSelection)
}
if !viewModel.canEditSelectedNotationBeat {
hotkeys.remove(.editHarmonyAtSelectedBeat)
}
return hotkeys
}

Expand Down Expand Up @@ -234,6 +237,8 @@ struct ContentView: View {
case .clearNotationMeasureSelection:
viewModel.clearNotationMeasureSelection()
return true
case .editHarmonyAtSelectedBeat:
return viewModel.requestEditSelectedNotationBeat()
}
}

Expand Down
2 changes: 2 additions & 0 deletions JammLab/DesignSystem/AppTheme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions JammLab/Models/NotationScoreModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
}
Expand Down
9 changes: 9 additions & 0 deletions JammLab/Utilities/AppHotkey.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]):
Expand Down Expand Up @@ -100,6 +103,8 @@ enum AppHotkey: CaseIterable, Hashable {
return "Cmd+V"
case .clearNotationMeasureSelection:
return "Esc"
case .editHarmonyAtSelectedBeat:
return "Cmd+K"
}
}

Expand Down Expand Up @@ -133,6 +138,8 @@ enum AppHotkey: CaseIterable, Hashable {
return "Paste Measure"
case .clearNotationMeasureSelection:
return "Clear Measure Selection"
case .editHarmonyAtSelectedBeat:
return "Edit Harmony"
}
}

Expand Down Expand Up @@ -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."
}
}
}
73 changes: 73 additions & 0 deletions JammLab/ViewModels/AudioPlayerViewModel+Notes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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()
Expand All @@ -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() {
Expand Down Expand Up @@ -136,6 +181,7 @@ extension AudioPlayerViewModel {

guard currentItemsByMeasure != pastedItemsByMeasure else {
selectedHarmonySymbolID = nil
selectedNotationBeat = nil
selectedNotationMeasures = targetMeasures.map(NotationMeasureSelection.init)
notationMeasureSelectionAnchor = selectedNotationMeasures.first
return true
Expand Down Expand Up @@ -164,6 +210,7 @@ extension AudioPlayerViewModel {

harmonySymbols = ProjectStateNormalizer.normalizedHarmonySymbols(harmonySymbols, duration: duration)
selectedHarmonySymbolID = nil
selectedNotationBeat = nil
selectedNotationMeasures = targetMeasures.map(NotationMeasureSelection.init)
notationMeasureSelectionAnchor = selectedNotationMeasures.first
}
Expand Down Expand Up @@ -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
}
Expand Down
1 change: 1 addition & 0 deletions JammLab/ViewModels/AudioPlayerViewModel+Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions JammLab/ViewModels/AudioPlayerViewModel+UndoDirty.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions JammLab/ViewModels/AudioPlayerViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
2 changes: 2 additions & 0 deletions JammLab/Views/MainWorkspacePanels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) },
Expand Down
13 changes: 9 additions & 4 deletions JammLab/Views/NotationHarmonyInlineTextField.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -27,6 +31,7 @@ struct HarmonyInlineTextField: NSViewRepresentable {
if nsView.stringValue != text {
nsView.stringValue = text
}
nsView.invalidateIntrinsicContentSize()
context.coordinator.focusAndSelectIfNeeded(nsView)
}

Expand Down
Loading