diff --git a/CHANGELOG.md b/CHANGELOG.md index acd79c0..317c134 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,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. +- 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. - 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/Services/NotationMeasureLayout.swift b/JammLab/Services/NotationMeasureLayout.swift index 3e8d62b..64440c5 100644 --- a/JammLab/Services/NotationMeasureLayout.swift +++ b/JammLab/Services/NotationMeasureLayout.swift @@ -67,6 +67,17 @@ struct NotationBarlineGeometry: Equatable { let isOuterBoundary: Bool } +struct NotationSelectionOverlayRun: Equatable, Identifiable { + let startMeasureIndex: Int + let endMeasureIndex: Int + let x: CGFloat + let width: CGFloat + + var id: String { + "\(startMeasureIndex)-\(endMeasureIndex)" + } +} + struct NotationMeasureLayout { static var measureNumberLabelWidth: CGFloat { AppTheme.Timeline.notationMeasureNumberLabelWidth @@ -279,6 +290,49 @@ struct NotationMeasureLayout { return barlines } + static func selectionOverlayRuns( + selectedMeasureIndices: [Int], + geometries: [NotationMeasureCanvasGeometry] + ) -> [NotationSelectionOverlayRun] { + let normalizedIndices = Set(selectedMeasureIndices) + .filter { geometries.indices.contains($0) } + .sorted() + guard !normalizedIndices.isEmpty else { return [] } + + var runs: [NotationSelectionOverlayRun] = [] + var runStart = normalizedIndices[0] + var previousIndex = normalizedIndices[0] + + func appendRun(start: Int, end: Int) { + guard geometries.indices.contains(start), + geometries.indices.contains(end) + else { return } + + let startGeometry = geometries[start] + let endGeometry = geometries[end] + runs.append(NotationSelectionOverlayRun( + startMeasureIndex: start, + endMeasureIndex: end, + x: startGeometry.cellStartX, + width: max(0, endGeometry.cellEndX - startGeometry.cellStartX) + )) + } + + for index in normalizedIndices.dropFirst() { + if index == previousIndex + 1 { + previousIndex = index + continue + } + + appendRun(start: runStart, end: previousIndex) + runStart = index + previousIndex = index + } + + appendRun(start: runStart, end: previousIndex) + return runs + } + static func attributeBlockWidth( for attributes: MeasureAttributes, display: NotationAttributeDisplay, diff --git a/JammLab/Views/NotationTrackView.swift b/JammLab/Views/NotationTrackView.swift index 0074771..893a24b 100644 --- a/JammLab/Views/NotationTrackView.swift +++ b/JammLab/Views/NotationTrackView.swift @@ -343,26 +343,29 @@ struct NotationTrackView: View { + AppTheme.Timeline.notationStaffLineSpacing * 4 + AppTheme.Spacing.lg let overlayHeight = max(1, overlayBottom - overlayY) + let selectedMeasureIndices = state.visibleMeasures.indices.filter { index in + selectedMeasures.contains(where: { $0.matches(state.visibleMeasures[index]) }) + } + let selectionRuns = NotationMeasureLayout.selectionOverlayRuns( + selectedMeasureIndices: selectedMeasureIndices, + geometries: geometries + ) 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) - } + ForEach(selectionRuns) { run in + 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, run.width), + height: overlayHeight + ) + .offset(x: run.x, y: overlayY) + .allowsHitTesting(false) + .accessibilityHidden(true) } } } diff --git a/JammLabTests/AudioTimingLogicTests.swift b/JammLabTests/AudioTimingLogicTests.swift index c0024e1..bee0d52 100644 --- a/JammLabTests/AudioTimingLogicTests.swift +++ b/JammLabTests/AudioTimingLogicTests.swift @@ -1323,6 +1323,70 @@ final class AudioTimingLogicTests: XCTestCase { ) } + func testNotationMeasureLayoutGroupsContiguousSelectionOverlayRuns() { + let geometries = selectionOverlayTestGeometries(count: 4, width: 100) + + let runs = NotationMeasureLayout.selectionOverlayRuns( + selectedMeasureIndices: [1, 2], + geometries: geometries + ) + + XCTAssertEqual(runs.count, 1) + XCTAssertEqual(runs[0].startMeasureIndex, 1) + XCTAssertEqual(runs[0].endMeasureIndex, 2) + XCTAssertEqual(runs[0].x, geometries[1].cellStartX, accuracy: 0.0001) + XCTAssertEqual(runs[0].width, geometries[2].cellEndX - geometries[1].cellStartX, accuracy: 0.0001) + } + + func testNotationMeasureLayoutKeepsSingleAndNonContiguousSelectionOverlayRunsSeparate() { + let geometries = selectionOverlayTestGeometries(count: 4, width: 100) + + let singleRun = NotationMeasureLayout.selectionOverlayRuns( + selectedMeasureIndices: [1], + geometries: geometries + ) + let separatedRuns = NotationMeasureLayout.selectionOverlayRuns( + selectedMeasureIndices: [0, 2], + geometries: geometries + ) + + XCTAssertEqual(singleRun.map(\.startMeasureIndex), [1]) + XCTAssertEqual(singleRun.map(\.endMeasureIndex), [1]) + XCTAssertEqual(separatedRuns.map(\.startMeasureIndex), [0, 2]) + XCTAssertEqual(separatedRuns.map(\.endMeasureIndex), [0, 2]) + } + + func testNotationMeasureLayoutNormalizesSelectionOverlayRunIndices() { + let geometries = selectionOverlayTestGeometries(count: 4, width: 100) + + let runs = NotationMeasureLayout.selectionOverlayRuns( + selectedMeasureIndices: [2, 1, 1, -1, 9], + geometries: geometries + ) + + XCTAssertEqual(runs.count, 1) + XCTAssertEqual(runs[0].startMeasureIndex, 1) + XCTAssertEqual(runs[0].endMeasureIndex, 2) + } + + func testNotationMeasureLayoutSelectionOverlayRunsStayWithinProvidedRowGeometry() { + let rowGeometries = selectionOverlayTestGeometries(count: 2, width: 100) + + let runs = NotationMeasureLayout.selectionOverlayRuns( + selectedMeasureIndices: [0, 1, 2], + geometries: rowGeometries + ) + let emptyRuns = NotationMeasureLayout.selectionOverlayRuns( + selectedMeasureIndices: [], + geometries: rowGeometries + ) + + XCTAssertEqual(runs.count, 1) + XCTAssertEqual(runs[0].startMeasureIndex, 0) + XCTAssertEqual(runs[0].endMeasureIndex, 1) + XCTAssertEqual(emptyRuns, []) + } + func testNotationMeasureLayoutPositionsSlashBeatCentersForFourFour() { let geometry = NotationMeasureCanvasGeometry( measureIndex: 0, @@ -2195,4 +2259,22 @@ final class AudioTimingLogicTests: XCTestCase { ) } + private func selectionOverlayTestGeometries( + count: Int, + width: CGFloat + ) -> [NotationMeasureCanvasGeometry] { + (0..