diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 66d1b87..0cb158f 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -40,12 +40,14 @@ 9FDD01042D60000100112233 /* NoteColorPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FDD00042D60000100112233 /* NoteColorPresentation.swift */; }; 9FDD01052D60000100112233 /* SettingsSections.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FDD00052D60000100112233 /* SettingsSections.swift */; }; 9FDD01062D60000100112233 /* SettingsContentViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FDD00062D60000100112233 /* SettingsContentViews.swift */; }; - 9FEA01012D70000100112233 /* ScoreDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FEA00012D70000100112233 /* ScoreDocument.swift */; }; + 9FEA01012D70000100112233 /* NotationScoreModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FEA00012D70000100112233 /* NotationScoreModels.swift */; }; 9FEA01022D70000100112233 /* NotationViewportState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FEA00022D70000100112233 /* NotationViewportState.swift */; }; 9FEA01032D70000100112233 /* NotationViewportFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FEA00032D70000100112233 /* NotationViewportFactory.swift */; }; 9FEA01042D70000100112233 /* NotationTrackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FEA00042D70000100112233 /* NotationTrackView.swift */; }; + 9FCB01062D80000100112233 /* NotationMeasureLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FCB00062D80000100112233 /* NotationMeasureLayout.swift */; }; 9FEA01052D70000100112233 /* NotationVisibleMeasureFitter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FEA00052D70000100112233 /* NotationVisibleMeasureFitter.swift */; }; 9FEA01062D70000100112233 /* ProjectKeySelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FEA00062D70000100112233 /* ProjectKeySelection.swift */; }; + 9FCB01072D80000100112233 /* NotationHarmonyInlineTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FCB00072D80000100112233 /* NotationHarmonyInlineTextField.swift */; }; 9FEC01012D71000100112233 /* NotationWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FEC00012D71000100112233 /* NotationWindowView.swift */; }; 9F8B01062C10000100112233 /* TestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F8B00072C10000100112233 /* TestSupport.swift */; }; 9FCD01012D50000100112233 /* PerformanceBenchmarkTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FCD00012D50000100112233 /* PerformanceBenchmarkTests.swift */; }; @@ -53,12 +55,20 @@ 9F8C01012C20000100112233 /* AppTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F8C00012C20000100112233 /* AppTheme.swift */; }; 9F8C01022C20000100112233 /* AppPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F8C00022C20000100112233 /* AppPanel.swift */; }; 9F8C01032C20000100112233 /* AppControls.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F8C00032C20000100112233 /* AppControls.swift */; }; + 9FCB01022D80000100112233 /* CompactValuePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FCB00022D80000100112233 /* CompactValuePicker.swift */; }; + 9FCB01032D80000100112233 /* AppLetterToggleButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FCB00032D80000100112233 /* AppLetterToggleButton.swift */; }; + 9FCB01042D80000100112233 /* JammModeToggleButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FCB00042D80000100112233 /* JammModeToggleButton.swift */; }; + 9FCB01052D80000100112233 /* AppPopoverDismissModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FCB00052D80000100112233 /* AppPopoverDismissModifier.swift */; }; + 9FCB01092D80000100112233 /* ClickToolbarButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FCB00092D80000100112233 /* ClickToolbarButton.swift */; }; + 9FCB010A2D80000100112233 /* BeatOneToolbarButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FCB000A2D80000100112233 /* BeatOneToolbarButton.swift */; }; + 9FCB010B2D80000100112233 /* StemSeparationToolbarButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FCB000B2D80000100112233 /* StemSeparationToolbarButton.swift */; }; 9F8C01042C20000100112233 /* AppHotkeyMonitorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F8C00042C20000100112233 /* AppHotkeyMonitorView.swift */; }; 9F8C01052C20000100112233 /* NoteRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F8C00052C20000100112233 /* NoteRowView.swift */; }; 9F8C01062C20000100112233 /* MainWorkspacePanels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F8C00062C20000100112233 /* MainWorkspacePanels.swift */; }; 9F8C01072C20000100112233 /* TransportBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F8C00072C20000100112233 /* TransportBarView.swift */; }; 9F8C01082C20000100112233 /* WindowTitleUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F8C00082C20000100112233 /* WindowTitleUpdater.swift */; }; 9F8C01092C20000100112233 /* TopToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F8C00092C20000100112233 /* TopToolbarView.swift */; }; + 9FCB01082D80000100112233 /* TransportButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FCB00082D80000100112233 /* TransportButton.swift */; }; 9F8D01012C30000100112233 /* StemModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F8D00012C30000100112233 /* StemModels.swift */; }; 9F8D01022C30000100112233 /* StemSeparationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F8D00022C30000100112233 /* StemSeparationService.swift */; }; 9F8D01032C30000100112233 /* StemMixAudioPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F8D00032C30000100112233 /* StemMixAudioPlayer.swift */; }; @@ -113,6 +123,7 @@ 9FB204052D32000100112233 /* PeakformBinaryCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FB204042D32000100112233 /* PeakformBinaryCache.swift */; }; 9FB205012D33000100112233 /* AppKitControlHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FB205002D33000100112233 /* AppKitControlHelpers.swift */; }; 9FBA01012D40000100112233 /* PitchDetection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBA00012D40000100112233 /* PitchDetection.swift */; }; + 9FCB01012D80000100112233 /* AudioSampleConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FCB00012D80000100112233 /* AudioSampleConverter.swift */; }; 9FBA01022D40000100112233 /* TrackPitchAnalyzer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBA00022D40000100112233 /* TrackPitchAnalyzer.swift */; }; 9FBA01032D40000100112233 /* TunerInputService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBA00032D40000100112233 /* TunerInputService.swift */; }; 9FBA01042D40000100112233 /* TunerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBA00042D40000100112233 /* TunerView.swift */; }; @@ -194,12 +205,14 @@ 9FDD00042D60000100112233 /* NoteColorPresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteColorPresentation.swift; sourceTree = ""; }; 9FDD00052D60000100112233 /* SettingsSections.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSections.swift; sourceTree = ""; }; 9FDD00062D60000100112233 /* SettingsContentViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsContentViews.swift; sourceTree = ""; }; - 9FEA00012D70000100112233 /* ScoreDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScoreDocument.swift; sourceTree = ""; }; + 9FEA00012D70000100112233 /* NotationScoreModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationScoreModels.swift; sourceTree = ""; }; 9FEA00022D70000100112233 /* NotationViewportState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationViewportState.swift; sourceTree = ""; }; 9FEA00032D70000100112233 /* NotationViewportFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationViewportFactory.swift; sourceTree = ""; }; 9FEA00042D70000100112233 /* NotationTrackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationTrackView.swift; sourceTree = ""; }; + 9FCB00062D80000100112233 /* NotationMeasureLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationMeasureLayout.swift; sourceTree = ""; }; 9FEA00052D70000100112233 /* NotationVisibleMeasureFitter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationVisibleMeasureFitter.swift; sourceTree = ""; }; 9FEA00062D70000100112233 /* ProjectKeySelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectKeySelection.swift; sourceTree = ""; }; + 9FCB00072D80000100112233 /* NotationHarmonyInlineTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationHarmonyInlineTextField.swift; sourceTree = ""; }; 9FEC00012D71000100112233 /* NotationWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationWindowView.swift; sourceTree = ""; }; 9F8B00062C10000100112233 /* JammLabTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = JammLabTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 9F8B00072C10000100112233 /* TestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestSupport.swift; sourceTree = ""; }; @@ -208,12 +221,20 @@ 9F8C00012C20000100112233 /* AppTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTheme.swift; sourceTree = ""; }; 9F8C00022C20000100112233 /* AppPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPanel.swift; sourceTree = ""; }; 9F8C00032C20000100112233 /* AppControls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppControls.swift; sourceTree = ""; }; + 9FCB00022D80000100112233 /* CompactValuePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompactValuePicker.swift; sourceTree = ""; }; + 9FCB00032D80000100112233 /* AppLetterToggleButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLetterToggleButton.swift; sourceTree = ""; }; + 9FCB00042D80000100112233 /* JammModeToggleButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JammModeToggleButton.swift; sourceTree = ""; }; + 9FCB00052D80000100112233 /* AppPopoverDismissModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPopoverDismissModifier.swift; sourceTree = ""; }; + 9FCB00092D80000100112233 /* ClickToolbarButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClickToolbarButton.swift; sourceTree = ""; }; + 9FCB000A2D80000100112233 /* BeatOneToolbarButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeatOneToolbarButton.swift; sourceTree = ""; }; + 9FCB000B2D80000100112233 /* StemSeparationToolbarButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemSeparationToolbarButton.swift; sourceTree = ""; }; 9F8C00042C20000100112233 /* AppHotkeyMonitorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppHotkeyMonitorView.swift; sourceTree = ""; }; 9F8C00052C20000100112233 /* NoteRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteRowView.swift; sourceTree = ""; }; 9F8C00062C20000100112233 /* MainWorkspacePanels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWorkspacePanels.swift; sourceTree = ""; }; 9F8C00072C20000100112233 /* TransportBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransportBarView.swift; sourceTree = ""; }; 9F8C00082C20000100112233 /* WindowTitleUpdater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowTitleUpdater.swift; sourceTree = ""; }; 9F8C00092C20000100112233 /* TopToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopToolbarView.swift; sourceTree = ""; }; + 9FCB00082D80000100112233 /* TransportButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransportButton.swift; sourceTree = ""; }; 9F8D00012C30000100112233 /* StemModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemModels.swift; sourceTree = ""; }; 9F8D00022C30000100112233 /* StemSeparationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemSeparationService.swift; sourceTree = ""; }; 9F8D00032C30000100112233 /* StemMixAudioPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemMixAudioPlayer.swift; sourceTree = ""; }; @@ -262,6 +283,7 @@ 9FB204042D32000100112233 /* PeakformBinaryCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeakformBinaryCache.swift; sourceTree = ""; }; 9FB205002D33000100112233 /* AppKitControlHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppKitControlHelpers.swift; sourceTree = ""; }; 9FBA00012D40000100112233 /* PitchDetection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PitchDetection.swift; sourceTree = ""; }; + 9FCB00012D80000100112233 /* AudioSampleConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSampleConverter.swift; sourceTree = ""; }; 9FBA00022D40000100112233 /* TrackPitchAnalyzer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackPitchAnalyzer.swift; sourceTree = ""; }; 9FBA00032D40000100112233 /* TunerInputService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunerInputService.swift; sourceTree = ""; }; 9FBA00042D40000100112233 /* TunerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunerView.swift; sourceTree = ""; }; @@ -351,7 +373,7 @@ 9FEA00062D70000100112233 /* ProjectKeySelection.swift */, 9F8B00022C10000100112233 /* ProjectStateNormalizer.swift */, 9FAC00012CF0000100112233 /* ProjectEditableState.swift */, - 9FEA00012D70000100112233 /* ScoreDocument.swift */, + 9FEA00012D70000100112233 /* NotationScoreModels.swift */, 9F9300012C90000100112233 /* StemBackendResolver.swift */, 9F8D00012C30000100112233 /* StemModels.swift */, 9F8E00012C40000100112233 /* StemSeparationJobModels.swift */, @@ -368,8 +390,10 @@ 9FB001002D06000100112233 /* AudioDeviceService.swift */, 9F8A00042C00000100112233 /* AudioFileImporter.swift */, 9F8F00022C50000100112233 /* AudioPlaybackControlling.swift */, + 9FCB00012D80000100112233 /* AudioSampleConverter.swift */, 9F8A00172C00000100112233 /* BeatGridCalculator.swift */, 9F8A00182C00000100112233 /* MetronomeClickScheduler.swift */, + 9FCB00062D80000100112233 /* NotationMeasureLayout.swift */, 9FEA00032D70000100112233 /* NotationViewportFactory.swift */, 9FEA00052D70000100112233 /* NotationVisibleMeasureFitter.swift */, 9F8A00192C00000100112233 /* ProjectArtifactStore.swift */, @@ -416,6 +440,7 @@ 9F8A00102C00000100112233 /* HotkeysHelpView.swift */, 9F8C00062C20000100112233 /* MainWorkspacePanels.swift */, 9FDD00042D60000100112233 /* NoteColorPresentation.swift */, + 9FCB00072D80000100112233 /* NotationHarmonyInlineTextField.swift */, 9FEA00042D70000100112233 /* NotationTrackView.swift */, 9FEC00012D71000100112233 /* NotationWindowView.swift */, 9F8B00032C10000100112233 /* PeakformTimelineView.swift */, @@ -493,16 +518,24 @@ 9F9500012CB0000100112233 /* AbletonNumberField.swift */, 9F9600012CC0000100112233 /* JammValueSlider.swift */, 9F8C00032C20000100112233 /* AppControls.swift */, + 9FCB00032D80000100112233 /* AppLetterToggleButton.swift */, + 9FCB00052D80000100112233 /* AppPopoverDismissModifier.swift */, 9F8C00042C20000100112233 /* AppHotkeyMonitorView.swift */, 9FB205002D33000100112233 /* AppKitControlHelpers.swift */, 9F8C00022C20000100112233 /* AppPanel.swift */, + 9FCB000A2D80000100112233 /* BeatOneToolbarButton.swift */, + 9FCB00092D80000100112233 /* ClickToolbarButton.swift */, + 9FCB00022D80000100112233 /* CompactValuePicker.swift */, 9FD001002D20000100112233 /* ControlHelpText.swift */, 9F9000032C60000100112233 /* InspectorSidebarView.swift */, + 9FCB00042D80000100112233 /* JammModeToggleButton.swift */, 9F8C00052C20000100112233 /* NoteRowView.swift */, 9F9800012CF0000100112233 /* RenameNoteDialog.swift */, + 9FCB000B2D80000100112233 /* StemSeparationToolbarButton.swift */, 9F9700012CD0000100112233 /* TimelineViewportControlBar.swift */, 9F8C00092C20000100112233 /* TopToolbarView.swift */, 9F8C00072C20000100112233 /* TransportBarView.swift */, + 9FCB00082D80000100112233 /* TransportButton.swift */, 9F9000012C60000100112233 /* TransportControlsView.swift */, 9FAF00022D02000100112233 /* WindowCloseGuard.swift */, 9F8C00082C20000100112233 /* WindowTitleUpdater.swift */, @@ -713,6 +746,7 @@ 9FB001012D06000100112233 /* AudioDeviceService.swift in Sources */, 9F8A01042C00000100112233 /* AudioFileImporter.swift in Sources */, 9F8F01032C50000100112233 /* AudioPlaybackControlling.swift in Sources */, + 9FCB01012D80000100112233 /* AudioSampleConverter.swift in Sources */, 9F8A01032C00000100112233 /* AudioPlayerViewModel.swift in Sources */, 9FB200012D30000100112233 /* AudioPlayerViewModel+Project.swift in Sources */, 9FB200022D30000100112233 /* AudioPlayerViewModel+Playback.swift in Sources */, @@ -722,12 +756,16 @@ 9FB200062D30000100112233 /* AudioPlayerViewModel+Video.swift in Sources */, 9FB200072D30000100112233 /* AudioPlayerViewModel+UndoDirty.swift in Sources */, 9F8C01032C20000100112233 /* AppControls.swift in Sources */, + 9FCB01032D80000100112233 /* AppLetterToggleButton.swift in Sources */, + 9FCB01052D80000100112233 /* AppPopoverDismissModifier.swift in Sources */, 9F8C01042C20000100112233 /* AppHotkeyMonitorView.swift in Sources */, 9FB205012D33000100112233 /* AppKitControlHelpers.swift in Sources */, 9F9501012CB0000100112233 /* AbletonNumberField.swift in Sources */, 9FD001012D20000100112233 /* ControlHelpText.swift in Sources */, 9F9601012CC0000100112233 /* JammValueSlider.swift in Sources */, + 9FCB01042D80000100112233 /* JammModeToggleButton.swift in Sources */, 9F8C01022C20000100112233 /* AppPanel.swift in Sources */, + 9FCB01022D80000100112233 /* CompactValuePicker.swift in Sources */, 9F8C01012C20000100112233 /* AppTheme.swift in Sources */, 9F8A01152C00000100112233 /* BeatGridCalculator.swift in Sources */, 9F8A01162C00000100112233 /* MetronomeClickScheduler.swift in Sources */, @@ -745,8 +783,10 @@ 9F8C01062C20000100112233 /* MainWorkspacePanels.swift in Sources */, 9F8C01052C20000100112233 /* NoteRowView.swift in Sources */, 9FEA01042D70000100112233 /* NotationTrackView.swift in Sources */, + 9FCB01072D80000100112233 /* NotationHarmonyInlineTextField.swift in Sources */, 9FEC01012D71000100112233 /* NotationWindowView.swift in Sources */, 9F8A010D2C00000100112233 /* AppHotkey.swift in Sources */, + 9FCB01062D80000100112233 /* NotationMeasureLayout.swift in Sources */, 9FEA01032D70000100112233 /* NotationViewportFactory.swift in Sources */, 9FEA01052D70000100112233 /* NotationVisibleMeasureFitter.swift in Sources */, 9FEA01022D70000100112233 /* NotationViewportState.swift in Sources */, @@ -759,7 +799,7 @@ 9FB202012D31000100112233 /* ProjectPersistenceCoordinator.swift in Sources */, 9F8A01132C00000100112233 /* RecentProjectsStore.swift in Sources */, 9F9401012CA0000100112233 /* AppSettingsStore.swift in Sources */, - 9FEA01012D70000100112233 /* ScoreDocument.swift in Sources */, + 9FEA01012D70000100112233 /* NotationScoreModels.swift in Sources */, 9F8D01042C30000100112233 /* AudioRenderState.swift in Sources */, 9F8D01052C30000100112233 /* ClickRenderState.swift in Sources */, 9F8D01032C30000100112233 /* StemMixAudioPlayer.swift in Sources */, @@ -781,9 +821,13 @@ 9F8B01042C10000100112233 /* TimelineTracks.swift in Sources */, 9F8B01012C10000100112233 /* TimelineViewport.swift in Sources */, 9F9701012CD0000100112233 /* TimelineViewportControlBar.swift in Sources */, + 9FCB010A2D80000100112233 /* BeatOneToolbarButton.swift in Sources */, + 9FCB01092D80000100112233 /* ClickToolbarButton.swift in Sources */, + 9FCB010B2D80000100112233 /* StemSeparationToolbarButton.swift in Sources */, 9F8C01092C20000100112233 /* TopToolbarView.swift in Sources */, 9FBA01022D40000100112233 /* TrackPitchAnalyzer.swift in Sources */, 9F8C01072C20000100112233 /* TransportBarView.swift in Sources */, + 9FCB01082D80000100112233 /* TransportButton.swift in Sources */, 9F9001012C60000100112233 /* TransportControlsView.swift in Sources */, 9FBA01032D40000100112233 /* TunerInputService.swift in Sources */, 9FBA01042D40000100112233 /* TunerView.swift in Sources */, diff --git a/JammLab/Models/ScoreDocument.swift b/JammLab/Models/NotationScoreModels.swift similarity index 94% rename from JammLab/Models/ScoreDocument.swift rename to JammLab/Models/NotationScoreModels.swift index 51f2050..7aadce4 100644 --- a/JammLab/Models/ScoreDocument.swift +++ b/JammLab/Models/NotationScoreModels.swift @@ -1,27 +1,5 @@ import Foundation -struct ScoreDocument: Equatable { - var title: String? - var parts: [ScorePart] - - init(title: String? = nil, parts: [ScorePart]) { - self.title = title - self.parts = parts - } -} - -struct ScorePart: Equatable, Identifiable { - var id: String - var name: String - var measures: [ScoreMeasure] - - init(id: String = "P1", name: String = "Notation", measures: [ScoreMeasure]) { - self.id = id - self.name = name - self.measures = measures - } -} - struct ScoreMeasure: Equatable, Identifiable { var number: Int var startTime: TimeInterval diff --git a/JammLab/Services/AudioSampleConverter.swift b/JammLab/Services/AudioSampleConverter.swift new file mode 100644 index 0000000..1240850 --- /dev/null +++ b/JammLab/Services/AudioSampleConverter.swift @@ -0,0 +1,60 @@ +import AVFoundation + +enum AudioSampleConverter { + static func monoFloatSamples(from buffer: AVAudioPCMBuffer) -> [Float]? { + let frameLength = Int(buffer.frameLength) + let channelCount = Int(buffer.format.channelCount) + guard frameLength > 0, channelCount > 0 else { return [] } + guard buffer.format.commonFormat == .pcmFormatFloat32 else { return nil } + + if buffer.format.isInterleaved { + return interleavedMonoFloatSamples(from: buffer, frameLength: frameLength, channelCount: channelCount) + } + + return nonInterleavedMonoFloatSamples(from: buffer, frameLength: frameLength, channelCount: channelCount) + } + + private static func nonInterleavedMonoFloatSamples( + from buffer: AVAudioPCMBuffer, + frameLength: Int, + channelCount: Int + ) -> [Float]? { + guard let channels = buffer.floatChannelData else { return nil } + + var mono = Array(repeating: Float(0), count: frameLength) + for channel in 0.. [Float]? { + let audioBuffers = UnsafeMutableAudioBufferListPointer(buffer.mutableAudioBufferList) + guard let audioBuffer = audioBuffers.first, + let data = audioBuffer.mData else { + return nil + } + + let sampleCount = frameLength * channelCount + let interleaved = data.bindMemory(to: Float.self, capacity: sampleCount) + var mono = Array(repeating: Float(0), count: frameLength) + for frame in 0.. NotationAttributeDisplay { + guard let previousAttributes else { return .full } + + return NotationAttributeDisplay( + showsClef: attributes.clef != previousAttributes.clef, + showsKeySignature: attributes.keySignature != previousAttributes.keySignature, + showsTimeSignature: attributes.timeSignature != previousAttributes.timeSignature + ) + } +} + +struct NotationMeasureCanvasGeometry: Equatable { + let measureIndex: Int + let cellStartX: CGFloat + let cellEndX: CGFloat + let contentStartX: CGFloat + let contentEndX: CGFloat + let staffStartX: CGFloat + let staffEndX: CGFloat + + var includesRawStartBarline: Bool { + measureIndex > 0 || !contentStartsAfterCellBoundary + } + + var contentStartsAfterCellBoundary: Bool { + contentStartX > cellStartX + 0.0001 + } + + var leadingBarlineX: CGFloat? { + if measureIndex == 0 { + return staffStartX + } + + return includesRawStartBarline ? cellStartX : nil + } +} + +struct NotationBarlineGeometry: Equatable { + let x: CGFloat + let isOuterBoundary: Bool +} + +struct NotationMeasureLayout { + static var measureNumberLabelWidth: CGFloat { + AppTheme.Timeline.notationMeasureNumberLabelWidth + } + + static func canvasWidth( + measureCount: Int, + availableWidth: CGFloat, + attributeReserveWidths: [CGFloat] + ) -> CGFloat { + let safeMeasureCount = max(1, measureCount) + let bodyWidth = baseMeasureBodyWidth( + measureCount: safeMeasureCount, + availableWidth: availableWidth + ) + return bodyWidth * CGFloat(safeMeasureCount) + attributeReserveWidths.reduce(0, +) + } + + static func minimumCanvasWidth( + measureCount: Int, + attributeReserveWidths: [CGFloat] + ) -> CGFloat { + AppTheme.Timeline.notationMeasureMinWidth * CGFloat(max(1, measureCount)) + + attributeReserveWidths.reduce(0, +) + } + + static func baseMeasureBodyWidth(measureCount: Int, availableWidth: CGFloat) -> CGFloat { + let safeMeasureCount = max(1, measureCount) + return max( + AppTheme.Timeline.notationMeasureMinWidth, + max(0, availableWidth) / CGFloat(safeMeasureCount) + ) + } + + static func measureBodyWidth( + measureCount: Int, + totalWidth: CGFloat, + attributeReserveWidths: [CGFloat] + ) -> CGFloat { + let safeMeasureCount = max(1, measureCount) + let remainingWidth = max(0, totalWidth - attributeReserveWidths.reduce(0, +)) + return max( + AppTheme.Timeline.notationMeasureMinWidth, + remainingWidth / CGFloat(safeMeasureCount) + ) + } + + static func canvasGeometries( + measureCount: Int, + totalWidth: CGFloat, + attributeReserveWidths: [CGFloat] + ) -> [NotationMeasureCanvasGeometry] { + let safeMeasureCount = max(1, measureCount) + let bodyWidth = measureBodyWidth( + measureCount: safeMeasureCount, + totalWidth: totalWidth, + attributeReserveWidths: attributeReserveWidths + ) + var cursorX: CGFloat = 0 + + return (0.. CGFloat { + systemMeasureNumberLabelTrailingX(geometry: geometry) - measureNumberLabelWidth + } + + static func systemMeasureNumberLabelTrailingX(geometry: NotationMeasureCanvasGeometry) -> CGFloat { + geometry.staffStartX + AppTheme.Spacing.sm + } + + static var systemMeasureNumberStaffGap: CGFloat { + AppTheme.Spacing.headerVertical + } + + static func systemMeasureNumberLabelY(staffTop: CGFloat) -> CGFloat { + max(AppTheme.Spacing.xs, staffTop - systemMeasureNumberStaffGap) + } + + static func harmonyLabelY( + staffTop: CGFloat, + elementHeight: CGFloat = AppTheme.ControlSize.abletonNumberFieldHeight, + gap: CGFloat = AppTheme.Spacing.xs + ) -> CGFloat { + max(AppTheme.Spacing.xs, staffTop - max(0, elementHeight) - max(0, gap)) + } + + static func regionLabelY( + staffTop: CGFloat, + labelHeight: CGFloat = AppTheme.Timeline.notationRegionLabelHeight, + gap: CGFloat = AppTheme.Timeline.notationRegionLabelGap + ) -> CGFloat { + let harmonyY = harmonyLabelY(staffTop: staffTop) + return max(AppTheme.Spacing.xxxs, harmonyY - max(0, labelHeight) - max(0, gap)) + } + + static func regionLabelX( + geometry: NotationMeasureCanvasGeometry, + offsetInQuarterNotes: Double, + timeSignature: TimeSignature, + labelWidth: CGFloat = AppTheme.Timeline.notationRegionLabelMaxWidth, + avoidsSystemMeasureNumber: Bool = false, + measureNumberGap: CGFloat = AppTheme.Spacing.sm + ) -> CGFloat { + let bounds = regionLabelXBounds( + geometry: geometry, + labelWidth: labelWidth, + avoidsSystemMeasureNumber: avoidsSystemMeasureNumber, + measureNumberGap: measureNumberGap + ) + return regionLabelX( + geometry: geometry, + offsetInQuarterNotes: offsetInQuarterNotes, + timeSignature: timeSignature, + bounds: bounds + ) + } + + static func regionLabelX( + geometry: NotationMeasureCanvasGeometry, + offsetInQuarterNotes: Double, + timeSignature: TimeSignature, + bounds: ClosedRange + ) -> CGFloat { + let anchorX = notationAnchorX( + geometry: geometry, + offsetInQuarterNotes: offsetInQuarterNotes, + timeSignature: timeSignature, + anchorInset: 0 + ) + return min(max(anchorX, bounds.lowerBound), bounds.upperBound) + } + + static func regionLabelXBounds( + geometry: NotationMeasureCanvasGeometry, + labelWidth: CGFloat = AppTheme.Timeline.notationRegionLabelMaxWidth, + avoidsSystemMeasureNumber: Bool = false, + measureNumberGap: CGFloat = AppTheme.Spacing.sm + ) -> ClosedRange { + let lowerBound = regionLabelLowerBound( + geometry: geometry, + avoidsSystemMeasureNumber: avoidsSystemMeasureNumber, + measureNumberGap: measureNumberGap + ) + let rawUpperBound = regionLabelUpperBound( + geometry: geometry, + labelWidth: labelWidth + ) + + return lowerBound...max(lowerBound, rawUpperBound) + } + + static func regionLabelLowerBound( + geometry: NotationMeasureCanvasGeometry, + avoidsSystemMeasureNumber: Bool, + measureNumberGap: CGFloat = AppTheme.Spacing.sm + ) -> CGFloat { + let baseLowerBound = max(geometry.staffStartX, geometry.cellStartX) + guard avoidsSystemMeasureNumber else { return baseLowerBound } + + return max( + baseLowerBound, + systemMeasureNumberLabelTrailingX(geometry: geometry) + max(0, measureNumberGap) + ) + } + + static func regionLabelUpperBound( + geometry: NotationMeasureCanvasGeometry, + labelWidth: CGFloat = AppTheme.Timeline.notationRegionLabelMaxWidth + ) -> CGFloat { + let visualStartX = max(geometry.staffStartX, geometry.cellStartX) + let visualEndX = max(visualStartX, geometry.staffEndX) + return max(visualStartX, visualEndX - max(0, labelWidth)) + } + + static func barlineGeometries(for geometries: [NotationMeasureCanvasGeometry]) -> [NotationBarlineGeometry] { + guard let lastGeometry = geometries.last else { return [] } + + var barlines = geometries.compactMap { geometry -> NotationBarlineGeometry? in + guard let x = geometry.leadingBarlineX else { return nil } + + return NotationBarlineGeometry( + x: x, + isOuterBoundary: geometry.measureIndex == 0 + ) + } + barlines.append( + NotationBarlineGeometry( + x: lastGeometry.staffEndX, + isOuterBoundary: true + ) + ) + return barlines + } + + static func attributeBlockWidth( + for attributes: MeasureAttributes, + display: NotationAttributeDisplay, + cellWidth: CGFloat + ) -> CGFloat { + let componentWidths = visibleComponentWidths(for: attributes, display: display) + guard !componentWidths.isEmpty else { return 0 } + + return componentWidths.reduce(0, +) + + spacingWidth(forVisibleComponentCount: componentWidths.count) + } + + static func attributeReserveWidth( + for attributes: MeasureAttributes, + display: NotationAttributeDisplay + ) -> CGFloat { + let blockWidth = attributeBlockWidth( + for: attributes, + display: display, + cellWidth: AppTheme.Timeline.notationMeasureMinWidth + ) + guard blockWidth > 0 else { return 0 } + return AppTheme.Spacing.md + blockWidth + AppTheme.Spacing.xs + } + + static func keySignatureWidth(for attributes: MeasureAttributes) -> CGFloat { + let glyphs = attributes.keySignature.notationAccidentalGlyphs(for: attributes.clef) + return glyphs.isEmpty + ? 0 + : max(12, CGFloat(glyphs.count) * AppTheme.Timeline.notationAccidentalWidth) + } + + static func visibleComponentCount( + for attributes: MeasureAttributes, + display: NotationAttributeDisplay + ) -> Int { + visibleComponentWidths(for: attributes, display: display).count + } + + static func spacingWidth(forVisibleComponentCount componentCount: Int) -> CGFloat { + AppTheme.Spacing.xs * CGFloat(max(0, componentCount - 1)) + } + + static func attributeStaffTopInset( + for _: MeasureAttributes, + display: NotationAttributeDisplay + ) -> CGFloat { + display.isEmpty ? 0 : AppTheme.Timeline.notationAttributeStaffTopInset + } + + static func contentStartX( + measureIndex: Int, + cellWidth: CGFloat, + attributes: MeasureAttributes, + display: NotationAttributeDisplay + ) -> CGFloat { + let cellStartX = CGFloat(measureIndex) * cellWidth + return cellStartX + attributeReserveWidth(for: attributes, display: display) + } + + static func contentWidth( + measureIndex: Int, + cellWidth: CGFloat, + attributes: MeasureAttributes, + display: NotationAttributeDisplay + ) -> CGFloat { + max(AppTheme.Timeline.notationMinimumMeasureContentWidth, cellWidth) + } + + static func playheadX( + measureIndex: Int, + cellWidth: CGFloat, + progress: CGFloat, + attributes: MeasureAttributes, + display: NotationAttributeDisplay + ) -> CGFloat { + let clampedProgress = max(0, min(progress, 1)) + let startX = contentStartX( + measureIndex: measureIndex, + cellWidth: cellWidth, + attributes: attributes, + display: display + ) + let width = contentWidth( + measureIndex: measureIndex, + cellWidth: cellWidth, + attributes: attributes, + display: display + ) + return startX + clampedProgress * width + } + + static func playheadX( + 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 + } + + static func playheadIndicatorX( + geometry: NotationMeasureCanvasGeometry, + progress: CGFloat, + indicatorWidth: CGFloat + ) -> CGFloat { + let rawX = playheadX(geometry: geometry, progress: progress) + let visualStartX = min(geometry.staffStartX, geometry.staffEndX) + let visualEndX = max(geometry.staffStartX, geometry.staffEndX) + let safeIndicatorWidth = max(0, indicatorWidth) + let maximumX = max(visualStartX, visualEndX - safeIndicatorWidth) + return min(max(rawX, visualStartX), maximumX) + } + + static func slashBeatCenters( + geometry: NotationMeasureCanvasGeometry, + timeSignature: TimeSignature, + minimumBeatSpacing: CGFloat = AppTheme.Timeline.notationSlashMinimumBeatSpacing + ) -> [CGFloat] { + let beatCount = timeSignature.beatsPerBar + let contentWidth = geometry.contentEndX - geometry.contentStartX + 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.. CGFloat { + let quarterLength = quarterLength(for: timeSignature) + guard quarterLength > 0 else { return geometry.contentStartX } + + 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) + } + + static func notationAnchorProgress( + atX x: CGFloat, + geometry: NotationMeasureCanvasGeometry, + anchorInset: CGFloat = AppTheme.Timeline.notationBeatAnchorInset + ) -> 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 + return Double(max(0, min(rawProgress, 1))) + } + + static func harmonyX( + geometry: NotationMeasureCanvasGeometry, + offsetInQuarterNotes: Double, + timeSignature: TimeSignature + ) -> CGFloat { + notationAnchorX( + geometry: geometry, + offsetInQuarterNotes: offsetInQuarterNotes, + timeSignature: timeSignature + ) + } + + static func harmonyLabelX( + geometry: NotationMeasureCanvasGeometry, + offsetInQuarterNotes: Double, + timeSignature: TimeSignature, + leadingOffset: CGFloat = AppTheme.Timeline.notationHarmonyAnchorLeadingOffset + ) -> CGFloat { + let anchorX = harmonyX( + geometry: geometry, + offsetInQuarterNotes: offsetInQuarterNotes, + timeSignature: timeSignature + ) + let lowerBound = max(geometry.staffStartX, geometry.contentStartX) + let upperBound = max(lowerBound, geometry.contentEndX) + let rawX = anchorX - max(0, leadingOffset) + return min(max(rawX, lowerBound), upperBound) + } + + static func snappedHarmonyOffset( + _ offset: Double, + timeSignature: TimeSignature, + resolution: HarmonyInputResolution + ) -> Double { + let step = resolution.stepInQuarterNotes + guard step > 0 else { return 0 } + let maximumOffset = maximumHarmonyOffset(timeSignature: timeSignature, resolution: resolution) + let snapped = (offset / step).rounded() * step + return max(0, min(snapped, maximumOffset)) + } + + static func time(forHarmonyOffset offset: Double, in measure: ScoreMeasure) -> TimeInterval { + let quarterLength = quarterLength(for: measure.attributes.timeSignature) + guard measure.duration > 0, quarterLength > 0 else { return measure.startTime } + let progress = max(0, min(offset / quarterLength, 1)) + return measure.startTime + progress * measure.duration + } + + static func quarterLength(for timeSignature: TimeSignature) -> Double { + Double(timeSignature.beatsPerBar) * 4.0 / Double(max(1, timeSignature.beatUnit)) + } + + static func canvasGeometry( + measureIndex: Int, + measureCount: Int, + cellWidth: CGFloat, + attributes: MeasureAttributes, + display: NotationAttributeDisplay, + totalWidth: CGFloat + ) -> NotationMeasureCanvasGeometry { + let cellStartX = CGFloat(measureIndex) * cellWidth + let contentStartX = cellStartX + attributeReserveWidth( + for: attributes, + display: display + ) + + return canvasGeometry( + measureIndex: measureIndex, + measureCount: measureCount, + cellStartX: cellStartX, + cellEndX: contentStartX + max(0, cellWidth), + contentStartX: contentStartX, + totalWidth: totalWidth + ) + } + + static func canvasGeometry( + measureIndex: Int, + measureCount: Int, + cellWidth: CGFloat, + contentStartX: CGFloat, + totalWidth: CGFloat + ) -> NotationMeasureCanvasGeometry { + let cellStartX = CGFloat(measureIndex) * cellWidth + return canvasGeometry( + measureIndex: measureIndex, + measureCount: measureCount, + cellStartX: cellStartX, + cellEndX: max(cellStartX + cellWidth, contentStartX + cellWidth), + contentStartX: contentStartX, + totalWidth: totalWidth + ) + } + + static func canvasGeometry( + measureIndex: Int, + measureCount: Int, + cellStartX: CGFloat, + cellEndX: CGFloat, + contentStartX: CGFloat, + totalWidth: CGFloat + ) -> NotationMeasureCanvasGeometry { + let safeCellStartX = max(0, cellStartX) + let safeCellEndX = max(safeCellStartX, cellEndX) + let clampedContentStartX = min(max(safeCellStartX, contentStartX), safeCellEndX) + let lastMeasureIndex = max(0, measureCount - 1) + var staffStartX = safeCellStartX + var staffEndX = safeCellEndX + + if measureIndex == 0 { + staffStartX = max( + safeCellStartX, + min(safeCellEndX, safeCellStartX + AppTheme.Timeline.notationStaffHorizontalInset) + ) + } + + if measureIndex == lastMeasureIndex { + staffEndX = max( + staffStartX, + safeCellEndX - AppTheme.Timeline.notationStaffHorizontalInset + ) + } + + return NotationMeasureCanvasGeometry( + measureIndex: measureIndex, + cellStartX: safeCellStartX, + cellEndX: safeCellEndX, + contentStartX: clampedContentStartX, + contentEndX: safeCellEndX, + staffStartX: staffStartX, + staffEndX: max(staffStartX, staffEndX) + ) + } + + static func fallbackCanvasGeometries( + measureCount: Int, + totalWidth: CGFloat + ) -> [NotationMeasureCanvasGeometry] { + let safeMeasureCount = max(1, measureCount) + let cellWidth = totalWidth / CGFloat(safeMeasureCount) + + return (0.. Int? { + guard !geometries.isEmpty else { return nil } + let clampedX = max(0, x) + + if let index = geometries.firstIndex(where: { geometry in + let isLastGeometry = geometry.measureIndex == geometries.last?.measureIndex + return clampedX >= geometry.cellStartX + && (clampedX < geometry.cellEndX || (isLastGeometry && clampedX <= geometry.cellEndX)) + }) { + return index + } + + return clampedX < geometries[0].cellStartX ? 0 : geometries.indices.last + } + + private static func visibleComponentWidths( + for attributes: MeasureAttributes, + display: NotationAttributeDisplay + ) -> [CGFloat] { + var widths: [CGFloat] = [] + + if display.showsClef { + widths.append(AppTheme.Timeline.notationClefWidth) + } + + let keyWidth = keySignatureWidth(for: attributes) + if display.showsKeySignature, keyWidth > 0 { + widths.append(keyWidth) + } + + if display.showsTimeSignature { + widths.append(AppTheme.Timeline.notationTimeSignatureWidth) + } + + return widths + } + + private static func maximumHarmonyOffset( + timeSignature: TimeSignature, + resolution: HarmonyInputResolution + ) -> Double { + let length = quarterLength(for: timeSignature) + let step = resolution.stepInQuarterNotes + guard length > 0, step > 0 else { return 0 } + let slots = max(0, Int(floor((length - 0.000_001) / step))) + return Double(slots) * step + } +} diff --git a/JammLab/Services/TrackPitchAnalyzer.swift b/JammLab/Services/TrackPitchAnalyzer.swift index 00c356a..a97755b 100644 --- a/JammLab/Services/TrackPitchAnalyzer.swift +++ b/JammLab/Services/TrackPitchAnalyzer.swift @@ -52,65 +52,6 @@ struct TrackPitchAnalyzer { } } -enum AudioSampleConverter { - static func monoFloatSamples(from buffer: AVAudioPCMBuffer) -> [Float]? { - let frameLength = Int(buffer.frameLength) - let channelCount = Int(buffer.format.channelCount) - guard frameLength > 0, channelCount > 0 else { return [] } - guard buffer.format.commonFormat == .pcmFormatFloat32 else { return nil } - - if buffer.format.isInterleaved { - return interleavedMonoFloatSamples(from: buffer, frameLength: frameLength, channelCount: channelCount) - } - - return nonInterleavedMonoFloatSamples(from: buffer, frameLength: frameLength, channelCount: channelCount) - } - - private static func nonInterleavedMonoFloatSamples( - from buffer: AVAudioPCMBuffer, - frameLength: Int, - channelCount: Int - ) -> [Float]? { - guard let channels = buffer.floatChannelData else { return nil } - - var mono = Array(repeating: Float(0), count: frameLength) - for channel in 0.. [Float]? { - let audioBuffers = UnsafeMutableAudioBufferListPointer(buffer.mutableAudioBufferList) - guard let audioBuffer = audioBuffers.first, - let data = audioBuffer.mData else { - return nil - } - - let sampleCount = frameLength * channelCount - let interleaved = data.bindMemory(to: Float.self, capacity: sampleCount) - var mono = Array(repeating: Float(0), count: frameLength) - for frame in 0..: NSViewRepresentable { - let values: [Value] - let selection: Binding - let titleForValue: (Value) -> String - let accessibilityLabel: String - - func makeNSView(context: Context) -> CompactValuePickerNSView { - let view = CompactValuePickerNSView() - view.popupButton.target = context.coordinator - view.popupButton.action = #selector(Coordinator.selectionChanged(_:)) - return view - } - - func updateNSView(_ nsView: CompactValuePickerNSView, context: Context) { - context.coordinator.parent = self - let titles = values.map(titleForValue) - let selectedIndex = values.firstIndex(of: selection.wrappedValue) ?? 0 - nsView.configure( - titles: titles, - selectedIndex: selectedIndex, - colors: context.environment.appColors, - isEnabled: context.environment.isEnabled, - accessibilityLabel: accessibilityLabel - ) - } - - func makeCoordinator() -> Coordinator { - Coordinator(parent: self) - } - - final class Coordinator: NSObject { - var parent: CompactValuePicker - - init(parent: CompactValuePicker) { - self.parent = parent - } - - @objc func selectionChanged(_ sender: NSPopUpButton) { - guard parent.values.indices.contains(sender.indexOfSelectedItem) else { return } - parent.selection.wrappedValue = parent.values[sender.indexOfSelectedItem] - } - } -} - -final class CompactValuePickerNSView: NSView { - let popupButton = NSPopUpButton(frame: .zero, pullsDown: false) - private var colors = AppThemeColors.default - private var isControlEnabled = true - - override var intrinsicContentSize: NSSize { - NSSize(width: NSView.noIntrinsicMetric, height: AppTheme.ControlSize.abletonNumberFieldHeight) - } - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - configureView() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - configureView() - } - - override func layout() { - super.layout() - popupButton.frame = bounds.insetBy(dx: 1, dy: max(0, (bounds.height - AppTheme.ControlSize.abletonNumberFieldHeight) / 2)) - updateLayerStyle() - } - - func configure( - titles: [String], - selectedIndex: Int, - colors: AppThemeColors, - isEnabled: Bool, - accessibilityLabel: String - ) { - self.colors = colors - isControlEnabled = isEnabled - popupButton.isEnabled = isEnabled - popupButton.setAccessibilityLabel(accessibilityLabel) - - if popupButton.itemTitles != titles { - popupButton.removeAllItems() - popupButton.addItems(withTitles: titles) - } - - if popupButton.numberOfItems > 0 { - popupButton.selectItem(at: max(0, min(selectedIndex, popupButton.numberOfItems - 1))) - } - - popupButton.setAccessibilityValue(popupButton.titleOfSelectedItem) - updateLayerStyle() - } - - private func configureView() { - wantsLayer = true - popupButton.isBordered = false - popupButton.font = .monospacedDigitSystemFont(ofSize: NSFont.smallSystemFontSize, weight: .medium) - popupButton.controlSize = .small - popupButton.translatesAutoresizingMaskIntoConstraints = true - addSubview(popupButton) - configureCompactVerticalControlSizing() - } - - private func updateLayerStyle() { - layer?.cornerRadius = AppTheme.Radius.small - layer?.borderWidth = AppTheme.Stroke.thin - layer?.borderColor = colors.nsColor(for: .border).cgColor - layer?.backgroundColor = backgroundColor.cgColor - popupButton.contentTintColor = textColor - } - - private var backgroundColor: NSColor { - isControlEnabled - ? colors.nsColor(for: .controlBackground) - : colors.nsColor(for: .controlBackground).withAlphaComponent(0.55) - } - - private var textColor: NSColor { - isControlEnabled ? colors.nsColor(for: .primaryText) : colors.nsColor(for: .disabledText) - } -} - -struct AppLetterToggleButton: View { - let title: String - var isActive = false - let activeFillColor: Color - let inactiveTextColor: Color - let action: () -> Void - @Environment(\.isEnabled) private var isEnabled - @Environment(\.appColors) private var appColors - - init( - title: String, - isActive: Bool = false, - activeFillColor: Color, - inactiveTextColor: Color, - action: @escaping () -> Void - ) { - self.title = title - self.isActive = isActive - self.activeFillColor = activeFillColor - self.inactiveTextColor = inactiveTextColor - self.action = action - } - - var body: some View { - Button(action: action) { - Text(title) - .font(.system(size: 12, weight: .bold, design: .default)) - .foregroundStyle(textColor) - .frame( - width: AppTheme.ControlSize.letterToggleButtonWidth, - height: AppTheme.ControlSize.letterToggleButtonHeight - ) - .background(backgroundColor) - .overlay { - Rectangle() - .stroke(borderColor, lineWidth: AppTheme.Stroke.thin) - } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .opacity(isEnabled ? 1 : 0.45) - } - - private var backgroundColor: Color { - isActive ? activeFillColor : appColors.statusButtonFill - } - - private var textColor: Color { - if !isEnabled { return appColors.disabledText } - return isActive ? appColors.primaryText : inactiveTextColor - } - - private var borderColor: Color { - isActive ? activeFillColor : appColors.border - } -} - -struct JammModeToggleButton: View { - @Binding var playbackMode: PlaybackMode - let isEnabled: Bool - let accessibilityLabel: String - - @State private var isHovered = false - @Environment(\.appColors) private var appColors - - var body: some View { - Button { - playbackMode = playbackMode == .stems ? .original : .stems - } label: { - JammModeToggleIcon(color: lineColor) - .frame(width: 24, height: 22) - .contentShape(Rectangle()) - } - .buttonStyle(JammModeToggleButtonStyle(isHovered: isHovered, isEnabled: isEnabled)) - .disabled(!isEnabled) - .onHover { isHovered = $0 } - .accessibilityLabel(accessibilityLabel) - .accessibilityValue(playbackMode.title) - } - - private var lineColor: Color { - guard isEnabled else { return appColors.disabledText.opacity(0.55) } - - if playbackMode == .stems { - return isHovered ? appColors.accentHover : appColors.accent - } - - return isHovered ? appColors.secondaryText : appColors.tertiaryText - } -} - -private struct JammModeToggleIcon: View { - let color: Color - - var body: some View { - VStack(spacing: 3) { - ForEach(0..<3, id: \.self) { _ in - Capsule() - .fill(color) - .frame(width: 14, height: 2) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) - } -} - -private struct JammModeToggleButtonStyle: ButtonStyle { - let isHovered: Bool - let isEnabled: Bool - - func makeBody(configuration: Configuration) -> some View { - configuration.label - .opacity(opacity(isPressed: configuration.isPressed)) - } - - private func opacity(isPressed: Bool) -> Double { - guard isEnabled else { return 0.45 } - if isPressed { return 0.72 } - return isHovered ? 1 : 0.86 - } -} - -extension View { - func onAppPopoverDismiss(isPresented: Bool, perform action: @escaping () -> Void) -> some View { - modifier(AppPopoverDismissModifier(isPresented: isPresented, action: action)) - } -} - -private struct AppPopoverDismissModifier: ViewModifier { - let isPresented: Bool - let action: () -> Void - - @State private var didPresent = false - - func body(content: Content) -> some View { - content - .onChange(of: isPresented) { _, newValue in - if newValue { - didPresent = true - } else if didPresent { - didPresent = false - DispatchQueue.main.async { - action() - } - } - } - } -} diff --git a/JammLab/Views/Components/AppLetterToggleButton.swift b/JammLab/Views/Components/AppLetterToggleButton.swift new file mode 100644 index 0000000..b46f960 --- /dev/null +++ b/JammLab/Views/Components/AppLetterToggleButton.swift @@ -0,0 +1,58 @@ +import SwiftUI + +struct AppLetterToggleButton: View { + let title: String + var isActive = false + let activeFillColor: Color + let inactiveTextColor: Color + let action: () -> Void + @Environment(\.isEnabled) private var isEnabled + @Environment(\.appColors) private var appColors + + init( + title: String, + isActive: Bool = false, + activeFillColor: Color, + inactiveTextColor: Color, + action: @escaping () -> Void + ) { + self.title = title + self.isActive = isActive + self.activeFillColor = activeFillColor + self.inactiveTextColor = inactiveTextColor + self.action = action + } + + var body: some View { + Button(action: action) { + Text(title) + .font(.system(size: 12, weight: .bold, design: .default)) + .foregroundStyle(textColor) + .frame( + width: AppTheme.ControlSize.letterToggleButtonWidth, + height: AppTheme.ControlSize.letterToggleButtonHeight + ) + .background(backgroundColor) + .overlay { + Rectangle() + .stroke(borderColor, lineWidth: AppTheme.Stroke.thin) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .opacity(isEnabled ? 1 : 0.45) + } + + private var backgroundColor: Color { + isActive ? activeFillColor : appColors.statusButtonFill + } + + private var textColor: Color { + if !isEnabled { return appColors.disabledText } + return isActive ? appColors.primaryText : inactiveTextColor + } + + private var borderColor: Color { + isActive ? activeFillColor : appColors.border + } +} diff --git a/JammLab/Views/Components/AppPopoverDismissModifier.swift b/JammLab/Views/Components/AppPopoverDismissModifier.swift new file mode 100644 index 0000000..99b7572 --- /dev/null +++ b/JammLab/Views/Components/AppPopoverDismissModifier.swift @@ -0,0 +1,28 @@ +import SwiftUI + +extension View { + func onAppPopoverDismiss(isPresented: Bool, perform action: @escaping () -> Void) -> some View { + modifier(AppPopoverDismissModifier(isPresented: isPresented, action: action)) + } +} + +private struct AppPopoverDismissModifier: ViewModifier { + let isPresented: Bool + let action: () -> Void + + @State private var didPresent = false + + func body(content: Content) -> some View { + content + .onChange(of: isPresented) { _, newValue in + if newValue { + didPresent = true + } else if didPresent { + didPresent = false + DispatchQueue.main.async { + action() + } + } + } + } +} diff --git a/JammLab/Views/Components/BeatOneToolbarButton.swift b/JammLab/Views/Components/BeatOneToolbarButton.swift new file mode 100644 index 0000000..d4f9df4 --- /dev/null +++ b/JammLab/Views/Components/BeatOneToolbarButton.swift @@ -0,0 +1,61 @@ +import SwiftUI + +struct BeatOneToolbarButton: View { + let canSetBeatOne: Bool + let canResetBeatGrid: Bool + let onSetBeatOne: () -> Void + let onResetBeatGrid: () -> Void + let onNudgeBeatGrid: (TimeInterval) -> Void + let onPopoverDismiss: () -> Void + + @State private var isPopoverPresented = false + + var body: some View { + Button(action: onSetBeatOne) { + Label("Beat 1", systemImage: "flag.fill") + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(!canSetBeatOne) + .help(ControlHelpText.setBeatOne) + .overlay { + RightClickCaptureView { _ in + guard canSetBeatOne else { return } + isPopoverPresented = true + } + } + .popover(isPresented: $isPopoverPresented, arrowEdge: .bottom) { + VStack(alignment: .leading, spacing: AppTheme.Spacing.lg) { + Button { + onResetBeatGrid() + } label: { + Label("Reset", systemImage: "arrow.counterclockwise") + } + .disabled(!canResetBeatGrid) + .help(ControlHelpText.resetBeatGrid) + + Divider() + + HStack(spacing: AppTheme.Spacing.md) { + beatGridNudgeButton("-50 ms", systemImage: "backward.end.fill", delta: -0.05) + beatGridNudgeButton("-10 ms", systemImage: "chevron.left", delta: -0.01) + beatGridNudgeButton("+10 ms", systemImage: "chevron.right", delta: 0.01) + beatGridNudgeButton("+50 ms", systemImage: "forward.end.fill", delta: 0.05) + } + } + .buttonStyle(.bordered) + .controlSize(.small) + .padding(AppTheme.Spacing.panelPadding) + } + .onAppPopoverDismiss(isPresented: isPopoverPresented, perform: onPopoverDismiss) + } + + private func beatGridNudgeButton(_ title: String, systemImage: String, delta: TimeInterval) -> some View { + Button { + onNudgeBeatGrid(delta) + } label: { + Label(title, systemImage: systemImage) + } + .help(ControlHelpText.beatGridNudge(title)) + } +} diff --git a/JammLab/Views/Components/ClickToolbarButton.swift b/JammLab/Views/Components/ClickToolbarButton.swift new file mode 100644 index 0000000..3e86ab9 --- /dev/null +++ b/JammLab/Views/Components/ClickToolbarButton.swift @@ -0,0 +1,66 @@ +import SwiftUI + +struct ClickToolbarButton: View { + let isEnabled: Bool + let isAvailable: Bool + let volume: Float + let volumeText: String + let onToggle: () -> Void + let onVolumeChanged: (Float) -> Void + let onPopoverDismiss: () -> Void + + @State private var isVolumePopoverPresented = false + @Environment(\.appColors) private var appColors + + var body: some View { + AppControlButton( + title: "Click", + systemImage: "metronome", + isActive: isEnabled, + action: onToggle + ) + .controlSize(.small) + .disabled(!isAvailable) + .help(ControlHelpText.click) + .overlay { + RightClickCaptureView { _ in + guard isAvailable else { return } + isVolumePopoverPresented = true + } + } + .popover(isPresented: $isVolumePopoverPresented, arrowEdge: .bottom) { + VStack(alignment: .leading, spacing: AppTheme.Spacing.md) { + HStack { + Label("Click Volume", systemImage: "speaker.wave.2") + Spacer() + Text(volumeText) + .font(AppTheme.Typography.captionMonospaced) + .foregroundStyle(appColors.secondaryText) + } + + JammValueSlider( + value: Binding( + get: { Double(volume) }, + set: { onVolumeChanged(Float($0)) } + ), + minValue: 0, + maxValue: 1, + defaultValue: Double(AppSliderDefaults.clickVolume), + step: 0.01, + sensitivity: 1, + precision: 0, + displayFormatter: { "\(Int(($0 * 100).rounded()))%" }, + accessibilityLabel: "Click Volume" + ) + .frame( + width: AppTheme.ControlSize.clickVolumeWidth, + height: AppTheme.ControlSize.jammValueSliderHeight + ) + .help(ControlHelpText.clickVolume) + } + .padding(AppTheme.Spacing.panelPadding) + .frame(width: AppTheme.ControlSize.clickVolumeWidth + 96) + } + .onAppPopoverDismiss(isPresented: isVolumePopoverPresented, perform: onPopoverDismiss) + } +} diff --git a/JammLab/Views/Components/CompactValuePicker.swift b/JammLab/Views/Components/CompactValuePicker.swift new file mode 100644 index 0000000..87fea03 --- /dev/null +++ b/JammLab/Views/Components/CompactValuePicker.swift @@ -0,0 +1,125 @@ +import AppKit +import SwiftUI + +struct CompactValuePicker: NSViewRepresentable { + let values: [Value] + let selection: Binding + let titleForValue: (Value) -> String + let accessibilityLabel: String + + func makeNSView(context: Context) -> CompactValuePickerNSView { + let view = CompactValuePickerNSView() + view.popupButton.target = context.coordinator + view.popupButton.action = #selector(Coordinator.selectionChanged(_:)) + return view + } + + func updateNSView(_ nsView: CompactValuePickerNSView, context: Context) { + context.coordinator.parent = self + let titles = values.map(titleForValue) + let selectedIndex = values.firstIndex(of: selection.wrappedValue) ?? 0 + nsView.configure( + titles: titles, + selectedIndex: selectedIndex, + colors: context.environment.appColors, + isEnabled: context.environment.isEnabled, + accessibilityLabel: accessibilityLabel + ) + } + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + final class Coordinator: NSObject { + var parent: CompactValuePicker + + init(parent: CompactValuePicker) { + self.parent = parent + } + + @objc func selectionChanged(_ sender: NSPopUpButton) { + guard parent.values.indices.contains(sender.indexOfSelectedItem) else { return } + parent.selection.wrappedValue = parent.values[sender.indexOfSelectedItem] + } + } +} + +final class CompactValuePickerNSView: NSView { + let popupButton = NSPopUpButton(frame: .zero, pullsDown: false) + private var colors = AppThemeColors.default + private var isControlEnabled = true + + override var intrinsicContentSize: NSSize { + NSSize(width: NSView.noIntrinsicMetric, height: AppTheme.ControlSize.abletonNumberFieldHeight) + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + configureView() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + configureView() + } + + override func layout() { + super.layout() + popupButton.frame = bounds.insetBy(dx: 1, dy: max(0, (bounds.height - AppTheme.ControlSize.abletonNumberFieldHeight) / 2)) + updateLayerStyle() + } + + func configure( + titles: [String], + selectedIndex: Int, + colors: AppThemeColors, + isEnabled: Bool, + accessibilityLabel: String + ) { + self.colors = colors + isControlEnabled = isEnabled + popupButton.isEnabled = isEnabled + popupButton.setAccessibilityLabel(accessibilityLabel) + + if popupButton.itemTitles != titles { + popupButton.removeAllItems() + popupButton.addItems(withTitles: titles) + } + + if popupButton.numberOfItems > 0 { + popupButton.selectItem(at: max(0, min(selectedIndex, popupButton.numberOfItems - 1))) + } + + popupButton.setAccessibilityValue(popupButton.titleOfSelectedItem) + updateLayerStyle() + } + + private func configureView() { + wantsLayer = true + popupButton.isBordered = false + popupButton.font = .monospacedDigitSystemFont(ofSize: NSFont.smallSystemFontSize, weight: .medium) + popupButton.controlSize = .small + popupButton.translatesAutoresizingMaskIntoConstraints = true + addSubview(popupButton) + configureCompactVerticalControlSizing() + } + + private func updateLayerStyle() { + layer?.cornerRadius = AppTheme.Radius.small + layer?.borderWidth = AppTheme.Stroke.thin + layer?.borderColor = colors.nsColor(for: .border).cgColor + layer?.backgroundColor = backgroundColor.cgColor + popupButton.contentTintColor = textColor + } + + private var backgroundColor: NSColor { + isControlEnabled + ? colors.nsColor(for: .controlBackground) + : colors.nsColor(for: .controlBackground).withAlphaComponent(0.55) + } + + private var textColor: NSColor { + isControlEnabled ? colors.nsColor(for: .primaryText) : colors.nsColor(for: .disabledText) + } +} diff --git a/JammLab/Views/Components/JammModeToggleButton.swift b/JammLab/Views/Components/JammModeToggleButton.swift new file mode 100644 index 0000000..fd2a013 --- /dev/null +++ b/JammLab/Views/Components/JammModeToggleButton.swift @@ -0,0 +1,66 @@ +import SwiftUI + +struct JammModeToggleButton: View { + @Binding var playbackMode: PlaybackMode + let isEnabled: Bool + let accessibilityLabel: String + + @State private var isHovered = false + @Environment(\.appColors) private var appColors + + var body: some View { + Button { + playbackMode = playbackMode == .stems ? .original : .stems + } label: { + JammModeToggleIcon(color: lineColor) + .frame(width: 24, height: 22) + .contentShape(Rectangle()) + } + .buttonStyle(JammModeToggleButtonStyle(isHovered: isHovered, isEnabled: isEnabled)) + .disabled(!isEnabled) + .onHover { isHovered = $0 } + .accessibilityLabel(accessibilityLabel) + .accessibilityValue(playbackMode.title) + } + + private var lineColor: Color { + guard isEnabled else { return appColors.disabledText.opacity(0.55) } + + if playbackMode == .stems { + return isHovered ? appColors.accentHover : appColors.accent + } + + return isHovered ? appColors.secondaryText : appColors.tertiaryText + } +} + +private struct JammModeToggleIcon: View { + let color: Color + + var body: some View { + VStack(spacing: 3) { + ForEach(0..<3, id: \.self) { _ in + Capsule() + .fill(color) + .frame(width: 14, height: 2) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } +} + +private struct JammModeToggleButtonStyle: ButtonStyle { + let isHovered: Bool + let isEnabled: Bool + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .opacity(opacity(isPressed: configuration.isPressed)) + } + + private func opacity(isPressed: Bool) -> Double { + guard isEnabled else { return 0.45 } + if isPressed { return 0.72 } + return isHovered ? 1 : 0.86 + } +} diff --git a/JammLab/Views/Components/StemSeparationToolbarButton.swift b/JammLab/Views/Components/StemSeparationToolbarButton.swift new file mode 100644 index 0000000..ca79ce4 --- /dev/null +++ b/JammLab/Views/Components/StemSeparationToolbarButton.swift @@ -0,0 +1,133 @@ +import SwiftUI + +struct StemSeparationToolbarButton: View { + let hasAudio: Bool + let separationState: StemSeparationViewState + let onSeparate: (StemSeparationMethod) -> Void + let onCancel: () -> Void + @State private var isMethodSheetPresented = false + + var body: some View { + HStack(spacing: AppTheme.Spacing.sm) { + if separationState.isProcessing { + Button(role: .cancel) { + onCancel() + } label: { + Label("Cancel", systemImage: "xmark.circle") + } + .help(ControlHelpText.cancelStemSeparation) + + ProgressView() + .controlSize(.small) + } else { + Button { + isMethodSheetPresented = true + } label: { + Label("Separate Stems", systemImage: "waveform.badge.magnifyingglass") + } + .disabled(!hasAudio) + .help(ControlHelpText.separateStems) + .sheet(isPresented: $isMethodSheetPresented) { + StemSeparationMethodSelectionSheet { method in + isMethodSheetPresented = false + onSeparate(method) + } onCancel: { + isMethodSheetPresented = false + } + } + } + } + .buttonStyle(.bordered) + .controlSize(.small) + } +} + +private struct StemSeparationMethodSelectionSheet: View { + let onSelect: (StemSeparationMethod) -> Void + let onCancel: () -> Void + @Environment(\.appColors) private var appColors + @State private var selectedMethodID = StemSeparationMethod.defaultValue.id + + var body: some View { + VStack(alignment: .leading, spacing: AppTheme.Spacing.lg) { + Text("Separate Stems") + .font(AppTheme.Typography.sectionTitle) + .foregroundStyle(appColors.primaryText) + + VStack(alignment: .leading, spacing: AppTheme.Spacing.md) { + ForEach(StemSeparationMethod.allCases) { method in + StemSeparationMethodOptionRow( + method: method, + isSelected: selectedMethodID == method.id + ) { + selectedMethodID = method.id + } + } + } + .accessibilityElement(children: .contain) + .accessibilityLabel("Stem separation method") + + HStack { + Spacer() + Button("Cancel", action: onCancel) + .keyboardShortcut(.cancelAction) + Button("Separate") { + onSelect(selectedMethod) + } + .keyboardShortcut(.defaultAction) + .help("Start stem separation with the selected method") + } + } + .padding(AppTheme.Spacing.xl) + .frame(width: 420) + .background(appColors.panelBackground) + } + + private var selectedMethod: StemSeparationMethod { + StemSeparationMethod.method(forID: selectedMethodID) ?? .defaultValue + } +} + +private struct StemSeparationMethodOptionRow: View { + let method: StemSeparationMethod + let isSelected: Bool + let onSelect: () -> Void + @Environment(\.appColors) private var appColors + + var body: some View { + Button(action: onSelect) { + HStack(alignment: .top, spacing: AppTheme.Spacing.md) { + Image(systemName: isSelected ? "largecircle.fill.circle" : "circle") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(isSelected ? appColors.accent : appColors.secondaryText) + .frame(width: 18, height: 18) + .padding(.top, 1) + + VStack(alignment: .leading, spacing: AppTheme.Spacing.xs) { + Text(method.title) + .font(AppTheme.Typography.noteTitle) + .foregroundStyle(appColors.primaryText) + Text(method.optionDescription) + .font(.caption) + .foregroundStyle(appColors.secondaryText) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(AppTheme.Spacing.md) + } + .buttonStyle(.plain) + .focusable(false) + .background(isSelected ? appColors.controlActive : appColors.controlBackground) + .clipShape(RoundedRectangle(cornerRadius: AppTheme.Radius.small, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: AppTheme.Radius.small, style: .continuous) + .stroke(isSelected ? appColors.accent : appColors.border, lineWidth: AppTheme.Stroke.thin) + } + .help("Select \(method.title)") + .accessibilityLabel(method.title) + .accessibilityValue(isSelected ? "Selected. \(method.optionDescription)" : method.optionDescription) + .accessibilityAddTraits(isSelected ? [.isSelected] : []) + } +} diff --git a/JammLab/Views/Components/TopToolbarView.swift b/JammLab/Views/Components/TopToolbarView.swift index 4c9849d..4b6e5e2 100644 --- a/JammLab/Views/Components/TopToolbarView.swift +++ b/JammLab/Views/Components/TopToolbarView.swift @@ -256,260 +256,3 @@ struct TopToolbarView: View { .clipShape(RoundedRectangle(cornerRadius: AppTheme.Radius.small)) } } - -private struct StemSeparationToolbarButton: View { - let hasAudio: Bool - let separationState: StemSeparationViewState - let onSeparate: (StemSeparationMethod) -> Void - let onCancel: () -> Void - @State private var isMethodSheetPresented = false - - var body: some View { - HStack(spacing: AppTheme.Spacing.sm) { - if separationState.isProcessing { - Button(role: .cancel) { - onCancel() - } label: { - Label("Cancel", systemImage: "xmark.circle") - } - .help(ControlHelpText.cancelStemSeparation) - - ProgressView() - .controlSize(.small) - } else { - Button { - isMethodSheetPresented = true - } label: { - Label("Separate Stems", systemImage: "waveform.badge.magnifyingglass") - } - .disabled(!hasAudio) - .help(ControlHelpText.separateStems) - .sheet(isPresented: $isMethodSheetPresented) { - StemSeparationMethodSelectionSheet { method in - isMethodSheetPresented = false - onSeparate(method) - } onCancel: { - isMethodSheetPresented = false - } - } - } - } - .buttonStyle(.bordered) - .controlSize(.small) - } -} - -private struct StemSeparationMethodSelectionSheet: View { - let onSelect: (StemSeparationMethod) -> Void - let onCancel: () -> Void - @Environment(\.appColors) private var appColors - @State private var selectedMethodID = StemSeparationMethod.defaultValue.id - - var body: some View { - VStack(alignment: .leading, spacing: AppTheme.Spacing.lg) { - Text("Separate Stems") - .font(AppTheme.Typography.sectionTitle) - .foregroundStyle(appColors.primaryText) - - VStack(alignment: .leading, spacing: AppTheme.Spacing.md) { - ForEach(StemSeparationMethod.allCases) { method in - StemSeparationMethodOptionRow( - method: method, - isSelected: selectedMethodID == method.id - ) { - selectedMethodID = method.id - } - } - } - .accessibilityElement(children: .contain) - .accessibilityLabel("Stem separation method") - - HStack { - Spacer() - Button("Cancel", action: onCancel) - .keyboardShortcut(.cancelAction) - Button("Separate") { - onSelect(selectedMethod) - } - .keyboardShortcut(.defaultAction) - .help("Start stem separation with the selected method") - } - } - .padding(AppTheme.Spacing.xl) - .frame(width: 420) - .background(appColors.panelBackground) - } - - private var selectedMethod: StemSeparationMethod { - StemSeparationMethod.method(forID: selectedMethodID) ?? .defaultValue - } -} - -private struct StemSeparationMethodOptionRow: View { - let method: StemSeparationMethod - let isSelected: Bool - let onSelect: () -> Void - @Environment(\.appColors) private var appColors - - var body: some View { - Button(action: onSelect) { - HStack(alignment: .top, spacing: AppTheme.Spacing.md) { - Image(systemName: isSelected ? "largecircle.fill.circle" : "circle") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(isSelected ? appColors.accent : appColors.secondaryText) - .frame(width: 18, height: 18) - .padding(.top, 1) - - VStack(alignment: .leading, spacing: AppTheme.Spacing.xs) { - Text(method.title) - .font(AppTheme.Typography.noteTitle) - .foregroundStyle(appColors.primaryText) - Text(method.optionDescription) - .font(.caption) - .foregroundStyle(appColors.secondaryText) - .fixedSize(horizontal: false, vertical: true) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(AppTheme.Spacing.md) - } - .buttonStyle(.plain) - .focusable(false) - .background(isSelected ? appColors.controlActive : appColors.controlBackground) - .clipShape(RoundedRectangle(cornerRadius: AppTheme.Radius.small, style: .continuous)) - .overlay { - RoundedRectangle(cornerRadius: AppTheme.Radius.small, style: .continuous) - .stroke(isSelected ? appColors.accent : appColors.border, lineWidth: AppTheme.Stroke.thin) - } - .help("Select \(method.title)") - .accessibilityLabel(method.title) - .accessibilityValue(isSelected ? "Selected. \(method.optionDescription)" : method.optionDescription) - .accessibilityAddTraits(isSelected ? [.isSelected] : []) - } -} - -private struct BeatOneToolbarButton: View { - let canSetBeatOne: Bool - let canResetBeatGrid: Bool - let onSetBeatOne: () -> Void - let onResetBeatGrid: () -> Void - let onNudgeBeatGrid: (TimeInterval) -> Void - let onPopoverDismiss: () -> Void - - @State private var isPopoverPresented = false - - var body: some View { - Button(action: onSetBeatOne) { - Label("Beat 1", systemImage: "flag.fill") - } - .buttonStyle(.bordered) - .controlSize(.small) - .disabled(!canSetBeatOne) - .help(ControlHelpText.setBeatOne) - .overlay { - RightClickCaptureView { _ in - guard canSetBeatOne else { return } - isPopoverPresented = true - } - } - .popover(isPresented: $isPopoverPresented, arrowEdge: .bottom) { - VStack(alignment: .leading, spacing: AppTheme.Spacing.lg) { - Button { - onResetBeatGrid() - } label: { - Label("Reset", systemImage: "arrow.counterclockwise") - } - .disabled(!canResetBeatGrid) - .help(ControlHelpText.resetBeatGrid) - - Divider() - - HStack(spacing: AppTheme.Spacing.md) { - beatGridNudgeButton("-50 ms", systemImage: "backward.end.fill", delta: -0.05) - beatGridNudgeButton("-10 ms", systemImage: "chevron.left", delta: -0.01) - beatGridNudgeButton("+10 ms", systemImage: "chevron.right", delta: 0.01) - beatGridNudgeButton("+50 ms", systemImage: "forward.end.fill", delta: 0.05) - } - } - .buttonStyle(.bordered) - .controlSize(.small) - .padding(AppTheme.Spacing.panelPadding) - } - .onAppPopoverDismiss(isPresented: isPopoverPresented, perform: onPopoverDismiss) - } - - private func beatGridNudgeButton(_ title: String, systemImage: String, delta: TimeInterval) -> some View { - Button { - onNudgeBeatGrid(delta) - } label: { - Label(title, systemImage: systemImage) - } - .help(ControlHelpText.beatGridNudge(title)) - } -} - -private struct ClickToolbarButton: View { - let isEnabled: Bool - let isAvailable: Bool - let volume: Float - let volumeText: String - let onToggle: () -> Void - let onVolumeChanged: (Float) -> Void - let onPopoverDismiss: () -> Void - - @State private var isVolumePopoverPresented = false - @Environment(\.appColors) private var appColors - - var body: some View { - AppControlButton( - title: "Click", - systemImage: "metronome", - isActive: isEnabled, - action: onToggle - ) - .controlSize(.small) - .disabled(!isAvailable) - .help(ControlHelpText.click) - .overlay { - RightClickCaptureView { _ in - guard isAvailable else { return } - isVolumePopoverPresented = true - } - } - .popover(isPresented: $isVolumePopoverPresented, arrowEdge: .bottom) { - VStack(alignment: .leading, spacing: AppTheme.Spacing.md) { - HStack { - Label("Click Volume", systemImage: "speaker.wave.2") - Spacer() - Text(volumeText) - .font(AppTheme.Typography.captionMonospaced) - .foregroundStyle(appColors.secondaryText) - } - - JammValueSlider( - value: Binding( - get: { Double(volume) }, - set: { onVolumeChanged(Float($0)) } - ), - minValue: 0, - maxValue: 1, - defaultValue: Double(AppSliderDefaults.clickVolume), - step: 0.01, - sensitivity: 1, - precision: 0, - displayFormatter: { "\(Int(($0 * 100).rounded()))%" }, - accessibilityLabel: "Click Volume" - ) - .frame( - width: AppTheme.ControlSize.clickVolumeWidth, - height: AppTheme.ControlSize.jammValueSliderHeight - ) - .help(ControlHelpText.clickVolume) - } - .padding(AppTheme.Spacing.panelPadding) - .frame(width: AppTheme.ControlSize.clickVolumeWidth + 96) - } - .onAppPopoverDismiss(isPresented: isVolumePopoverPresented, perform: onPopoverDismiss) - } -} diff --git a/JammLab/Views/Components/TransportButton.swift b/JammLab/Views/Components/TransportButton.swift new file mode 100644 index 0000000..ccf7bd9 --- /dev/null +++ b/JammLab/Views/Components/TransportButton.swift @@ -0,0 +1,361 @@ +import SwiftUI + +struct TransportButton: View { + let type: TransportControlType + let action: () -> Void + + @Environment(\.isEnabled) private var isEnabled + @State private var isHovered = false + + var body: some View { + Button(action: action) { + Image(systemName: type.systemImage) + .font(.system(size: AppTheme.TransportControls.iconSize, weight: .semibold)) + .symbolRenderingMode(.hierarchical) + .frame(width: type.size.width, height: type.size.height) + } + .buttonStyle(TransportButtonStyle(type: type, isHovered: isHovered && isEnabled)) + .onHover { isHovered = isEnabled && $0 } + .help(type.helpText) + .accessibilityLabel(type.accessibilityLabel) + .accessibilityValue(type.accessibilityValue) + } +} + +struct TransportButtonStyle: ButtonStyle { + let type: TransportControlType + let isHovered: Bool + + @Environment(\.isEnabled) private var isEnabled + @Environment(\.appColors) private var appColors + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .foregroundStyle(foregroundColor) + .background { + buttonFill(isPressed: configuration.isPressed) + } + .overlay { + buttonBorder + } + .overlay(alignment: .top) { + buttonHighlight + } + .shadow( + color: isEnabled ? .black.opacity(0.36) : .clear, + radius: AppTheme.TransportControls.shadowRadius, + x: 0, + y: AppTheme.TransportControls.shadowY + ) + .opacity(isEnabled ? 1 : 0.56) + .offset(y: configuration.isPressed ? AppTheme.TransportControls.pressedOffset : 0) + .animation(.easeOut(duration: AppTheme.Animation.fast), value: isHovered) + .animation(.easeOut(duration: AppTheme.Animation.fast), value: configuration.isPressed) + .contentShape(Rectangle()) + } + + private var foregroundColor: Color { + guard isEnabled else { return appColors.disabledText } + if type.isActive { return type.activeColor(appColors) } + return isHovered ? appColors.primaryText : appColors.secondaryText + } + + @ViewBuilder + private func buttonFill(isPressed: Bool) -> some View { + let gradient = LinearGradient( + colors: buttonGradientColors(isPressed: isPressed), + startPoint: .top, + endPoint: .bottom + ) + + if type.isRound { + Circle().fill(gradient) + } else { + TransportButtonShape(corners: type.corners) + .fill(gradient) + } + } + + @ViewBuilder + private var buttonBorder: some View { + let color = type.isActive ? type.activeColor(appColors) : appColors.border + + if type.isRound { + Circle().stroke(color, lineWidth: AppTheme.TransportControls.buttonBorderWidth) + } else { + TransportButtonShape(corners: type.corners) + .stroke(color, lineWidth: AppTheme.TransportControls.buttonBorderWidth) + } + } + + @ViewBuilder + private var buttonHighlight: some View { + let highlight = appColors.primaryText.opacity(isHovered && isEnabled ? 0.24 : 0.18) + + if type.isRound { + Circle() + .trim(from: 0.08, to: 0.43) + .stroke(highlight, lineWidth: AppTheme.TransportControls.buttonBorderWidth) + .padding(2) + } else { + TransportButtonShape(corners: type.highlightCorners) + .stroke(highlight, lineWidth: AppTheme.TransportControls.buttonBorderWidth) + .padding(2) + } + } + + private func buttonGradientColors(isPressed: Bool) -> [Color] { + if isPressed { + return [ + appColors.accentPressed.opacity(type.isActive ? 0.55 : 0.32), + appColors.controlBackground + ] + } + + let top = (isHovered && isEnabled ? appColors.controlHover : appColors.controlBackground) + let bottom = type.isActive + ? type.activeColor(appColors).opacity(0.28) + : appColors.elevatedSurface + + return [top, bottom] + } +} + +enum TransportControlType { + case goToStart + case goToEnd + case playStop(isPlaying: Bool) + case pause + case loop(isActive: Bool) + + var systemImage: String { + switch self { + case .goToStart: + return "backward.end.fill" + case .goToEnd: + return "forward.end.fill" + case .playStop(let isPlaying): + return isPlaying ? "stop.fill" : "play.fill" + case .pause: + return "pause.fill" + case .loop: + return "repeat" + } + } + + var accessibilityLabel: String { + switch self { + case .goToStart: + return "Go to start" + case .goToEnd: + return "Go to end" + case .playStop(let isPlaying): + return isPlaying ? "Stop" : "Play" + case .pause: + return "Pause" + case .loop: + return "Loop" + } + } + + var accessibilityValue: String { + switch self { + case .loop(let isActive): + return isActive ? "On" : "Off" + default: + return "" + } + } + + var helpText: String { + switch self { + case .goToStart: + return ControlHelpText.goToStart + case .goToEnd: + return ControlHelpText.goToEnd + case .playStop(let isPlaying): + return isPlaying ? ControlHelpText.stop : ControlHelpText.play + case .pause: + return ControlHelpText.pause + case .loop(let isActive): + return isActive ? ControlHelpText.deactivateLoop : ControlHelpText.activateLoop + } + } + + var isActive: Bool { + if case .loop(let isActive) = self { + return isActive + } + + return false + } + + func activeColor(_ colors: AppThemeColors) -> Color { + switch self { + case .loop: + return colors.loopButtonActive + default: + return colors.accent + } + } + + var isRound: Bool { + switch self { + case .playStop, .loop: + return true + case .goToStart, .goToEnd, .pause: + return false + } + } + + var size: CGSize { + switch self { + case .goToStart, .goToEnd: + return CGSize( + width: AppTheme.TransportControls.skipButtonWidth, + height: AppTheme.TransportControls.skipButtonHeight + ) + case .playStop, .loop: + return CGSize( + width: AppTheme.TransportControls.roundButtonSize, + height: AppTheme.TransportControls.roundButtonSize + ) + case .pause: + return CGSize( + width: AppTheme.TransportControls.stopButtonSize, + height: AppTheme.TransportControls.stopButtonSize + ) + } + } + + var cornerRadius: CGFloat { + switch self { + case .playStop, .loop: + return AppTheme.TransportControls.roundButtonSize / 2 + case .goToStart, .goToEnd: + return AppTheme.TransportControls.skipButtonRadius + case .pause: + return AppTheme.TransportControls.stopButtonRadius + } + } + + var corners: TransportButtonCorners { + switch self { + case .goToStart: + return TransportButtonCorners( + topLeft: AppTheme.TransportControls.skipButtonRadius, + topRight: 0, + bottomRight: 0, + bottomLeft: AppTheme.TransportControls.skipButtonRadius + ) + case .goToEnd: + return TransportButtonCorners( + topLeft: 0, + topRight: AppTheme.TransportControls.skipButtonRadius, + bottomRight: AppTheme.TransportControls.skipButtonRadius, + bottomLeft: 0 + ) + case .pause: + return TransportButtonCorners(radius: AppTheme.TransportControls.stopButtonRadius) + case .playStop, .loop: + return TransportButtonCorners(radius: cornerRadius) + } + } + + var highlightCorners: TransportButtonCorners { + let insetRadius = max(1, cornerRadius - 1) + + switch self { + case .goToStart: + return TransportButtonCorners(topLeft: insetRadius, topRight: 0, bottomRight: 0, bottomLeft: insetRadius) + case .goToEnd: + return TransportButtonCorners(topLeft: 0, topRight: insetRadius, bottomRight: insetRadius, bottomLeft: 0) + case .pause: + return TransportButtonCorners(radius: insetRadius) + case .playStop, .loop: + return TransportButtonCorners(radius: insetRadius) + } + } +} + +struct TransportButtonCorners { + var topLeft: CGFloat + var topRight: CGFloat + var bottomRight: CGFloat + var bottomLeft: CGFloat + + init(topLeft: CGFloat, topRight: CGFloat, bottomRight: CGFloat, bottomLeft: CGFloat) { + self.topLeft = topLeft + self.topRight = topRight + self.bottomRight = bottomRight + self.bottomLeft = bottomLeft + } + + init(radius: CGFloat) { + self.init(topLeft: radius, topRight: radius, bottomRight: radius, bottomLeft: radius) + } +} + +struct TransportButtonShape: Shape { + let corners: TransportButtonCorners + + func path(in rect: CGRect) -> Path { + let topLeft = min(corners.topLeft, min(rect.width, rect.height) / 2) + let topRight = min(corners.topRight, min(rect.width, rect.height) / 2) + let bottomRight = min(corners.bottomRight, min(rect.width, rect.height) / 2) + let bottomLeft = min(corners.bottomLeft, min(rect.width, rect.height) / 2) + + var path = Path() + path.move(to: CGPoint(x: rect.minX + topLeft, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX - topRight, y: rect.minY)) + + if topRight > 0 { + path.addArc( + center: CGPoint(x: rect.maxX - topRight, y: rect.minY + topRight), + radius: topRight, + startAngle: .degrees(-90), + endAngle: .degrees(0), + clockwise: false + ) + } + + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - bottomRight)) + + if bottomRight > 0 { + path.addArc( + center: CGPoint(x: rect.maxX - bottomRight, y: rect.maxY - bottomRight), + radius: bottomRight, + startAngle: .degrees(0), + endAngle: .degrees(90), + clockwise: false + ) + } + + path.addLine(to: CGPoint(x: rect.minX + bottomLeft, y: rect.maxY)) + + if bottomLeft > 0 { + path.addArc( + center: CGPoint(x: rect.minX + bottomLeft, y: rect.maxY - bottomLeft), + radius: bottomLeft, + startAngle: .degrees(90), + endAngle: .degrees(180), + clockwise: false + ) + } + + path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + topLeft)) + + if topLeft > 0 { + path.addArc( + center: CGPoint(x: rect.minX + topLeft, y: rect.minY + topLeft), + radius: topLeft, + startAngle: .degrees(180), + endAngle: .degrees(270), + clockwise: false + ) + } + + path.closeSubpath() + return path + } +} diff --git a/JammLab/Views/Components/TransportControlsView.swift b/JammLab/Views/Components/TransportControlsView.swift index bb555c7..b59bb61 100644 --- a/JammLab/Views/Components/TransportControlsView.swift +++ b/JammLab/Views/Components/TransportControlsView.swift @@ -51,366 +51,6 @@ struct TransportControlsView: View { } } -struct TransportButton: View { - let type: TransportControlType - let action: () -> Void - - @Environment(\.isEnabled) private var isEnabled - @State private var isHovered = false - - var body: some View { - Button(action: action) { - Image(systemName: type.systemImage) - .font(.system(size: AppTheme.TransportControls.iconSize, weight: .semibold)) - .symbolRenderingMode(.hierarchical) - .frame(width: type.size.width, height: type.size.height) - } - .buttonStyle(TransportButtonStyle(type: type, isHovered: isHovered && isEnabled)) - .onHover { isHovered = isEnabled && $0 } - .help(type.helpText) - .accessibilityLabel(type.accessibilityLabel) - .accessibilityValue(type.accessibilityValue) - } -} - -struct TransportButtonStyle: ButtonStyle { - let type: TransportControlType - let isHovered: Bool - - @Environment(\.isEnabled) private var isEnabled - @Environment(\.appColors) private var appColors - - func makeBody(configuration: Configuration) -> some View { - configuration.label - .foregroundStyle(foregroundColor) - .background { - buttonFill(isPressed: configuration.isPressed) - } - .overlay { - buttonBorder - } - .overlay(alignment: .top) { - buttonHighlight - } - .shadow( - color: isEnabled ? .black.opacity(0.36) : .clear, - radius: AppTheme.TransportControls.shadowRadius, - x: 0, - y: AppTheme.TransportControls.shadowY - ) - .opacity(isEnabled ? 1 : 0.56) - .offset(y: configuration.isPressed ? AppTheme.TransportControls.pressedOffset : 0) - .animation(.easeOut(duration: AppTheme.Animation.fast), value: isHovered) - .animation(.easeOut(duration: AppTheme.Animation.fast), value: configuration.isPressed) - .contentShape(Rectangle()) - } - - private var foregroundColor: Color { - guard isEnabled else { return appColors.disabledText } - if type.isActive { return type.activeColor(appColors) } - return isHovered ? appColors.primaryText : appColors.secondaryText - } - - @ViewBuilder - private func buttonFill(isPressed: Bool) -> some View { - let gradient = LinearGradient( - colors: buttonGradientColors(isPressed: isPressed), - startPoint: .top, - endPoint: .bottom - ) - - if type.isRound { - Circle().fill(gradient) - } else { - TransportButtonShape(corners: type.corners) - .fill(gradient) - } - } - - @ViewBuilder - private var buttonBorder: some View { - let color = type.isActive ? type.activeColor(appColors) : appColors.border - - if type.isRound { - Circle().stroke(color, lineWidth: AppTheme.TransportControls.buttonBorderWidth) - } else { - TransportButtonShape(corners: type.corners) - .stroke(color, lineWidth: AppTheme.TransportControls.buttonBorderWidth) - } - } - - @ViewBuilder - private var buttonHighlight: some View { - let highlight = appColors.primaryText.opacity(isHovered && isEnabled ? 0.24 : 0.18) - - if type.isRound { - Circle() - .trim(from: 0.08, to: 0.43) - .stroke(highlight, lineWidth: AppTheme.TransportControls.buttonBorderWidth) - .padding(2) - } else { - TransportButtonShape(corners: type.highlightCorners) - .stroke(highlight, lineWidth: AppTheme.TransportControls.buttonBorderWidth) - .padding(2) - } - } - - private func buttonGradientColors(isPressed: Bool) -> [Color] { - if isPressed { - return [ - appColors.accentPressed.opacity(type.isActive ? 0.55 : 0.32), - appColors.controlBackground - ] - } - - let top = (isHovered && isEnabled ? appColors.controlHover : appColors.controlBackground) - let bottom = type.isActive - ? type.activeColor(appColors).opacity(0.28) - : appColors.elevatedSurface - - return [top, bottom] - } -} - -enum TransportControlType { - case goToStart - case goToEnd - case playStop(isPlaying: Bool) - case pause - case loop(isActive: Bool) - - var systemImage: String { - switch self { - case .goToStart: - return "backward.end.fill" - case .goToEnd: - return "forward.end.fill" - case .playStop(let isPlaying): - return isPlaying ? "stop.fill" : "play.fill" - case .pause: - return "pause.fill" - case .loop: - return "repeat" - } - } - - var accessibilityLabel: String { - switch self { - case .goToStart: - return "Go to start" - case .goToEnd: - return "Go to end" - case .playStop(let isPlaying): - return isPlaying ? "Stop" : "Play" - case .pause: - return "Pause" - case .loop: - return "Loop" - } - } - - var accessibilityValue: String { - switch self { - case .loop(let isActive): - return isActive ? "On" : "Off" - default: - return "" - } - } - - var helpText: String { - switch self { - case .goToStart: - return ControlHelpText.goToStart - case .goToEnd: - return ControlHelpText.goToEnd - case .playStop(let isPlaying): - return isPlaying ? ControlHelpText.stop : ControlHelpText.play - case .pause: - return ControlHelpText.pause - case .loop(let isActive): - return isActive ? ControlHelpText.deactivateLoop : ControlHelpText.activateLoop - } - } - - var isActive: Bool { - if case .loop(let isActive) = self { - return isActive - } - - return false - } - - func activeColor(_ colors: AppThemeColors) -> Color { - switch self { - case .loop: - return colors.loopButtonActive - default: - return colors.accent - } - } - - var isRound: Bool { - switch self { - case .playStop, .loop: - return true - case .goToStart, .goToEnd, .pause: - return false - } - } - - var size: CGSize { - switch self { - case .goToStart, .goToEnd: - return CGSize( - width: AppTheme.TransportControls.skipButtonWidth, - height: AppTheme.TransportControls.skipButtonHeight - ) - case .playStop, .loop: - return CGSize( - width: AppTheme.TransportControls.roundButtonSize, - height: AppTheme.TransportControls.roundButtonSize - ) - case .pause: - return CGSize( - width: AppTheme.TransportControls.stopButtonSize, - height: AppTheme.TransportControls.stopButtonSize - ) - } - } - - var cornerRadius: CGFloat { - switch self { - case .playStop, .loop: - return AppTheme.TransportControls.roundButtonSize / 2 - case .goToStart, .goToEnd: - return AppTheme.TransportControls.skipButtonRadius - case .pause: - return AppTheme.TransportControls.stopButtonRadius - } - } - - var corners: TransportButtonCorners { - switch self { - case .goToStart: - return TransportButtonCorners( - topLeft: AppTheme.TransportControls.skipButtonRadius, - topRight: 0, - bottomRight: 0, - bottomLeft: AppTheme.TransportControls.skipButtonRadius - ) - case .goToEnd: - return TransportButtonCorners( - topLeft: 0, - topRight: AppTheme.TransportControls.skipButtonRadius, - bottomRight: AppTheme.TransportControls.skipButtonRadius, - bottomLeft: 0 - ) - case .pause: - return TransportButtonCorners(radius: AppTheme.TransportControls.stopButtonRadius) - case .playStop, .loop: - return TransportButtonCorners(radius: cornerRadius) - } - } - - var highlightCorners: TransportButtonCorners { - let insetRadius = max(1, cornerRadius - 1) - - switch self { - case .goToStart: - return TransportButtonCorners(topLeft: insetRadius, topRight: 0, bottomRight: 0, bottomLeft: insetRadius) - case .goToEnd: - return TransportButtonCorners(topLeft: 0, topRight: insetRadius, bottomRight: insetRadius, bottomLeft: 0) - case .pause: - return TransportButtonCorners(radius: insetRadius) - case .playStop, .loop: - return TransportButtonCorners(radius: insetRadius) - } - } -} - -struct TransportButtonCorners { - var topLeft: CGFloat - var topRight: CGFloat - var bottomRight: CGFloat - var bottomLeft: CGFloat - - init(topLeft: CGFloat, topRight: CGFloat, bottomRight: CGFloat, bottomLeft: CGFloat) { - self.topLeft = topLeft - self.topRight = topRight - self.bottomRight = bottomRight - self.bottomLeft = bottomLeft - } - - init(radius: CGFloat) { - self.init(topLeft: radius, topRight: radius, bottomRight: radius, bottomLeft: radius) - } -} - -struct TransportButtonShape: Shape { - let corners: TransportButtonCorners - - func path(in rect: CGRect) -> Path { - let topLeft = min(corners.topLeft, min(rect.width, rect.height) / 2) - let topRight = min(corners.topRight, min(rect.width, rect.height) / 2) - let bottomRight = min(corners.bottomRight, min(rect.width, rect.height) / 2) - let bottomLeft = min(corners.bottomLeft, min(rect.width, rect.height) / 2) - - var path = Path() - path.move(to: CGPoint(x: rect.minX + topLeft, y: rect.minY)) - path.addLine(to: CGPoint(x: rect.maxX - topRight, y: rect.minY)) - - if topRight > 0 { - path.addArc( - center: CGPoint(x: rect.maxX - topRight, y: rect.minY + topRight), - radius: topRight, - startAngle: .degrees(-90), - endAngle: .degrees(0), - clockwise: false - ) - } - - path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - bottomRight)) - - if bottomRight > 0 { - path.addArc( - center: CGPoint(x: rect.maxX - bottomRight, y: rect.maxY - bottomRight), - radius: bottomRight, - startAngle: .degrees(0), - endAngle: .degrees(90), - clockwise: false - ) - } - - path.addLine(to: CGPoint(x: rect.minX + bottomLeft, y: rect.maxY)) - - if bottomLeft > 0 { - path.addArc( - center: CGPoint(x: rect.minX + bottomLeft, y: rect.maxY - bottomLeft), - radius: bottomLeft, - startAngle: .degrees(90), - endAngle: .degrees(180), - clockwise: false - ) - } - - path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + topLeft)) - - if topLeft > 0 { - path.addArc( - center: CGPoint(x: rect.minX + topLeft, y: rect.minY + topLeft), - radius: topLeft, - startAngle: .degrees(180), - endAngle: .degrees(270), - clockwise: false - ) - } - - path.closeSubpath() - return path - } -} - #Preview("Transport Controls") { VStack(alignment: .leading, spacing: 18) { TransportControlsView( diff --git a/JammLab/Views/NotationHarmonyInlineTextField.swift b/JammLab/Views/NotationHarmonyInlineTextField.swift new file mode 100644 index 0000000..c7d8430 --- /dev/null +++ b/JammLab/Views/NotationHarmonyInlineTextField.swift @@ -0,0 +1,101 @@ +import AppKit +import SwiftUI + +struct HarmonyInlineTextField: NSViewRepresentable { + @Binding var text: String + let onCommit: () -> Void + let onCancel: () -> Void + let onNavigate: (HarmonyNavigationDirection) -> Void + + func makeNSView(context: Context) -> HarmonyInlineNSTextField { + let textField = HarmonyInlineNSTextField(string: text) + textField.isBordered = true + textField.isBezeled = true + textField.bezelStyle = .roundedBezel + textField.focusRingType = .default + textField.delegate = context.coordinator + textField.font = .systemFont(ofSize: 13, weight: .semibold) + textField.onWindowAttached = { [weak coordinator = context.coordinator, weak textField] in + guard let textField else { return } + coordinator?.focusAndSelectIfNeeded(textField) + } + return textField + } + + func updateNSView(_ nsView: HarmonyInlineNSTextField, context: Context) { + context.coordinator.parent = self + if nsView.stringValue != text { + nsView.stringValue = text + } + context.coordinator.focusAndSelectIfNeeded(nsView) + } + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + final class Coordinator: NSObject, NSTextFieldDelegate { + var parent: HarmonyInlineTextField + private var didAutoSelect = false + + init(parent: HarmonyInlineTextField) { + self.parent = parent + } + + func controlTextDidChange(_ notification: Notification) { + guard let textField = notification.object as? NSTextField else { return } + parent.text = textField.stringValue + } + + func focusAndSelectIfNeeded(_ textField: NSTextField) { + guard !didAutoSelect else { return } + + DispatchQueue.main.async { [weak self, weak textField] in + guard let self, let textField, !self.didAutoSelect else { return } + guard let window = textField.window else { return } + + window.makeFirstResponder(textField) + textField.selectText(nil) + self.didAutoSelect = true + } + } + + func control( + _ control: NSControl, + textView: NSTextView, + doCommandBy commandSelector: Selector + ) -> Bool { + switch commandSelector { + case #selector(NSResponder.insertNewline(_:)): + parent.text = textView.string + parent.onCommit() + return true + case #selector(NSResponder.cancelOperation(_:)): + parent.onCancel() + return true + case #selector(NSResponder.insertTab(_:)): + parent.text = textView.string + parent.onNavigate(.next) + return true + case #selector(NSResponder.insertBacktab(_:)): + parent.text = textView.string + parent.onNavigate(.previous) + return true + default: + return false + } + } + } +} + +final class HarmonyInlineNSTextField: NSTextField { + var onWindowAttached: (() -> Void)? + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + + if window != nil { + onWindowAttached?() + } + } +} diff --git a/JammLab/Views/NotationTrackView.swift b/JammLab/Views/NotationTrackView.swift index 7fc8801..abd5542 100644 --- a/JammLab/Views/NotationTrackView.swift +++ b/JammLab/Views/NotationTrackView.swift @@ -927,41 +927,6 @@ private struct KeySignatureAccidentalsView: View { } } -struct NotationAttributeDisplay: Equatable { - var showsClef: Bool - var showsKeySignature: Bool - var showsTimeSignature: Bool - - static let none = NotationAttributeDisplay( - showsClef: false, - showsKeySignature: false, - showsTimeSignature: false - ) - - static let full = NotationAttributeDisplay( - showsClef: true, - showsKeySignature: true, - showsTimeSignature: true - ) - - var isEmpty: Bool { - !showsClef && !showsKeySignature && !showsTimeSignature - } - - static func display( - for attributes: MeasureAttributes, - previousAttributes: MeasureAttributes? - ) -> NotationAttributeDisplay { - guard let previousAttributes else { return .full } - - return NotationAttributeDisplay( - showsClef: attributes.clef != previousAttributes.clef, - showsKeySignature: attributes.keySignature != previousAttributes.keySignature, - showsTimeSignature: attributes.timeSignature != previousAttributes.timeSignature - ) - } -} - private struct HarmonyEditorDraft: Equatable { var id: HarmonySymbol.ID var time: TimeInterval @@ -1002,717 +967,6 @@ private struct NotationHarmonyPlacement: Equatable { } } -struct NotationMeasureCanvasGeometry: Equatable { - let measureIndex: Int - let cellStartX: CGFloat - let cellEndX: CGFloat - let contentStartX: CGFloat - let contentEndX: CGFloat - let staffStartX: CGFloat - let staffEndX: CGFloat - - var includesRawStartBarline: Bool { - measureIndex > 0 || !contentStartsAfterCellBoundary - } - - var contentStartsAfterCellBoundary: Bool { - contentStartX > cellStartX + 0.0001 - } - - var leadingBarlineX: CGFloat? { - if measureIndex == 0 { - return staffStartX - } - - return includesRawStartBarline ? cellStartX : nil - } -} - -struct NotationBarlineGeometry: Equatable { - let x: CGFloat - let isOuterBoundary: Bool -} - -struct NotationMeasureLayout { - static var measureNumberLabelWidth: CGFloat { - AppTheme.Timeline.notationMeasureNumberLabelWidth - } - - static func canvasWidth( - measureCount: Int, - availableWidth: CGFloat, - attributeReserveWidths: [CGFloat] - ) -> CGFloat { - let safeMeasureCount = max(1, measureCount) - let bodyWidth = baseMeasureBodyWidth( - measureCount: safeMeasureCount, - availableWidth: availableWidth - ) - return bodyWidth * CGFloat(safeMeasureCount) + attributeReserveWidths.reduce(0, +) - } - - static func minimumCanvasWidth( - measureCount: Int, - attributeReserveWidths: [CGFloat] - ) -> CGFloat { - AppTheme.Timeline.notationMeasureMinWidth * CGFloat(max(1, measureCount)) - + attributeReserveWidths.reduce(0, +) - } - - static func baseMeasureBodyWidth(measureCount: Int, availableWidth: CGFloat) -> CGFloat { - let safeMeasureCount = max(1, measureCount) - return max( - AppTheme.Timeline.notationMeasureMinWidth, - max(0, availableWidth) / CGFloat(safeMeasureCount) - ) - } - - static func measureBodyWidth( - measureCount: Int, - totalWidth: CGFloat, - attributeReserveWidths: [CGFloat] - ) -> CGFloat { - let safeMeasureCount = max(1, measureCount) - let remainingWidth = max(0, totalWidth - attributeReserveWidths.reduce(0, +)) - return max( - AppTheme.Timeline.notationMeasureMinWidth, - remainingWidth / CGFloat(safeMeasureCount) - ) - } - - static func canvasGeometries( - measureCount: Int, - totalWidth: CGFloat, - attributeReserveWidths: [CGFloat] - ) -> [NotationMeasureCanvasGeometry] { - let safeMeasureCount = max(1, measureCount) - let bodyWidth = measureBodyWidth( - measureCount: safeMeasureCount, - totalWidth: totalWidth, - attributeReserveWidths: attributeReserveWidths - ) - var cursorX: CGFloat = 0 - - return (0.. CGFloat { - systemMeasureNumberLabelTrailingX(geometry: geometry) - measureNumberLabelWidth - } - - static func systemMeasureNumberLabelTrailingX(geometry: NotationMeasureCanvasGeometry) -> CGFloat { - geometry.staffStartX + AppTheme.Spacing.sm - } - - static var systemMeasureNumberStaffGap: CGFloat { - AppTheme.Spacing.headerVertical - } - - static func systemMeasureNumberLabelY(staffTop: CGFloat) -> CGFloat { - max(AppTheme.Spacing.xs, staffTop - systemMeasureNumberStaffGap) - } - - static func harmonyLabelY( - staffTop: CGFloat, - elementHeight: CGFloat = AppTheme.ControlSize.abletonNumberFieldHeight, - gap: CGFloat = AppTheme.Spacing.xs - ) -> CGFloat { - max(AppTheme.Spacing.xs, staffTop - max(0, elementHeight) - max(0, gap)) - } - - static func regionLabelY( - staffTop: CGFloat, - labelHeight: CGFloat = AppTheme.Timeline.notationRegionLabelHeight, - gap: CGFloat = AppTheme.Timeline.notationRegionLabelGap - ) -> CGFloat { - let harmonyY = harmonyLabelY(staffTop: staffTop) - return max(AppTheme.Spacing.xxxs, harmonyY - max(0, labelHeight) - max(0, gap)) - } - - static func regionLabelX( - geometry: NotationMeasureCanvasGeometry, - offsetInQuarterNotes: Double, - timeSignature: TimeSignature, - labelWidth: CGFloat = AppTheme.Timeline.notationRegionLabelMaxWidth, - avoidsSystemMeasureNumber: Bool = false, - measureNumberGap: CGFloat = AppTheme.Spacing.sm - ) -> CGFloat { - let bounds = regionLabelXBounds( - geometry: geometry, - labelWidth: labelWidth, - avoidsSystemMeasureNumber: avoidsSystemMeasureNumber, - measureNumberGap: measureNumberGap - ) - return regionLabelX( - geometry: geometry, - offsetInQuarterNotes: offsetInQuarterNotes, - timeSignature: timeSignature, - bounds: bounds - ) - } - - static func regionLabelX( - geometry: NotationMeasureCanvasGeometry, - offsetInQuarterNotes: Double, - timeSignature: TimeSignature, - bounds: ClosedRange - ) -> CGFloat { - let anchorX = notationAnchorX( - geometry: geometry, - offsetInQuarterNotes: offsetInQuarterNotes, - timeSignature: timeSignature, - anchorInset: 0 - ) - return min(max(anchorX, bounds.lowerBound), bounds.upperBound) - } - - static func regionLabelXBounds( - geometry: NotationMeasureCanvasGeometry, - labelWidth: CGFloat = AppTheme.Timeline.notationRegionLabelMaxWidth, - avoidsSystemMeasureNumber: Bool = false, - measureNumberGap: CGFloat = AppTheme.Spacing.sm - ) -> ClosedRange { - let lowerBound = regionLabelLowerBound( - geometry: geometry, - avoidsSystemMeasureNumber: avoidsSystemMeasureNumber, - measureNumberGap: measureNumberGap - ) - let rawUpperBound = regionLabelUpperBound( - geometry: geometry, - labelWidth: labelWidth - ) - - return lowerBound...max(lowerBound, rawUpperBound) - } - - static func regionLabelLowerBound( - geometry: NotationMeasureCanvasGeometry, - avoidsSystemMeasureNumber: Bool, - measureNumberGap: CGFloat = AppTheme.Spacing.sm - ) -> CGFloat { - let baseLowerBound = max(geometry.staffStartX, geometry.cellStartX) - guard avoidsSystemMeasureNumber else { return baseLowerBound } - - return max( - baseLowerBound, - systemMeasureNumberLabelTrailingX(geometry: geometry) + max(0, measureNumberGap) - ) - } - - static func regionLabelUpperBound( - geometry: NotationMeasureCanvasGeometry, - labelWidth: CGFloat = AppTheme.Timeline.notationRegionLabelMaxWidth - ) -> CGFloat { - let visualStartX = max(geometry.staffStartX, geometry.cellStartX) - let visualEndX = max(visualStartX, geometry.staffEndX) - return max(visualStartX, visualEndX - max(0, labelWidth)) - } - - static func barlineGeometries(for geometries: [NotationMeasureCanvasGeometry]) -> [NotationBarlineGeometry] { - guard let lastGeometry = geometries.last else { return [] } - - var barlines = geometries.compactMap { geometry -> NotationBarlineGeometry? in - guard let x = geometry.leadingBarlineX else { return nil } - - return NotationBarlineGeometry( - x: x, - isOuterBoundary: geometry.measureIndex == 0 - ) - } - barlines.append( - NotationBarlineGeometry( - x: lastGeometry.staffEndX, - isOuterBoundary: true - ) - ) - return barlines - } - - static func attributeBlockWidth( - for attributes: MeasureAttributes, - display: NotationAttributeDisplay, - cellWidth: CGFloat - ) -> CGFloat { - let componentWidths = visibleComponentWidths(for: attributes, display: display) - guard !componentWidths.isEmpty else { return 0 } - - return componentWidths.reduce(0, +) - + spacingWidth(forVisibleComponentCount: componentWidths.count) - } - - static func attributeReserveWidth( - for attributes: MeasureAttributes, - display: NotationAttributeDisplay - ) -> CGFloat { - let blockWidth = attributeBlockWidth( - for: attributes, - display: display, - cellWidth: AppTheme.Timeline.notationMeasureMinWidth - ) - guard blockWidth > 0 else { return 0 } - return AppTheme.Spacing.md + blockWidth + AppTheme.Spacing.xs - } - - static func keySignatureWidth(for attributes: MeasureAttributes) -> CGFloat { - let glyphs = attributes.keySignature.notationAccidentalGlyphs(for: attributes.clef) - return glyphs.isEmpty - ? 0 - : max(12, CGFloat(glyphs.count) * AppTheme.Timeline.notationAccidentalWidth) - } - - static func visibleComponentCount( - for attributes: MeasureAttributes, - display: NotationAttributeDisplay - ) -> Int { - visibleComponentWidths(for: attributes, display: display).count - } - - static func spacingWidth(forVisibleComponentCount componentCount: Int) -> CGFloat { - AppTheme.Spacing.xs * CGFloat(max(0, componentCount - 1)) - } - - static func attributeStaffTopInset( - for _: MeasureAttributes, - display: NotationAttributeDisplay - ) -> CGFloat { - display.isEmpty ? 0 : AppTheme.Timeline.notationAttributeStaffTopInset - } - - static func contentStartX( - measureIndex: Int, - cellWidth: CGFloat, - attributes: MeasureAttributes, - display: NotationAttributeDisplay - ) -> CGFloat { - let cellStartX = CGFloat(measureIndex) * cellWidth - return cellStartX + attributeReserveWidth(for: attributes, display: display) - } - - static func contentWidth( - measureIndex: Int, - cellWidth: CGFloat, - attributes: MeasureAttributes, - display: NotationAttributeDisplay - ) -> CGFloat { - max(AppTheme.Timeline.notationMinimumMeasureContentWidth, cellWidth) - } - - static func playheadX( - measureIndex: Int, - cellWidth: CGFloat, - progress: CGFloat, - attributes: MeasureAttributes, - display: NotationAttributeDisplay - ) -> CGFloat { - let clampedProgress = max(0, min(progress, 1)) - let startX = contentStartX( - measureIndex: measureIndex, - cellWidth: cellWidth, - attributes: attributes, - display: display - ) - let width = contentWidth( - measureIndex: measureIndex, - cellWidth: cellWidth, - attributes: attributes, - display: display - ) - return startX + clampedProgress * width - } - - static func playheadX( - 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 - } - - static func playheadIndicatorX( - geometry: NotationMeasureCanvasGeometry, - progress: CGFloat, - indicatorWidth: CGFloat - ) -> CGFloat { - let rawX = playheadX(geometry: geometry, progress: progress) - let visualStartX = min(geometry.staffStartX, geometry.staffEndX) - let visualEndX = max(geometry.staffStartX, geometry.staffEndX) - let safeIndicatorWidth = max(0, indicatorWidth) - let maximumX = max(visualStartX, visualEndX - safeIndicatorWidth) - return min(max(rawX, visualStartX), maximumX) - } - - static func slashBeatCenters( - geometry: NotationMeasureCanvasGeometry, - timeSignature: TimeSignature, - minimumBeatSpacing: CGFloat = AppTheme.Timeline.notationSlashMinimumBeatSpacing - ) -> [CGFloat] { - let beatCount = timeSignature.beatsPerBar - let contentWidth = geometry.contentEndX - geometry.contentStartX - 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.. CGFloat { - let quarterLength = quarterLength(for: timeSignature) - guard quarterLength > 0 else { return geometry.contentStartX } - - 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) - } - - static func notationAnchorProgress( - atX x: CGFloat, - geometry: NotationMeasureCanvasGeometry, - anchorInset: CGFloat = AppTheme.Timeline.notationBeatAnchorInset - ) -> 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 - return Double(max(0, min(rawProgress, 1))) - } - - static func harmonyX( - geometry: NotationMeasureCanvasGeometry, - offsetInQuarterNotes: Double, - timeSignature: TimeSignature - ) -> CGFloat { - notationAnchorX( - geometry: geometry, - offsetInQuarterNotes: offsetInQuarterNotes, - timeSignature: timeSignature - ) - } - - static func harmonyLabelX( - geometry: NotationMeasureCanvasGeometry, - offsetInQuarterNotes: Double, - timeSignature: TimeSignature, - leadingOffset: CGFloat = AppTheme.Timeline.notationHarmonyAnchorLeadingOffset - ) -> CGFloat { - let anchorX = harmonyX( - geometry: geometry, - offsetInQuarterNotes: offsetInQuarterNotes, - timeSignature: timeSignature - ) - let lowerBound = max(geometry.staffStartX, geometry.contentStartX) - let upperBound = max(lowerBound, geometry.contentEndX) - let rawX = anchorX - max(0, leadingOffset) - return min(max(rawX, lowerBound), upperBound) - } - - static func snappedHarmonyOffset( - _ offset: Double, - timeSignature: TimeSignature, - resolution: HarmonyInputResolution - ) -> Double { - let step = resolution.stepInQuarterNotes - guard step > 0 else { return 0 } - let maximumOffset = maximumHarmonyOffset(timeSignature: timeSignature, resolution: resolution) - let snapped = (offset / step).rounded() * step - return max(0, min(snapped, maximumOffset)) - } - - static func time(forHarmonyOffset offset: Double, in measure: ScoreMeasure) -> TimeInterval { - let quarterLength = quarterLength(for: measure.attributes.timeSignature) - guard measure.duration > 0, quarterLength > 0 else { return measure.startTime } - let progress = max(0, min(offset / quarterLength, 1)) - return measure.startTime + progress * measure.duration - } - - static func quarterLength(for timeSignature: TimeSignature) -> Double { - Double(timeSignature.beatsPerBar) * 4.0 / Double(max(1, timeSignature.beatUnit)) - } - - static func canvasGeometry( - measureIndex: Int, - measureCount: Int, - cellWidth: CGFloat, - attributes: MeasureAttributes, - display: NotationAttributeDisplay, - totalWidth: CGFloat - ) -> NotationMeasureCanvasGeometry { - let cellStartX = CGFloat(measureIndex) * cellWidth - let contentStartX = cellStartX + attributeReserveWidth( - for: attributes, - display: display - ) - - return canvasGeometry( - measureIndex: measureIndex, - measureCount: measureCount, - cellStartX: cellStartX, - cellEndX: contentStartX + max(0, cellWidth), - contentStartX: contentStartX, - totalWidth: totalWidth - ) - } - - static func canvasGeometry( - measureIndex: Int, - measureCount: Int, - cellWidth: CGFloat, - contentStartX: CGFloat, - totalWidth: CGFloat - ) -> NotationMeasureCanvasGeometry { - let cellStartX = CGFloat(measureIndex) * cellWidth - return canvasGeometry( - measureIndex: measureIndex, - measureCount: measureCount, - cellStartX: cellStartX, - cellEndX: max(cellStartX + cellWidth, contentStartX + cellWidth), - contentStartX: contentStartX, - totalWidth: totalWidth - ) - } - - static func canvasGeometry( - measureIndex: Int, - measureCount: Int, - cellStartX: CGFloat, - cellEndX: CGFloat, - contentStartX: CGFloat, - totalWidth: CGFloat - ) -> NotationMeasureCanvasGeometry { - let safeCellStartX = max(0, cellStartX) - let safeCellEndX = max(safeCellStartX, cellEndX) - let clampedContentStartX = min(max(safeCellStartX, contentStartX), safeCellEndX) - let lastMeasureIndex = max(0, measureCount - 1) - var staffStartX = safeCellStartX - var staffEndX = safeCellEndX - - if measureIndex == 0 { - staffStartX = max( - safeCellStartX, - min(safeCellEndX, safeCellStartX + AppTheme.Timeline.notationStaffHorizontalInset) - ) - } - - if measureIndex == lastMeasureIndex { - staffEndX = max( - staffStartX, - safeCellEndX - AppTheme.Timeline.notationStaffHorizontalInset - ) - } - - return NotationMeasureCanvasGeometry( - measureIndex: measureIndex, - cellStartX: safeCellStartX, - cellEndX: safeCellEndX, - contentStartX: clampedContentStartX, - contentEndX: safeCellEndX, - staffStartX: staffStartX, - staffEndX: max(staffStartX, staffEndX) - ) - } - - static func fallbackCanvasGeometries( - measureCount: Int, - totalWidth: CGFloat - ) -> [NotationMeasureCanvasGeometry] { - let safeMeasureCount = max(1, measureCount) - let cellWidth = totalWidth / CGFloat(safeMeasureCount) - - return (0.. Int? { - guard !geometries.isEmpty else { return nil } - let clampedX = max(0, x) - - if let index = geometries.firstIndex(where: { geometry in - let isLastGeometry = geometry.measureIndex == geometries.last?.measureIndex - return clampedX >= geometry.cellStartX - && (clampedX < geometry.cellEndX || (isLastGeometry && clampedX <= geometry.cellEndX)) - }) { - return index - } - - return clampedX < geometries[0].cellStartX ? 0 : geometries.indices.last - } - - private static func visibleComponentWidths( - for attributes: MeasureAttributes, - display: NotationAttributeDisplay - ) -> [CGFloat] { - var widths: [CGFloat] = [] - - if display.showsClef { - widths.append(AppTheme.Timeline.notationClefWidth) - } - - let keyWidth = keySignatureWidth(for: attributes) - if display.showsKeySignature, keyWidth > 0 { - widths.append(keyWidth) - } - - if display.showsTimeSignature { - widths.append(AppTheme.Timeline.notationTimeSignatureWidth) - } - - return widths - } - - private static func maximumHarmonyOffset( - timeSignature: TimeSignature, - resolution: HarmonyInputResolution - ) -> Double { - let length = quarterLength(for: timeSignature) - let step = resolution.stepInQuarterNotes - guard length > 0, step > 0 else { return 0 } - let slots = max(0, Int(floor((length - 0.000_001) / step))) - return Double(slots) * step - } -} - -private struct HarmonyInlineTextField: NSViewRepresentable { - @Binding var text: String - let onCommit: () -> Void - let onCancel: () -> Void - let onNavigate: (HarmonyNavigationDirection) -> Void - - func makeNSView(context: Context) -> HarmonyInlineNSTextField { - let textField = HarmonyInlineNSTextField(string: text) - textField.isBordered = true - textField.isBezeled = true - textField.bezelStyle = .roundedBezel - textField.focusRingType = .default - textField.delegate = context.coordinator - textField.font = .systemFont(ofSize: 13, weight: .semibold) - textField.onWindowAttached = { [weak coordinator = context.coordinator, weak textField] in - guard let textField else { return } - coordinator?.focusAndSelectIfNeeded(textField) - } - return textField - } - - func updateNSView(_ nsView: HarmonyInlineNSTextField, context: Context) { - context.coordinator.parent = self - if nsView.stringValue != text { - nsView.stringValue = text - } - context.coordinator.focusAndSelectIfNeeded(nsView) - } - - func makeCoordinator() -> Coordinator { - Coordinator(parent: self) - } - - final class Coordinator: NSObject, NSTextFieldDelegate { - var parent: HarmonyInlineTextField - private var didAutoSelect = false - - init(parent: HarmonyInlineTextField) { - self.parent = parent - } - - func controlTextDidChange(_ notification: Notification) { - guard let textField = notification.object as? NSTextField else { return } - parent.text = textField.stringValue - } - - func focusAndSelectIfNeeded(_ textField: NSTextField) { - guard !didAutoSelect else { return } - - DispatchQueue.main.async { [weak self, weak textField] in - guard let self, let textField, !self.didAutoSelect else { return } - guard let window = textField.window else { return } - - window.makeFirstResponder(textField) - textField.selectText(nil) - self.didAutoSelect = true - } - } - - func control( - _ control: NSControl, - textView: NSTextView, - doCommandBy commandSelector: Selector - ) -> Bool { - switch commandSelector { - case #selector(NSResponder.insertNewline(_:)): - parent.text = textView.string - parent.onCommit() - return true - case #selector(NSResponder.cancelOperation(_:)): - parent.onCancel() - return true - case #selector(NSResponder.insertTab(_:)): - parent.text = textView.string - parent.onNavigate(.next) - return true - case #selector(NSResponder.insertBacktab(_:)): - parent.text = textView.string - parent.onNavigate(.previous) - return true - default: - return false - } - } - } -} - -private final class HarmonyInlineNSTextField: NSTextField { - var onWindowAttached: (() -> Void)? - - override func viewDidMoveToWindow() { - super.viewDidMoveToWindow() - - if window != nil { - onWindowAttached?() - } - } -} - private extension NotationTrackActions { static let noop = NotationTrackActions( selectHarmony: { _ in },