From 0e8d1bac95c7db97efb59a416cf092ba68080f50 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Mon, 27 Jul 2026 07:00:20 +0300 Subject: [PATCH] ref: Improved Notation measure spacing --- CHANGELOG.md | 1 + JammLab/DesignSystem/AppTheme.swift | 3 + JammLab/Models/NotationScoreModels.swift | 35 + JammLab/Models/NotationViewportState.swift | 54 +- JammLab/Services/NotationMeasureLayout.swift | 173 ++++- .../Services/NotationViewportFactory.swift | 78 ++- .../NotationVisibleMeasureFitter.swift | 625 ++++++++++++++++++ JammLab/Views/MainWorkspacePanels.swift | 121 +++- JammLab/Views/NotationTrackLayoutItems.swift | 197 +----- JammLab/Views/NotationTrackView.swift | 18 +- JammLab/Views/NotationWindowScoreLayout.swift | 288 ++++++-- JammLab/Views/NotationWindowView.swift | 4 +- JammLab/Views/WaveformTimelineView.swift | 5 + JammLabTests/NotationAnchorLayoutTests.swift | 31 +- .../NotationAttributeGeometryTests.swift | 2 +- .../NotationFallbackGeometryTests.swift | 4 +- .../NotationHarmonyLabelLayoutTests.swift | 10 +- .../NotationMeasureGeometryTests.swift | 2 +- JammLabTests/NotationMeasureTimingTests.swift | 70 ++ JammLabTests/NotationPrimitivesTests.swift | 2 +- .../NotationSlashBeatLayoutTests.swift | 54 +- .../NotationViewportTempoMapTests.swift | 53 ++ JammLabTests/NotationViewportTests.swift | 47 ++ .../NotationVisibleMeasureFitterTests.swift | 568 +++++++++++++++- 24 files changed, 2110 insertions(+), 335 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb9e18d..4baa435 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ development artifact builds use `vMAJOR.MINOR.PATCH-dev.N`. ## Unreleased +- Improved Notation measure spacing to prevent late notes from stretching a single measure across the view, keep visible parts aligned, backfill the final page, and balance score systems without avoidable one-measure rows. - Added Backspace and Delete support for clearing selected Notation measures in the selected part while preserving harmony symbols and leaving default whole-measure rests. - Added a separate Bass 8 clef with Leland notation, octave-down note preview and MusicXML export, and made it the default clef for new bass-guitar notation tracks while preserving existing projects. - Added fully offline per-stem Audio-to-MIDI transcription with a bundled Basic Pitch model, native C++ inference, cancellable track progress, polyphonic Notation/MIDI notes, and project persistence. Basic Pitch is unavailable for Drum stems, and re-transcription now warns before replacing existing stem notes and rests. diff --git a/JammLab/DesignSystem/AppTheme.swift b/JammLab/DesignSystem/AppTheme.swift index e1256a7..57dcd44 100644 --- a/JammLab/DesignSystem/AppTheme.swift +++ b/JammLab/DesignSystem/AppTheme.swift @@ -344,6 +344,9 @@ enum AppTheme { static let notationSlashWidth: CGFloat = 7 static let notationSlashMinimumBeatSpacing: CGFloat = 16 static let notationItemAnchorInset: CGFloat = AppTheme.Spacing.lg + static let notationRhythmicGlyphRadius: CGFloat = notationStaffLineSpacing * 1.75 + static let notationRhythmicColumnGap: CGFloat = AppTheme.Spacing.sm + static let notationRhythmicDotRadius: CGFloat = notationStaffLineSpacing * 0.25 static let notationChordSecondOffset: CGFloat = 4 static let notationPolyphonicLaneSpacing: CGFloat = 9 static let notationDuplicateNoteOffset: CGFloat = notationStaffLineSpacing * 1.1 diff --git a/JammLab/Models/NotationScoreModels.swift b/JammLab/Models/NotationScoreModels.swift index 22a104a..4b71d18 100644 --- a/JammLab/Models/NotationScoreModels.swift +++ b/JammLab/Models/NotationScoreModels.swift @@ -318,6 +318,41 @@ enum NotationMeasureTiming { static func quarterLength(for timeSignature: TimeSignature) -> Double { Double(timeSignature.beatsPerBar) * 4.0 / Double(max(1, timeSignature.beatUnit)) } + + static func isSingleFullMeasureWholeRest( + _ measure: ScoreMeasure, + item expectedItem: NotationMeasureItem? = nil + ) -> Bool { + guard measure.notationItems.count == 1, + let item = measure.notationItems.first, + expectedItem == nil || expectedItem?.id == item.id, + item.kind == .rest, + item.displayDuration.denominator == 1, + abs(item.offsetInQuarterNotes) <= timelineTolerance + else { + return false + } + + return abs( + item.durationInQuarterNotes + - quarterLength(for: measure.attributes.timeSignature) + ) <= timelineTolerance + } + + static func visibleMeasureIndex( + containing time: TimeInterval, + in measures: [ScoreMeasure] + ) -> Int? { + measures.indices.first { index in + let measure = measures[index] + let isLastMeasure = index == measures.indices.upperBound - 1 + return time >= measure.startTime + && ( + time < measure.endTime + || (isLastMeasure && time <= measure.endTime) + ) + } + } } struct HarmonySymbol: Identifiable, Codable, Equatable { diff --git a/JammLab/Models/NotationViewportState.swift b/JammLab/Models/NotationViewportState.swift index dea2b1e..f4eceee 100644 --- a/JammLab/Models/NotationViewportState.swift +++ b/JammLab/Models/NotationViewportState.swift @@ -68,32 +68,44 @@ struct NotationScoreState: Equatable { guard isReady, !measures.isEmpty else { return [] } let safeMeasuresPerSystem = max(1, measuresPerSystem) - return stride(from: 0, to: measures.count, by: safeMeasuresPerSystem).map { startIndex in + return stride(from: 0, to: measures.count, by: safeMeasuresPerSystem).compactMap { startIndex in let endIndex = min(startIndex + safeMeasuresPerSystem, measures.count) - let systemMeasures = Array(measures[startIndex..) -> NotationSystemState? { + guard isReady, + !measureRange.isEmpty, + measureRange.lowerBound >= measures.startIndex, + measureRange.upperBound <= measures.endIndex + else { return nil } + + let systemMeasures = Array(measures[measureRange]) + return NotationSystemState( + index: index, + viewportState: NotationViewportState( + availability: availability, + clef: systemMeasures.first?.attributes.clef ?? .treble, + keySignature: systemMeasures.first?.attributes.keySignature ?? keySignature, + timeSignature: systemMeasures.first?.attributes.timeSignature ?? .fourFour, + firstVisibleMeasureNumber: systemMeasures.first?.number ?? 1, + visibleMeasureCount: systemMeasures.count, + visibleMeasures: systemMeasures, + anchorTime: anchorTime, + activeMeasureNumber: activeMeasureNumber, + tieConnections: NotationTieResolver.connections( + tieConnections, + visibleIn: systemMeasures + ), + previousPageStartTime: nil, + nextPageStartTime: nil + ) + ) + } } struct NotationScoreContent: Equatable { diff --git a/JammLab/Services/NotationMeasureLayout.swift b/JammLab/Services/NotationMeasureLayout.swift index 9232db0..9c30cd4 100644 --- a/JammLab/Services/NotationMeasureLayout.swift +++ b/JammLab/Services/NotationMeasureLayout.swift @@ -44,6 +44,44 @@ struct NotationMeasureCanvasGeometry: Equatable { let contentEndX: CGFloat let staffStartX: CGFloat let staffEndX: CGFloat + let rhythmicStartX: CGFloat + let rhythmicEndX: CGFloat + let rhythmicSpacingMap: NotationRhythmicSpacingMap? + + init( + measureIndex: Int, + cellStartX: CGFloat, + cellEndX: CGFloat, + contentStartX: CGFloat, + contentEndX: CGFloat, + staffStartX: CGFloat, + staffEndX: CGFloat, + leadingAnchorInset: CGFloat = AppTheme.Timeline.notationItemAnchorInset, + trailingAnchorInset: CGFloat = AppTheme.Timeline.notationItemAnchorInset, + rhythmicSpacingMap: NotationRhythmicSpacingMap? = nil + ) { + self.measureIndex = measureIndex + self.cellStartX = cellStartX + self.cellEndX = cellEndX + self.contentStartX = contentStartX + self.contentEndX = contentEndX + self.staffStartX = staffStartX + self.staffEndX = staffEndX + self.rhythmicSpacingMap = rhythmicSpacingMap + + let visualStartX = max(contentStartX, staffStartX) + let visualEndX = max(visualStartX, min(contentEndX, staffEndX)) + let proposedStartX = visualStartX + max(0, leadingAnchorInset) + let proposedEndX = visualEndX - max(0, trailingAnchorInset) + if proposedStartX <= proposedEndX { + rhythmicStartX = proposedStartX + rhythmicEndX = proposedEndX + } else { + let midpoint = (visualStartX + visualEndX) / 2 + rhythmicStartX = midpoint + rhythmicEndX = midpoint + } + } var includesRawStartBarline: Bool { measureIndex > 0 || !contentStartsAfterCellBoundary @@ -174,6 +212,48 @@ struct NotationMeasureLayout { } } + static func canvasGeometries( + totalWidth: CGFloat, + bodyWidths: [CGFloat], + attributeReserveWidths: [CGFloat], + leadingAnchorInsets: [CGFloat], + trailingAnchorInsets: [CGFloat], + rhythmicSpacingMaps: [NotationRhythmicSpacingMap?] + ) -> [NotationMeasureCanvasGeometry] { + let measureCount = bodyWidths.count + guard measureCount > 0 else { return [] } + + var cursorX: CGFloat = 0 + return bodyWidths.indices.map { index in + let cellStartX = cursorX + let reserveWidth = attributeReserveWidths.indices.contains(index) + ? max(0, attributeReserveWidths[index]) + : 0 + let bodyWidth = max(0, bodyWidths[index]) + let contentStartX = cellStartX + reserveWidth + let cellEndX = contentStartX + bodyWidth + cursorX = cellEndX + + return canvasGeometry( + measureIndex: index, + measureCount: measureCount, + cellStartX: cellStartX, + cellEndX: cellEndX, + contentStartX: contentStartX, + totalWidth: totalWidth, + leadingAnchorInset: leadingAnchorInsets.indices.contains(index) + ? leadingAnchorInsets[index] + : AppTheme.Timeline.notationItemAnchorInset, + trailingAnchorInset: trailingAnchorInsets.indices.contains(index) + ? trailingAnchorInsets[index] + : AppTheme.Timeline.notationItemAnchorInset, + rhythmicSpacingMap: rhythmicSpacingMaps.indices.contains(index) + ? rhythmicSpacingMaps[index] + : nil + ) + } + } + static func systemMeasureNumberLabelX(geometry: NotationMeasureCanvasGeometry) -> CGFloat { systemMeasureNumberLabelTrailingX(geometry: geometry) - measureNumberLabelWidth } @@ -245,8 +325,7 @@ struct NotationMeasureLayout { let anchorX = notationAnchorX( geometry: geometry, offsetInQuarterNotes: offsetInQuarterNotes, - timeSignature: timeSignature, - anchorInset: 0 + timeSignature: timeSignature ) return min(max(anchorX, bounds.lowerBound), bounds.upperBound) } @@ -481,9 +560,10 @@ struct NotationMeasureLayout { geometry: NotationMeasureCanvasGeometry, progress: CGFloat ) -> CGFloat { - let clampedProgress = max(0, min(progress, 1)) - let width = max(0, geometry.contentEndX - geometry.contentStartX) - return geometry.contentStartX + clampedProgress * width + rhythmicX( + forProgress: Double(max(0, min(progress, 1))), + geometry: geometry + ) } static func playheadIndicatorX( @@ -505,48 +585,52 @@ struct NotationMeasureLayout { minimumBeatSpacing: CGFloat = AppTheme.Timeline.notationSlashMinimumBeatSpacing ) -> [CGFloat] { let beatCount = timeSignature.beatsPerBar - let contentWidth = geometry.contentEndX - geometry.contentStartX + let contentWidth = geometry.rhythmicEndX - geometry.rhythmicStartX guard beatCount > 0, contentWidth > 0 else { return [] } - let beatSpacing = contentWidth / CGFloat(beatCount) - guard beatSpacing >= max(0, minimumBeatSpacing) else { return [] } - let beatLength = 4.0 / Double(max(1, timeSignature.beatUnit)) - return (0..= safeMinimumBeatSpacing + }) else { + return [] + } + return centers } static func notationAnchorX( geometry: NotationMeasureCanvasGeometry, offsetInQuarterNotes: Double, - timeSignature: TimeSignature, - anchorInset: CGFloat = AppTheme.Timeline.notationItemAnchorInset + timeSignature: TimeSignature ) -> CGFloat { let quarterLength = quarterLength(for: timeSignature) - guard quarterLength > 0 else { return geometry.contentStartX } + guard quarterLength > 0 else { return geometry.rhythmicStartX } - let contentWidth = max(0, geometry.contentEndX - geometry.contentStartX) - let effectiveInset = min(max(0, anchorInset), contentWidth) let progress = max(0, min(offsetInQuarterNotes / quarterLength, 1)) - let rawX = geometry.contentStartX + effectiveInset + CGFloat(progress) * contentWidth - return min(max(rawX, geometry.contentStartX), geometry.contentEndX) + return rhythmicX(forProgress: progress, geometry: geometry) } static func notationAnchorProgress( atX x: CGFloat, - geometry: NotationMeasureCanvasGeometry, - anchorInset: CGFloat = AppTheme.Timeline.notationItemAnchorInset + geometry: NotationMeasureCanvasGeometry ) -> Double { - let contentWidth = max(0, geometry.contentEndX - geometry.contentStartX) - guard contentWidth > 0 else { return 0 } - - let effectiveInset = min(max(0, anchorInset), contentWidth) - let rawProgress = (x - geometry.contentStartX - effectiveInset) / contentWidth + let width = max(0, geometry.rhythmicEndX - geometry.rhythmicStartX) + guard width > 0 else { return 0 } + if let spacingMap = geometry.rhythmicSpacingMap { + return spacingMap.progress( + atX: x, + startX: geometry.rhythmicStartX, + endX: geometry.rhythmicEndX + ) + } + let rawProgress = (x - geometry.rhythmicStartX) / width return Double(max(0, min(rawProgress, 1))) } @@ -562,6 +646,22 @@ struct NotationMeasureLayout { ) } + private static func rhythmicX( + forProgress progress: Double, + geometry: NotationMeasureCanvasGeometry + ) -> CGFloat { + if let spacingMap = geometry.rhythmicSpacingMap { + return spacingMap.x( + forProgress: progress, + startX: geometry.rhythmicStartX, + endX: geometry.rhythmicEndX + ) + } + let clampedProgress = max(0, min(progress, 1)) + let width = max(0, geometry.rhythmicEndX - geometry.rhythmicStartX) + return geometry.rhythmicStartX + CGFloat(clampedProgress) * width + } + static func notationItemX( geometry: NotationMeasureCanvasGeometry, measure: ScoreMeasure, @@ -580,17 +680,12 @@ struct NotationMeasureLayout { private static func centersSingleFullMeasureWholeRest( measure: ScoreMeasure, - item: NotationMeasureItem, - tolerance: Double = 0.0001 + item: NotationMeasureItem ) -> Bool { - guard measure.notationItems.count == 1 else { return false } - guard measure.notationItems.first?.id == item.id else { return false } - guard item.kind == .rest else { return false } - guard item.displayDuration.denominator == 1 else { return false } - guard abs(item.offsetInQuarterNotes) <= tolerance else { return false } - - let measureQuarterLength = quarterLength(for: measure.attributes.timeSignature) - return abs(item.durationInQuarterNotes - measureQuarterLength) <= tolerance + NotationMeasureTiming.isSingleFullMeasureWholeRest( + measure, + item: item + ) } static func harmonyLabelX( @@ -666,7 +761,10 @@ struct NotationMeasureLayout { cellStartX: CGFloat, cellEndX: CGFloat, contentStartX: CGFloat, - totalWidth: CGFloat + totalWidth: CGFloat, + leadingAnchorInset: CGFloat = AppTheme.Timeline.notationItemAnchorInset, + trailingAnchorInset: CGFloat = AppTheme.Timeline.notationItemAnchorInset, + rhythmicSpacingMap: NotationRhythmicSpacingMap? = nil ) -> NotationMeasureCanvasGeometry { let safeCellStartX = max(0, cellStartX) let safeCellEndX = max(safeCellStartX, cellEndX) @@ -696,7 +794,10 @@ struct NotationMeasureLayout { contentStartX: clampedContentStartX, contentEndX: safeCellEndX, staffStartX: staffStartX, - staffEndX: max(staffStartX, staffEndX) + staffEndX: max(staffStartX, staffEndX), + leadingAnchorInset: leadingAnchorInset, + trailingAnchorInset: trailingAnchorInset, + rhythmicSpacingMap: rhythmicSpacingMap ) } diff --git a/JammLab/Services/NotationViewportFactory.swift b/JammLab/Services/NotationViewportFactory.swift index 25553af..0689929 100644 --- a/JammLab/Services/NotationViewportFactory.swift +++ b/JammLab/Services/NotationViewportFactory.swift @@ -190,12 +190,27 @@ struct NotationViewportFactory { } let requestedPageStartIndex = pageStartMeasureTime.flatMap(content.measureIndex(containing:)) - let pageStart = requestedPageStartIndex ?? pageStartIndex( + let rawPageStart = requestedPageStartIndex ?? pageStartIndex( forActiveMeasureIndex: activeMeasureIndex, visibleMeasureCount: safeVisibleMeasureCount ) + let pageStart = Self.normalizedPageStartIndex( + rawStartIndex: rawPageStart, + visibleMeasureCount: safeVisibleMeasureCount, + totalMeasureCount: content.measures.count + ) let pageEnd = min(content.measures.count, pageStart + safeVisibleMeasureCount) let visibleMeasures = Array(content.measures[pageStart.. 0 - ? content.measures[max(0, pageStart - safeVisibleMeasureCount)].startTime - : nil, - nextPageStartTime: pageEnd < content.measures.count - ? content.measures[pageEnd].startTime - : nil + previousPageStartTime: previousPageStartIndex.map { + content.measures[$0].startTime + }, + nextPageStartTime: nextPageStartIndex.map { + content.measures[$0].startTime + } ) } @@ -377,6 +392,55 @@ struct NotationViewportFactory { return (activeMeasureIndex / safeVisibleMeasureCount) * safeVisibleMeasureCount } + static func normalizedPageStartIndex( + rawStartIndex: Int, + visibleMeasureCount: Int, + totalMeasureCount: Int + ) -> Int { + let safeVisibleMeasureCount = max(1, visibleMeasureCount) + let safeTotalMeasureCount = max(0, totalMeasureCount) + let finalPageStart = max(0, safeTotalMeasureCount - safeVisibleMeasureCount) + return min(max(0, rawStartIndex), finalPageStart) + } + + static func previousPageStartIndex( + currentStartIndex: Int, + visibleMeasureCount: Int, + totalMeasureCount: Int + ) -> Int? { + let safeVisibleMeasureCount = max(1, visibleMeasureCount) + let currentStart = normalizedPageStartIndex( + rawStartIndex: currentStartIndex, + visibleMeasureCount: safeVisibleMeasureCount, + totalMeasureCount: totalMeasureCount + ) + guard currentStart > 0 else { return nil } + + let finalPageStart = max(0, max(0, totalMeasureCount) - safeVisibleMeasureCount) + if currentStart == finalPageStart, + !currentStart.isMultiple(of: safeVisibleMeasureCount) { + return (currentStart / safeVisibleMeasureCount) * safeVisibleMeasureCount + } + return max(0, currentStart - safeVisibleMeasureCount) + } + + static func nextPageStartIndex( + currentStartIndex: Int, + visibleMeasureCount: Int, + totalMeasureCount: Int + ) -> Int? { + let safeVisibleMeasureCount = max(1, visibleMeasureCount) + let currentStart = normalizedPageStartIndex( + rawStartIndex: currentStartIndex, + visibleMeasureCount: safeVisibleMeasureCount, + totalMeasureCount: totalMeasureCount + ) + let finalPageStart = max(0, max(0, totalMeasureCount) - safeVisibleMeasureCount) + guard currentStart < finalPageStart else { return nil } + + return min(currentStart + safeVisibleMeasureCount, finalPageStart) + } + private func globalMeasureIndex(for target: ScoreMeasure, tempoMap: TempoMap) -> Int? { guard var cursor = measure(containing: 0, tempoMap: tempoMap) else { return nil } diff --git a/JammLab/Services/NotationVisibleMeasureFitter.swift b/JammLab/Services/NotationVisibleMeasureFitter.swift index ad5b684..952ed1d 100644 --- a/JammLab/Services/NotationVisibleMeasureFitter.swift +++ b/JammLab/Services/NotationVisibleMeasureFitter.swift @@ -1,5 +1,602 @@ import CoreGraphics +struct NotationMeasureIdentity: Hashable, Equatable { + var number: Int + var startTimeMicroseconds: Int64 + var endTimeMicroseconds: Int64 + + init(_ measure: ScoreMeasure) { + number = measure.number + startTimeMicroseconds = Int64((measure.startTime * 1_000_000).rounded()) + endTimeMicroseconds = Int64((measure.endTime * 1_000_000).rounded()) + } +} + +struct NotationChordHorizontalLayout: Equatable { + var xOffsetByItemID: [String: CGFloat] = [:] + var stemDirectionByItemID: [String: NotationStemDirection] = [:] +} + +enum NotationHorizontalLayoutResolver { + static func chordLayout(in measure: ScoreMeasure) -> NotationChordHorizontalLayout { + let notes = measure.notationItems.filter { $0.kind == .note && $0.pitch != nil } + let onsetGroups = Dictionary(grouping: notes) { + Int(($0.offsetInQuarterNotes * 1_000_000).rounded()) + } + var layout = NotationChordHorizontalLayout() + + for onsetGroup in onsetGroups.values where onsetGroup.count > 1 { + let durationGroups = Dictionary(grouping: onsetGroup) { + Int(($0.durationInQuarterNotes * 1_000_000).rounded()) + }.values.sorted { + let lhsDuration = $0.first?.durationInQuarterNotes ?? 0 + let rhsDuration = $1.first?.durationInQuarterNotes ?? 0 + if abs(lhsDuration - rhsDuration) > NotationMeasureTiming.timelineTolerance { + return lhsDuration > rhsDuration + } + return ($0.first?.id ?? "") < ($1.first?.id ?? "") + } + + for (laneIndex, durationGroup) in durationGroups.enumerated() { + let laneOffset = (CGFloat(laneIndex) - CGFloat(durationGroups.count - 1) / 2) + * AppTheme.Timeline.notationPolyphonicLaneSpacing + let positioned = durationGroup.compactMap { note -> PositionedItem? in + guard let pitch = note.pitch else { return nil } + return PositionedItem( + item: note, + staffPosition: NotationPitchMapper.staffPosition( + for: pitch, + clef: measure.attributes.clef + ), + drumStemDirection: measure.attributes.clef == .drums + ? NotationDrumStemLayout.direction( + forMIDINoteNumber: pitch.midiNoteNumber + ) + : nil + ) + } + .sorted { + $0.staffPosition == $1.staffPosition + ? $0.item.id < $1.item.id + : $0.staffPosition < $1.staffPosition + } + + if measure.attributes.clef == .drums, + positioned.allSatisfy({ $0.drumStemDirection != nil }) { + for stemDirection in [NotationStemDirection.up, .down] { + applyChordLayout( + to: positioned.filter { $0.drumStemDirection == stemDirection }, + laneOffset: laneOffset, + stemDirection: stemDirection, + layout: &layout + ) + } + } else { + applyChordLayout( + to: positioned, + laneOffset: laneOffset, + stemDirection: averageStemDirection(for: positioned), + layout: &layout + ) + } + } + } + return layout + } + + static func accidentalColumnByItemID(in measure: ScoreMeasure) -> [String: Int] { + let candidates = measure.notationItems.compactMap { item -> AccidentalCandidate? in + guard item.kind == .note, + item.explicitAccidental != nil, + let pitch = item.pitch + else { return nil } + return AccidentalCandidate( + itemID: item.id, + onsetTick: Int((item.offsetInQuarterNotes * 1_000_000).rounded()), + staffPosition: NotationPitchMapper.staffPosition( + for: pitch, + clef: measure.attributes.clef + ) + ) + } + let groups = Dictionary(grouping: candidates, by: \.onsetTick) + var result: [String: Int] = [:] + + for group in groups.values { + var staffPositionsByColumn: [[Int]] = [] + for candidate in group.sorted(by: { + $0.staffPosition == $1.staffPosition + ? $0.itemID < $1.itemID + : $0.staffPosition < $1.staffPosition + }) { + let column = staffPositionsByColumn.firstIndex { positions in + positions.allSatisfy { + abs($0 - candidate.staffPosition) + >= AppTheme.Timeline.notationInlineAccidentalMinimumStaffPositionDistance + } + } ?? staffPositionsByColumn.count + if column == staffPositionsByColumn.count { + staffPositionsByColumn.append([]) + } + staffPositionsByColumn[column].append(candidate.staffPosition) + result[candidate.itemID] = column + } + } + return result + } + + private static func averageStemDirection( + for notes: [PositionedItem] + ) -> NotationStemDirection { + let staffPositions = notes.map(\.staffPosition) + let averagePosition = staffPositions.isEmpty + ? 4 + : Int((Double(staffPositions.reduce(0, +)) / Double(staffPositions.count)).rounded()) + return NotationStemDirection.direction(forStaffPosition: averagePosition) + } + + private static func applyChordLayout( + to notes: [PositionedItem], + laneOffset: CGFloat, + stemDirection: NotationStemDirection, + layout: inout NotationChordHorizontalLayout + ) { + guard !notes.isEmpty else { return } + + var shiftsRight = stemDirection == .up + var countsByStaffPosition: [Int: Int] = [:] + for (noteIndex, note) in notes.enumerated() { + var noteOffset = laneOffset + let staffPosition = note.staffPosition + let duplicateIndex = countsByStaffPosition[staffPosition, default: 0] + countsByStaffPosition[staffPosition] = duplicateIndex + 1 + if !duplicateIndex.isMultiple(of: 2) { + noteOffset += AppTheme.Timeline.notationDuplicateNoteOffset + } + if noteIndex > 0, + abs(staffPosition - notes[noteIndex - 1].staffPosition) == 1 { + noteOffset += shiftsRight + ? AppTheme.Timeline.notationChordSecondOffset + : -AppTheme.Timeline.notationChordSecondOffset + shiftsRight.toggle() + } + layout.xOffsetByItemID[note.item.id] = noteOffset + layout.stemDirectionByItemID[note.item.id] = stemDirection + } + } + + private struct PositionedItem { + var item: NotationMeasureItem + var staffPosition: Int + var drumStemDirection: NotationStemDirection? + } + + private struct AccidentalCandidate { + var itemID: String + var onsetTick: Int + var staffPosition: Int + } +} + +struct NotationRhythmicSpacingAnchor: Equatable { + var progress: Double + var compactX: CGFloat +} + +struct NotationRhythmicSpacingMap: Equatable { + var anchors: [NotationRhythmicSpacingAnchor] + var compactSpan: CGFloat + + func x( + forProgress progress: Double, + startX: CGFloat, + endX: CGFloat + ) -> CGFloat { + let safeStartX = min(startX, endX) + let safeEndX = max(startX, endX) + let width = safeEndX - safeStartX + guard width > 0 else { return safeStartX } + + let clampedProgress = max(0, min(progress, 1)) + let compactX = compactX(forProgress: clampedProgress) + let projectedX: CGFloat + if compactSpan > CGFloat(NotationMeasureTiming.timelineTolerance) { + if width >= compactSpan { + projectedX = compactX + + (width - compactSpan) * CGFloat(clampedProgress) + } else { + projectedX = compactX * (width / compactSpan) + } + } else { + projectedX = width * CGFloat(clampedProgress) + } + return safeStartX + min(max(0, projectedX), width) + } + + func progress( + atX x: CGFloat, + startX: CGFloat, + endX: CGFloat + ) -> Double { + let safeStartX = min(startX, endX) + let safeEndX = max(startX, endX) + let width = safeEndX - safeStartX + guard width > 0 else { return 0 } + + let relativeX = min(max(x, safeStartX), safeEndX) - safeStartX + let projectedAnchors = anchors.map { + ( + progress: $0.progress, + x: projectedX(for: $0, width: width) + ) + } + guard let first = projectedAnchors.first, + let last = projectedAnchors.last, + projectedAnchors.count >= 2 + else { + return Double(relativeX / width) + } + if relativeX <= first.x { return max(0, min(first.progress, 1)) } + if relativeX >= last.x { return max(0, min(last.progress, 1)) } + + for (lhs, rhs) in zip(projectedAnchors, projectedAnchors.dropFirst()) + where relativeX <= rhs.x { + let segmentWidth = rhs.x - lhs.x + guard segmentWidth > CGFloat(NotationMeasureTiming.timelineTolerance) else { + return max(0, min(rhs.progress, 1)) + } + let segmentProgress = Double((relativeX - lhs.x) / segmentWidth) + return max( + 0, + min( + lhs.progress + (rhs.progress - lhs.progress) * segmentProgress, + 1 + ) + ) + } + return 1 + } + + private func compactX(forProgress progress: Double) -> CGFloat { + guard let first = anchors.first, + let last = anchors.last, + anchors.count >= 2 + else { + return compactSpan * CGFloat(progress) + } + if progress <= first.progress { return first.compactX } + if progress >= last.progress { return last.compactX } + + for (lhs, rhs) in zip(anchors, anchors.dropFirst()) + where progress <= rhs.progress { + let progressSpan = rhs.progress - lhs.progress + guard progressSpan > NotationMeasureTiming.timelineTolerance else { + return rhs.compactX + } + let interpolation = CGFloat((progress - lhs.progress) / progressSpan) + return lhs.compactX + (rhs.compactX - lhs.compactX) * interpolation + } + return last.compactX + } + + private func projectedX( + for anchor: NotationRhythmicSpacingAnchor, + width: CGFloat + ) -> CGFloat { + guard compactSpan > CGFloat(NotationMeasureTiming.timelineTolerance) else { + return width * CGFloat(anchor.progress) + } + if width >= compactSpan { + return anchor.compactX + + (width - compactSpan) * CGFloat(anchor.progress) + } + return anchor.compactX * (width / compactSpan) + } +} + +struct NotationRhythmicColumn: Equatable { + var quantizedProgress: Int64 + var progress: Double + var leftFootprint: CGFloat + var rightFootprint: CGFloat +} + +struct NotationMeasureSpacingRequirements: Equatable { + var leadingAnchorInset: CGFloat + var trailingAnchorInset: CGFloat + var columns: [NotationRhythmicColumn] +} + +enum NotationMeasureSpacingAnalyzer { + static let progressScale: Double = 1_000_000 + + static func requirements(for measure: ScoreMeasure) -> NotationMeasureSpacingRequirements { + let baseInset = AppTheme.Timeline.notationItemAnchorInset + let gap = AppTheme.Timeline.notationRhythmicColumnGap + guard !NotationMeasureTiming.isSingleFullMeasureWholeRest(measure) else { + return NotationMeasureSpacingRequirements( + leadingAnchorInset: baseInset, + trailingAnchorInset: baseInset, + columns: [] + ) + } + + let quarterLength = NotationMeasureTiming.quarterLength( + for: measure.attributes.timeSignature + ) + guard quarterLength > NotationMeasureTiming.timelineTolerance else { + return NotationMeasureSpacingRequirements( + leadingAnchorInset: baseInset, + trailingAnchorInset: baseInset, + columns: [] + ) + } + + let chordLayout = NotationHorizontalLayoutResolver.chordLayout(in: measure) + let accidentalColumns = NotationHorizontalLayoutResolver.accidentalColumnByItemID( + in: measure + ) + let rhythmicItems = measure.notationItems.filter { + $0.kind == .rest || ($0.kind == .note && $0.pitch != nil) + } + let grouped = Dictionary(grouping: rhythmicItems) { + Int64(($0.offsetInQuarterNotes * progressScale).rounded()) + } + let columns = grouped.values.compactMap { items -> NotationRhythmicColumn? in + guard let first = items.first else { return nil } + var left = AppTheme.Timeline.notationRhythmicGlyphRadius + var right = AppTheme.Timeline.notationRhythmicGlyphRadius + + for item in items { + let xOffset = chordLayout.xOffsetByItemID[item.id] ?? 0 + left = max(left, AppTheme.Timeline.notationRhythmicGlyphRadius - xOffset) + right = max(right, AppTheme.Timeline.notationRhythmicGlyphRadius + xOffset) + + if let accidentalColumn = accidentalColumns[item.id] { + let accidentalCenter = xOffset + - AppTheme.Timeline.notationInlineAccidentalNoteOffset + - CGFloat(accidentalColumn) + * AppTheme.Timeline.notationInlineAccidentalColumnSpacing + left = max( + left, + -accidentalCenter + AppTheme.Timeline.notationAccidentalWidth / 2 + ) + } + if item.displayDuration.isDotted { + right = max( + right, + xOffset + + AppTheme.Timeline.notationStaffLineSpacing + + AppTheme.Timeline.notationRhythmicDotRadius + ) + } + } + + let progress = max(0, min(first.offsetInQuarterNotes / quarterLength, 1)) + return NotationRhythmicColumn( + quantizedProgress: Int64((progress * progressScale).rounded()), + progress: progress, + leftFootprint: left, + rightFootprint: right + ) + } + .sorted { $0.quantizedProgress < $1.quantizedProgress } + + guard let first = columns.first, let last = columns.last else { + return NotationMeasureSpacingRequirements( + leadingAnchorInset: baseInset, + trailingAnchorInset: baseInset, + columns: [] + ) + } + + return NotationMeasureSpacingRequirements( + leadingAnchorInset: max(baseInset, first.leftFootprint + gap), + trailingAnchorInset: max(baseInset, last.rightFootprint + gap), + columns: columns + ) + } + +} + +enum NotationRhythmicSpacingMapBuilder { + static func make( + rows: [NotationMeasureSpacingRequirements], + leadingInset: CGFloat, + trailingInset: CGFloat + ) -> NotationRhythmicSpacingMap { + let scale = NotationMeasureSpacingAnalyzer.progressScale + let endTick = Int64(scale) + let ticks = Set( + rows.flatMap { $0.columns.map(\.quantizedProgress) } + [0, endTick] + ).map { + min(max(0, $0), endTick) + }.sorted() + let safeTicks = ticks.count >= 2 ? ticks : [0, endTick] + let indexByTick = Dictionary( + uniqueKeysWithValues: safeTicks.enumerated().map { ($0.element, $0.offset) } + ) + let baseSpan = max( + 0, + AppTheme.Timeline.notationMeasureMinWidth + - max(0, leadingInset) + - max(0, trailingInset) + ) + var collisionConstraintsByTarget: [Int: [(source: Int, distance: CGFloat)]] = [:] + let gap = AppTheme.Timeline.notationRhythmicColumnGap + + for row in rows { + for (lhs, rhs) in zip(row.columns, row.columns.dropFirst()) { + guard let source = indexByTick[lhs.quantizedProgress], + let target = indexByTick[rhs.quantizedProgress], + source < target + else { + continue + } + collisionConstraintsByTarget[target, default: []].append( + ( + source: source, + distance: lhs.rightFootprint + gap + rhs.leftFootprint + ) + ) + } + } + + var positions = Array(repeating: CGFloat.zero, count: safeTicks.count) + for target in safeTicks.indices.dropFirst() { + let progressDelta = Double(safeTicks[target] - safeTicks[target - 1]) / scale + positions[target] = positions[target - 1] + + baseSpan * CGFloat(max(0, progressDelta)) + for constraint in collisionConstraintsByTarget[target, default: []] { + positions[target] = max( + positions[target], + positions[constraint.source] + constraint.distance + ) + } + } + + return NotationRhythmicSpacingMap( + anchors: safeTicks.indices.map { index in + NotationRhythmicSpacingAnchor( + progress: Double(safeTicks[index]) / scale, + compactX: positions[index] + ) + }, + compactSpan: positions.last ?? baseSpan + ) + } +} + +struct NotationMeasureLayoutSlot: Equatable { + var identity: NotationMeasureIdentity + var attributeReserveWidth: CGFloat + var minimumBodyWidth: CGFloat + var leadingAnchorInset: CGFloat + var trailingAnchorInset: CGFloat + var rhythmicSpacingMap: NotationRhythmicSpacingMap +} + +struct NotationSystemMeasureLayout: Equatable { + var slots: [NotationMeasureLayoutSlot] + + var minimumRequiredWidth: CGFloat { + slots.map(\.attributeReserveWidth).reduce(0, +) + + requiredBodyWidths.reduce(0, +) + } + + static func make(measureRows: [[ScoreMeasure]]) -> NotationSystemMeasureLayout? { + guard let referenceRow = measureRows.first, !referenceRow.isEmpty else { return nil } + let identities = referenceRow.map(NotationMeasureIdentity.init) + guard measureRows.allSatisfy({ + $0.count == referenceRow.count + && $0.map(NotationMeasureIdentity.init) == identities + }) else { + return nil + } + + let slots = referenceRow.indices.map { index -> NotationMeasureLayoutSlot in + var reserveWidth: CGFloat = 0 + var spacingRequirements: [NotationMeasureSpacingRequirements] = [] + + for row in measureRows { + let measure = row[index] + let previousAttributes = index > 0 ? row[index - 1].attributes : nil + let display = NotationAttributeDisplay.display( + for: measure.attributes, + previousAttributes: previousAttributes + ) + reserveWidth = max( + reserveWidth, + NotationMeasureLayout.attributeReserveWidth( + for: measure.attributes, + display: display + ) + ) + spacingRequirements.append( + NotationMeasureSpacingAnalyzer.requirements(for: measure) + ) + } + let leadingInset = spacingRequirements + .map(\.leadingAnchorInset) + .max() ?? AppTheme.Timeline.notationItemAnchorInset + let trailingInset = spacingRequirements + .map(\.trailingAnchorInset) + .max() ?? AppTheme.Timeline.notationItemAnchorInset + let rhythmicSpacingMap = NotationRhythmicSpacingMapBuilder.make( + rows: spacingRequirements, + leadingInset: leadingInset, + trailingInset: trailingInset + ) + + return NotationMeasureLayoutSlot( + identity: identities[index], + attributeReserveWidth: reserveWidth, + minimumBodyWidth: max( + AppTheme.Timeline.notationMeasureMinWidth, + leadingInset + rhythmicSpacingMap.compactSpan + trailingInset + ), + leadingAnchorInset: leadingInset, + trailingAnchorInset: trailingInset, + rhythmicSpacingMap: rhythmicSpacingMap + ) + } + return NotationSystemMeasureLayout(slots: slots) + } + + static func make(states: [NotationViewportState]) -> NotationSystemMeasureLayout? { + make(measureRows: states.map(\.visibleMeasures)) + } + + func matches(_ measures: [ScoreMeasure]) -> Bool { + slots.map(\.identity) == measures.map(NotationMeasureIdentity.init) + } + + func geometries(totalWidth: CGFloat) -> [NotationMeasureCanvasGeometry] { + guard !slots.isEmpty else { return [] } + let safeTotalWidth = max(0, totalWidth) + var reserveWidths = slots.map(\.attributeReserveWidth) + let reserveTotal = reserveWidths.reduce(0, +) + if reserveTotal > safeTotalWidth, reserveTotal > 0 { + let scale = safeTotalWidth / reserveTotal + reserveWidths = reserveWidths.map { $0 * scale } + } + + let availableBodyWidth = max(0, safeTotalWidth - reserveWidths.reduce(0, +)) + let minimumBodyWidths = requiredBodyWidths + let minimumBodyTotal = minimumBodyWidths.reduce(0, +) + let bodyWidths: [CGFloat] + if minimumBodyTotal <= availableBodyWidth { + let extraPerMeasure = (availableBodyWidth - minimumBodyTotal) / CGFloat(slots.count) + bodyWidths = minimumBodyWidths.map { $0 + extraPerMeasure } + } else if minimumBodyTotal > 0 { + let scale = availableBodyWidth / minimumBodyTotal + bodyWidths = minimumBodyWidths.map { $0 * scale } + } else { + bodyWidths = Array( + repeating: availableBodyWidth / CGFloat(slots.count), + count: slots.count + ) + } + + return NotationMeasureLayout.canvasGeometries( + totalWidth: safeTotalWidth, + bodyWidths: bodyWidths, + attributeReserveWidths: reserveWidths, + leadingAnchorInsets: slots.map(\.leadingAnchorInset), + trailingAnchorInsets: slots.map(\.trailingAnchorInset), + rhythmicSpacingMaps: slots.map { Optional($0.rhythmicSpacingMap) } + ) + } + + private var requiredBodyWidths: [CGFloat] { + guard !slots.isEmpty else { return [] } + var widths = slots.map(\.minimumBodyWidth) + widths[0] += AppTheme.Timeline.notationStaffHorizontalInset + widths[widths.count - 1] += AppTheme.Timeline.notationStaffHorizontalInset + return widths + } +} + struct NotationVisibleMeasureFitter { static let widthTolerance: CGFloat = 0.5 @@ -23,6 +620,9 @@ struct NotationVisibleMeasureFitter { } static func minimumRequiredWidth(for state: NotationViewportState) -> CGFloat { + if let layout = NotationSystemMeasureLayout.make(states: [state]) { + return layout.minimumRequiredWidth + } let measureCount = max( 1, state.visibleMeasures.isEmpty ? state.visibleMeasureCount : state.visibleMeasures.count @@ -34,6 +634,31 @@ struct NotationVisibleMeasureFitter { ) } + static func minimumRequiredWidth(for states: [NotationViewportState]) -> CGFloat { + NotationSystemMeasureLayout.make(states: states)?.minimumRequiredWidth + ?? states.map(minimumRequiredWidth(for:)).max() + ?? 0 + } + + static func fittedMeasureCount( + availableWidth: CGFloat, + maximumMeasureCount: Int, + statesForMeasureCount: (Int) -> [NotationViewportState] + ) -> Int { + let safeMaximumMeasureCount = max(1, maximumMeasureCount) + let safeAvailableWidth = max(0, availableWidth) + + for measureCount in stride(from: safeMaximumMeasureCount, through: 1, by: -1) { + let requiredWidth = minimumRequiredWidth( + for: statesForMeasureCount(measureCount) + ) + if requiredWidth <= safeAvailableWidth + widthTolerance { + return measureCount + } + } + return 1 + } + static func attributeReserveWidths( for state: NotationViewportState, measureCount: Int diff --git a/JammLab/Views/MainWorkspacePanels.swift b/JammLab/Views/MainWorkspacePanels.swift index bc8f8fc..92f332f 100644 --- a/JammLab/Views/MainWorkspacePanels.swift +++ b/JammLab/Views/MainWorkspacePanels.swift @@ -20,6 +20,12 @@ enum NotesFilter: String, CaseIterable, Identifiable { } } +struct TimelineNotationLayoutBundle { + var mainViewport: NotationViewportState + var stemViewports: [StemType: NotationViewportState] + var measureLayout: NotationSystemMeasureLayout? +} + extension ContentView { var scrollableWorkspaceContent: some View { GeometryReader { proxy in @@ -156,7 +162,10 @@ extension ContentView { } func timelineViewState(notationTrackContentWidth: CGFloat) -> TimelineViewState { - TimelineViewState( + let notationLayoutBundle = timelineNotationLayoutBundle( + availableWidth: notationTrackContentWidth + ) + return TimelineViewState( peakformData: viewModel.peakformData, duration: viewModel.duration, currentTime: viewModel.currentTime, @@ -172,13 +181,9 @@ extension ContentView { pendingHarmonyEditorRequest: viewModel.pendingHarmonyEditorRequest, selectedRegionID: viewModel.selectedRegionID, beatGrid: beatGrid, - notationViewport: viewModel.isNotationTrackCollapsed - ? .pending( - visibleMeasureCount: 1, - keySignature: KeySignature.normalized(from: viewModel.effectiveKeyName) - ) - : notationViewportState(availableWidth: notationTrackContentWidth, partID: .main), - stemNotationViewports: stemNotationViewports(availableWidth: notationTrackContentWidth), + notationViewport: notationLayoutBundle.mainViewport, + stemNotationViewports: notationLayoutBundle.stemViewports, + notationMeasureLayout: notationLayoutBundle.measureLayout, notationDurationDenominator: viewModel.notationDurationDenominator, notationDurationIsDotted: viewModel.notationDurationIsDotted, pendingNotationAccidental: viewModel.pendingNotationAccidental, @@ -246,23 +251,97 @@ extension ContentView { ) } - func stemNotationViewports(availableWidth: CGFloat) -> [StemType: NotationViewportState] { - Dictionary(uniqueKeysWithValues: viewModel.stemFiles.map { stemFile in - let isCollapsed = viewModel.isStemNotationTrackCollapsed(stemFile.type) - let viewport: NotationViewportState = isCollapsed - ? .pending( - visibleMeasureCount: 1, - keySignature: KeySignature.normalized(from: viewModel.effectiveKeyName) + func timelineNotationLayoutBundle( + availableWidth: CGFloat + ) -> TimelineNotationLayoutBundle { + let pendingViewport = NotationViewportState.pending( + visibleMeasureCount: 1, + keySignature: KeySignature.normalized(from: viewModel.effectiveKeyName) + ) + var sharedPartIDs: [NotationPartID] = [] + if !viewModel.isNotationTrackCollapsed { + sharedPartIDs.append(.main) + } + sharedPartIDs.append(contentsOf: viewModel.stemFiles.compactMap { stemFile in + guard !viewModel.isStemNotationTrackCollapsed(stemFile.type), + viewModel.stemNoteDisplayMode(for: stemFile.type) == .notation + else { return nil } + return .stem(stemFile.type) + }) + + let factory = NotationViewportFactory() + let contentsByPart = Dictionary( + uniqueKeysWithValues: sharedPartIDs.map { partID in + ( + partID, + notationProjectionCache.content( + tempoMap: viewModel.tempoMap, + duration: viewModel.duration, + keyName: viewModel.effectiveKeyName, + clef: viewModel.notationClef(for: partID), + partID: partID, + includesHarmonies: partID.isMain, + notationItems: viewModel.notationItems, + harmonySymbols: viewModel.harmonySymbols, + notes: viewModel.notes + ) ) - : notationViewportState( + } + ) + let statesForMeasureCount: (Int) -> [NotationViewportState] = { measureCount in + sharedPartIDs.compactMap { partID in + guard let content = contentsByPart[partID] else { return nil } + return factory.viewportState( + content: content, + duration: viewModel.duration, + currentTime: viewModel.currentTime, + playbackMarkerTime: viewModel.playbackMarkerTime, + isPlaying: viewModel.playbackState == .playing, + visibleMeasureCount: measureCount, + pageStartMeasureTime: nil + ) + } + } + + let sharedStates: [NotationViewportState] + if sharedPartIDs.isEmpty { + sharedStates = [] + } else { + let measureCount = NotationVisibleMeasureFitter.fittedMeasureCount( + availableWidth: availableWidth, + maximumMeasureCount: AppTheme.Timeline.notationMaximumVisibleMeasureCount, + statesForMeasureCount: statesForMeasureCount + ) + sharedStates = statesForMeasureCount(measureCount) + } + let sharedStateByPart = Dictionary( + uniqueKeysWithValues: zip(sharedPartIDs, sharedStates) + ) + let mainViewport = viewModel.isNotationTrackCollapsed + ? pendingViewport + : sharedStateByPart[.main] ?? pendingViewport + let stemViewports = Dictionary(uniqueKeysWithValues: viewModel.stemFiles.map { stemFile in + let type = stemFile.type + let viewport: NotationViewportState + if viewModel.isStemNotationTrackCollapsed(type) { + viewport = pendingViewport + } else if viewModel.stemNoteDisplayMode(for: type) == .midi { + viewport = notationViewportState( availableWidth: availableWidth, - partID: .stem(stemFile.type), - pageStartMeasureTime: viewModel.stemNoteDisplayMode(for: stemFile.type) == .midi - ? stemMIDIPageStartTimes[stemFile.type] - : nil + partID: .stem(type), + pageStartMeasureTime: stemMIDIPageStartTimes[type] ) - return (stemFile.type, viewport) + } else { + viewport = sharedStateByPart[.stem(type)] ?? pendingViewport + } + return (type, viewport) }) + + return TimelineNotationLayoutBundle( + mainViewport: mainViewport, + stemViewports: stemViewports, + measureLayout: NotationSystemMeasureLayout.make(states: sharedStates) + ) } var timelineViewActions: TimelineViewActions { diff --git a/JammLab/Views/NotationTrackLayoutItems.swift b/JammLab/Views/NotationTrackLayoutItems.swift index d7f1b19..a35ce26 100644 --- a/JammLab/Views/NotationTrackLayoutItems.swift +++ b/JammLab/Views/NotationTrackLayoutItems.swift @@ -176,7 +176,7 @@ enum NotationTrackLayoutItems { item: notationItem ) } - let chordLayout = chordLayout(in: measure) + let chordLayout = NotationHorizontalLayoutResolver.chordLayout(in: measure) return measure.notationItems.map { notationItem in NotationItemLayoutItem( measure: measure, @@ -223,6 +223,19 @@ enum NotationTrackLayoutItems { static func accidentals( from layoutItems: [NotationItemLayoutItem] ) -> [NotationAccidentalLayoutItem] { + let columnsByMeasure = Dictionary( + uniqueKeysWithValues: Dictionary( + grouping: layoutItems, + by: { NotationMeasureIdentity($0.measure) } + ).compactMap { identity, items -> (NotationMeasureIdentity, [String: Int])? in + guard let measure = items.first?.measure else { return nil } + return ( + identity, + NotationHorizontalLayoutResolver.accidentalColumnByItemID(in: measure) + ) + } + ) + let candidates = layoutItems.compactMap { item -> AccidentalCandidate? in guard item.measure.attributes.clef != .drums, item.notationItem.kind == .note, @@ -231,166 +244,31 @@ enum NotationTrackLayoutItems { else { return nil } return AccidentalCandidate( itemID: item.notationItem.id, - measureNumber: item.measure.number, - measureStartTime: item.measure.startTime, - onset: item.notationItem.offsetInQuarterNotes, accidental: accidental, noteX: item.x, staffPosition: NotationPitchMapper.staffPosition( for: pitch, clef: item.measure.attributes.clef - ) + ), + column: columnsByMeasure[NotationMeasureIdentity(item.measure)]?[ + item.notationItem.id + ] ?? 0 ) } - let grouped = Dictionary(grouping: candidates) { - AccidentalGroupKey( - measureNumber: $0.measureNumber, - measureStartTick: Int(($0.measureStartTime * 1_000_000).rounded()), - onsetTick: Int(($0.onset * 1_000_000).rounded()) + return candidates.map { candidate in + NotationAccidentalLayoutItem( + itemID: candidate.itemID, + accidental: candidate.accidental, + x: candidate.noteX + - AppTheme.Timeline.notationInlineAccidentalNoteOffset + - CGFloat(candidate.column) + * AppTheme.Timeline.notationInlineAccidentalColumnSpacing, + staffPosition: candidate.staffPosition ) } - - return grouped.values.flatMap { group -> [NotationAccidentalLayoutItem] in - var staffPositionsByColumn: [[Int]] = [] - return group.sorted { - if $0.staffPosition != $1.staffPosition { - return $0.staffPosition < $1.staffPosition - } - return $0.itemID < $1.itemID - }.map { candidate in - let column = staffPositionsByColumn.firstIndex { - columnPositions in - columnPositions.allSatisfy { - abs($0 - candidate.staffPosition) - >= AppTheme.Timeline.notationInlineAccidentalMinimumStaffPositionDistance - } - } ?? staffPositionsByColumn.count - if column == staffPositionsByColumn.count { - staffPositionsByColumn.append([]) - } - staffPositionsByColumn[column].append(candidate.staffPosition) - return NotationAccidentalLayoutItem( - itemID: candidate.itemID, - accidental: candidate.accidental, - x: candidate.noteX - - AppTheme.Timeline.notationInlineAccidentalNoteOffset - - CGFloat(column) - * AppTheme.Timeline.notationInlineAccidentalColumnSpacing, - staffPosition: candidate.staffPosition - ) - } - } .sorted { $0.itemID < $1.itemID } } - private static func chordLayout(in measure: ScoreMeasure) -> NotationChordLayout { - let notes = measure.notationItems.filter { $0.kind == .note && $0.pitch != nil } - let onsetGroups = Dictionary(grouping: notes) { - Int(($0.offsetInQuarterNotes * 1_000_000).rounded()) - } - var layout = NotationChordLayout() - - for onsetGroup in onsetGroups.values where onsetGroup.count > 1 { - let durationGroups = Dictionary(grouping: onsetGroup) { - Int(($0.durationInQuarterNotes * 1_000_000).rounded()) - }.values.sorted { - let lhsDuration = $0.first?.durationInQuarterNotes ?? 0 - let rhsDuration = $1.first?.durationInQuarterNotes ?? 0 - if abs(lhsDuration - rhsDuration) > NotationMeasureTiming.timelineTolerance { - return lhsDuration > rhsDuration - } - return ($0.first?.id ?? "") < ($1.first?.id ?? "") - } - - for (laneIndex, durationGroup) in durationGroups.enumerated() { - let laneOffset = (CGFloat(laneIndex) - CGFloat(durationGroups.count - 1) / 2) - * AppTheme.Timeline.notationPolyphonicLaneSpacing - let positioned = durationGroup.compactMap { note -> PositionedNotationItem? in - guard let pitch = note.pitch else { return nil } - return PositionedNotationItem( - item: note, - staffPosition: NotationPitchMapper.staffPosition( - for: pitch, - clef: measure.attributes.clef - ), - drumStemDirection: measure.attributes.clef == .drums - ? NotationDrumStemLayout.direction( - forMIDINoteNumber: pitch.midiNoteNumber - ) - : nil - ) - } - .sorted { - $0.staffPosition == $1.staffPosition - ? $0.item.id < $1.item.id - : $0.staffPosition < $1.staffPosition - } - if measure.attributes.clef == .drums, - positioned.allSatisfy({ $0.drumStemDirection != nil }) { - for stemDirection in [NotationStemDirection.up, .down] { - let stemGroup = positioned.filter { - $0.drumStemDirection == stemDirection - } - applyChordLayout( - to: stemGroup, - laneOffset: laneOffset, - stemDirection: stemDirection, - layout: &layout - ) - } - } else { - applyChordLayout( - to: positioned, - laneOffset: laneOffset, - stemDirection: averageStaffPositionStemDirection(for: positioned), - layout: &layout - ) - } - } - } - return layout - } - - private static func averageStaffPositionStemDirection( - for notes: [PositionedNotationItem] - ) -> NotationStemDirection { - let staffPositions = notes.map(\.staffPosition) - let averagePosition = staffPositions.isEmpty - ? 4 - : Int((Double(staffPositions.reduce(0, +)) / Double(staffPositions.count)).rounded()) - return NotationStemDirection.direction(forStaffPosition: averagePosition) - } - - private static func applyChordLayout( - to notes: [PositionedNotationItem], - laneOffset: CGFloat, - stemDirection: NotationStemDirection, - layout: inout NotationChordLayout - ) { - guard !notes.isEmpty else { return } - - var shiftsRight = stemDirection == .up - var countsByStaffPosition: [Int: Int] = [:] - for (noteIndex, note) in notes.enumerated() { - var noteOffset = laneOffset - let staffPosition = note.staffPosition - let duplicateIndex = countsByStaffPosition[staffPosition, default: 0] - countsByStaffPosition[staffPosition] = duplicateIndex + 1 - if !duplicateIndex.isMultiple(of: 2) { - noteOffset += AppTheme.Timeline.notationDuplicateNoteOffset - } - if noteIndex > 0, - abs(staffPosition - notes[noteIndex - 1].staffPosition) == 1 { - noteOffset += shiftsRight - ? AppTheme.Timeline.notationChordSecondOffset - : -AppTheme.Timeline.notationChordSecondOffset - shiftsRight.toggle() - } - layout.xOffsetByItemID[note.item.id] = noteOffset - layout.stemDirectionByItemID[note.item.id] = stemDirection - } - } - static func ties( visibleMeasures: [ScoreMeasure], geometries: [NotationMeasureCanvasGeometry], @@ -461,29 +339,10 @@ enum NotationTrackLayoutItems { private struct AccidentalCandidate { var itemID: String - var measureNumber: Int - var measureStartTime: TimeInterval - var onset: Double var accidental: NotationAccidental var noteX: CGFloat var staffPosition: Int -} - -private struct AccidentalGroupKey: Hashable { - var measureNumber: Int - var measureStartTick: Int - var onsetTick: Int -} - -private struct NotationChordLayout { - var xOffsetByItemID: [String: CGFloat] = [:] - var stemDirectionByItemID: [String: NotationStemDirection] = [:] -} - -private struct PositionedNotationItem { - var item: NotationMeasureItem - var staffPosition: Int - var drumStemDirection: NotationStemDirection? + var column: Int } private struct NotationChordRenderKey: Hashable { diff --git a/JammLab/Views/NotationTrackView.swift b/JammLab/Views/NotationTrackView.swift index 39f53d3..4631e23 100644 --- a/JammLab/Views/NotationTrackView.swift +++ b/JammLab/Views/NotationTrackView.swift @@ -23,6 +23,7 @@ struct NotationTrackActions { struct NotationTrackView: View { let state: NotationViewportState + let measureLayout: NotationSystemMeasureLayout? let partID: NotationPartID let playbackDisplayState: PlaybackDisplayState? let selectedHarmonySymbolID: HarmonySymbol.ID? @@ -46,6 +47,7 @@ struct NotationTrackView: View { init( state: NotationViewportState, + measureLayout: NotationSystemMeasureLayout? = nil, partID: NotationPartID = .main, playbackDisplayState: PlaybackDisplayState? = nil, selectedHarmonySymbolID: HarmonySymbol.ID? = nil, @@ -61,6 +63,7 @@ struct NotationTrackView: View { cornerRadius: CGFloat = AppTheme.Radius.small ) { self.state = state + self.measureLayout = measureLayout self.partID = partID self.playbackDisplayState = playbackDisplayState self.selectedHarmonySymbolID = selectedHarmonySymbolID @@ -267,6 +270,11 @@ struct NotationTrackView: View { width: CGFloat, attributeDisplays: [NotationAttributeDisplay] ) -> [NotationMeasureCanvasGeometry] { + if let measureLayout, + measureLayout.matches(state.visibleMeasures) { + return measureLayout.geometries(totalWidth: width) + } + let safeMeasureCount = max(1, measureCount) return NotationMeasureLayout.canvasGeometries( measureCount: safeMeasureCount, @@ -1803,12 +1811,10 @@ struct NotationTrackView: View { } private func activeMeasureIndex(for anchorTime: TimeInterval) -> Int? { - state.visibleMeasures.indices.first { index in - let measure = state.visibleMeasures[index] - let isLastVisibleMeasure = index == state.visibleMeasures.indices.upperBound - 1 - return state.anchorTime >= measure.startTime - && (state.anchorTime < measure.endTime || (isLastVisibleMeasure && state.anchorTime <= measure.endTime)) - } + NotationMeasureTiming.visibleMeasureIndex( + containing: anchorTime, + in: state.visibleMeasures + ) } private func attributeDisplay( diff --git a/JammLab/Views/NotationWindowScoreLayout.swift b/JammLab/Views/NotationWindowScoreLayout.swift index 4e25d64..0a951c1 100644 --- a/JammLab/Views/NotationWindowScoreLayout.swift +++ b/JammLab/Views/NotationWindowScoreLayout.swift @@ -19,6 +19,7 @@ struct NotationWindowStaffRenderState: Equatable, Identifiable { struct NotationWindowScoreSystem: Equatable, Identifiable { var id: String var staves: [NotationWindowStaffRenderState] + var measureLayout: NotationSystemMeasureLayout var connectorHeight: CGFloat { let rowCount = CGFloat(staves.count) @@ -64,51 +65,60 @@ struct NotationWindowScoreLayout: Equatable { contentWidth: CGFloat ) -> NotationWindowScoreLayout { let readyPartStates = partStates.filter { $0.scoreState.isReady && !$0.scoreState.measures.isEmpty } - let partIDs = readyPartStates.map(\.part.id) let usesPartGutter = readyPartStates.count > 1 let reservedWidth = usesPartGutter ? AppTheme.NotationWindow.partLabelWidth + AppTheme.NotationWindow.partGutterSpacing : 0 let staffWidth = max(1, contentWidth - reservedWidth) - let measuresPerSystem = fittedMeasuresPerSystem( + let systemLayouts = fittedSystemLayouts( width: staffWidth, partStates: readyPartStates ) - let systemsByPart = readyPartStates.map { partState in - (partState, partState.scoreState.systems(measuresPerSystem: measuresPerSystem)) - } - let referenceSystems = systemsByPart.first?.1 ?? [] - let systems = referenceSystems.compactMap { referenceSystem -> NotationWindowScoreSystem? in - let staves = systemsByPart.enumerated().compactMap { partIndex, entry -> NotationWindowStaffRenderState? in - guard entry.1.indices.contains(referenceSystem.index) else { - assertionFailure("Notation part is missing score system \(referenceSystem.index)") - return nil - } + return make( + readyPartStates: readyPartStates, + systemLayouts: systemLayouts, + usesPartGutter: usesPartGutter + ) + } - let candidate = entry.1[referenceSystem.index] - guard hasMatchingMeasureRange(referenceSystem, candidate) else { - assertionFailure("Notation parts have mismatched score system ranges") + fileprivate static func make( + readyPartStates: [NotationWindowPartRenderState], + systemLayouts: [FittedSystemLayout], + usesPartGutter: Bool + ) -> NotationWindowScoreLayout { + let partIDs = readyPartStates.map(\.part.id) + let systems = systemLayouts.enumerated().compactMap { + systemIndex, + systemLayout -> NotationWindowScoreSystem? in + let staves = readyPartStates.enumerated().compactMap { + partIndex, + partState -> NotationWindowStaffRenderState? in + guard let system = partState.scoreState.system( + index: systemIndex, + measureRange: systemLayout.measureRange + ) else { + assertionFailure("Notation part is missing score system \(systemIndex)") return nil } - return NotationWindowStaffRenderState( - part: entry.0.part, - system: candidate, + part: partState.part, + system: system, showsRegionLabels: partIndex == 0 ) } - guard !staves.isEmpty else { return nil } + guard let referenceSystem = staves.first?.system, !staves.isEmpty else { return nil } return NotationWindowScoreSystem( id: systemID(for: referenceSystem), - staves: staves + staves: staves, + measureLayout: systemLayout.layout ) } return NotationWindowScoreLayout( systems: systems, anchorTime: readyPartStates.first?.scoreState.anchorTime ?? 0, - measuresPerSystem: measuresPerSystem, + measuresPerSystem: systemLayouts.map(\.measureRange.count).max() ?? 1, usesPartGutter: usesPartGutter, signature: NotationWindowLayoutSignature( partIDs: partIDs, @@ -117,42 +127,132 @@ struct NotationWindowScoreLayout: Equatable { ) } - private static func fittedMeasuresPerSystem( + fileprivate static func fittedSystemLayouts( width: CGFloat, partStates: [NotationWindowPartRenderState] - ) -> Int { - guard !partStates.isEmpty else { return 1 } - - for count in stride( - from: AppTheme.NotationWindow.maximumMeasuresPerSystem, - through: 1, - by: -1 - ) { - let requiredWidth = partStates - .flatMap { $0.scoreState.systems(measuresPerSystem: count) } - .map { NotationVisibleMeasureFitter.minimumRequiredWidth(for: $0.viewportState) } - .max() ?? 0 - if requiredWidth <= width + NotationVisibleMeasureFitter.widthTolerance { - return count + ) -> [FittedSystemLayout] { + guard let referenceMeasures = partStates.first?.scoreState.measures, + !referenceMeasures.isEmpty, + partStates.allSatisfy({ + $0.scoreState.measures.map(NotationMeasureIdentity.init) + == referenceMeasures.map(NotationMeasureIdentity.init) + }) + else { return [] } + + let candidatesByStart = referenceMeasures.indices.map { startIndex in + let maximumCount = min( + AppTheme.NotationWindow.maximumMeasuresPerSystem, + referenceMeasures.endIndex - startIndex + ) + return (1...maximumCount).compactMap { count -> FittedSystemLayout? in + let range = startIndex..<(startIndex + count) + let rows = partStates.map { Array($0.scoreState.measures[range]) } + guard let layout = NotationSystemMeasureLayout.make(measureRows: rows) else { + return nil + } + return FittedSystemLayout( + measureRange: range, + layout: layout + ) } } - - return 1 + let totalMeasureCount = referenceMeasures.count + if totalMeasureCount > 1, + let balanced = bestSystemLayout( + candidatesByStart: candidatesByStart, + totalMeasureCount: totalMeasureCount, + width: width, + allowsSingleMeasureSystems: false + ) { + return balanced + } + return bestSystemLayout( + candidatesByStart: candidatesByStart, + totalMeasureCount: totalMeasureCount, + width: width, + allowsSingleMeasureSystems: true + ) ?? [] } - private static func hasMatchingMeasureRange( - _ lhs: NotationSystemState, - _ rhs: NotationSystemState - ) -> Bool { - let lhsMeasures = lhs.viewportState.visibleMeasures - let rhsMeasures = rhs.viewportState.visibleMeasures - guard lhsMeasures.count == rhsMeasures.count else { return false } - - return zip(lhsMeasures, rhsMeasures).allSatisfy { lhsMeasure, rhsMeasure in - lhsMeasure.number == rhsMeasure.number - && abs(lhsMeasure.startTime - rhsMeasure.startTime) < NotationMeasureTiming.timelineTolerance - && abs(lhsMeasure.endTime - rhsMeasure.endTime) < NotationMeasureTiming.timelineTolerance + private static func bestSystemLayout( + candidatesByStart: [[FittedSystemLayout]], + totalMeasureCount: Int, + width: CGFloat, + allowsSingleMeasureSystems: Bool + ) -> [FittedSystemLayout]? { + guard totalMeasureCount > 0 else { return [] } + + var costByStart = Array(repeating: nil, count: totalMeasureCount + 1) + var decisionByStart = Array( + repeating: nil, + count: totalMeasureCount + ) + costByStart[totalMeasureCount] = LayoutCost( + systemCount: 0, + singleMeasureSystemCount: 0, + stretchPenalty: 0 + ) + let safeWidth = max(1, width) + + for startIndex in stride(from: totalMeasureCount - 1, through: 0, by: -1) { + guard candidatesByStart.indices.contains(startIndex) else { continue } + var bestCost: LayoutCost? + var bestCandidate: FittedSystemLayout? + + for candidate in candidatesByStart[startIndex] { + let measureCount = candidate.measureRange.count + guard measureCount > 0, + candidate.measureRange.upperBound <= totalMeasureCount, + let tailCost = costByStart[candidate.measureRange.upperBound] + else { + continue + } + if measureCount == 1 { + guard allowsSingleMeasureSystems else { continue } + } else { + guard candidate.layout.minimumRequiredWidth + <= width + NotationVisibleMeasureFitter.widthTolerance + else { + continue + } + } + + let normalizedSlack = max( + 0, + (safeWidth - candidate.layout.minimumRequiredWidth) / safeWidth + ) + let candidateCost = LayoutCost( + systemCount: tailCost.systemCount + 1, + singleMeasureSystemCount: tailCost.singleMeasureSystemCount + + (measureCount == 1 ? 1 : 0), + stretchPenalty: tailCost.stretchPenalty + + Double(normalizedSlack * normalizedSlack) + ) + if bestCost == nil + || candidateCost.isPreferred( + over: bestCost!, + firstMeasureCount: measureCount, + otherFirstMeasureCount: bestCandidate?.measureRange.count ?? 0, + considersSingleMeasureSystems: allowsSingleMeasureSystems + ) { + bestCost = candidateCost + bestCandidate = candidate + } + } + costByStart[startIndex] = bestCost + decisionByStart[startIndex] = bestCandidate } + + guard costByStart[0] != nil else { return nil } + var layouts: [FittedSystemLayout] = [] + var startIndex = 0 + while startIndex < totalMeasureCount, + decisionByStart.indices.contains(startIndex), + let decision = decisionByStart[startIndex] { + layouts.append(decision) + startIndex = decision.measureRange.upperBound + } + return startIndex == totalMeasureCount ? layouts : nil } private static func systemID(for system: NotationSystemState) -> String { @@ -164,4 +264,92 @@ struct NotationWindowScoreLayout: Equatable { return "score-system-\(first.number)-\(first.startTime)-\(last.number)-\(last.endTime)" } + + fileprivate struct FittedSystemLayout { + var measureRange: Range + var layout: NotationSystemMeasureLayout + } + + private struct LayoutCost { + var systemCount: Int + var singleMeasureSystemCount: Int + var stretchPenalty: Double + + func isPreferred( + over other: LayoutCost, + firstMeasureCount: Int, + otherFirstMeasureCount: Int, + considersSingleMeasureSystems: Bool + ) -> Bool { + if considersSingleMeasureSystems, + singleMeasureSystemCount != other.singleMeasureSystemCount { + return singleMeasureSystemCount < other.singleMeasureSystemCount + } + if systemCount != other.systemCount { + return systemCount < other.systemCount + } + if abs(stretchPenalty - other.stretchPenalty) > 0.000_001 { + return stretchPenalty < other.stretchPenalty + } + return firstMeasureCount > otherFirstMeasureCount + } + } +} + +final class NotationWindowScoreLayoutCache { + private var entry: Entry? + private(set) var cacheMissCount = 0 + + func layout( + partStates: [NotationWindowPartRenderState], + contentWidth: CGFloat + ) -> NotationWindowScoreLayout { + let readyPartStates = partStates.filter { + $0.scoreState.isReady && !$0.scoreState.measures.isEmpty + } + let usesPartGutter = readyPartStates.count > 1 + let reservedWidth = usesPartGutter + ? AppTheme.NotationWindow.partLabelWidth + AppTheme.NotationWindow.partGutterSpacing + : 0 + let staffWidth = max(1, contentWidth - reservedWidth) + let inputs = Inputs( + partIDs: readyPartStates.map(\.part.id), + measureRows: readyPartStates.map(\.scoreState.measures), + staffWidth: staffWidth + ) + let systemLayouts: [NotationWindowScoreLayout.FittedSystemLayout] + + if let entry, entry.inputs == inputs { + systemLayouts = entry.systemLayouts + } else { + cacheMissCount += 1 + systemLayouts = NotationWindowScoreLayout.fittedSystemLayouts( + width: staffWidth, + partStates: readyPartStates + ) + entry = Entry(inputs: inputs, systemLayouts: systemLayouts) + } + + return NotationWindowScoreLayout.make( + readyPartStates: readyPartStates, + systemLayouts: systemLayouts, + usesPartGutter: usesPartGutter + ) + } + + func invalidate() { + entry = nil + cacheMissCount = 0 + } + + private struct Inputs: Equatable { + var partIDs: [NotationPartID] + var measureRows: [[ScoreMeasure]] + var staffWidth: CGFloat + } + + private struct Entry { + var inputs: Inputs + var systemLayouts: [NotationWindowScoreLayout.FittedSystemLayout] + } } diff --git a/JammLab/Views/NotationWindowView.swift b/JammLab/Views/NotationWindowView.swift index 3d79029..d67ef60 100644 --- a/JammLab/Views/NotationWindowView.swift +++ b/JammLab/Views/NotationWindowView.swift @@ -10,6 +10,7 @@ struct NotationWindowView: View { @State private var lastAutoScrolledSystemID: NotationSystemState.ID? @State private var resumeAutoScrollTask: Task? @State private var notationProjectionCache = NotationProjectionCache() + @State private var scoreLayoutCache = NotationWindowScoreLayoutCache() var body: some View { GeometryReader { proxy in @@ -212,6 +213,7 @@ struct NotationWindowView: View { NotationTrackView( state: staff.system.viewportState, + measureLayout: system.measureLayout, partID: staff.part.id, playbackDisplayState: viewModel.playbackDisplayState, selectedHarmonySymbolID: staff.part.id.isMain ? viewModel.selectedHarmonySymbolID : nil, @@ -280,7 +282,7 @@ struct NotationWindowView: View { scoreState: scoreState ) } - return NotationWindowScoreLayout.make( + return scoreLayoutCache.layout( partStates: partStates, contentWidth: contentWidth ) diff --git a/JammLab/Views/WaveformTimelineView.swift b/JammLab/Views/WaveformTimelineView.swift index 018a156..488f9d1 100644 --- a/JammLab/Views/WaveformTimelineView.swift +++ b/JammLab/Views/WaveformTimelineView.swift @@ -34,6 +34,7 @@ struct TimelineViewState: Equatable { var beatGrid: BeatGridConfiguration var notationViewport: NotationViewportState var stemNotationViewports: [StemType: NotationViewportState] + var notationMeasureLayout: NotationSystemMeasureLayout? var notationDurationDenominator: Int var notationDurationIsDotted: Bool var pendingNotationAccidental: NotationAccidental? @@ -227,6 +228,7 @@ struct WaveformTimelineView: View { notationDurationIsDotted: state.notationDurationIsDotted, pendingNotationAccidental: state.pendingNotationAccidental, notationViewports: state.stemNotationViewports, + measureLayout: state.notationMeasureLayout, notationCollapsed: state.stemNotationTrackCollapsed, noteDisplayModes: state.stemNoteDisplayModes, selectedLogicalItemIDs: state.selectedLogicalNotationItemIDs, @@ -435,6 +437,7 @@ struct WaveformTimelineView: View { if !state.isNotationTrackCollapsed { NotationTrackView( state: state.notationViewport, + measureLayout: state.notationMeasureLayout, partID: .main, playbackDisplayState: state.playbackDisplayState, selectedHarmonySymbolID: state.selectedHarmonySymbolID, @@ -657,6 +660,7 @@ private struct StemTracksSection: View { let notationDurationIsDotted: Bool let pendingNotationAccidental: NotationAccidental? let notationViewports: [StemType: NotationViewportState] + let measureLayout: NotationSystemMeasureLayout? let notationCollapsed: [StemType: Bool] let noteDisplayModes: [StemType: StemNoteDisplayMode] let selectedLogicalItemIDs: Set @@ -905,6 +909,7 @@ private struct StemTracksSection: View { } else { NotationTrackView( state: viewportState, + measureLayout: measureLayout, partID: .stem(type), playbackDisplayState: nil, selectedHarmonySymbolID: nil, diff --git a/JammLabTests/NotationAnchorLayoutTests.swift b/JammLabTests/NotationAnchorLayoutTests.swift index 2465acc..5ec5bdc 100644 --- a/JammLabTests/NotationAnchorLayoutTests.swift +++ b/JammLabTests/NotationAnchorLayoutTests.swift @@ -54,8 +54,9 @@ final class NotationAnchorLayoutTests: XCTestCase { timeSignature: .fourFour ) - XCTAssertEqual(endX, geometry.contentEndX, accuracy: 0.0001) - XCTAssertEqual(outOfRangeX, geometry.contentEndX, accuracy: 0.0001) + XCTAssertEqual(endX, geometry.rhythmicEndX, accuracy: 0.0001) + XCTAssertEqual(outOfRangeX, geometry.rhythmicEndX, accuracy: 0.0001) + XCTAssertLessThan(endX, geometry.staffEndX) } func testNotationMeasureLayoutMapsAnchorXBackToProgress() { @@ -120,4 +121,30 @@ final class NotationAnchorLayoutTests: XCTestCase { ) XCTAssertGreaterThan(harmonyStartX, geometry.cellStartX) } + + func testLateSixteenthAnchorStaysClearOfTrailingBarlineAndRoundTrips() { + let geometry = NotationMeasureCanvasGeometry( + measureIndex: 0, + cellStartX: 0, + cellEndX: 180, + contentStartX: 0, + contentEndX: 180, + staffStartX: 10, + staffEndX: 170, + trailingAnchorInset: 20 + ) + let progress = 15.0 / 16.0 + let x = NotationMeasureLayout.notationAnchorX( + geometry: geometry, + offsetInQuarterNotes: 3.75, + timeSignature: .fourFour + ) + + XCTAssertGreaterThanOrEqual(geometry.staffEndX - x, 20) + XCTAssertEqual( + NotationMeasureLayout.notationAnchorProgress(atX: x, geometry: geometry), + progress, + accuracy: 0.0001 + ) + } } diff --git a/JammLabTests/NotationAttributeGeometryTests.swift b/JammLabTests/NotationAttributeGeometryTests.swift index 8b4adb1..8d62cc6 100644 --- a/JammLabTests/NotationAttributeGeometryTests.swift +++ b/JammLabTests/NotationAttributeGeometryTests.swift @@ -62,7 +62,7 @@ final class NotationAttributeGeometryTests: XCTestCase { XCTAssertEqual(geometry.contentEndX - geometry.contentStartX, cellWidth, accuracy: 0.0001) XCTAssertEqual( NotationMeasureLayout.playheadX(geometry: geometry, progress: 1), - geometry.contentEndX, + geometry.rhythmicEndX, accuracy: 0.0001 ) XCTAssertEqual(geometry.staffStartX, AppTheme.Timeline.notationStaffHorizontalInset, accuracy: 0.0001) diff --git a/JammLabTests/NotationFallbackGeometryTests.swift b/JammLabTests/NotationFallbackGeometryTests.swift index 01ca68d..e275358 100644 --- a/JammLabTests/NotationFallbackGeometryTests.swift +++ b/JammLabTests/NotationFallbackGeometryTests.swift @@ -21,7 +21,7 @@ final class NotationFallbackGeometryTests: XCTestCase { XCTAssertEqual(barlines.last?.x ?? -1, geometries[0].staffEndX, accuracy: 0.0001) XCTAssertEqual( NotationMeasureLayout.playheadX(geometry: geometries[0], progress: 0), - geometries[0].contentStartX, + geometries[0].rhythmicStartX, accuracy: 0.0001 ) XCTAssertEqual( @@ -30,7 +30,7 @@ final class NotationFallbackGeometryTests: XCTestCase { progress: 0, indicatorWidth: AppTheme.Stroke.thick ), - geometries[0].staffStartX, + geometries[0].rhythmicStartX, accuracy: 0.0001 ) } diff --git a/JammLabTests/NotationHarmonyLabelLayoutTests.swift b/JammLabTests/NotationHarmonyLabelLayoutTests.swift index 8859aa8..9f48191 100644 --- a/JammLabTests/NotationHarmonyLabelLayoutTests.swift +++ b/JammLabTests/NotationHarmonyLabelLayoutTests.swift @@ -31,7 +31,7 @@ final class NotationHarmonyLabelLayoutTests: XCTestCase { ) } - func testNotationMeasureLayoutClampsFirstHarmonyLabelToVisibleStaffStart() { + func testNotationMeasureLayoutKeepsFirstHarmonyLabelInsideVisibleStaff() { let geometry = NotationMeasureCanvasGeometry( measureIndex: 0, cellStartX: 0, @@ -48,7 +48,13 @@ final class NotationHarmonyLabelLayoutTests: XCTestCase { timeSignature: .fourFour ) - XCTAssertEqual(labelX, geometry.staffStartX, accuracy: 0.0001) + XCTAssertGreaterThanOrEqual(labelX, geometry.staffStartX) + XCTAssertEqual( + labelX, + geometry.rhythmicStartX + - AppTheme.Timeline.notationHarmonyAnchorLeadingOffset, + accuracy: 0.0001 + ) } func testNotationMeasureLayoutKeepsAttributedFirstHarmonyLabelAfterAttributes() { diff --git a/JammLabTests/NotationMeasureGeometryTests.swift b/JammLabTests/NotationMeasureGeometryTests.swift index ce8a5e5..228f038 100644 --- a/JammLabTests/NotationMeasureGeometryTests.swift +++ b/JammLabTests/NotationMeasureGeometryTests.swift @@ -115,7 +115,7 @@ final class NotationMeasureGeometryTests: XCTestCase { XCTAssertEqual(geometries[3].contentEndX, totalWidth, accuracy: 0.0001) XCTAssertEqual( NotationMeasureLayout.playheadX(geometry: geometries[3], progress: 1), - totalWidth, + geometries[3].rhythmicEndX, accuracy: 0.0001 ) XCTAssertLessThanOrEqual( diff --git a/JammLabTests/NotationMeasureTimingTests.swift b/JammLabTests/NotationMeasureTimingTests.swift index 6607ee8..c8d809c 100644 --- a/JammLabTests/NotationMeasureTimingTests.swift +++ b/JammLabTests/NotationMeasureTimingTests.swift @@ -34,4 +34,74 @@ final class NotationMeasureTimingTests: XCTestCase { accuracy: 0.0001 ) } + + func testNotationMeasureTimingRecognizesOnlySingleFullMeasureWholeRest() { + let rest = NotationMeasureItem( + id: "whole-rest", + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 4, + displayDuration: NotationDuration(denominator: 1) + ) + var measure = ScoreMeasure( + number: 1, + startTime: 0, + endTime: 2, + attributes: .defaultTreble, + notationItems: [rest] + ) + + XCTAssertTrue( + NotationMeasureTiming.isSingleFullMeasureWholeRest( + measure, + item: rest + ) + ) + measure.notationItems.append( + NotationMeasureItem( + id: "extra-rest", + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 2, + durationInQuarterNotes: 2, + displayDuration: NotationDuration(denominator: 2) + ) + ) + XCTAssertFalse( + NotationMeasureTiming.isSingleFullMeasureWholeRest(measure) + ) + } + + func testVisibleMeasureIndexUsesSuppliedTimeAndHalfOpenInternalBoundaries() { + let measures = (0..<3).map { index in + ScoreMeasure( + number: index + 1, + startTime: Double(index) * 2, + endTime: Double(index + 1) * 2, + attributes: .defaultTreble + ) + } + + XCTAssertEqual( + NotationMeasureTiming.visibleMeasureIndex( + containing: 2, + in: measures + ), + 1 + ) + XCTAssertEqual( + NotationMeasureTiming.visibleMeasureIndex( + containing: 6, + in: measures + ), + 2 + ) + XCTAssertNil( + NotationMeasureTiming.visibleMeasureIndex( + containing: -0.1, + in: measures + ) + ) + } } diff --git a/JammLabTests/NotationPrimitivesTests.swift b/JammLabTests/NotationPrimitivesTests.swift index 1178ca8..91f8665 100644 --- a/JammLabTests/NotationPrimitivesTests.swift +++ b/JammLabTests/NotationPrimitivesTests.swift @@ -776,7 +776,7 @@ final class NotationPrimitivesTests: XCTestCase { selectedDuration: NotationDuration(denominator: 4) )) - XCTAssertEqual(placement.offsetInQuarterNotes, 3.25, accuracy: 0.0001) + XCTAssertEqual(placement.offsetInQuarterNotes, 3.75, accuracy: 0.0001) XCTAssertEqual(placement.pitch.step, .e) XCTAssertEqual(placement.pitch.octave, 4) } diff --git a/JammLabTests/NotationSlashBeatLayoutTests.swift b/JammLabTests/NotationSlashBeatLayoutTests.swift index 2e27a1c..f8c9675 100644 --- a/JammLabTests/NotationSlashBeatLayoutTests.swift +++ b/JammLabTests/NotationSlashBeatLayoutTests.swift @@ -17,12 +17,25 @@ final class NotationSlashBeatLayoutTests: XCTestCase { geometry: geometry, timeSignature: .fourFour ) + let beatSpacing = (geometry.rhythmicEndX - geometry.rhythmicStartX) / 4 XCTAssertEqual(centers.count, 4) - XCTAssertEqual(centers[0], 10, accuracy: 0.0001) - XCTAssertEqual(centers[1], 50, accuracy: 0.0001) - XCTAssertEqual(centers[2], 90, accuracy: 0.0001) - XCTAssertEqual(centers[3], 130, accuracy: 0.0001) + XCTAssertEqual(centers[0], geometry.rhythmicStartX, accuracy: 0.0001) + XCTAssertEqual( + centers[1], + geometry.rhythmicStartX + beatSpacing, + accuracy: 0.0001 + ) + XCTAssertEqual( + centers[2], + geometry.rhythmicStartX + beatSpacing * 2, + accuracy: 0.0001 + ) + XCTAssertEqual( + centers[3], + geometry.rhythmicStartX + beatSpacing * 3, + accuracy: 0.0001 + ) } func testNotationMeasureLayoutPositionsSlashBeatCentersAfterAttributes() { @@ -44,22 +57,22 @@ final class NotationSlashBeatLayoutTests: XCTestCase { geometry: geometry, timeSignature: attributes.timeSignature ) - let beatSpacing = (geometry.contentEndX - geometry.contentStartX) / 3 + let beatSpacing = (geometry.rhythmicEndX - geometry.rhythmicStartX) / 3 XCTAssertEqual(centers.count, 3) XCTAssertEqual( centers[0], - geometry.contentStartX + AppTheme.Timeline.notationItemAnchorInset, + geometry.rhythmicStartX, accuracy: 0.0001 ) XCTAssertEqual( centers[1], - geometry.contentStartX + AppTheme.Timeline.notationItemAnchorInset + beatSpacing, + geometry.rhythmicStartX + beatSpacing, accuracy: 0.0001 ) XCTAssertEqual( centers[2], - geometry.contentStartX + AppTheme.Timeline.notationItemAnchorInset + beatSpacing * 2, + geometry.rhythmicStartX + beatSpacing * 2, accuracy: 0.0001 ) } @@ -81,8 +94,13 @@ final class NotationSlashBeatLayoutTests: XCTestCase { ) XCTAssertEqual(centers.count, 7) - XCTAssertEqual(centers[0], 10, accuracy: 0.0001) - XCTAssertEqual(centers[6], 190, accuracy: 0.0001) + XCTAssertEqual(centers[0], geometry.rhythmicStartX, accuracy: 0.0001) + XCTAssertEqual( + centers[6], + geometry.rhythmicStartX + + (geometry.rhythmicEndX - geometry.rhythmicStartX) * 6 / 7, + accuracy: 0.0001 + ) } func testNotationMeasureLayoutPositionsSlashBeatCentersForNonQuarterBeatUnit() { @@ -105,9 +123,19 @@ final class NotationSlashBeatLayoutTests: XCTestCase { ) XCTAssertEqual(centers.count, 6) - XCTAssertEqual(centers[0], 10, accuracy: 0.0001) - XCTAssertEqual(centers[1], 40, accuracy: 0.0001) - XCTAssertEqual(centers[5], 160, accuracy: 0.0001) + XCTAssertEqual(centers[0], geometry.rhythmicStartX, accuracy: 0.0001) + XCTAssertEqual( + centers[1], + geometry.rhythmicStartX + + (geometry.rhythmicEndX - geometry.rhythmicStartX) / 6, + accuracy: 0.0001 + ) + XCTAssertEqual( + centers[5], + geometry.rhythmicStartX + + (geometry.rhythmicEndX - geometry.rhythmicStartX) * 5 / 6, + accuracy: 0.0001 + ) } func testNotationMeasureLayoutOmitsSlashBeatCentersWhenContentIsInvalidOrTooNarrow() { diff --git a/JammLabTests/NotationViewportTempoMapTests.swift b/JammLabTests/NotationViewportTempoMapTests.swift index 5d35c4d..2e9453c 100644 --- a/JammLabTests/NotationViewportTempoMapTests.swift +++ b/JammLabTests/NotationViewportTempoMapTests.swift @@ -121,6 +121,59 @@ final class NotationViewportTempoMapTests: XCTestCase { XCTAssertEqual(state.visibleMeasures.map(\.number), [1, 2, 1, 2, 3, 4, 5, 6]) } + func testFinalPageNavigationRoundTripsAcrossMeterChangeAndNonAlignedDuration() throws { + let tempoMap = fourFourTempoMap( + duration: 14.2, + markers: [timeSignatureMarker(time: 4, beatsPerBar: 3)] + ) + let factory = NotationViewportFactory() + let finalPage = factory.viewportState( + tempoMap: tempoMap, + duration: tempoMap.duration, + currentTime: 14, + playbackMarkerTime: 0, + isPlaying: true, + keyName: "C major", + visibleMeasureCount: 4, + harmonySymbols: [], + notes: [] + ) + let previousPage = factory.viewportState( + tempoMap: tempoMap, + duration: tempoMap.duration, + currentTime: 0, + playbackMarkerTime: 0, + isPlaying: false, + keyName: "C major", + visibleMeasureCount: 4, + harmonySymbols: [], + notes: [], + pageStartMeasureTime: try XCTUnwrap(finalPage.previousPageStartTime) + ) + + XCTAssertEqual(finalPage.visibleMeasures.count, 4) + XCTAssertGreaterThanOrEqual( + try XCTUnwrap(finalPage.visibleMeasures.last).endTime, + tempoMap.duration + ) + XCTAssertEqual( + try XCTUnwrap(previousPage.nextPageStartTime), + try XCTUnwrap(finalPage.visibleMeasures.first).startTime, + accuracy: 0.0001 + ) + XCTAssertEqual( + try XCTUnwrap(finalPage.previousPageStartTime), + try XCTUnwrap(previousPage.visibleMeasures.first).startTime, + accuracy: 0.0001 + ) + XCTAssertEqual( + Set(previousPage.visibleMeasures.map(NotationMeasureIdentity.init)) + .intersection(finalPage.visibleMeasures.map(NotationMeasureIdentity.init)) + .count, + 3 + ) + } + private func fourFourTempoMap( duration: TimeInterval, firstBeatTime: TimeInterval = 0, diff --git a/JammLabTests/NotationViewportTests.swift b/JammLabTests/NotationViewportTests.swift index 94bc065..4dc2d35 100644 --- a/JammLabTests/NotationViewportTests.swift +++ b/JammLabTests/NotationViewportTests.swift @@ -80,6 +80,53 @@ final class NotationViewportTests: XCTestCase { XCTAssertEqual(state.nextPageStartTime, 12) } + func testFinalViewportPageBackfillsPreviousMeasures() { + let tempoMap = fourFourTempoMap(duration: 22) + let state = notationViewportState( + tempoMap: tempoMap, + currentTime: 21, + visibleMeasureCount: 5 + ) + + XCTAssertEqual(state.visibleMeasures.map(\.number), [7, 8, 9, 10, 11]) + XCTAssertEqual(state.previousPageStartTime, 10) + XCTAssertNil(state.nextPageStartTime) + } + + func testExplicitFinalPageStartBackfillsAndRoundTripsNavigation() throws { + let tempoMap = fourFourTempoMap(duration: 22) + let factory = NotationViewportFactory() + let previousPage = factory.viewportState( + tempoMap: tempoMap, + duration: tempoMap.duration, + currentTime: 0, + playbackMarkerTime: 0, + isPlaying: false, + keyName: "C major", + visibleMeasureCount: 5, + pageStartMeasureTime: 10 + ) + let finalPage = factory.viewportState( + tempoMap: tempoMap, + duration: tempoMap.duration, + currentTime: 0, + playbackMarkerTime: 0, + isPlaying: false, + keyName: "C major", + visibleMeasureCount: 5, + pageStartMeasureTime: 20 + ) + + XCTAssertEqual(previousPage.visibleMeasures.map(\.number), [6, 7, 8, 9, 10]) + XCTAssertEqual(previousPage.nextPageStartTime, 12) + XCTAssertEqual(finalPage.visibleMeasures.map(\.number), [7, 8, 9, 10, 11]) + XCTAssertEqual(finalPage.previousPageStartTime, 10) + XCTAssertEqual( + try XCTUnwrap(previousPage.nextPageStartTime), + try XCTUnwrap(finalPage.visibleMeasures.first).startTime + ) + } + func testNotationViewportStartsAtMeasureOneWhenTrackStartsAtZero() throws { let state = notationViewportState( tempoMap: fourFourTempoMap(duration: 120), diff --git a/JammLabTests/NotationVisibleMeasureFitterTests.swift b/JammLabTests/NotationVisibleMeasureFitterTests.swift index 997a7ec..f257e56 100644 --- a/JammLabTests/NotationVisibleMeasureFitterTests.swift +++ b/JammLabTests/NotationVisibleMeasureFitterTests.swift @@ -123,6 +123,368 @@ final class NotationVisibleMeasureFitterTests: XCTestCase { } } +final class NotationSystemMeasureLayoutTests: XCTestCase { + func testDenseMeasureReceivesMoreWidthThanSparseNeighbor() throws { + let dense = scoreMeasure( + number: 1, + startTime: 0, + items: stride(from: 0.0, through: 3.75, by: 0.25).enumerated().map { + index, + offset in + notationItem(id: "dense-\(index)", measureNumber: 1, offset: offset) + } + ) + let sparse = scoreMeasure( + number: 2, + startTime: 2, + items: [notationItem(id: "sparse", measureNumber: 2, offset: 0)] + ) + let layout = try XCTUnwrap( + NotationSystemMeasureLayout.make(measureRows: [[dense, sparse]]) + ) + let geometries = layout.geometries(totalWidth: layout.minimumRequiredWidth) + + XCTAssertGreaterThan(layout.slots[0].minimumBodyWidth, layout.slots[1].minimumBodyWidth) + XCTAssertGreaterThan( + geometries[0].contentEndX - geometries[0].contentStartX, + geometries[1].contentEndX - geometries[1].contentStartX + ) + } + + func testSharedLayoutUsesDensestPartAndKeepsAlignedMeasureBoundaries() throws { + let denseMain = scoreMeasure( + number: 1, + startTime: 0, + items: [ + notationItem(id: "main-a", measureNumber: 1, offset: 3.5), + notationItem(id: "main-b", measureNumber: 1, offset: 3.75) + ] + ) + let sparseBass = scoreMeasure( + number: 1, + startTime: 0, + items: [notationItem(id: "bass", measureNumber: 1, offset: 0)] + ) + let layout = try XCTUnwrap( + NotationSystemMeasureLayout.make(measureRows: [[denseMain], [sparseBass]]) + ) + let geometry = try XCTUnwrap( + layout.geometries(totalWidth: layout.minimumRequiredWidth).first + ) + let lateX = NotationMeasureLayout.notationItemX( + geometry: geometry, + measure: denseMain, + item: try XCTUnwrap(denseMain.notationItems.last) + ) + + XCTAssertGreaterThan( + layout.slots[0].trailingAnchorInset, + AppTheme.Timeline.notationItemAnchorInset + ) + XCTAssertLessThanOrEqual( + lateX + + AppTheme.Timeline.notationRhythmicGlyphRadius + + AppTheme.Timeline.notationRhythmicColumnGap, + geometry.staffEndX + 0.0001 + ) + XCTAssertTrue(layout.matches([sparseBass])) + } + + func testLateSixteenthPairUsesCompactSpacingWithoutStretchingWholeMeasure() throws { + let measure = scoreMeasure( + number: 1, + startTime: 0, + items: [ + notationItem(id: "late-a", measureNumber: 1, offset: 3.5), + notationItem(id: "late-b", measureNumber: 1, offset: 3.75) + ] + ) + let layout = try XCTUnwrap( + NotationSystemMeasureLayout.make(measureRows: [[measure]]) + ) + let geometry = try XCTUnwrap( + layout.geometries(totalWidth: layout.minimumRequiredWidth).first + ) + let itemXs = measure.notationItems.map { + NotationMeasureLayout.notationItemX( + geometry: geometry, + measure: measure, + item: $0 + ) + } + let minimumColumnDistance = + AppTheme.Timeline.notationRhythmicGlyphRadius * 2 + + AppTheme.Timeline.notationRhythmicColumnGap + let firstItemX = try XCTUnwrap(itemXs.first) + let lastItemX = try XCTUnwrap(itemXs.last) + + XCTAssertLessThan( + layout.slots[0].minimumBodyWidth, + AppTheme.Timeline.notationMeasureMinWidth * 1.5 + ) + XCTAssertGreaterThanOrEqual( + lastItemX - firstItemX, + minimumColumnDistance - 0.0001 + ) + } + + func testRhythmicMapRoundTripsAnchorsAndKeepsPlayheadAligned() throws { + let measure = scoreMeasure( + number: 1, + startTime: 0, + items: [ + notationItem(id: "early", measureNumber: 1, offset: 0.25), + notationItem(id: "late-a", measureNumber: 1, offset: 3.5), + notationItem(id: "late-b", measureNumber: 1, offset: 3.75) + ] + ) + let layout = try XCTUnwrap( + NotationSystemMeasureLayout.make(measureRows: [[measure]]) + ) + let geometry = try XCTUnwrap( + layout.geometries(totalWidth: layout.minimumRequiredWidth + 240).first + ) + + for progress in [0.0, 0.0625, 0.5, 0.875, 0.9375, 1.0] { + let x = NotationMeasureLayout.notationAnchorX( + geometry: geometry, + offsetInQuarterNotes: progress * 4, + timeSignature: .fourFour + ) + + XCTAssertEqual( + NotationMeasureLayout.notationAnchorProgress( + atX: x, + geometry: geometry + ), + progress, + accuracy: 0.000_001 + ) + XCTAssertEqual( + NotationMeasureLayout.playheadX( + geometry: geometry, + progress: CGFloat(progress) + ), + x, + accuracy: 0.0001 + ) + } + } + + func testCompressedRhythmicMapStillRoundTripsHitTesting() throws { + let measure = scoreMeasure( + number: 1, + startTime: 0, + items: stride(from: 0.0, through: 3.75, by: 0.25).enumerated().map { + notationItem( + id: "compressed-\($0.offset)", + measureNumber: 1, + offset: $0.element + ) + } + ) + let layout = try XCTUnwrap( + NotationSystemMeasureLayout.make(measureRows: [[measure]]) + ) + let geometry = try XCTUnwrap( + layout.geometries(totalWidth: layout.minimumRequiredWidth * 0.75).first + ) + let rhythmicWidth = geometry.rhythmicEndX - geometry.rhythmicStartX + + XCTAssertLessThan( + rhythmicWidth, + layout.slots[0].rhythmicSpacingMap.compactSpan + ) + for progress in [0.0, 0.125, 0.5, 0.875, 1.0] { + let x = NotationMeasureLayout.playheadX( + geometry: geometry, + progress: CGFloat(progress) + ) + XCTAssertEqual( + NotationMeasureLayout.notationAnchorProgress( + atX: x, + geometry: geometry + ), + progress, + accuracy: 0.000_001 + ) + } + } + + func testComplexChordFootprintsStayInsideStaffAtMeasureEdges() throws { + let earlyItems = complexChordItems(offset: 0, idPrefix: "early") + let lateItems = complexChordItems(offset: 3.75, idPrefix: "late") + let measure = scoreMeasure( + number: 1, + startTime: 0, + items: earlyItems + lateItems + ) + let layout = try XCTUnwrap( + NotationSystemMeasureLayout.make(measureRows: [[measure]]) + ) + let geometry = try XCTUnwrap( + layout.geometries(totalWidth: layout.minimumRequiredWidth).first + ) + let chordLayout = NotationHorizontalLayoutResolver.chordLayout(in: measure) + let accidentalColumns = + NotationHorizontalLayoutResolver.accidentalColumnByItemID(in: measure) + let gap = AppTheme.Timeline.notationRhythmicColumnGap + + for item in measure.notationItems { + let anchorX = NotationMeasureLayout.notationItemX( + geometry: geometry, + measure: measure, + item: item + ) + let xOffset = chordLayout.xOffsetByItemID[item.id] ?? 0 + var leftEdge = anchorX + xOffset + - AppTheme.Timeline.notationRhythmicGlyphRadius + var rightEdge = anchorX + xOffset + + AppTheme.Timeline.notationRhythmicGlyphRadius + + if let accidentalColumn = accidentalColumns[item.id] { + let accidentalCenter = anchorX + xOffset + - AppTheme.Timeline.notationInlineAccidentalNoteOffset + - CGFloat(accidentalColumn) + * AppTheme.Timeline.notationInlineAccidentalColumnSpacing + leftEdge = min( + leftEdge, + accidentalCenter - AppTheme.Timeline.notationAccidentalWidth / 2 + ) + } + if item.displayDuration.isDotted { + rightEdge = max( + rightEdge, + anchorX + + xOffset + + AppTheme.Timeline.notationStaffLineSpacing + + AppTheme.Timeline.notationRhythmicDotRadius + ) + } + + XCTAssertGreaterThanOrEqual( + leftEdge, + geometry.staffStartX + gap - 0.0001 + ) + XCTAssertLessThanOrEqual( + rightEdge, + geometry.staffEndX - gap + 0.0001 + ) + } + } + + func testIntermediateAnchorInAnotherPartDoesNotCreateCrossStaffCollisionConstraint() throws { + let main = scoreMeasure( + number: 1, + startTime: 0, + items: [ + notationItem(id: "main-a", measureNumber: 1, offset: 0), + notationItem(id: "main-b", measureNumber: 1, offset: 0.25) + ] + ) + let bass = scoreMeasure( + number: 1, + startTime: 0, + items: [ + notationItem(id: "bass-middle", measureNumber: 1, offset: 0.125) + ] + ) + let mainOnlyLayout = try XCTUnwrap( + NotationSystemMeasureLayout.make(measureRows: [[main]]) + ) + let sharedLayout = try XCTUnwrap( + NotationSystemMeasureLayout.make(measureRows: [[main], [bass]]) + ) + + XCTAssertEqual( + sharedLayout.slots[0].rhythmicSpacingMap.compactSpan, + mainOnlyLayout.slots[0].rhythmicSpacingMap.compactSpan, + accuracy: 0.0001 + ) + XCTAssertEqual( + sharedLayout.slots[0].minimumBodyWidth, + mainOnlyLayout.slots[0].minimumBodyWidth, + accuracy: 0.0001 + ) + } + + private func scoreMeasure( + number: Int, + startTime: TimeInterval, + items: [NotationMeasureItem] + ) -> ScoreMeasure { + ScoreMeasure( + number: number, + startTime: startTime, + endTime: startTime + 2, + attributes: MeasureAttributes( + keySignature: .cMajor, + timeSignature: .fourFour, + clef: .treble + ), + notationItems: items + ) + } + + private func notationItem( + id: String, + measureNumber: Int, + offset: Double + ) -> NotationMeasureItem { + NotationMeasureItem( + id: id, + kind: .note, + pitch: NotationPitch(step: .c, octave: 4), + measureNumber: measureNumber, + measureStartTime: TimeInterval(measureNumber - 1) * 2, + offsetInQuarterNotes: offset, + durationInQuarterNotes: 0.25, + displayDuration: NotationDuration(denominator: 16) + ) + } + + private func complexChordItems( + offset: Double, + idPrefix: String + ) -> [NotationMeasureItem] { + [ + NotationMeasureItem( + id: "\(idPrefix)-short", + kind: .note, + pitch: NotationPitch(step: .c, octave: 4), + explicitAccidental: .sharp, + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: offset, + durationInQuarterNotes: 0.375, + displayDuration: NotationDuration(denominator: 16, isDotted: true) + ), + NotationMeasureItem( + id: "\(idPrefix)-second", + kind: .note, + pitch: NotationPitch(step: .d, octave: 4), + explicitAccidental: .flat, + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: offset, + durationInQuarterNotes: 0.25, + displayDuration: NotationDuration(denominator: 16) + ), + NotationMeasureItem( + id: "\(idPrefix)-duplicate", + kind: .note, + pitch: NotationPitch(step: .c, octave: 4), + explicitAccidental: .natural, + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: offset, + durationInQuarterNotes: 0.5, + displayDuration: NotationDuration(denominator: 8) + ) + ] + } +} + final class NotationWindowScoreLayoutTests: XCTestCase { func testMultiPartLayoutAlignsMeasureRangesAndHostsRegionsOnTopStaffOnly() { let partStates = [ @@ -200,9 +562,211 @@ final class NotationWindowScoreLayoutTests: XCTestCase { XCTAssertNotEqual(narrowLayout.activeSystemID, wideLayout.activeSystemID) } + func testDenseMeasureOnlyReducesItsOwnSystemCapacity() { + var main = partState(part: .main, currentTime: 1) + main.scoreState.measures[0].notationItems = stride( + from: 0.0, + through: 3.75, + by: 0.25 + ).enumerated().map { index, offset in + NotationMeasureItem( + id: "dense-window-\(index)", + kind: .rest, + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: offset, + durationInQuarterNotes: 0.25, + displayDuration: NotationDuration(denominator: 16) + ) + } + + let layout = NotationWindowScoreLayout.make( + partStates: [main], + contentWidth: 720 + ) + let measureCounts = layout.systems.compactMap { + $0.staves.first?.system.viewportState.visibleMeasures.count + } + + XCTAssertEqual(measureCounts.first, 1) + XCTAssertTrue( + measureCounts.dropFirst().contains(AppTheme.NotationWindow.maximumMeasuresPerSystem) + ) + } + + func testDenseSecondaryPartControlsSharedSystemBreaks() { + let main = partState(part: .main, currentTime: 1) + var bass = partState(part: .stem(.bass), currentTime: 1) + bass.scoreState.measures[0].notationItems = stride( + from: 0.0, + through: 3.75, + by: 0.25 + ).enumerated().map { index, offset in + NotationMeasureItem( + id: "dense-bass-\(index)", + partID: .stem(.bass), + kind: .note, + pitch: NotationPitch(step: .c, octave: 3), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: offset, + durationInQuarterNotes: 0.25, + displayDuration: NotationDuration(denominator: 16) + ) + } + + let layout = NotationWindowScoreLayout.make( + partStates: [main, bass], + contentWidth: 720 + ) + + XCTAssertEqual(systemMeasureCounts(in: layout).first, 1) + for system in layout.systems { + let referenceNumbers = system.staves[0].system.viewportState + .visibleMeasures.map(\.number) + XCTAssertTrue(system.staves.dropFirst().allSatisfy { + $0.system.viewportState.visibleMeasures.map(\.number) + == referenceNumbers + }) + XCTAssertTrue(system.staves.allSatisfy { + system.measureLayout.matches( + $0.system.viewportState.visibleMeasures + ) + }) + } + } + + func testFiveMeasuresBalanceWithoutSingleMeasureSystem() { + let layout = NotationWindowScoreLayout.make( + partStates: [partState(part: .main, currentTime: 1, duration: 10)], + contentWidth: 720 + ) + + XCTAssertEqual(systemMeasureCounts(in: layout), [3, 2]) + assertOrderedMeasureCoverage(in: layout, expectedCount: 5) + } + + func testSixMeasuresBalanceAcrossEqualSystems() { + let layout = NotationWindowScoreLayout.make( + partStates: [partState(part: .main, currentTime: 1, duration: 12)], + contentWidth: 720 + ) + + XCTAssertEqual(systemMeasureCounts(in: layout), [3, 3]) + assertOrderedMeasureCoverage(in: layout, expectedCount: 6) + } + + func testElevenMeasuresDoNotLeaveLastMeasureAlone() { + let layout = NotationWindowScoreLayout.make( + partStates: [partState(part: .main, currentTime: 21, duration: 22)], + contentWidth: 720 + ) + let counts = systemMeasureCounts(in: layout) + + XCTAssertEqual(counts.reduce(0, +), 11) + XCTAssertFalse(counts.contains(1)) + XCTAssertEqual(counts, [4, 4, 3]) + assertOrderedMeasureCoverage(in: layout, expectedCount: 11) + } + + func testScoreLayoutCacheReusesPlanWhenOnlyPlaybackAnchorChanges() { + let cache = NotationWindowScoreLayoutCache() + let initialStates = [partState(part: .main, currentTime: 1)] + let initialLayout = cache.layout( + partStates: initialStates, + contentWidth: 720 + ) + var movedStates = initialStates + movedStates[0].scoreState.anchorTime = 9 + movedStates[0].scoreState.activeMeasureNumber = 5 + let movedLayout = cache.layout( + partStates: movedStates, + contentWidth: 720 + ) + + XCTAssertEqual(cache.cacheMissCount, 1) + XCTAssertEqual(initialLayout.signature, movedLayout.signature) + XCTAssertNotEqual(initialLayout.activeSystemID, movedLayout.activeSystemID) + XCTAssertEqual(movedLayout.anchorTime, 9, accuracy: 0.0001) + } + + func testScoreLayoutCacheInvalidatesForWidthPartsAndNotationContent() { + let cache = NotationWindowScoreLayoutCache() + let main = partState(part: .main, currentTime: 1) + let initialLayout = cache.layout( + partStates: [main], + contentWidth: 720 + ) + let narrowLayout = cache.layout( + partStates: [main], + contentWidth: 300 + ) + let bass = partState(part: .stem(.bass), currentTime: 1) + let multiPartLayout = cache.layout( + partStates: [main, bass], + contentWidth: 720 + ) + var denseBass = bass + denseBass.scoreState.measures[0].notationItems = stride( + from: 0.0, + through: 3.75, + by: 0.25 + ).enumerated().map { index, offset in + NotationMeasureItem( + id: "cache-bass-\(index)", + partID: .stem(.bass), + kind: .note, + pitch: NotationPitch(step: .c, octave: 3), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: offset, + durationInQuarterNotes: 0.25, + displayDuration: NotationDuration(denominator: 16) + ) + } + let denseLayout = cache.layout( + partStates: [main, denseBass], + contentWidth: 720 + ) + + XCTAssertEqual(cache.cacheMissCount, 4) + XCTAssertNotEqual(initialLayout.signature, narrowLayout.signature) + XCTAssertFalse(narrowLayout.usesPartGutter) + XCTAssertTrue(multiPartLayout.usesPartGutter) + XCTAssertNotEqual(multiPartLayout.signature, denseLayout.signature) + } + + private func systemMeasureCounts( + in layout: NotationWindowScoreLayout + ) -> [Int] { + layout.systems.compactMap { + $0.staves.first?.system.viewportState.visibleMeasures.count + } + } + + private func assertOrderedMeasureCoverage( + in layout: NotationWindowScoreLayout, + expectedCount: Int, + file: StaticString = #filePath, + line: UInt = #line + ) { + for partIndex in 0..<(layout.systems.first?.staves.count ?? 0) { + let measureNumbers = layout.systems.flatMap { + $0.staves[partIndex].system.viewportState.visibleMeasures.map(\.number) + } + XCTAssertEqual( + measureNumbers, + Array(1...expectedCount), + file: file, + line: line + ) + } + } + private func partState( part: NotationPartDescriptor, - currentTime: TimeInterval + currentTime: TimeInterval, + duration: TimeInterval = 16 ) -> NotationWindowPartRenderState { let tempoMap = TempoMap( baseSettings: BeatGridSettings( @@ -211,7 +775,7 @@ final class NotationWindowScoreLayoutTests: XCTestCase { timeSignature: .fourFour ), markers: [], - duration: 16 + duration: duration ) let scoreState = NotationViewportFactory().scoreState( tempoMap: tempoMap,