From adede2a38a435d157f987700d2de06624e82a4b5 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Thu, 2 Jul 2026 13:39:25 +0300 Subject: [PATCH] feat: add collapsible notation track --- CHANGELOG.md | 1 + JammLab/DesignSystem/AppTheme.swift | 35 ++++- JammLab/Models/JammLabProject.swift | 8 +- JammLab/Models/ProjectEditableState.swift | 1 + .../ProjectPersistenceCoordinator.swift | 4 +- .../AudioPlayerViewModel+Project.swift | 6 +- .../AudioPlayerViewModel+UndoDirty.swift | 9 +- JammLab/ViewModels/AudioPlayerViewModel.swift | 1 + .../Views/Components/ControlHelpText.swift | 2 + .../TimelineViewportControlBar.swift | 43 +++++- JammLab/Views/MainWorkspacePanels.swift | 8 +- JammLab/Views/WaveformTimelineView.swift | 109 +++++++++------ JammLabTests/StemWorkflowLogicTests.swift | 28 +++- JammLabTests/ViewModelLifecycleTests.swift | 129 ++++++++++++++++++ 14 files changed, 320 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1a55c8..2da180f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ development artifact builds use `vMAJOR.MINOR.PATCH-dev.N`. ## Unreleased +- Added a collapsible Notation track in the main timeline, collapsed by default and saved per project. - Changed notation harmony entry so beat slashes are selectable and Cmd+K opens chord input for the selected beat. - Added notation measure range selection with Shift-click, Cmd+C/Cmd+V measure copy and replace-paste for harmony contents, and Esc to clear the selection. - Improved notation measure range selection so adjacent selected measures draw as one continuous frame. diff --git a/JammLab/DesignSystem/AppTheme.swift b/JammLab/DesignSystem/AppTheme.swift index 788a00c..76ff7a9 100644 --- a/JammLab/DesignSystem/AppTheme.swift +++ b/JammLab/DesignSystem/AppTheme.swift @@ -255,6 +255,7 @@ enum AppTheme { static let tempoTrackHeight: CGFloat = 38 static let waveformTrackHeight: CGFloat = 110 static let notationTrackHeight: CGFloat = 124 + static let notationTrackCollapsedHeight: CGFloat = 36 static let notationMaximumVisibleMeasureCount = 8 static let notationMeasureMinWidth: CGFloat = 148 static let notationStaffLineSpacing: CGFloat = 8 @@ -288,8 +289,14 @@ enum AppTheme { static var zoomableUpperTrackStackHeight: CGFloat { regionTrackHeight + markerTrackHeight + tempoTrackHeight + waveformTrackHeight } + static func notationTrackCurrentHeight(isCollapsed: Bool) -> CGFloat { + isCollapsed ? notationTrackCollapsedHeight : notationTrackHeight + } + static func upperTrackStackHeight(isNotationTrackCollapsed: Bool) -> CGFloat { + zoomableUpperTrackStackHeight + notationTrackCurrentHeight(isCollapsed: isNotationTrackCollapsed) + } static var upperTrackStackHeight: CGFloat { - zoomableUpperTrackStackHeight + notationTrackHeight + upperTrackStackHeight(isNotationTrackCollapsed: false) } static func stemTracksHeight(rowCount: Int) -> CGFloat { let visibleRows = max(defaultVisibleStemRows, rowCount) @@ -299,8 +306,10 @@ enum AppTheme { static var stemTracksHeight: CGFloat { stemTracksHeight(rowCount: defaultVisibleStemRows) } - static func tracksMinimumHeight(stemRowCount: Int) -> CGFloat { - upperTrackStackHeight + trackSpacing + stemTracksHeight(rowCount: stemRowCount) + static func tracksMinimumHeight(stemRowCount: Int, isNotationTrackCollapsed: Bool = false) -> CGFloat { + upperTrackStackHeight(isNotationTrackCollapsed: isNotationTrackCollapsed) + + trackSpacing + + stemTracksHeight(rowCount: stemRowCount) } static var tracksMinimumHeight: CGFloat { tracksMinimumHeight(stemRowCount: defaultVisibleStemRows) @@ -309,14 +318,26 @@ enum AppTheme { static var trackControlsMinimumHeight: CGFloat { tracksMinimumHeight } - static func timelineBlockMinimumHeight(stemRowCount: Int) -> CGFloat { - tracksMinimumHeight(stemRowCount: stemRowCount) + viewportFooterGap + viewportControlBarHeight + static func timelineBlockMinimumHeight( + stemRowCount: Int, + isNotationTrackCollapsed: Bool = false + ) -> CGFloat { + tracksMinimumHeight( + stemRowCount: stemRowCount, + isNotationTrackCollapsed: isNotationTrackCollapsed + ) + viewportFooterGap + viewportControlBarHeight } static var timelineBlockMinimumHeight: CGFloat { timelineBlockMinimumHeight(stemRowCount: defaultVisibleStemRows) } - static func minimumContentHeight(stemRowCount: Int) -> CGFloat { - timelineBlockMinimumHeight(stemRowCount: stemRowCount) + static func minimumContentHeight( + stemRowCount: Int, + isNotationTrackCollapsed: Bool = false + ) -> CGFloat { + timelineBlockMinimumHeight( + stemRowCount: stemRowCount, + isNotationTrackCollapsed: isNotationTrackCollapsed + ) } static var minimumContentHeight: CGFloat { timelineBlockMinimumHeight diff --git a/JammLab/Models/JammLabProject.swift b/JammLab/Models/JammLabProject.swift index 5957890..98cb426 100644 --- a/JammLab/Models/JammLabProject.swift +++ b/JammLab/Models/JammLabProject.swift @@ -40,6 +40,7 @@ struct JammLabProject: Codable { var timelineVisibleRange: ProjectTimelineVisibleRange? var stemState: StemProjectState? var isVideoWindowOpen: Bool? + var isNotationTrackCollapsed: Bool? init( formatVersion: Int = 10, @@ -66,7 +67,8 @@ struct JammLabProject: Codable { playbackMarkerTime: TimeInterval? = nil, timelineVisibleRange: ProjectTimelineVisibleRange? = nil, stemState: StemProjectState? = nil, - isVideoWindowOpen: Bool? = nil + isVideoWindowOpen: Bool? = nil, + isNotationTrackCollapsed: Bool? = nil ) { self.formatVersion = formatVersion self.audioBookmarkData = audioBookmarkData @@ -93,6 +95,7 @@ struct JammLabProject: Codable { self.timelineVisibleRange = timelineVisibleRange self.stemState = stemState self.isVideoWindowOpen = isVideoWindowOpen + self.isNotationTrackCollapsed = isNotationTrackCollapsed } private enum CodingKeys: String, CodingKey { @@ -121,6 +124,7 @@ struct JammLabProject: Codable { case timelineVisibleRange case stemState case isVideoWindowOpen + case isNotationTrackCollapsed } init(from decoder: Decoder) throws { @@ -150,6 +154,7 @@ struct JammLabProject: Codable { timelineVisibleRange = try container.decodeIfPresent(ProjectTimelineVisibleRange.self, forKey: .timelineVisibleRange) stemState = try container.decodeIfPresent(StemProjectState.self, forKey: .stemState) isVideoWindowOpen = try container.decodeIfPresent(Bool.self, forKey: .isVideoWindowOpen) + isNotationTrackCollapsed = try container.decodeIfPresent(Bool.self, forKey: .isNotationTrackCollapsed) } func encode(to encoder: Encoder) throws { @@ -179,6 +184,7 @@ struct JammLabProject: Codable { try container.encodeIfPresent(timelineVisibleRange, forKey: .timelineVisibleRange) try container.encodeIfPresent(stemState, forKey: .stemState) try container.encodeIfPresent(isVideoWindowOpen, forKey: .isVideoWindowOpen) + try container.encodeIfPresent(isNotationTrackCollapsed, forKey: .isNotationTrackCollapsed) } func resolvedAudioURL() throws -> URL { diff --git a/JammLab/Models/ProjectEditableState.swift b/JammLab/Models/ProjectEditableState.swift index d3caaf0..5d9eb77 100644 --- a/JammLab/Models/ProjectEditableState.swift +++ b/JammLab/Models/ProjectEditableState.swift @@ -40,4 +40,5 @@ struct ProjectPersistedEditableState: Equatable { var playbackMarkerTime: TimeInterval var timelineVisibleRange: ClosedRange var isVideoWindowOpen: Bool + var isNotationTrackCollapsed: Bool } diff --git a/JammLab/Services/ProjectPersistenceCoordinator.swift b/JammLab/Services/ProjectPersistenceCoordinator.swift index 4962315..44ff98b 100644 --- a/JammLab/Services/ProjectPersistenceCoordinator.swift +++ b/JammLab/Services/ProjectPersistenceCoordinator.swift @@ -48,6 +48,7 @@ struct ProjectDocumentSnapshot { let timelineVisibleRange: ClosedRange let stemState: StemProjectState? let isVideoWindowOpen: Bool + let isNotationTrackCollapsed: Bool } struct ProjectPersistenceCoordinator { @@ -227,7 +228,8 @@ struct ProjectPersistenceCoordinator { ProjectStateNormalizer.normalizedTimelineVisibleRange(snapshot.timelineVisibleRange, duration: snapshot.duration) ), stemState: snapshot.stemState, - isVideoWindowOpen: snapshot.importedFile.mediaKind == .video ? snapshot.isVideoWindowOpen : nil + isVideoWindowOpen: snapshot.importedFile.mediaKind == .video ? snapshot.isVideoWindowOpen : nil, + isNotationTrackCollapsed: snapshot.isNotationTrackCollapsed ) } diff --git a/JammLab/ViewModels/AudioPlayerViewModel+Project.swift b/JammLab/ViewModels/AudioPlayerViewModel+Project.swift index ba6f683..9d2ccb9 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel+Project.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel+Project.swift @@ -130,6 +130,7 @@ extension AudioPlayerViewModel { isLooping = false isClickEnabled = false isSnapEnabled = false + isNotationTrackCollapsed = true mainTrackVolume = AppSliderDefaults.mainTrackVolume clickVolume = AppSliderDefaults.clickVolume errorMessage = nil @@ -184,6 +185,7 @@ extension AudioPlayerViewModel { timelineVisibleRange = 0...file.duration userTimelineVisibleRange = timelineVisibleRange playbackState = .stopped + isNotationTrackCollapsed = true resetStemState() isImporting = false } @@ -274,6 +276,7 @@ extension AudioPlayerViewModel { restorePlaybackMode(restoredPlaybackMode, preservedTime: currentTime) setPlaybackMarkerExactly(to: restoredPlaybackMarkerTime) restoreVideoWindowOpenState(file.mediaKind == .video && project.isVideoWindowOpen == true) + isNotationTrackCollapsed = project.isNotationTrackCollapsed ?? true isImporting = false clearUndoHistory() markProjectClean() @@ -393,7 +396,8 @@ extension AudioPlayerViewModel { playbackMarkerTime: playbackMarkerTime, timelineVisibleRange: userTimelineVisibleRange, stemState: makeStemProjectState(), - isVideoWindowOpen: isVideoWindowOpen + isVideoWindowOpen: isVideoWindowOpen, + isNotationTrackCollapsed: isNotationTrackCollapsed ) } diff --git a/JammLab/ViewModels/AudioPlayerViewModel+UndoDirty.swift b/JammLab/ViewModels/AudioPlayerViewModel+UndoDirty.swift index c5ac2f1..68b1413 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel+UndoDirty.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel+UndoDirty.swift @@ -47,7 +47,8 @@ extension AudioPlayerViewModel { isSnapEnabled: isSnapEnabled, playbackMarkerTime: ProjectStateNormalizer.normalizedTimelineTime(playbackMarkerTime, duration: duration), timelineVisibleRange: ProjectStateNormalizer.normalizedTimelineVisibleRange(userTimelineVisibleRange, duration: duration), - isVideoWindowOpen: importedFile?.mediaKind == .video && isVideoWindowOpen + isVideoWindowOpen: importedFile?.mediaKind == .video && isVideoWindowOpen, + isNotationTrackCollapsed: isNotationTrackCollapsed ) } @@ -121,6 +122,12 @@ extension AudioPlayerViewModel { } } + func setNotationTrackCollapsed(_ isCollapsed: Bool) { + guard isNotationTrackCollapsed != isCollapsed else { return } + isNotationTrackCollapsed = isCollapsed + refreshProjectModifiedState() + } + func registerUndoState(_ state: ProjectEditableState, actionName: String) { guard !isRestoringUndoState, let undoManager else { return } diff --git a/JammLab/ViewModels/AudioPlayerViewModel.swift b/JammLab/ViewModels/AudioPlayerViewModel.swift index fd5d3bb..b051069 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel.swift @@ -62,6 +62,7 @@ final class AudioPlayerViewModel: ObservableObject { @Published var isClickEnabled = false @Published var isSnapEnabled = false @Published var isVideoWindowOpen = false + @Published var isNotationTrackCollapsed = true @Published var mainTrackVolume: Float = AppSliderDefaults.mainTrackVolume @Published var clickVolume: Float = AudioPlayerViewModel.restoredClickVolume() @Published var undoStateRevision = 0 diff --git a/JammLab/Views/Components/ControlHelpText.swift b/JammLab/Views/Components/ControlHelpText.swift index c8dfc43..828b662 100644 --- a/JammLab/Views/Components/ControlHelpText.swift +++ b/JammLab/Views/Components/ControlHelpText.swift @@ -31,6 +31,8 @@ enum ControlHelpText { static let timelinePanRight = "Move timeline right" static let timelineZoomIn = "Zoom in" static let timelineZoomOut = "Zoom out" + static let expandNotationTrack = "Expand Notation track" + static let collapseNotationTrack = "Collapse Notation track" static let resetThemeColors = "Reset theme colors to defaults" static let resetClickDefaults = "Restore the current built-in click sound: 1760/1120 Hz and 36/26 ms." diff --git a/JammLab/Views/Components/TimelineViewportControlBar.swift b/JammLab/Views/Components/TimelineViewportControlBar.swift index e9f4ee7..7fbd0e6 100644 --- a/JammLab/Views/Components/TimelineViewportControlBar.swift +++ b/JammLab/Views/Components/TimelineViewportControlBar.swift @@ -54,7 +54,6 @@ struct TimelineViewportControlBar: View { let onPanRight: () -> Void let onZoomIn: () -> Void let onZoomOut: () -> Void - @Environment(\.appColors) private var appColors var body: some View { HStack(spacing: AppTheme.Spacing.md) { @@ -69,22 +68,51 @@ struct TimelineViewportControlBar: View { .frame(height: AppTheme.Timeline.viewportControlBarHeight) HStack(spacing: AppTheme.Spacing.xs) { - viewportButton(systemName: "chevron.left", helpText: ControlHelpText.timelinePanLeft, action: onPanLeft) - viewportButton(systemName: "chevron.right", helpText: ControlHelpText.timelinePanRight, action: onPanRight) - viewportButton(systemName: "plus", helpText: ControlHelpText.timelineZoomIn, action: onZoomIn) - viewportButton(systemName: "minus", helpText: ControlHelpText.timelineZoomOut, action: onZoomOut) + TimelineIconButton(systemName: "chevron.left", helpText: ControlHelpText.timelinePanLeft, action: onPanLeft) + TimelineIconButton(systemName: "chevron.right", helpText: ControlHelpText.timelinePanRight, action: onPanRight) + TimelineIconButton(systemName: "plus", helpText: ControlHelpText.timelineZoomIn, action: onZoomIn) + TimelineIconButton(systemName: "minus", helpText: ControlHelpText.timelineZoomOut, action: onZoomOut) } } .frame(height: AppTheme.Timeline.viewportControlBarHeight) .disabled(duration <= 0) .opacity(duration > 0 ? 1 : 0.45) } +} - private func viewportButton( +struct TimelineIconButton: View { + let systemName: String + let helpText: String + let accessibilityLabel: String? + let accessibilityValue: String? + let action: () -> Void + @Environment(\.appColors) private var appColors + + init( systemName: String, helpText: String, + accessibilityLabel: String? = nil, + accessibilityValue: String? = nil, action: @escaping () -> Void - ) -> some View { + ) { + self.systemName = systemName + self.helpText = helpText + self.accessibilityLabel = accessibilityLabel + self.accessibilityValue = accessibilityValue + self.action = action + } + + var body: some View { + if let accessibilityLabel { + baseButton + .accessibilityLabel(accessibilityLabel) + .accessibilityValue(accessibilityValue ?? "") + } else { + baseButton + } + } + + private var baseButton: some View { Button(action: action) { Image(systemName: systemName) .font(.system(size: 10, weight: .semibold)) @@ -99,6 +127,7 @@ struct TimelineViewportControlBar: View { RoundedRectangle(cornerRadius: AppTheme.Timeline.viewportControlButtonRadius, style: .continuous) .stroke(appColors.border, lineWidth: AppTheme.Stroke.thin) } + .contentShape(Rectangle()) } .buttonStyle(.plain) .help(helpText) diff --git a/JammLab/Views/MainWorkspacePanels.swift b/JammLab/Views/MainWorkspacePanels.swift index dfb52f9..91c50b0 100644 --- a/JammLab/Views/MainWorkspacePanels.swift +++ b/JammLab/Views/MainWorkspacePanels.swift @@ -107,7 +107,10 @@ extension ContentView { } var timelineTracksHeight: CGFloat { - AppTheme.Timeline.tracksMinimumHeight(stemRowCount: timelineStemRowCount) + AppTheme.Timeline.tracksMinimumHeight( + stemRowCount: timelineStemRowCount, + isNotationTrackCollapsed: viewModel.isNotationTrackCollapsed + ) } var timelineMinimumContentHeight: CGFloat { @@ -137,6 +140,7 @@ extension ContentView { selectedRegionID: viewModel.selectedRegionID, beatGrid: beatGrid, notationViewport: notationViewportState(availableWidth: notationTrackContentWidth), + isNotationTrackCollapsed: viewModel.isNotationTrackCollapsed, isLoadingPeakform: viewModel.isBuildingWaveform, mainTrackVolume: viewModel.mainTrackVolume, playbackMode: viewModel.playbackMode, @@ -184,7 +188,6 @@ extension ContentView { TimelineViewActions( locatePlaybackMarker: { viewModel.locatePlaybackMarker(to: $0) }, addNote: { viewModel.addNote(at: $0) }, - harmonyInputResolutionChanged: { viewModel.setHarmonyInputResolutionDenominator($0) }, selectHarmony: { viewModel.selectHarmonySymbol(id: $0) }, selectNotationMeasure: { viewModel.selectNotationMeasure($0, extendingSelection: $1) }, selectNotationBeat: { viewModel.selectNotationBeat($0) }, @@ -207,6 +210,7 @@ extension ContentView { loopRegionChanged: { viewModel.updateLoopRegion(start: $0, end: $1) }, timelineScroll: { viewModel.handleTimelineScroll(deltaX: $0, deltaY: $1, anchorTime: $2) }, mainTrackVolumeChanged: { viewModel.setMainTrackVolume($0) }, + notationTrackCollapsedChanged: { viewModel.setNotationTrackCollapsed($0) }, showNotationWindow: { openWindow(id: AppWindowID.notation) } ) } diff --git a/JammLab/Views/WaveformTimelineView.swift b/JammLab/Views/WaveformTimelineView.swift index 9647b6b..6efd3a8 100644 --- a/JammLab/Views/WaveformTimelineView.swift +++ b/JammLab/Views/WaveformTimelineView.swift @@ -32,6 +32,7 @@ struct TimelineViewState: Equatable { var selectedRegionID: TimecodedNote.ID? var beatGrid: BeatGridConfiguration var notationViewport: NotationViewportState + var isNotationTrackCollapsed: Bool var isLoadingPeakform: Bool var mainTrackVolume: Float var playbackMode: PlaybackMode @@ -44,7 +45,6 @@ struct TimelineViewState: Equatable { struct TimelineViewActions { var locatePlaybackMarker: (TimeInterval) -> Void var addNote: (TimeInterval) -> Void - var harmonyInputResolutionChanged: (Int) -> Void var selectHarmony: (HarmonySymbol.ID?) -> Void var selectNotationMeasure: (ScoreMeasure?, Bool) -> Void var selectNotationBeat: (NotationBeatSelection?) -> Void @@ -67,6 +67,7 @@ struct TimelineViewActions { var loopRegionChanged: (TimeInterval, TimeInterval) -> Void var timelineScroll: (Double, Double, TimeInterval?) -> Void var mainTrackVolumeChanged: (Float) -> Void + var notationTrackCollapsedChanged: (Bool) -> Void var showNotationWindow: () -> Void } @@ -126,7 +127,22 @@ struct WaveformTimelineView: View { } private var tracksHeight: CGFloat { - AppTheme.Timeline.tracksMinimumHeight(stemRowCount: visibleStemRowCount) + AppTheme.Timeline.tracksMinimumHeight( + stemRowCount: visibleStemRowCount, + isNotationTrackCollapsed: state.isNotationTrackCollapsed + ) + } + + private var upperTrackStackHeight: CGFloat { + AppTheme.Timeline.upperTrackStackHeight( + isNotationTrackCollapsed: state.isNotationTrackCollapsed + ) + } + + private var notationTrackCurrentHeight: CGFloat { + AppTheme.Timeline.notationTrackCurrentHeight( + isCollapsed: state.isNotationTrackCollapsed + ) } private var upperTrackStack: some View { @@ -179,7 +195,7 @@ struct WaveformTimelineView: View { .frame(height: AppTheme.Timeline.waveformTrackHeight) notationTrackRow - .frame(height: AppTheme.Timeline.notationTrackHeight) + .frame(height: notationTrackCurrentHeight) } } @@ -190,7 +206,7 @@ struct WaveformTimelineView: View { timelineScrollCaptureArea .frame(height: stemTracksHeight) - .offset(y: AppTheme.Timeline.upperTrackStackHeight + AppTheme.Timeline.trackSpacing) + .offset(y: upperTrackStackHeight + AppTheme.Timeline.trackSpacing) } .frame(height: tracksHeight, alignment: .topLeading) } @@ -231,11 +247,18 @@ struct WaveformTimelineView: View { } private var notationTrackRow: some View { - HStack(spacing: AppTheme.Spacing.md) { + HStack(alignment: .top, spacing: AppTheme.Spacing.md) { notationTrackControls .frame(width: trackControlWidth) - .frame(height: AppTheme.Timeline.notationTrackHeight) + .frame(height: notationTrackCurrentHeight, alignment: .topLeading) + + notationTrackContent + } + } + @ViewBuilder + private var notationTrackContent: some View { + if !state.isNotationTrackCollapsed { NotationTrackView( state: state.notationViewport, selectedHarmonySymbolID: state.selectedHarmonySymbolID, @@ -252,55 +275,55 @@ struct WaveformTimelineView: View { adjacentHarmonyPlacement: actions.adjacentHarmonyPlacement ) ) - .frame(height: AppTheme.Timeline.notationTrackHeight) - } - .overlay { - if state.duration > 0 { - RightClickMenuCaptureView( - title: "Show in the Window", - action: actions.showNotationWindow - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) + .frame(height: AppTheme.Timeline.notationTrackHeight) + .overlay { + if state.duration > 0 { + RightClickMenuCaptureView( + title: "Show in the Window", + action: actions.showNotationWindow + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } } + } else { + Color.clear + .frame(maxWidth: .infinity) + .frame(height: notationTrackCurrentHeight) } } private var notationTrackControls: some View { - VStack(alignment: .leading, spacing: AppTheme.Spacing.sm) { + VStack(alignment: .leading, spacing: AppTheme.Spacing.none) { + notationTrackHeader + } + .padding(.horizontal, AppTheme.Spacing.md) + .padding(.vertical, AppTheme.Spacing.sm) + .controlSize(.small) + } + + private var notationTrackHeader: some View { + HStack(spacing: AppTheme.Spacing.sm) { Text("Notation") .font(AppTheme.Typography.noteTitle) .lineLimit(1) - .frame(maxWidth: .infinity, alignment: .leading) - HStack(spacing: AppTheme.Spacing.xxs) { - Text("1/") - .font(AppTheme.Typography.captionMonospaced) - .foregroundStyle(appColors.secondaryText) + Spacer(minLength: AppTheme.Spacing.sm) - AbletonNumberField( - value: Binding( - get: { Double(state.harmonyInputResolutionDenominator) }, - set: { actions.harmonyInputResolutionChanged(Int($0.rounded())) } - ), - minValue: 1, - maxValue: 8, - defaultValue: Double(HarmonyInputResolution.defaultDenominator), - step: 1, - precision: 0, - accessibilityLabel: "Harmony Input Resolution" - ) - .frame( - width: AppTheme.ControlSize.toolbarTimeSignatureNumberFieldWidth, - height: AppTheme.ControlSize.abletonNumberFieldHeight - ) - .disabled(!state.notationViewport.isReady) - .help(ControlHelpText.harmonyInputResolution) + TimelineIconButton( + systemName: state.isNotationTrackCollapsed ? "plus" : "minus", + helpText: notationTrackToggleHelpText, + accessibilityLabel: notationTrackToggleHelpText, + accessibilityValue: state.isNotationTrackCollapsed ? "Collapsed" : "Expanded" + ) { + actions.notationTrackCollapsedChanged(!state.isNotationTrackCollapsed) } } - .padding(.horizontal, AppTheme.Spacing.md) - .padding(.vertical, AppTheme.Spacing.sm) - .controlSize(.small) - .opacity(state.notationViewport.isReady ? 1 : 0.5) + } + + private var notationTrackToggleHelpText: String { + state.isNotationTrackCollapsed + ? ControlHelpText.expandNotationTrack + : ControlHelpText.collapseNotationTrack } private var mainTrackControls: some View { diff --git a/JammLabTests/StemWorkflowLogicTests.swift b/JammLabTests/StemWorkflowLogicTests.swift index 773864b..0e3b0e9 100644 --- a/JammLabTests/StemWorkflowLogicTests.swift +++ b/JammLabTests/StemWorkflowLogicTests.swift @@ -139,7 +139,8 @@ final class StemWorkflowLogicTests: XCTestCase { playbackMode: .stems, playbackMarkerTime: 12.5, stemState: metadata, - isVideoWindowOpen: true + isVideoWindowOpen: true, + isNotationTrackCollapsed: false ) let decoded = try JSONDecoder().decode(JammLabProject.self, from: JSONEncoder().encode(project)) @@ -156,6 +157,7 @@ final class StemWorkflowLogicTests: XCTestCase { XCTAssertEqual(decoded.isSnapEnabled, true) XCTAssertEqual(decoded.playbackMode, .stems) XCTAssertEqual(decoded.isVideoWindowOpen, true) + XCTAssertEqual(decoded.isNotationTrackCollapsed, false) XCTAssertEqual(decoded.stemState?.cacheKey, "cache-123") XCTAssertEqual(decoded.stemState?.playbackMode, .stems) XCTAssertEqual(try XCTUnwrap(decoded.stemState?.mixState.effectiveVolume(for: .vocals)), 0, accuracy: 0.0001) @@ -197,6 +199,29 @@ final class StemWorkflowLogicTests: XCTestCase { ) } + func testTimelineHeightHelpersCollapseNotationTrackWithoutChangingStemExpansion() { + let collapsedHeight = AppTheme.Timeline.tracksMinimumHeight( + stemRowCount: StemSeparationMethod.defaultValue.stemTypes.count, + isNotationTrackCollapsed: true + ) + let expandedHeight = AppTheme.Timeline.tracksMinimumHeight( + stemRowCount: StemSeparationMethod.defaultValue.stemTypes.count, + isNotationTrackCollapsed: false + ) + let sixStemCollapsedHeight = AppTheme.Timeline.tracksMinimumHeight( + stemRowCount: StemSeparationMethod.sixStem.stemTypes.count, + isNotationTrackCollapsed: true + ) + + XCTAssertLessThan(collapsedHeight, expandedHeight) + XCTAssertEqual( + expandedHeight - collapsedHeight, + AppTheme.Timeline.notationTrackHeight - AppTheme.Timeline.notationTrackCollapsedHeight, + accuracy: 0.0001 + ) + XCTAssertGreaterThan(sixStemCollapsedHeight, collapsedHeight) + } + func testLegacyProjectWithoutStemStateStillDecodes() throws { let json = """ { @@ -228,6 +253,7 @@ final class StemWorkflowLogicTests: XCTestCase { XCTAssertNil(decoded.playbackMode) XCTAssertNil(decoded.mediaKind) XCTAssertNil(decoded.isVideoWindowOpen) + XCTAssertNil(decoded.isNotationTrackCollapsed) } func testMediaImporterClassifiesSupportedFormats() { diff --git a/JammLabTests/ViewModelLifecycleTests.swift b/JammLabTests/ViewModelLifecycleTests.swift index b7c7f4a..7f3b16e 100644 --- a/JammLabTests/ViewModelLifecycleTests.swift +++ b/JammLabTests/ViewModelLifecycleTests.swift @@ -87,6 +87,7 @@ final class ViewModelLifecycleTests: XCTestCase { viewModel.toggleStemMute(.vocals) viewModel.toggleSnap() viewModel.setLooping(true) + viewModel.setNotationTrackCollapsed(false) viewModel.newProject() @@ -99,6 +100,7 @@ final class ViewModelLifecycleTests: XCTestCase { XCTAssertFalse(viewModel.stemMixState.item(for: .vocals).isMuted) XCTAssertFalse(viewModel.isSnapEnabled) XCTAssertFalse(viewModel.isLooping) + XCTAssertTrue(viewModel.isNotationTrackCollapsed) XCTAssertNil(viewModel.importedFile) XCTAssertEqual(try XCTUnwrap(viewModel.tempoBPM), AppDefaults.defaultTempoBPM, accuracy: 0.0001) XCTAssertEqual(try XCTUnwrap(viewModel.beatGridSettings.bpm), AppDefaults.defaultTempoBPM, accuracy: 0.0001) @@ -2228,6 +2230,113 @@ final class ViewModelLifecycleTests: XCTestCase { XCTAssertFalse(viewModel.isProjectModified) } + @MainActor + func testNotationTrackCollapsedDefaultsToClosedForImportedMediaAndPersistsChanges() async throws { + let audioURL = try temporaryAudioFile() + let projectURL = temporaryDirectory().appendingPathComponent("notation-collapse-save.jammlab") + try FileManager.default.createDirectory(at: projectURL.deletingLastPathComponent(), withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: audioURL) + try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) + } + + let projectService = ProjectDocumentService() + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: MockPlaybackEngine(), + projectService: projectService, + recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), + isSandboxed: { false } + ) + let media = ImportedAudioFile(url: audioURL, displayName: "notation.wav", duration: 0.5) + + try viewModel.loadImportedAudio(media) + + XCTAssertTrue(viewModel.isNotationTrackCollapsed) + XCTAssertFalse(viewModel.isProjectModified) + + viewModel.setNotationTrackCollapsed(false) + + XCTAssertFalse(viewModel.isNotationTrackCollapsed) + XCTAssertTrue(viewModel.isProjectModified) + + let didSave = await viewModel.saveProject(to: projectURL) + + XCTAssertTrue(didSave) + XCTAssertFalse(viewModel.isProjectModified) + XCTAssertEqual(try projectService.load(from: projectURL).isNotationTrackCollapsed, false) + } + + @MainActor + func testOpenProjectRestoresNotationTrackCollapsedStateAndKeepsLegacyClean() async throws { + let audioURL = try temporaryAudioFile() + let directory = temporaryDirectory() + let expandedProjectURL = directory.appendingPathComponent("notation-expanded.jammlab") + let collapsedProjectURL = directory.appendingPathComponent("notation-collapsed.jammlab") + let legacyProjectURL = directory.appendingPathComponent("notation-legacy.jammlab") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: audioURL) + try? FileManager.default.removeItem(at: directory) + } + + let projectService = ProjectDocumentService() + try projectService.save( + notationCollapseProject(audioURL: audioURL, projectService: projectService, collapsed: false), + to: expandedProjectURL + ) + try projectService.save( + notationCollapseProject(audioURL: audioURL, projectService: projectService, collapsed: true), + to: collapsedProjectURL + ) + try projectService.save( + notationCollapseProject(audioURL: audioURL, projectService: projectService, collapsed: nil), + to: legacyProjectURL + ) + + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: MockPlaybackEngine(), + projectService: projectService, + recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), + isSandboxed: { false } + ) + + await viewModel.openProject(at: expandedProjectURL) + XCTAssertFalse(viewModel.isNotationTrackCollapsed) + XCTAssertFalse(viewModel.isProjectModified) + + await viewModel.openProject(at: collapsedProjectURL) + XCTAssertTrue(viewModel.isNotationTrackCollapsed) + XCTAssertFalse(viewModel.isProjectModified) + + await viewModel.openProject(at: legacyProjectURL) + XCTAssertTrue(viewModel.isNotationTrackCollapsed) + XCTAssertFalse(viewModel.isProjectModified) + } + + @MainActor + func testNotationTrackCollapsedStateIsNotUndoable() throws { + let undoManager = UndoManager() + let viewModel = AudioPlayerViewModel(playbackEngine: MockPlaybackEngine()) + viewModel.undoManager = undoManager + + viewModel.setNotationTrackCollapsed(false) + + XCTAssertFalse(viewModel.isNotationTrackCollapsed) + XCTAssertFalse(viewModel.canUndo) + + viewModel.setMainTrackVolume(0.25) + XCTAssertTrue(viewModel.canUndo) + + viewModel.undoLastEdit() + + XCTAssertFalse(viewModel.isNotationTrackCollapsed) + XCTAssertEqual(viewModel.mainTrackVolume, AppSliderDefaults.mainTrackVolume, accuracy: 0.0001) + } + @MainActor func testOpenProjectRestoresSavedVideoWindowOpenState() async throws { let fixture = try makeVideoProjectFixture( @@ -2430,6 +2539,26 @@ final class ViewModelLifecycleTests: XCTestCase { ) } + private func notationCollapseProject( + audioURL: URL, + projectService: ProjectDocumentService, + collapsed: Bool? + ) throws -> JammLabProject { + JammLabProject( + audioBookmarkData: try projectService.bookmarkData(for: audioURL), + audioDisplayName: audioURL.lastPathComponent, + audioDuration: 0.5, + notes: [], + loopStart: 0, + loopEnd: 0.5, + playbackRate: AppSliderDefaults.playbackRate, + pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, + tempoBPM: AppDefaults.defaultTempoBPM, + beatGridSettings: BeatGridSettings(bpm: AppDefaults.defaultTempoBPM), + isNotationTrackCollapsed: collapsed + ) + } + @MainActor func testProjectEditableStateRestoreAppliesEngineBackedSettings() { let engine = MockPlaybackEngine()