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 @@ -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.
Expand Down
54 changes: 54 additions & 0 deletions JammLab/Services/NotationMeasureLayout.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
39 changes: 21 additions & 18 deletions JammLab/Views/NotationTrackView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Expand Down
82 changes: 82 additions & 0 deletions JammLabTests/AudioTimingLogicTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -2195,4 +2259,22 @@ final class AudioTimingLogicTests: XCTestCase {
)
}

private func selectionOverlayTestGeometries(
count: Int,
width: CGFloat
) -> [NotationMeasureCanvasGeometry] {
(0..<count).map { index in
let startX = CGFloat(index) * width
return NotationMeasureCanvasGeometry(
measureIndex: index,
cellStartX: startX,
cellEndX: startX + width,
contentStartX: startX,
contentEndX: startX + width,
staffStartX: startX,
staffEndX: startX + width
)
}
}

}