diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index 15d4f127db..6a26fcfb40 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -458,7 +458,6 @@ 6CB9144B29BEC7F100BC47F2 /* (null) in Sources */ = {isa = PBXBuildFile; }; 6CB94CFE2C9F1C9A00E8651C /* TextView+LSPRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CB94CFD2C9F1C9A00E8651C /* TextView+LSPRange.swift */; }; 6CB94D032CA1205100E8651C /* AsyncAlgorithms in Frameworks */ = {isa = PBXBuildFile; productRef = 6CB94D022CA1205100E8651C /* AsyncAlgorithms */; }; - 6CBA0D512A1BF524002C6FAA /* SegmentedControlImproved.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CBA0D502A1BF524002C6FAA /* SegmentedControlImproved.swift */; }; 6CBD1BC62978DE53006639D5 /* Font+Caption3.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CBD1BC52978DE53006639D5 /* Font+Caption3.swift */; }; 6CBE1CFB2B71DAA6003AC32E /* Loopable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CBE1CFA2B71DAA6003AC32E /* Loopable.swift */; }; 6CC00A8B2CBEF150004E8134 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6CC00A8A2CBEF150004E8134 /* CodeEditSourceEditor */; }; @@ -527,6 +526,10 @@ B607184C2B17E037009CDAB4 /* SourceControlStashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B607184B2B17E037009CDAB4 /* SourceControlStashView.swift */; }; B60BE8BD297A167600841125 /* AcknowledgementRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B60BE8BC297A167600841125 /* AcknowledgementRowView.swift */; }; B6152B802ADAE421004C6012 /* CodeEditWindowControllerExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6152B7F2ADAE421004C6012 /* CodeEditWindowControllerExtensions.swift */; }; + B616EA882D651ADA00DF9029 /* ScrollOffsetPreferenceKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = B616EA872D651ADA00DF9029 /* ScrollOffsetPreferenceKey.swift */; }; + B616EA892D651ADA00DF9029 /* OverlayButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = B616EA862D651ADA00DF9029 /* OverlayButtonStyle.swift */; }; + B616EA8D2D65238900DF9029 /* InternalDevelopmentInspectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B616EA8B2D65238900DF9029 /* InternalDevelopmentInspectorView.swift */; }; + B616EA8F2D662E9800DF9029 /* InternalDevelopmentNotificationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B616EA8E2D662E9800DF9029 /* InternalDevelopmentNotificationsView.swift */; }; B61A606129F188AB009B43F9 /* ExternalLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = B61A606029F188AB009B43F9 /* ExternalLink.swift */; }; B61A606929F4481A009B43F9 /* MonospacedFontPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B61A606829F4481A009B43F9 /* MonospacedFontPicker.swift */; }; B61DA9DF29D929E100BF4A43 /* GeneralSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B61DA9DE29D929E100BF4A43 /* GeneralSettingsView.swift */; }; @@ -558,6 +561,8 @@ B65B10FE2B08B07D002852CF /* SourceControlNavigatorChangesList.swift in Sources */ = {isa = PBXBuildFile; fileRef = B65B10FD2B08B07D002852CF /* SourceControlNavigatorChangesList.swift */; }; B65B11012B09D5D4002852CF /* GitClient+Pull.swift in Sources */ = {isa = PBXBuildFile; fileRef = B65B11002B09D5D4002852CF /* GitClient+Pull.swift */; }; B65B11042B09DB1C002852CF /* GitClient+Fetch.swift in Sources */ = {isa = PBXBuildFile; fileRef = B65B11032B09DB1C002852CF /* GitClient+Fetch.swift */; }; + B66460592D600E9500EC1411 /* NotificationManager+Delegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B66460572D600E9500EC1411 /* NotificationManager+Delegate.swift */; }; + B664605A2D600E9500EC1411 /* NotificationManager+System.swift in Sources */ = {isa = PBXBuildFile; fileRef = B66460582D600E9500EC1411 /* NotificationManager+System.swift */; }; B664C3B02B965F6C00816B4E /* NavigationSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = B664C3AF2B965F6C00816B4E /* NavigationSettings.swift */; }; B664C3B32B96634F00816B4E /* NavigationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B664C3B22B96634F00816B4E /* NavigationSettingsView.swift */; }; B66A4E4529C8E86D004573B4 /* CommandsFixes.swift in Sources */ = {isa = PBXBuildFile; fileRef = B66A4E4429C8E86D004573B4 /* CommandsFixes.swift */; }; @@ -580,6 +585,11 @@ B67DBB942CD5FC08007F4F18 /* GlobPatternListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B67DBB932CD5FBE2007F4F18 /* GlobPatternListItem.swift */; }; B68108042C60287F008B27C1 /* StartTaskToolbarButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68108032C60287F008B27C1 /* StartTaskToolbarButton.swift */; }; B685DE7929CC9CCD002860C8 /* StatusBarIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = B685DE7829CC9CCD002860C8 /* StatusBarIcon.swift */; }; + B68DE5DF2D5A61E5009A43EF /* CENotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68DE5D72D5A61E5009A43EF /* CENotification.swift */; }; + B68DE5E02D5A61E5009A43EF /* NotificationBannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68DE5D92D5A61E5009A43EF /* NotificationBannerView.swift */; }; + B68DE5E22D5A61E5009A43EF /* NotificationToolbarItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68DE5DB2D5A61E5009A43EF /* NotificationToolbarItem.swift */; }; + B68DE5E32D5A61E5009A43EF /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68DE5DD2D5A61E5009A43EF /* NotificationManager.swift */; }; + B68DE5E52D5A7988009A43EF /* NotificationPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68DE5E42D5A7988009A43EF /* NotificationPanelView.swift */; }; B6966A282C2F683300259C2D /* SourceControlPullView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6966A272C2F683300259C2D /* SourceControlPullView.swift */; }; B6966A2A2C2F687A00259C2D /* SourceControlFetchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6966A292C2F687A00259C2D /* SourceControlFetchView.swift */; }; B6966A2E2C3056AD00259C2D /* SourceControlCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6966A2D2C3056AD00259C2D /* SourceControlCommands.swift */; }; @@ -588,6 +598,7 @@ B6966A342C34996B00259C2D /* SourceControlManager+GitClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6966A332C34996B00259C2D /* SourceControlManager+GitClient.swift */; }; B696A7E62CFE20C40048CFE1 /* FeatureIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = B696A7E52CFE20C40048CFE1 /* FeatureIcon.swift */; }; B697937A29FF5668002027EC /* AccountsSettingsAccountLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = B697937929FF5668002027EC /* AccountsSettingsAccountLink.swift */; }; + B69970322D63E5C700BB132D /* NotificationPanelViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B69970302D63E5C700BB132D /* NotificationPanelViewModel.swift */; }; B69BFDC72B0686910050D9A6 /* GitClient+Initiate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B69BFDC62B0686910050D9A6 /* GitClient+Initiate.swift */; }; B69D3EDE2C5E85A2005CF43A /* StopTaskToolbarButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B69D3EDD2C5E85A2005CF43A /* StopTaskToolbarButton.swift */; }; B69D3EE12C5F5357005CF43A /* TaskView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B69D3EE02C5F5357005CF43A /* TaskView.swift */; }; @@ -1146,7 +1157,6 @@ 6CABB1A029C5593800340467 /* SearchPanelView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchPanelView.swift; sourceTree = ""; }; 6CB52DC82AC8DC3E002E75B3 /* CEWorkspaceFileManager+FileManagement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CEWorkspaceFileManager+FileManagement.swift"; sourceTree = ""; }; 6CB94CFD2C9F1C9A00E8651C /* TextView+LSPRange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TextView+LSPRange.swift"; sourceTree = ""; }; - 6CBA0D502A1BF524002C6FAA /* SegmentedControlImproved.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentedControlImproved.swift; sourceTree = ""; }; 6CBD1BC52978DE53006639D5 /* Font+Caption3.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Font+Caption3.swift"; sourceTree = ""; }; 6CBE1CFA2B71DAA6003AC32E /* Loopable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Loopable.swift; sourceTree = ""; }; 6CC17B502C43311900834E2C /* ProjectNavigatorViewController+NSOutlineViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProjectNavigatorViewController+NSOutlineViewDataSource.swift"; sourceTree = ""; }; @@ -1212,6 +1222,10 @@ B607184B2B17E037009CDAB4 /* SourceControlStashView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceControlStashView.swift; sourceTree = ""; }; B60BE8BC297A167600841125 /* AcknowledgementRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AcknowledgementRowView.swift; sourceTree = ""; }; B6152B7F2ADAE421004C6012 /* CodeEditWindowControllerExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeEditWindowControllerExtensions.swift; sourceTree = ""; }; + B616EA862D651ADA00DF9029 /* OverlayButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverlayButtonStyle.swift; sourceTree = ""; }; + B616EA872D651ADA00DF9029 /* ScrollOffsetPreferenceKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollOffsetPreferenceKey.swift; sourceTree = ""; }; + B616EA8B2D65238900DF9029 /* InternalDevelopmentInspectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InternalDevelopmentInspectorView.swift; sourceTree = ""; }; + B616EA8E2D662E9800DF9029 /* InternalDevelopmentNotificationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InternalDevelopmentNotificationsView.swift; sourceTree = ""; }; B61A606029F188AB009B43F9 /* ExternalLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExternalLink.swift; sourceTree = ""; }; B61A606829F4481A009B43F9 /* MonospacedFontPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MonospacedFontPicker.swift; sourceTree = ""; }; B61DA9DE29D929E100BF4A43 /* GeneralSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingsView.swift; sourceTree = ""; }; @@ -1248,6 +1262,8 @@ B65B10FD2B08B07D002852CF /* SourceControlNavigatorChangesList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceControlNavigatorChangesList.swift; sourceTree = ""; }; B65B11002B09D5D4002852CF /* GitClient+Pull.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GitClient+Pull.swift"; sourceTree = ""; }; B65B11032B09DB1C002852CF /* GitClient+Fetch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GitClient+Fetch.swift"; sourceTree = ""; }; + B66460572D600E9500EC1411 /* NotificationManager+Delegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationManager+Delegate.swift"; sourceTree = ""; }; + B66460582D600E9500EC1411 /* NotificationManager+System.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationManager+System.swift"; sourceTree = ""; }; B664C3AF2B965F6C00816B4E /* NavigationSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationSettings.swift; sourceTree = ""; }; B664C3B22B96634F00816B4E /* NavigationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationSettingsView.swift; sourceTree = ""; }; B66A4E4429C8E86D004573B4 /* CommandsFixes.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CommandsFixes.swift; sourceTree = ""; }; @@ -1270,6 +1286,11 @@ B67DBB932CD5FBE2007F4F18 /* GlobPatternListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobPatternListItem.swift; sourceTree = ""; }; B68108032C60287F008B27C1 /* StartTaskToolbarButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartTaskToolbarButton.swift; sourceTree = ""; }; B685DE7829CC9CCD002860C8 /* StatusBarIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusBarIcon.swift; sourceTree = ""; }; + B68DE5D72D5A61E5009A43EF /* CENotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CENotification.swift; sourceTree = ""; }; + B68DE5D92D5A61E5009A43EF /* NotificationBannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationBannerView.swift; sourceTree = ""; }; + B68DE5DB2D5A61E5009A43EF /* NotificationToolbarItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationToolbarItem.swift; sourceTree = ""; }; + B68DE5DD2D5A61E5009A43EF /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = ""; }; + B68DE5E42D5A7988009A43EF /* NotificationPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPanelView.swift; sourceTree = ""; }; B6966A272C2F683300259C2D /* SourceControlPullView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceControlPullView.swift; sourceTree = ""; }; B6966A292C2F687A00259C2D /* SourceControlFetchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceControlFetchView.swift; sourceTree = ""; }; B6966A2D2C3056AD00259C2D /* SourceControlCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceControlCommands.swift; sourceTree = ""; }; @@ -1278,6 +1299,7 @@ B6966A332C34996B00259C2D /* SourceControlManager+GitClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceControlManager+GitClient.swift"; sourceTree = ""; }; B696A7E52CFE20C40048CFE1 /* FeatureIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureIcon.swift; sourceTree = ""; }; B697937929FF5668002027EC /* AccountsSettingsAccountLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountsSettingsAccountLink.swift; sourceTree = ""; }; + B69970302D63E5C700BB132D /* NotificationPanelViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPanelViewModel.swift; sourceTree = ""; }; B69BFDC62B0686910050D9A6 /* GitClient+Initiate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GitClient+Initiate.swift"; sourceTree = ""; }; B69D3EDD2C5E85A2005CF43A /* StopTaskToolbarButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StopTaskToolbarButton.swift; sourceTree = ""; }; B69D3EE02C5F5357005CF43A /* TaskView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskView.swift; sourceTree = ""; }; @@ -1761,6 +1783,7 @@ 58A5DF9D29339F6400D1BD5D /* Keybindings */, 30B087FB2C0D53080063A882 /* LSP */, 287776EA27E350A100D46668 /* NavigatorArea */, + B68DE5DE2D5A61E5009A43EF /* Notifications */, 5878DAA0291AE76700DD95A3 /* OpenQuickly */, 58798210292D92370085B254 /* Search */, B67B270029D7868000FB9301 /* Settings */, @@ -2100,6 +2123,7 @@ 587B9D7529300ABD00AC7927 /* CodeEditUI */ = { isa = PBXGroup; children = ( + B616EA8A2D651B0A00DF9029 /* Styles */, 587B9D8629300ABD00AC7927 /* Views */, ); path = CodeEditUI; @@ -2115,21 +2139,19 @@ B65B10FA2B08B054002852CF /* Divided.swift */, 587B9D8B29300ABD00AC7927 /* EffectView.swift */, 587B9D9029300ABD00AC7927 /* HelpButton.swift */, - B67DB0F82AFDF638002DC647 /* IconButtonStyle.swift */, - B67DB0FB2AFDF71F002DC647 /* IconToggleStyle.swift */, + B616EA872D651ADA00DF9029 /* ScrollOffsetPreferenceKey.swift */, 587B9D8D29300ABD00AC7927 /* SearchPanel.swift */, 6CABB1A029C5593800340467 /* SearchPanelView.swift */, 61816B822C81DC2C00C71BF7 /* SearchField.swift */, - 611028C72C8DC7F100DFD845 /* MenuWithButtonStyle.swift */, 587B9D8929300ABD00AC7927 /* PanelDivider.swift */, B67DB0EE2AF3E381002DC647 /* PaneTextField.swift */, 587B9D8E29300ABD00AC7927 /* PressActionsModifier.swift */, 587B9D8829300ABD00AC7927 /* SegmentedControl.swift */, - 6CBA0D502A1BF524002C6FAA /* SegmentedControlImproved.swift */, 587B9D8C29300ABD00AC7927 /* SettingsTextEditor.swift */, 587B9D8F29300ABD00AC7927 /* ToolbarBranchPicker.swift */, B6DCDAC52CCDE2B90099FBF9 /* InstantPopoverModifier.swift */, 2897E1C62979A29200741E32 /* TrackableScrollView.swift */, + B696A7E52CFE20C40048CFE1 /* FeatureIcon.swift */, B60718302B15A9A3009CDAB4 /* CEOutlineGroup.swift */, ); path = Views; @@ -3253,6 +3275,26 @@ path = Views; sourceTree = ""; }; + B616EA8A2D651B0A00DF9029 /* Styles */ = { + isa = PBXGroup; + children = ( + B67DB0F82AFDF638002DC647 /* IconButtonStyle.swift */, + B67DB0FB2AFDF71F002DC647 /* IconToggleStyle.swift */, + B616EA862D651ADA00DF9029 /* OverlayButtonStyle.swift */, + 611028C72C8DC7F100DFD845 /* MenuWithButtonStyle.swift */, + ); + path = Styles; + sourceTree = ""; + }; + B616EA8C2D65238900DF9029 /* InternalDevelopmentInspector */ = { + isa = PBXGroup; + children = ( + B616EA8B2D65238900DF9029 /* InternalDevelopmentInspectorView.swift */, + B616EA8E2D662E9800DF9029 /* InternalDevelopmentNotificationsView.swift */, + ); + path = InternalDevelopmentInspector; + sourceTree = ""; + }; B61DA9DD29D929BF00BF4A43 /* Pages */ = { isa = PBXGroup; children = ( @@ -3547,6 +3589,37 @@ path = Settings; sourceTree = ""; }; + B68DE5D82D5A61E5009A43EF /* Models */ = { + isa = PBXGroup; + children = ( + B68DE5D72D5A61E5009A43EF /* CENotification.swift */, + ); + path = Models; + sourceTree = ""; + }; + B68DE5DC2D5A61E5009A43EF /* Views */ = { + isa = PBXGroup; + children = ( + B68DE5D92D5A61E5009A43EF /* NotificationBannerView.swift */, + B68DE5E42D5A7988009A43EF /* NotificationPanelView.swift */, + B68DE5DB2D5A61E5009A43EF /* NotificationToolbarItem.swift */, + ); + path = Views; + sourceTree = ""; + }; + B68DE5DE2D5A61E5009A43EF /* Notifications */ = { + isa = PBXGroup; + children = ( + B68DE5D82D5A61E5009A43EF /* Models */, + B69970312D63E5C700BB132D /* ViewModels */, + B68DE5DC2D5A61E5009A43EF /* Views */, + B68DE5DD2D5A61E5009A43EF /* NotificationManager.swift */, + B66460572D600E9500EC1411 /* NotificationManager+Delegate.swift */, + B66460582D600E9500EC1411 /* NotificationManager+System.swift */, + ); + path = Notifications; + sourceTree = ""; + }; B6966A262C2F673A00259C2D /* Views */ = { isa = PBXGroup; children = ( @@ -3563,6 +3636,14 @@ path = Views; sourceTree = ""; }; + B69970312D63E5C700BB132D /* ViewModels */ = { + isa = PBXGroup; + children = ( + B69970302D63E5C700BB132D /* NotificationPanelViewModel.swift */, + ); + path = ViewModels; + sourceTree = ""; + }; B69D3EDC2C5E856F005CF43A /* Views */ = { isa = PBXGroup; children = ( @@ -3643,7 +3724,6 @@ B6CF632A29E5436C0085880A /* Views */ = { isa = PBXGroup; children = ( - B696A7E52CFE20C40048CFE1 /* FeatureIcon.swift */, B67DBB932CD5FBE2007F4F18 /* GlobPatternListItem.swift */, B67DBB912CD5EAA4007F4F18 /* GlobPatternList.swift */, B6041F4C29D7A4E9000F3454 /* SettingsPageView.swift */, @@ -3698,6 +3778,7 @@ 3026F50B2AC006A10061227E /* ViewModels */, B67660672AA972B000CD56B0 /* FileInspector */, B67660662AA9726F00CD56B0 /* HistoryInspector */, + B616EA8C2D65238900DF9029 /* InternalDevelopmentInspector */, 20EBB50B280C382800F3A5DA /* Models */, 20EBB4FF280C325000F3A5DA /* Views */, ); @@ -4065,6 +4146,10 @@ B6BF41422C2C672A003AB4B3 /* SourceControlPushView.swift in Sources */, 587B9E8429301D8F00AC7927 /* GitHubUser.swift in Sources */, 04BA7C1C2AE2D84100584E1C /* GitClient+Commit.swift in Sources */, + B68DE5DF2D5A61E5009A43EF /* CENotification.swift in Sources */, + B68DE5E02D5A61E5009A43EF /* NotificationBannerView.swift in Sources */, + B68DE5E22D5A61E5009A43EF /* NotificationToolbarItem.swift in Sources */, + B68DE5E32D5A61E5009A43EF /* NotificationManager.swift in Sources */, B65B10EC2B073913002852CF /* CEContentUnavailableView.swift in Sources */, 5B698A0A2B262FA000DE9392 /* SearchSettingsView.swift in Sources */, B65B10FB2B08B054002852CF /* Divided.swift in Sources */, @@ -4112,6 +4197,7 @@ 587B9E8829301D8F00AC7927 /* GitHubFiles.swift in Sources */, 587B9DA729300ABD00AC7927 /* HelpButton.swift in Sources */, 77EF6C0B2C60C80800984B69 /* URL+Filename.swift in Sources */, + B616EA8D2D65238900DF9029 /* InternalDevelopmentInspectorView.swift in Sources */, 30B088172C0D53080063A882 /* LSPUtil.swift in Sources */, 6C5B63DE29C76213005454BA /* WindowCodeFileView.swift in Sources */, 58F2EB08292FB2B0004A9BDE /* TextEditingSettings.swift in Sources */, @@ -4183,6 +4269,8 @@ B6B2D7A12CE8797B00379967 /* GitConfigExtensions.swift in Sources */, 587B9E7329301D8F00AC7927 /* GitRouter.swift in Sources */, 6C2C156129B4F52F00EA60A5 /* SplitViewModifiers.swift in Sources */, + B69970322D63E5C700BB132D /* NotificationPanelViewModel.swift in Sources */, + B68DE5E52D5A7988009A43EF /* NotificationPanelView.swift in Sources */, 61A53A812B4449F00093BF8A /* WorkspaceDocument+Index.swift in Sources */, 66AF6CE22BF17CC300D83C9D /* StatusBarViewModel.swift in Sources */, 30CB648D2C12680F00CC8A9E /* LSPService+Events.swift in Sources */, @@ -4235,7 +4323,6 @@ 611192082B08CCFD00D4459B /* SearchIndexer+Terms.swift in Sources */, B67DBB902CD5EA77007F4F18 /* GlobPattern.swift in Sources */, 28B8F884280FFE4600596236 /* NSTableView+Background.swift in Sources */, - 6CBA0D512A1BF524002C6FAA /* SegmentedControlImproved.swift in Sources */, 58F2EB06292FB2B0004A9BDE /* KeybindingsSettings.swift in Sources */, 618725A12C29EFCC00987354 /* SchemeDropDownView.swift in Sources */, 587B9E8E29301D8F00AC7927 /* BitBucketRepositoryRouter.swift in Sources */, @@ -4388,6 +4475,7 @@ 587B9E7929301D8F00AC7927 /* GitHubIssueRouter.swift in Sources */, B67700F92D2A2662004FD61F /* WorkspacePanelView.swift in Sources */, 587B9E8029301D8F00AC7927 /* GitHubConfiguration.swift in Sources */, + B616EA8F2D662E9800DF9029 /* InternalDevelopmentNotificationsView.swift in Sources */, 58822524292C280D00E83CDE /* StatusBarView.swift in Sources */, 581550D429FBD37D00684881 /* ProjectNavigatorToolbarBottom.swift in Sources */, 66AF6CE72BF17FFB00D83C9D /* UpdateStatusBarInfo.swift in Sources */, @@ -4481,6 +4569,8 @@ B6A43C5D29FC4AF00027E0E0 /* CreateSSHKeyView.swift in Sources */, B6EA200229DB7F81001BF195 /* View+ConstrainHeightToWindow.swift in Sources */, 66F370342BEE537B00D3B823 /* NonTextFileView.swift in Sources */, + B66460592D600E9500EC1411 /* NotificationManager+Delegate.swift in Sources */, + B664605A2D600E9500EC1411 /* NotificationManager+System.swift in Sources */, 613899B72B6E702F00A5CAF6 /* String+LengthOfMatchingPrefix.swift in Sources */, 6C48D8F42972DB1A00D6D205 /* Env+Window.swift in Sources */, 6C5FDF7A29E6160000BC08C0 /* AppSettings.swift in Sources */, @@ -4584,6 +4674,8 @@ 613899BC2B6E709C00A5CAF6 /* URL+FuzzySearchable.swift in Sources */, 611192002B08CCD700D4459B /* SearchIndexer+Memory.swift in Sources */, 587B9E8129301D8F00AC7927 /* PublicKey.swift in Sources */, + B616EA882D651ADA00DF9029 /* ScrollOffsetPreferenceKey.swift in Sources */, + B616EA892D651ADA00DF9029 /* OverlayButtonStyle.swift in Sources */, 6CCEE7F52D2C91F700B2B854 /* UtilityAreaTerminalPicker.swift in Sources */, 611191FE2B08CCD200D4459B /* SearchIndexer+File.swift in Sources */, B69D3EE52C5F54B3005CF43A /* TasksPopoverMenuItem.swift in Sources */, diff --git a/CodeEdit/Features/About/Views/BlurButtonStyle.swift b/CodeEdit/Features/About/Views/BlurButtonStyle.swift index 768f841310..a86f21bcfe 100644 --- a/CodeEdit/Features/About/Views/BlurButtonStyle.swift +++ b/CodeEdit/Features/About/Views/BlurButtonStyle.swift @@ -9,12 +9,18 @@ import SwiftUI extension ButtonStyle where Self == BlurButtonStyle { static var blur: BlurButtonStyle { BlurButtonStyle() } + static var secondaryBlur: BlurButtonStyle { BlurButtonStyle(isSecondary: true) } } struct BlurButtonStyle: ButtonStyle { + var isSecondary: Bool = false + @Environment(\.controlSize) var controlSize + @Environment(\.colorScheme) + var colorScheme + var height: CGFloat { switch controlSize { case .large: @@ -24,32 +30,40 @@ struct BlurButtonStyle: ButtonStyle { } } - @Environment(\.colorScheme) - var colorScheme - func makeBody(configuration: Configuration) -> some View { configuration.label + .padding(.horizontal, 8) .frame(height: height) - .buttonStyle(.bordered) .background { switch colorScheme { case .dark: - Color - .gray - .opacity(0.001) - .overlay(.regularMaterial.blendMode(.plusLighter)) - .overlay(Color.gray.opacity(0.30)) - .overlay(Color.white.opacity(configuration.isPressed ? 0.20 : 0.00)) + ZStack { + Color.gray.opacity(0.001) + if isSecondary { + Rectangle() + .fill(.regularMaterial) + } else { + Rectangle() + .fill(.regularMaterial) + .blendMode(.plusLighter) + } + Color.gray.opacity(isSecondary ? 0.10 : 0.30) + Color.white.opacity(configuration.isPressed ? 0.10 : 0.00) + } case .light: - Color - .gray - .opacity(0.001) - .overlay(.regularMaterial.blendMode(.darken)) - .overlay(Color.gray.opacity(0.15).blendMode(.plusDarker)) + ZStack { + Color.gray.opacity(0.001) + Rectangle() + .fill(.regularMaterial) + .blendMode(.darken) + Color.gray.opacity(isSecondary ? 0.05 : 0.15) + .blendMode(.plusDarker) + Color.gray.opacity(configuration.isPressed ? 0.10 : 0.00) + } @unknown default: Color.black } } - .clipShape(RoundedRectangle(cornerRadius: 6)) + .clipShape(RoundedRectangle(cornerRadius: controlSize == .large ? 6 : 5)) } } diff --git a/CodeEdit/Features/ActivityViewer/Notifications/TaskNotificationHandler.swift b/CodeEdit/Features/ActivityViewer/Notifications/TaskNotificationHandler.swift index 8685ec7eb5..ae0fdc645a 100644 --- a/CodeEdit/Features/ActivityViewer/Notifications/TaskNotificationHandler.swift +++ b/CodeEdit/Features/ActivityViewer/Notifications/TaskNotificationHandler.swift @@ -76,7 +76,7 @@ import Combine /// "id": "uniqueTaskID", /// "action": "update", /// "title": "Updated Task Title", -/// "message": "Updated Task Message" +/// "message": "Updated Task Message", /// "percentage": 0.5, /// "isLoading": true /// ] diff --git a/CodeEdit/Features/CodeEditUI/Views/IconButtonStyle.swift b/CodeEdit/Features/CodeEditUI/Styles/IconButtonStyle.swift similarity index 100% rename from CodeEdit/Features/CodeEditUI/Views/IconButtonStyle.swift rename to CodeEdit/Features/CodeEditUI/Styles/IconButtonStyle.swift diff --git a/CodeEdit/Features/CodeEditUI/Views/IconToggleStyle.swift b/CodeEdit/Features/CodeEditUI/Styles/IconToggleStyle.swift similarity index 100% rename from CodeEdit/Features/CodeEditUI/Views/IconToggleStyle.swift rename to CodeEdit/Features/CodeEditUI/Styles/IconToggleStyle.swift diff --git a/CodeEdit/Features/CodeEditUI/Views/MenuWithButtonStyle.swift b/CodeEdit/Features/CodeEditUI/Styles/MenuWithButtonStyle.swift similarity index 100% rename from CodeEdit/Features/CodeEditUI/Views/MenuWithButtonStyle.swift rename to CodeEdit/Features/CodeEditUI/Styles/MenuWithButtonStyle.swift diff --git a/CodeEdit/Features/CodeEditUI/Styles/OverlayButtonStyle.swift b/CodeEdit/Features/CodeEditUI/Styles/OverlayButtonStyle.swift new file mode 100644 index 0000000000..7e9b962c68 --- /dev/null +++ b/CodeEdit/Features/CodeEditUI/Styles/OverlayButtonStyle.swift @@ -0,0 +1,34 @@ +import SwiftUI + +/// A button style for overlay buttons (like close, action buttons in notifications) +struct OverlayButtonStyle: ButtonStyle { + @Environment(\.colorScheme) + private var colorScheme + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: 10)) + .foregroundColor(.secondary) + .frame(width: 20, height: 20, alignment: .center) + .background(Color.primary.opacity(configuration.isPressed ? colorScheme == .dark ? 0.10 : 0.05 : 0.00)) + .background(.regularMaterial) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color(nsColor: .separatorColor), lineWidth: 2) + ) + .cornerRadius(10) + .shadow( + color: Color(.black.withAlphaComponent(colorScheme == .dark ? 0.2 : 0.1)), + radius: 5, + x: 0, + y: 2 + ) + } +} + +extension ButtonStyle where Self == OverlayButtonStyle { + /// A button style for overlay buttons + static var overlay: OverlayButtonStyle { + OverlayButtonStyle() + } +} diff --git a/CodeEdit/Features/CodeEditUI/Views/FeatureIcon.swift b/CodeEdit/Features/CodeEditUI/Views/FeatureIcon.swift new file mode 100644 index 0000000000..33d0ae09cf --- /dev/null +++ b/CodeEdit/Features/CodeEditUI/Views/FeatureIcon.swift @@ -0,0 +1,98 @@ +// +// FeatureIcon.swift +// CodeEdit +// +// Created by Austin Condiff on 12/2/24. +// + +import SwiftUI +import CodeEditSymbols + +struct FeatureIcon: View { + private let content: IconContent + private let color: Color? + private let size: CGFloat + + init( + symbol: String, + color: Color? = nil, + size: CGFloat? = nil + ) { + self.content = .symbol(symbol) + self.color = color ?? .accentColor + self.size = size ?? 20 + } + + init( + text: String, + textColor: Color? = nil, + color: Color? = nil, + size: CGFloat? = nil + ) { + self.content = .text(text, textColor: textColor) + self.color = color ?? .accentColor + self.size = size ?? 20 + } + + init( + image: Image, + size: CGFloat? = nil + ) { + self.content = .image(image) + self.color = nil + self.size = size ?? 20 + } + + private func getSafeImage(named: String) -> Image { + if NSImage(systemSymbolName: named, accessibilityDescription: nil) != nil { + return Image(systemName: named) + } else { + return Image(symbol: named) + } + } + + var body: some View { + RoundedRectangle(cornerRadius: size / 4, style: .continuous) + .fill(background) + .overlay { + switch content { + case let .symbol(name): + getSafeImage(named: name) + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(.white) + .padding(size / 8) + case let .text(text, textColor): + Text(text) + .font(.system(size: size * 0.65)) + .foregroundColor(textColor ?? .primary) + case let .image(image): + image + .resizable() + .aspectRatio(contentMode: .fill) + } + } + .clipShape(RoundedRectangle(cornerRadius: size / 4, style: .continuous)) + .shadow( + color: Color(NSColor.black).opacity(0.25), + radius: size / 40, + y: size / 40 + ) + .frame(width: size, height: size) + } + + private var background: AnyShapeStyle { + switch content { + case .symbol, .text: + return AnyShapeStyle((color ?? .accentColor).gradient) + case .image: + return AnyShapeStyle(.regularMaterial) + } + } +} + +private enum IconContent { + case symbol(String) + case text(String, textColor: Color?) + case image(Image) +} diff --git a/CodeEdit/Features/CodeEditUI/Views/ScrollOffsetPreferenceKey.swift b/CodeEdit/Features/CodeEditUI/Views/ScrollOffsetPreferenceKey.swift new file mode 100644 index 0000000000..13ca285116 --- /dev/null +++ b/CodeEdit/Features/CodeEditUI/Views/ScrollOffsetPreferenceKey.swift @@ -0,0 +1,10 @@ +import SwiftUI + +/// Tracks scroll offset in scrollable views +struct ScrollOffsetPreferenceKey: PreferenceKey { + typealias Value = CGFloat + static var defaultValue = CGFloat.zero + static func reduce(value: inout Value, nextValue: () -> Value) { + value += nextValue() + } +} diff --git a/CodeEdit/Features/CodeEditUI/Views/SegmentedControlImproved.swift b/CodeEdit/Features/CodeEditUI/Views/SegmentedControlImproved.swift deleted file mode 100644 index 2578cd7b7c..0000000000 --- a/CodeEdit/Features/CodeEditUI/Views/SegmentedControlImproved.swift +++ /dev/null @@ -1,145 +0,0 @@ -// -// SegmentedControlImproved.swift -// CodeEdit -// -// Created by Wouter Hennen on 22/05/2023. -// - -import SwiftUI - -extension ButtonStyle where Self == XcodeButtonStyle { - static func xcodeButton( - isActive: Bool, - prominent: Bool, - isHovering: Bool, - namespace: Namespace.ID = Namespace().wrappedValue - ) -> XcodeButtonStyle { - XcodeButtonStyle(isActive: isActive, prominent: prominent, isHovering: isHovering, namespace: namespace) - } -} - -struct XcodeButtonStyle: ButtonStyle { - var isActive: Bool - var prominent: Bool - var isHovering: Bool - var namespace: Namespace.ID - - @Environment(\.controlSize) - var controlSize - - @Environment(\.colorScheme) - var colorScheme - - @Environment(\.controlActiveState) - private var activeState - - func makeBody(configuration: Configuration) -> some View { - configuration.label - .padding(.horizontal, controlSizePadding.horizontal) - .padding(.vertical, controlSizePadding.vertical) - .font(fontSize) - .foregroundColor(isActive ? .white : .primary) - .opacity(textOpacity) - .background { - if isActive { - RoundedRectangle(cornerRadius: 5) - .foregroundColor(.accentColor) - .opacity(configuration.isPressed ? (prominent ? 0.75 : 0.5) : (prominent ? 1 : 0.75)) - .matchedGeometryEffect(id: "xcodebuttonbackground", in: namespace) - - } else if isHovering { - RoundedRectangle(cornerRadius: 5) - .foregroundColor(.gray) - .opacity(0.2) - .transition(.opacity) - .animation(.easeInOut, value: isHovering) - } - } - .opacity(activeState == .inactive ? 0.6 : 1) - .animation(.interpolatingSpring(stiffness: 600, damping: 50), value: isActive) - } - - var fontSize: Font { - switch controlSize { - case .mini: - return .footnote - case .small, .regular: - return .subheadline - default: - return .callout - } - } - - var controlSizePadding: (vertical: CGFloat, horizontal: CGFloat) { - switch controlSize { - case .mini: - return (1, 2) - case .small: - return (2, 4) - case .regular: - return (3, 8) - case .large: - return (6, 12) - default: - return (4, 8) - } - } - - private var textOpacity: Double { - if prominent { - return activeState != .inactive ? 1 : isActive ? 1 : 0.3 - } else { - return activeState != .inactive ? 1 : isActive ? 0.5 : 0.3 - } - } -} - -private struct MyTag: _ViewTraitKey { - static var defaultValue: AnyHashable? = Optional.none -} - -extension View { - func segmentedTag(_ value: Value) -> some View { - _trait(MyTag.self, value) - } -} - -struct SegmentedControlV2: View { - @Binding var selection: Selection - var prominent: Bool - @ViewBuilder var content: Content - - @State private var hoveringOver: Selection? - - @Namespace var namespace - - var body: some View { - content.variadic { children in - HStack(spacing: 8) { - ForEach(children, id: \.id) { option in - let tag: Selection? = option[MyTag.self].flatMap { $0 as? Selection } - Button { - hoveringOver = nil - if let tag { - selection = tag - } - } label: { - option - } - .buttonStyle( - .xcodeButton( - isActive: tag == selection, - prominent: prominent, - isHovering: tag == hoveringOver, - namespace: namespace - ) - ) - .onHover { hover in - hoveringOver = hover ? tag : nil - } - .animation(.interpolatingSpring(stiffness: 600, damping: 50), value: selection) - } - } - } - } -} diff --git a/CodeEdit/Features/Documents/Controllers/CodeEditWindowController+Toolbar.swift b/CodeEdit/Features/Documents/Controllers/CodeEditWindowController+Toolbar.swift index b0d612b3e2..e7d5f02822 100644 --- a/CodeEdit/Features/Documents/Controllers/CodeEditWindowController+Toolbar.swift +++ b/CodeEdit/Features/Documents/Controllers/CodeEditWindowController+Toolbar.swift @@ -31,6 +31,7 @@ extension CodeEditWindowController { .branchPicker, .flexibleSpace, .activityViewer, + .notificationItem, .flexibleSpace, .itemListTrackingSeparator, .flexibleSpace, @@ -47,6 +48,7 @@ extension CodeEditWindowController { .toggleLastSidebarItem, .branchPicker, .activityViewer, + .notificationItem, .startTaskSidebarItem, .stopTaskSidebarItem ] @@ -173,6 +175,15 @@ extension CodeEditWindowController { strongWidth ]) + toolbarItem.view = view + return toolbarItem + case .notificationItem: + let toolbarItem = NSToolbarItem(itemIdentifier: .notificationItem) + guard let workspace = workspace else { return nil } + let view = NSHostingView( + rootView: NotificationToolbarItem() + .environmentObject(workspace) + ) toolbarItem.view = view return toolbarItem default: diff --git a/CodeEdit/Features/Documents/Controllers/CodeEditWindowControllerExtensions.swift b/CodeEdit/Features/Documents/Controllers/CodeEditWindowControllerExtensions.swift index 9d7ffe4d2d..88e7dbc9b0 100644 --- a/CodeEdit/Features/Documents/Controllers/CodeEditWindowControllerExtensions.swift +++ b/CodeEdit/Features/Documents/Controllers/CodeEditWindowControllerExtensions.swift @@ -140,4 +140,5 @@ extension NSToolbarItem.Identifier { static let itemListTrackingSeparator = NSToolbarItem.Identifier("ItemListTrackingSeparator") static let branchPicker: NSToolbarItem.Identifier = NSToolbarItem.Identifier("BranchPicker") static let activityViewer: NSToolbarItem.Identifier = NSToolbarItem.Identifier("ActivityViewer") + static let notificationItem = NSToolbarItem.Identifier("notificationItem") } diff --git a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift index aaac6e235a..1b96e4fca2 100644 --- a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift +++ b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift @@ -43,8 +43,21 @@ final class WorkspaceDocument: NSDocument, ObservableObject, NSToolbarDelegate { var workspaceSettingsManager: CEWorkspaceSettings? var taskNotificationHandler: TaskNotificationHandler = TaskNotificationHandler() + @Published var notificationPanel = NotificationPanelViewModel() private var cancellables = Set() + override init() { + super.init() + + // Observe changes to notification panel + notificationPanel.objectWillChange + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) + } + deinit { cancellables.forEach { $0.cancel() } NotificationCenter.default.removeObserver(self) diff --git a/CodeEdit/Features/InspectorArea/FileInspector/FileInspectorView.swift b/CodeEdit/Features/InspectorArea/FileInspector/FileInspectorView.swift index d11f75fbd8..50cbd983b3 100644 --- a/CodeEdit/Features/InspectorArea/FileInspector/FileInspectorView.swift +++ b/CodeEdit/Features/InspectorArea/FileInspector/FileInspectorView.swift @@ -85,7 +85,7 @@ struct FileInspectorView: View { if let file { TextField("Name", text: $fileName) .background( - file.validateFileName(for: fileName) ? Color.clear : Color(errorRed) + fileName != file.fileName() && !file.validateFileName(for: fileName) ? Color(errorRed) : Color.clear ) .onSubmit { if file.validateFileName(for: fileName) { diff --git a/CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentInspectorView.swift b/CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentInspectorView.swift new file mode 100644 index 0000000000..0906bbcbfb --- /dev/null +++ b/CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentInspectorView.swift @@ -0,0 +1,16 @@ +// +// InternalDevelopmentInspectorView.swift +// CodeEdit +// +// Created by Austin Condiff on 2/19/24. +// + +import SwiftUI + +struct InternalDevelopmentInspectorView: View { + var body: some View { + Form { + InternalDevelopmentNotificationsView() + } + } +} diff --git a/CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentNotificationsView.swift b/CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentNotificationsView.swift new file mode 100644 index 0000000000..5334880996 --- /dev/null +++ b/CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentNotificationsView.swift @@ -0,0 +1,206 @@ +// +// InternalDevelopmentNotificationsView.swift +// CodeEdit +// +// Created by Austin Condiff on 2/19/24. +// + +import SwiftUI + +struct InternalDevelopmentNotificationsView: View { + enum IconType: String, CaseIterable { + case symbol = "Symbol" + case image = "Image" + case text = "Text" + case emoji = "Emoji" + } + + @State private var delay: Bool = false + @State private var sticky: Bool = false + @State private var selectedIconType: IconType = .symbol + @State private var actionButtonText: String = "View" + @State private var notificationTitle: String = "Test Notification" + @State private var notificationDescription: String = "This is a test notification." + + // Icon selection states + @State private var selectedSymbol: String? + @State private var selectedEmoji: String? + @State private var selectedText: String? + @State private var selectedImage: String? + @State private var selectedColor: Color? + + private let availableSymbols = [ + "bell.fill", "bell.badge.fill", "exclamationmark.triangle.fill", + "info.circle.fill", "checkmark.seal.fill", "xmark.octagon.fill", + "bubble.left.fill", "envelope.fill", "phone.fill", "megaphone.fill", + "clock.fill", "calendar", "flag.fill", "bookmark.fill", "bolt.fill", + "shield.lefthalf.fill", "gift.fill", "heart.fill", "star.fill", + "curlybraces" + ] + + private let availableEmojis = [ + "🔔", "🚨", "⚠️", "👋", "😍", "😎", "😘", "😜", "😝", "😀", "😁", + "😂", "🤣", "😃", "😄", "😅", "😆", "😇", "😉", "😊", "😋", "😌" + ] + + private let availableImages = [ + "GitHubIcon", "BitBucketIcon", "GitLabIcon" + ] + + private let availableColors: [(String, Color)] = [ + ("Red", .red), ("Orange", .orange), ("Yellow", .yellow), + ("Green", .green), ("Mint", .mint), ("Cyan", .cyan), + ("Teal", .teal), ("Blue", .blue), ("Indigo", .indigo), + ("Purple", .purple), ("Pink", .pink), ("Gray", .gray) + ] + + var body: some View { + Section("Notifications") { + Toggle("Delay 5s", isOn: $delay) + Toggle("Sticky", isOn: $sticky) + + Picker("Icon Type", selection: $selectedIconType) { + ForEach(IconType.allCases, id: \.self) { type in + Text(type.rawValue).tag(type) + } + } + + Group { + switch selectedIconType { + case .symbol: + Picker("Symbol", selection: $selectedSymbol) { + Label("Random", systemImage: "dice").tag(nil as String?) + Divider() + ForEach(availableSymbols, id: \.self) { symbol in + Label(symbol, systemImage: symbol).tag(symbol as String?) + } + } + case .emoji: + Picker("Emoji", selection: $selectedEmoji) { + Label("Random", systemImage: "dice").tag(nil as String?) + Divider() + ForEach(availableEmojis, id: \.self) { emoji in + Text(emoji).tag(emoji as String?) + } + } + case .text: + Picker("Text", selection: $selectedText) { + Label("Random", systemImage: "dice").tag(nil as String?) + Divider() + ForEach("ABCDEFGHIJKLMNOPQRSTUVWXYZ".map { String($0) }, id: \.self) { letter in + Text(letter).tag(letter as String?) + } + } + case .image: + Picker("Image", selection: $selectedImage) { + Label("Random", systemImage: "dice").tag(nil as String?) + Divider() + ForEach(availableImages, id: \.self) { image in + Text(image).tag(image as String?) + } + } + } + + if selectedIconType == .symbol || selectedIconType == .text || selectedIconType == .emoji { + Picker("Icon Color", selection: $selectedColor) { + Label("Random", systemImage: "dice").tag(nil as Color?) + Divider() + ForEach(availableColors, id: \.0) { name, color in + HStack { + Circle() + .fill(color) + .frame(width: 12, height: 12) + Text(name) + }.tag(color as Color?) + } + } + } + } + + TextField("Title", text: $notificationTitle) + TextField("Description", text: $notificationDescription, axis: .vertical) + .lineLimit(1...5) + TextField("Action Button", text: $actionButtonText) + + Button("Add Notification") { + let action = { + switch selectedIconType { + case .symbol: + let iconSymbol = selectedSymbol ?? availableSymbols.randomElement() ?? "bell.fill" + let iconColor = selectedColor ?? availableColors.randomElement()?.1 ?? .blue + + NotificationManager.shared.post( + iconSymbol: iconSymbol, + iconColor: iconColor, + title: notificationTitle, + description: notificationDescription, + actionButtonTitle: actionButtonText, + action: { + print("Test notification action triggered") + }, + isSticky: sticky + ) + case .image: + let imageName = selectedImage ?? availableImages.randomElement() ?? "GitHubIcon" + + NotificationManager.shared.post( + iconImage: Image(imageName), + title: notificationTitle, + description: notificationDescription, + actionButtonTitle: actionButtonText, + action: { + print("Test notification action triggered") + }, + isSticky: sticky + ) + case .text: + let text = selectedText ?? randomLetter() + let iconColor = selectedColor ?? availableColors.randomElement()?.1 ?? .blue + + NotificationManager.shared.post( + iconText: text, + iconTextColor: .white, + iconColor: iconColor, + title: notificationTitle, + description: notificationDescription, + actionButtonTitle: actionButtonText, + action: { + print("Test notification action triggered") + }, + isSticky: sticky + ) + case .emoji: + let emoji = selectedEmoji ?? availableEmojis.randomElement() ?? "🔔" + let iconColor = selectedColor ?? availableColors.randomElement()?.1 ?? .blue + + NotificationManager.shared.post( + iconText: emoji, + iconTextColor: .white, + iconColor: iconColor, + title: notificationTitle, + description: notificationDescription, + actionButtonTitle: actionButtonText, + action: { + print("Test notification action triggered") + }, + isSticky: sticky + ) + } + } + + if delay { + DispatchQueue.main.asyncAfter(deadline: .now() + 5) { + action() + } + } else { + action() + } + } + } + } + + private func randomLetter() -> String { + let letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".map { String($0) } + return letters.randomElement() ?? "A" + } +} diff --git a/CodeEdit/Features/InspectorArea/Models/InspectorTab.swift b/CodeEdit/Features/InspectorArea/Models/InspectorTab.swift index 209083a072..f9311cea32 100644 --- a/CodeEdit/Features/InspectorArea/Models/InspectorTab.swift +++ b/CodeEdit/Features/InspectorArea/Models/InspectorTab.swift @@ -12,6 +12,7 @@ import ExtensionFoundation enum InspectorTab: WorkspacePanelTab { case file case gitHistory + case internalDevelopment case uiExtension(endpoint: AppExtensionIdentity, data: ResolvedSidebar.SidebarStore) var systemImage: String { @@ -20,6 +21,8 @@ enum InspectorTab: WorkspacePanelTab { return "doc" case .gitHistory: return "clock" + case .internalDevelopment: + return "hammer" case .uiExtension(_, let data): return data.icon ?? "e.square" } @@ -38,6 +41,8 @@ enum InspectorTab: WorkspacePanelTab { return "File Inspector" case .gitHistory: return "History Inspector" + case .internalDevelopment: + return "Internal Development" case .uiExtension(_, let data): return data.help ?? data.sceneID } @@ -49,6 +54,8 @@ enum InspectorTab: WorkspacePanelTab { FileInspectorView() case .gitHistory: HistoryInspectorView() + case .internalDevelopment: + InternalDevelopmentInspectorView() case let .uiExtension(endpoint, data): ExtensionSceneView(with: endpoint, sceneID: data.sceneID) } diff --git a/CodeEdit/Features/InspectorArea/Views/InspectorAreaView.swift b/CodeEdit/Features/InspectorArea/Views/InspectorAreaView.swift index c6491f00d5..ecfe7df841 100644 --- a/CodeEdit/Features/InspectorArea/Views/InspectorAreaView.swift +++ b/CodeEdit/Features/InspectorArea/Views/InspectorAreaView.swift @@ -16,21 +16,32 @@ struct InspectorAreaView: View { @AppSettings(\.general.inspectorTabBarPosition) var sidebarPosition: SettingsData.SidebarTabBarPosition + @AppSettings(\.developerSettings.showInternalDevelopmentInspector) + var showInternalDevelopmentInspector + init(viewModel: InspectorAreaViewModel) { self.viewModel = viewModel + updateTabs() + } + + private func updateTabs() { + var tabs: [InspectorTab] = [.file, .gitHistory] + + if showInternalDevelopmentInspector { + tabs.append(.internalDevelopment) + } - viewModel.tabItems = [.file, .gitHistory] + - extensionManager - .extensions - .map { ext in - ext.availableFeatures.compactMap { - if case .sidebarItem(let data) = $0, data.kind == .inspector { - return InspectorTab.uiExtension(endpoint: ext.endpoint, data: data) - } - return nil + viewModel.tabItems = tabs + extensionManager + .extensions + .map { ext in + ext.availableFeatures.compactMap { + if case .sidebarItem(let data) = $0, data.kind == .inspector { + return InspectorTab.uiExtension(endpoint: ext.endpoint, data: data) } + return nil } - .joined() + } + .joined() } var body: some View { @@ -43,5 +54,8 @@ struct InspectorAreaView: View { .formStyle(.grouped) .accessibilityElement(children: .contain) .accessibilityLabel("inspector") + .onChange(of: showInternalDevelopmentInspector) { _ in + updateTabs() + } } } diff --git a/CodeEdit/Features/Notifications/Models/CENotification.swift b/CodeEdit/Features/Notifications/Models/CENotification.swift new file mode 100644 index 0000000000..f71d39a49e --- /dev/null +++ b/CodeEdit/Features/Notifications/Models/CENotification.swift @@ -0,0 +1,122 @@ +// +// CENotification.swift +// CodeEdit +// +// Created by Austin Condiff on 2/10/24. +// + +import Foundation +import SwiftUI + +struct CENotification: Identifiable, Equatable { + let id: UUID + let icon: IconType + let title: String + let description: String + let actionButtonTitle: String + let action: () -> Void + let isSticky: Bool + var isRead: Bool + let timestamp: Date + var isBeingDismissed: Bool = false + + enum IconType { + case symbol(name: String, color: Color?) + case image(Image) + case text(String, backgroundColor: Color?, textColor: Color?) + } + + init( + id: UUID = UUID(), + iconSymbol: String, + iconColor: Color? = nil, + title: String, + description: String, + actionButtonTitle: String, + action: @escaping () -> Void, + isSticky: Bool = false, + isRead: Bool = false + ) { + self.init( + id: id, + icon: .symbol(name: iconSymbol, color: iconColor), + title: title, + description: description, + actionButtonTitle: actionButtonTitle, + action: action, + isSticky: isSticky, + isRead: isRead + ) + } + + init( + id: UUID = UUID(), + iconText: String, + iconTextColor: Color? = nil, + iconColor: Color? = nil, + title: String, + description: String, + actionButtonTitle: String, + action: @escaping () -> Void, + isSticky: Bool = false, + isRead: Bool = false + ) { + self.init( + id: id, + icon: .text(iconText, backgroundColor: iconColor, textColor: iconTextColor), + title: title, + description: description, + actionButtonTitle: actionButtonTitle, + action: action, + isSticky: isSticky, + isRead: isRead + ) + } + + init( + id: UUID = UUID(), + iconImage: Image, + title: String, + description: String, + actionButtonTitle: String, + action: @escaping () -> Void, + isSticky: Bool = false, + isRead: Bool = false + ) { + self.init( + id: id, + icon: .image(iconImage), + title: title, + description: description, + actionButtonTitle: actionButtonTitle, + action: action, + isSticky: isSticky, + isRead: isRead + ) + } + + private init( + id: UUID, + icon: IconType, + title: String, + description: String, + actionButtonTitle: String, + action: @escaping () -> Void, + isSticky: Bool, + isRead: Bool + ) { + self.id = id + self.icon = icon + self.title = title + self.description = description + self.actionButtonTitle = actionButtonTitle + self.action = action + self.isSticky = isSticky + self.isRead = isRead + self.timestamp = Date() + } + + static func == (lhs: CENotification, rhs: CENotification) -> Bool { + lhs.id == rhs.id + } +} diff --git a/CodeEdit/Features/Notifications/NotificationManager+Delegate.swift b/CodeEdit/Features/Notifications/NotificationManager+Delegate.swift new file mode 100644 index 0000000000..967023db1f --- /dev/null +++ b/CodeEdit/Features/Notifications/NotificationManager+Delegate.swift @@ -0,0 +1,67 @@ +// +// NotificationManager+Delegate.swift +// CodeEdit +// +// Created by Austin Condiff on 2/14/24. +// + +import AppKit +import UserNotifications + +extension NotificationManager: UNUserNotificationCenterDelegate { + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + if let notification = notifications.first(where: { + $0.id.uuidString == response.notification.request.identifier + }) { + // Focus CodeEdit and run action if action button was clicked + if response.actionIdentifier == "ACTION_BUTTON" || + response.actionIdentifier == UNNotificationDefaultActionIdentifier { + NSApp.activate(ignoringOtherApps: true) + notification.action() + } + + // Remove the notification for both action and dismiss + if response.actionIdentifier == "ACTION_BUTTON" || + response.actionIdentifier == UNNotificationDefaultActionIdentifier || + response.actionIdentifier == UNNotificationDismissActionIdentifier { + dismissNotification(notification) + } + } + + completionHandler() + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + completionHandler([.banner, .sound]) + } + + func setupNotificationDelegate() { + UNUserNotificationCenter.current().delegate = self + + // Create action button + let action = UNNotificationAction( + identifier: "ACTION_BUTTON", + title: "Action", // This will be replaced with actual button title + options: .foreground + ) + + // Create category with action button + let actionCategory = UNNotificationCategory( + identifier: "ACTIONABLE", + actions: [action], + intentIdentifiers: [], + options: .customDismissAction + ) + + UNUserNotificationCenter.current().setNotificationCategories([actionCategory]) + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { _, _ in } + } +} diff --git a/CodeEdit/Features/Notifications/NotificationManager+System.swift b/CodeEdit/Features/Notifications/NotificationManager+System.swift new file mode 100644 index 0000000000..24522d7c4b --- /dev/null +++ b/CodeEdit/Features/Notifications/NotificationManager+System.swift @@ -0,0 +1,46 @@ +// +// NotificationManager+System.swift +// CodeEdit +// +// Created by Austin Condiff on 2/14/24. +// + +import Foundation +import UserNotifications + +extension NotificationManager { + /// Shows a system notification when app is in background + func showSystemNotification(_ notification: CENotification) { + let content = UNMutableNotificationContent() + content.title = notification.title + content.body = notification.description + + if !notification.actionButtonTitle.isEmpty { + content.categoryIdentifier = "ACTIONABLE" + } + + let request = UNNotificationRequest( + identifier: notification.id.uuidString, + content: content, + trigger: nil + ) + + UNUserNotificationCenter.current().add(request) + } + + /// Removes a system notification + func removeSystemNotification(_ notification: CENotification) { + UNUserNotificationCenter.current().removeDeliveredNotifications( + withIdentifiers: [notification.id.uuidString] + ) + } + + /// Handles response from system notification + func handleSystemNotificationResponse(id: String) { + if let uuid = UUID(uuidString: id), + let notification = notifications.first(where: { $0.id == uuid }) { + notification.action() + dismissNotification(notification) + } + } +} diff --git a/CodeEdit/Features/Notifications/NotificationManager.swift b/CodeEdit/Features/Notifications/NotificationManager.swift new file mode 100644 index 0000000000..e270514189 --- /dev/null +++ b/CodeEdit/Features/Notifications/NotificationManager.swift @@ -0,0 +1,196 @@ +// +// NotificationManager.swift +// CodeEdit +// +// Created by Austin Condiff on 2/10/24. +// + +import SwiftUI +import Combine +import UserNotifications + +/// Manages the application's notification system, handling both in-app notifications and system notifications. +/// This class is responsible for: +/// - Managing notification persistence +/// - Tracking notification read status +/// - Broadcasting notifications to workspaces +final class NotificationManager: NSObject, ObservableObject { + /// Shared instance for accessing the notification manager + static let shared = NotificationManager() + + /// Collection of all notifications, both read and unread + @Published private(set) var notifications: [CENotification] = [] + + private var isAppActive: Bool = true + + /// Number of unread notifications + var unreadCount: Int { + notifications.filter { !$0.isRead }.count + } + + /// Posts a new notification + /// - Parameters: + /// - iconSymbol: SF Symbol or CodeEditSymbol name for the notification icon + /// - iconColor: Color for the icon + /// - title: Main notification title + /// - description: Detailed notification message + /// - actionButtonTitle: Title for the action button + /// - action: Closure to execute when action button is clicked + /// - isSticky: Whether the notification should persist until manually dismissed + func post( + iconSymbol: String, + iconColor: Color? = Color(.systemBlue), + title: String, + description: String, + actionButtonTitle: String, + action: @escaping () -> Void, + isSticky: Bool = false + ) { + let notification = CENotification( + iconSymbol: iconSymbol, + iconColor: iconColor, + title: title, + description: description, + actionButtonTitle: actionButtonTitle, + action: action, + isSticky: isSticky, + isRead: false + ) + + postNotification(notification) + } + + /// Posts a new notification + /// - Parameters: + /// - iconImage: Image for the notification icon + /// - title: Main notification title + /// - description: Detailed notification message + /// - actionButtonTitle: Title for the action button + /// - action: Closure to execute when action button is clicked + /// - isSticky: Whether the notification should persist until manually dismissed + func post( + iconImage: Image, + title: String, + description: String, + actionButtonTitle: String, + action: @escaping () -> Void, + isSticky: Bool = false + ) { + let notification = CENotification( + iconImage: iconImage, + title: title, + description: description, + actionButtonTitle: actionButtonTitle, + action: action, + isSticky: isSticky + ) + + postNotification(notification) + } + + /// Posts a new notification + /// - Parameters: + /// - iconText: Text or emoji for the notification icon + /// - iconTextColor: Color of the text/emoji (defaults to primary label color) + /// - iconColor: Background color for the icon + /// - title: Main notification title + /// - description: Detailed notification message + /// - actionButtonTitle: Title for the action button + /// - action: Closure to execute when action button is clicked + /// - isSticky: Whether the notification should persist until manually dismissed + func post( + iconText: String, + iconTextColor: Color? = nil, + iconColor: Color? = Color(.systemBlue), + title: String, + description: String, + actionButtonTitle: String, + action: @escaping () -> Void, + isSticky: Bool = false + ) { + let notification = CENotification( + iconText: iconText, + iconTextColor: iconTextColor, + iconColor: iconColor, + title: title, + description: description, + actionButtonTitle: actionButtonTitle, + action: action, + isSticky: isSticky + ) + + postNotification(notification) + } + + /// Dismisses a specific notification + func dismissNotification(_ notification: CENotification) { + notifications.removeAll(where: { $0.id == notification.id }) + markAsRead(notification) + + // Remove system notification if it exists + removeSystemNotification(notification) + + NotificationCenter.default.post( + name: .init("NotificationDismissed"), + object: notification + ) + } + + /// Marks a notification as read + /// - Parameter notification: The notification to mark as read + func markAsRead(_ notification: CENotification) { + if let index = notifications.firstIndex(where: { $0.id == notification.id }) { + notifications[index].isRead = true + } + } + + override init() { + super.init() + setupNotificationDelegate() + + // Observe app active state + NotificationCenter.default.addObserver( + self, + selector: #selector(handleAppDidBecomeActive), + name: NSApplication.didBecomeActiveNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleAppDidResignActive), + name: NSApplication.didResignActiveNotification, + object: nil + ) + } + + @objc + private func handleAppDidBecomeActive() { + isAppActive = true + // Remove any system notifications when app becomes active + UNUserNotificationCenter.current().removeAllDeliveredNotifications() + } + + @objc + private func handleAppDidResignActive() { + isAppActive = false + } + + /// Posts a notification to workspaces and system + private func postNotification(_ notification: CENotification) { + DispatchQueue.main.async { [weak self] in + self?.notifications.append(notification) + + // Always notify workspaces of new notification + NotificationCenter.default.post( + name: .init("NewNotificationAdded"), + object: notification + ) + + // Additionally show system notification when app is in background + if self?.isAppActive != true { + self?.showSystemNotification(notification) + } + } + } +} diff --git a/CodeEdit/Features/Notifications/ViewModels/NotificationPanelViewModel.swift b/CodeEdit/Features/Notifications/ViewModels/NotificationPanelViewModel.swift new file mode 100644 index 0000000000..9343856b92 --- /dev/null +++ b/CodeEdit/Features/Notifications/ViewModels/NotificationPanelViewModel.swift @@ -0,0 +1,265 @@ +// +// NotificationPanelViewModel.swift +// CodeEdit +// +// Created by Austin Condiff on 2/14/24. +// + +import SwiftUI + +final class NotificationPanelViewModel: ObservableObject { + /// Currently displayed notifications in the panel + @Published private(set) var activeNotifications: [CENotification] = [] + + /// Whether notifications panel was manually shown via toolbar + @Published private(set) var isPresented: Bool = false + + /// Set of hidden notification IDs + @Published private(set) var hiddenNotificationIds: Set = [] + + /// Timers for notifications + private var timers: [UUID: Timer] = [:] + + /// Display duration for notifications + private let displayDuration: TimeInterval = 5.0 + + /// Whether notifications are paused + private var isPaused: Bool = false + + private var notificationManager = NotificationManager.shared + + @Published var scrolledToTop: Bool = true + + /// Whether a notification should be visible in the panel + func isNotificationVisible(_ notification: CENotification) -> Bool { + if notification.isBeingDismissed { + return true // Always show notifications being dismissed + } + if notification.isSticky { + return true // Always show sticky notifications + } + if isPresented { + return true // Show all notifications when manually shown + } + return !hiddenNotificationIds.contains(notification.id) + } + + /// Handles focus changes for the notification panel + func handleFocusChange(isFocused: Bool) { + if !isFocused { + // Only hide if manually shown and focus is completely lost + if isPresented { + toggleNotificationsVisibility() + } + } + } + + /// Toggles visibility of notifications in the panel + func toggleNotificationsVisibility() { + if isPresented { + if !scrolledToTop { + // Just set isPresented to false to trigger the offset animation + withAnimation(.easeInOut(duration: 0.3)) { + isPresented = false + } + + // After the slide-out animation, hide notifications + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + // Hide non-sticky notifications + self.activeNotifications + .filter { !$0.isSticky } + .forEach { self.hiddenNotificationIds.insert($0.id) } + self.objectWillChange.send() + + // After notifications are hidden, reset scroll position + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.scrolledToTop = true + } + } + } else { + // At top, just hide normally + hideNotifications() + } + } else { + withAnimation(.easeInOut(duration: 0.3)) { + isPresented = true + hiddenNotificationIds.removeAll() + objectWillChange.send() + } + } + } + + private func hideNotifications() { + withAnimation(.easeInOut(duration: 0.3)) { + self.isPresented = false + self.activeNotifications + .filter { !$0.isSticky } + .forEach { self.hiddenNotificationIds.insert($0.id) } + self.objectWillChange.send() + } + } + + /// Starts the timer to automatically hide a notification + func startHideTimer(for notification: CENotification) { + guard !notification.isSticky && !isPresented else { return } + + timers[notification.id]?.invalidate() + timers[notification.id] = nil + + guard !isPaused else { return } + + timers[notification.id] = Timer.scheduledTimer( + withTimeInterval: displayDuration, + repeats: false + ) { [weak self] _ in + guard let self = self else { return } + self.timers[notification.id] = nil + + // Ensure we're on the main thread and animate the change + DispatchQueue.main.async { + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.3 + context.allowsImplicitAnimation = true + + withAnimation(.easeInOut(duration: 0.3)) { + var newHiddenIds = self.hiddenNotificationIds + newHiddenIds.insert(notification.id) + self.hiddenNotificationIds = newHiddenIds + } + } + } + } + } + + /// Pauses all auto-hide timers + func pauseTimer() { + isPaused = true + timers.values.forEach { $0.invalidate() } + } + + /// Resumes all auto-hide timers + func resumeTimer() { + isPaused = false + // Only restart timers for notifications that are currently visible + activeNotifications + .filter { !$0.isSticky && isNotificationVisible($0) } + .forEach { startHideTimer(for: $0) } + } + + /// Inserts a notification in the correct position (sticky notifications on top) + private func insertNotification(_ notification: CENotification) { + if notification.isSticky { + // Find the first sticky notification (to insert before it) + if let firstStickyIndex = activeNotifications.firstIndex(where: { $0.isSticky }) { + // Insert at the very start of sticky group + activeNotifications.insert(notification, at: firstStickyIndex) + } else { + // No sticky notifications yet, insert at the start + activeNotifications.insert(notification, at: 0) + } + } else { + // Find the first non-sticky notification + if let firstNonStickyIndex = activeNotifications.firstIndex(where: { !$0.isSticky }) { + // Insert at the start of non-sticky group + activeNotifications.insert(notification, at: firstNonStickyIndex) + } else { + // No non-sticky notifications yet, append at the end + activeNotifications.append(notification) + } + } + } + + /// Handles a new notification being added + func handleNewNotification(_ notification: CENotification) { + withAnimation(.easeInOut(duration: 0.3)) { + insertNotification(notification) + hiddenNotificationIds.remove(notification.id) + if !isPresented && !notification.isSticky { + startHideTimer(for: notification) + } + } + } + + /// Dismisses a specific notification + func dismissNotification(_ notification: CENotification, disableAnimation: Bool = false) { + // Clean up timers + timers[notification.id]?.invalidate() + timers[notification.id] = nil + hiddenNotificationIds.remove(notification.id) + + // Mark as being dismissed for animation + if let index = activeNotifications.firstIndex(where: { $0.id == notification.id }) { + if disableAnimation { + self.activeNotifications.removeAll(where: { $0.id == notification.id }) + NotificationManager.shared.markAsRead(notification) + NotificationManager.shared.dismissNotification(notification) + return + } + + var dismissingNotification = activeNotifications[index] + dismissingNotification.isBeingDismissed = true + activeNotifications[index] = dismissingNotification + + // Wait for fade animation before removing + DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { + withAnimation(.easeOut(duration: 0.2)) { + self.activeNotifications.removeAll(where: { $0.id == notification.id }) + if self.activeNotifications.isEmpty && self.isPresented { + self.isPresented = false + } + } + + NotificationManager.shared.markAsRead(notification) + NotificationManager.shared.dismissNotification(notification) + } + } + } + + init() { + // Observe new notifications + NotificationCenter.default.addObserver( + self, + selector: #selector(handleNewNotificationAdded(_:)), + name: .init("NewNotificationAdded"), + object: nil + ) + + // Observe notification dismissals + NotificationCenter.default.addObserver( + self, + selector: #selector(handleNotificationRemoved(_:)), + name: .init("NotificationDismissed"), + object: nil + ) + + // Load initial notifications from NotificationManager + notificationManager.notifications.forEach { notification in + handleNewNotification(notification) + } + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + @objc + private func handleNewNotificationAdded(_ notification: Notification) { + guard let ceNotification = notification.object as? CENotification else { return } + handleNewNotification(ceNotification) + } + + @objc + private func handleNotificationRemoved(_ notification: Notification) { + guard let ceNotification = notification.object as? CENotification else { return } + + // Just remove from active notifications without triggering global state changes + withAnimation(.easeOut(duration: 0.2)) { + activeNotifications.removeAll(where: { $0.id == ceNotification.id }) + + // If this was the last notification and they were manually shown, hide the panel + if activeNotifications.isEmpty && isPresented { + isPresented = false + } + } + } +} diff --git a/CodeEdit/Features/Notifications/Views/DismissTransition.swift b/CodeEdit/Features/Notifications/Views/DismissTransition.swift new file mode 100644 index 0000000000..d977a2e92a --- /dev/null +++ b/CodeEdit/Features/Notifications/Views/DismissTransition.swift @@ -0,0 +1,12 @@ +import SwiftUI + +struct DismissTransition: ViewModifier { + let useOpactityTransition: Bool + let isIdentity: Bool + + func body(content: Content) -> some View { + content + .opacity(useOpactityTransition ? (isIdentity ? 1 : 0) : 1) + .offset(x: useOpactityTransition ? 0 : (isIdentity ? 0 : 350)) + } +} diff --git a/CodeEdit/Features/Notifications/Views/NotificationBannerView.swift b/CodeEdit/Features/Notifications/Views/NotificationBannerView.swift new file mode 100644 index 0000000000..11a90696ac --- /dev/null +++ b/CodeEdit/Features/Notifications/Views/NotificationBannerView.swift @@ -0,0 +1,170 @@ +// +// NotificationBannerView.swift +// CodeEdit +// +// Created by Austin Condiff on 2/10/24. +// + +import SwiftUI + +struct NotificationBannerView: View { + @Environment(\.colorScheme) + private var colorScheme + + @EnvironmentObject private var workspace: WorkspaceDocument + @ObservedObject private var notificationManager = NotificationManager.shared + + let notification: CENotification + let onDismiss: () -> Void + let onAction: () -> Void + + @State private var isHovering = false + + let cornerRadius: CGFloat = 10 + + private var backgroundContainer: some View { + RoundedRectangle(cornerRadius: cornerRadius) + .fill(.regularMaterial) + } + + private var borderOverlay: some View { + RoundedRectangle(cornerRadius: cornerRadius) + .stroke(Color(nsColor: .separatorColor), lineWidth: 2) + } + + var body: some View { + VStack(spacing: 10) { + HStack(alignment: .top, spacing: 10) { + switch notification.icon { + case let .symbol(name, color): + FeatureIcon( + symbol: name, + color: color ?? Color(.systemBlue), + size: 26 + ) + case let .text(text, backgroundColor, textColor): + FeatureIcon( + text: text, + textColor: textColor ?? .primary, + color: backgroundColor ?? Color(.systemBlue), + size: 26 + ) + case let .image(image): + FeatureIcon( + image: image, + size: 26 + ) + } + VStack(alignment: .leading, spacing: 1) { + Text(notification.title) + .font(.system(size: 12)) + .fontWeight(.semibold) + .padding(.top, -3) + Text(notification.description) + .font(.callout) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .mask( + LinearGradient( + gradient: Gradient( + colors: [ + .black, + .black, + !notification.isSticky && isHovering ? .clear : .black, + !notification.isSticky && isHovering ? .clear : .black + ] + ), + startPoint: .leading, + endPoint: .trailing + ) + ) + } + if notification.isSticky { + HStack(spacing: 8) { + Button(action: onDismiss, label: { + Text("Dismiss") + .frame(maxWidth: .infinity) + }) + .buttonStyle(.secondaryBlur) + .controlSize(.small) + Button(action: onAction, label: { + Text(notification.actionButtonTitle) + .frame(maxWidth: .infinity) + }) + .buttonStyle(.secondaryBlur) + .controlSize(.small) + } + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + .padding(10) + .background(backgroundContainer) + .overlay(borderOverlay) + .cornerRadius(cornerRadius) + .shadow( + color: Color(.black.withAlphaComponent(colorScheme == .dark ? 0.2 : 0.1)), + radius: 5, + x: 0, + y: 2 + ) + .overlay(alignment: .bottomTrailing) { + if !notification.isSticky && isHovering { + Button(action: onAction, label: { + Text(notification.actionButtonTitle) + }) + .buttonStyle(.secondaryBlur) + .controlSize(.small) + .padding(10) + .transition(.opacity) + } + } + .overlay(alignment: .topLeading) { + if !notification.isSticky && isHovering { + Button(action: onDismiss) { + Image(systemName: "xmark") + } + .buttonStyle(.overlay) + .padding(.top, -5) + .padding(.leading, -5) + .transition(.opacity) + } + } + .frame(width: 300) + .transition(.asymmetric( + insertion: .move(edge: .trailing), + removal: .modifier( + active: DismissTransition( + useOpactityTransition: notification.isBeingDismissed, + isIdentity: false + ), + identity: DismissTransition( + useOpactityTransition: notification.isBeingDismissed, + isIdentity: true + ) + ) + )) + .onHover { hovering in + withAnimation(.easeOut(duration: 0.2)) { + isHovering = hovering + } + + if hovering { + workspace.notificationPanel.pauseTimer() + } else { + workspace.notificationPanel.resumeTimer() + } + } + } +} + +struct DismissTransition: ViewModifier { + let useOpactityTransition: Bool + let isIdentity: Bool + + func body(content: Content) -> some View { + content + .opacity(useOpactityTransition && !isIdentity ? 0 : 1) + .offset(x: !useOpactityTransition && !isIdentity ? 350 : 0) + } +} diff --git a/CodeEdit/Features/Notifications/Views/NotificationPanelView.swift b/CodeEdit/Features/Notifications/Views/NotificationPanelView.swift new file mode 100644 index 0000000000..c2c764eeb9 --- /dev/null +++ b/CodeEdit/Features/Notifications/Views/NotificationPanelView.swift @@ -0,0 +1,164 @@ +// +// NotificationPanelView.swift +// CodeEdit +// +// Created by Austin Condiff on 2/10/24. +// + +import SwiftUI + +struct NotificationPanelView: View { + @EnvironmentObject private var workspace: WorkspaceDocument + @Environment(\.controlActiveState) + private var controlActiveState + + @ObservedObject private var notificationManager = NotificationManager.shared + @FocusState private var isFocused: Bool + + // ID for the top anchor + private let topID = "top" + + // Fixed width for notifications + private let notificationWidth: CGFloat = 320 // 300 + 10 padding on each side + + @State private var hasOverflow: Bool = false + @State private var contentHeight: CGFloat = 0.0 + + private func updateOverflow(contentHeight: CGFloat, containerHeight: CGFloat) { + if !hasOverflow && contentHeight > containerHeight { + hasOverflow = true + } else if hasOverflow && contentHeight <= containerHeight { + hasOverflow = false + } + } + + @ViewBuilder var notifications: some View { + let visibleNotifications = workspace.notificationPanel.activeNotifications.filter { + workspace.notificationPanel.isNotificationVisible($0) + } + + VStack(spacing: 8) { + ForEach(visibleNotifications, id: \.id) { notification in + NotificationBannerView( + notification: notification, + onDismiss: { + workspace.notificationPanel.dismissNotification(notification) + }, + onAction: { + notification.action() + if workspace.notificationPanel.isPresented { + workspace.notificationPanel.toggleNotificationsVisibility() + workspace.notificationPanel.dismissNotification(notification, disableAnimation: true) + } else { + workspace.notificationPanel.dismissNotification(notification) + } + } + ) + } + } + .padding(10) + .animation(.easeInOut(duration: 0.3), value: visibleNotifications) + } + + @ViewBuilder var notificationsWithScrollView: some View { + GeometryReader { geometry in + HStack { + Spacer() + ScrollViewReader { proxy in + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .trailing, spacing: 0) { + Color.clear + .frame(height: 0) + .id(topID) + .background( + GeometryReader { + Color.clear.preference( + key: ViewOffsetKey.self, + value: -$0.frame(in: .named("scroll")).origin.y + ) + } + ) + .onPreferenceChange(ViewOffsetKey.self) { + if $0 <= 0.0 && !workspace.notificationPanel.scrolledToTop { + workspace.notificationPanel.scrolledToTop = true + } else if $0 > 0.0 && workspace.notificationPanel.scrolledToTop { + workspace.notificationPanel.scrolledToTop = false + } + } + notifications + } + .background( + GeometryReader { proxy in + Color.clear.onChange(of: proxy.size.height) { newValue in + contentHeight = newValue + updateOverflow(contentHeight: newValue, containerHeight: geometry.size.height) + } + } + ) + } + .frame(maxWidth: notificationWidth, alignment: .trailing) + .frame(height: min(geometry.size.height, contentHeight)) + .scrollDisabled(!hasOverflow) + .coordinateSpace(name: "scroll") + .onChange(of: isFocused) { newValue in + workspace.notificationPanel.handleFocusChange(isFocused: newValue) + } + .onChange(of: geometry.size.height) { newValue in + updateOverflow(contentHeight: contentHeight, containerHeight: newValue) + } + .onChange(of: workspace.notificationPanel.isPresented) { isPresented in + if !isPresented && !workspace.notificationPanel.scrolledToTop { + // If scrolled, delay scroll animation until after notifications are hidden + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + withAnimation(.easeOut(duration: 0.3)) { + proxy.scrollTo(topID, anchor: .top) + } + } + } + } + .allowsHitTesting( + workspace.notificationPanel.activeNotifications + .contains { workspace.notificationPanel.isNotificationVisible($0) } + ) + } + } + } + } + + var body: some View { + Group { + if #available(macOS 14.0, *) { + notificationsWithScrollView + .scrollClipDisabled(true) + .focusable() + .focusEffectDisabled() + .focused($isFocused) + .onChange(of: workspace.notificationPanel.isPresented) { isPresented in + if isPresented { + isFocused = true + } + } + .onChange(of: controlActiveState) { newState in + if newState != .active && newState != .key && workspace.notificationPanel.isPresented { + // Delay hiding notifications to match animation timing + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + workspace.notificationPanel.toggleNotificationsVisibility() + } + } + } + } else { + notificationsWithScrollView + } + } + .opacity(controlActiveState == .active || controlActiveState == .key ? 1 : 0) + .offset( + x: (controlActiveState == .active || controlActiveState == .key) && + (workspace.notificationPanel.isPresented || workspace.notificationPanel.scrolledToTop) + ? 0 + : 350 + ) + .animation(.easeInOut(duration: 0.3), value: workspace.notificationPanel.isPresented) + .animation(.easeInOut(duration: 0.3), value: workspace.notificationPanel.scrolledToTop) + .animation(.easeInOut(duration: 0.2), value: controlActiveState) + } +} diff --git a/CodeEdit/Features/Notifications/Views/NotificationToolbarItem.swift b/CodeEdit/Features/Notifications/Views/NotificationToolbarItem.swift new file mode 100644 index 0000000000..9729330ddd --- /dev/null +++ b/CodeEdit/Features/Notifications/Views/NotificationToolbarItem.swift @@ -0,0 +1,35 @@ +// +// NotificationToolbarItem.swift +// CodeEdit +// +// Created by Austin Condiff on 2/10/24. +// + +import SwiftUI + +struct NotificationToolbarItem: View { + @EnvironmentObject private var workspace: WorkspaceDocument + @ObservedObject private var notificationManager = NotificationManager.shared + @Environment(\.controlActiveState) + private var controlActiveState + + var body: some View { + let visibleNotifications = workspace.notificationPanel.activeNotifications.filter { + !workspace.notificationPanel.hiddenNotificationIds.contains($0.id) + } + + if notificationManager.unreadCount > 0 || !visibleNotifications.isEmpty { + Button { + workspace.notificationPanel.toggleNotificationsVisibility() + } label: { + HStack(spacing: 4) { + Image(systemName: "bell.badge.fill") + .symbolRenderingMode(.palette) + .foregroundStyle(controlActiveState == .inactive ? .secondary : Color.accentColor, .primary) + Text("\(notificationManager.unreadCount)") + .monospacedDigit() + } + } + } + } +} diff --git a/CodeEdit/Features/Settings/Pages/AccountsSettings/AccountsSettingsAccountLink.swift b/CodeEdit/Features/Settings/Pages/AccountsSettings/AccountsSettingsAccountLink.swift index 51a92e099d..0461a3fd0d 100644 --- a/CodeEdit/Features/Settings/Pages/AccountsSettings/AccountsSettingsAccountLink.swift +++ b/CodeEdit/Features/Settings/Pages/AccountsSettings/AccountsSettingsAccountLink.swift @@ -22,13 +22,8 @@ struct AccountsSettingsAccountLink: View { .font(.footnote) .foregroundColor(.secondary) } icon: { - Image(account.provider.iconName) - .resizable() - .aspectRatio(contentMode: .fill) - .clipShape(RoundedRectangle(cornerRadius: 6)) - .frame(width: 26, height: 26) - .padding(.top, 2) - .padding(.bottom, 2) + FeatureIcon(image: Image(account.provider.iconName), size: 26) + .padding(.vertical, 2) .padding(.leading, 2) } } diff --git a/CodeEdit/Features/Settings/Pages/AccountsSettings/AccountsSettingsProviderRow.swift b/CodeEdit/Features/Settings/Pages/AccountsSettings/AccountsSettingsProviderRow.swift index d986fc7dbf..7d570464d0 100644 --- a/CodeEdit/Features/Settings/Pages/AccountsSettings/AccountsSettingsProviderRow.swift +++ b/CodeEdit/Features/Settings/Pages/AccountsSettings/AccountsSettingsProviderRow.swift @@ -17,11 +17,7 @@ struct AccountsSettingsProviderRow: View { var body: some View { HStack { - Image(iconName) - .resizable() - .aspectRatio(contentMode: .fill) - .clipShape(RoundedRectangle(cornerRadius: 6)) - .frame(width: 28, height: 28) + FeatureIcon(image: Image(iconName), size: 28) Text(name) Spacer() if hovering { diff --git a/CodeEdit/Features/Settings/Pages/AccountsSettings/AccountsSettingsSigninView.swift b/CodeEdit/Features/Settings/Pages/AccountsSettings/AccountsSettingsSigninView.swift index 3d20848f3c..7672d50b63 100644 --- a/CodeEdit/Features/Settings/Pages/AccountsSettings/AccountsSettingsSigninView.swift +++ b/CodeEdit/Features/Settings/Pages/AccountsSettings/AccountsSettingsSigninView.swift @@ -64,11 +64,7 @@ struct AccountsSettingsSigninView: View { }, header: { VStack(alignment: .center, spacing: 10) { - Image(provider.iconName) - .resizable() - .aspectRatio(contentMode: .fill) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .frame(width: 52, height: 52) + FeatureIcon(image: Image(provider.iconName), size: 52) .padding(.top, 5) Text("Sign in to \(provider.name)") .multilineTextAlignment(.center) @@ -81,6 +77,7 @@ struct AccountsSettingsSigninView: View { Text("\(provider.name) personal access tokens must have these scopes set:") .font(.system(size: 10.5)) .foregroundColor(.secondary) + .multilineTextAlignment(.leading) HStack(alignment: .center) { Spacer() VStack(alignment: .leading) { diff --git a/CodeEdit/Features/Settings/Pages/DeveloperSettings/DeveloperSettingsView.swift b/CodeEdit/Features/Settings/Pages/DeveloperSettings/DeveloperSettingsView.swift index fa552ace93..eac495daef 100644 --- a/CodeEdit/Features/Settings/Pages/DeveloperSettings/DeveloperSettingsView.swift +++ b/CodeEdit/Features/Settings/Pages/DeveloperSettings/DeveloperSettingsView.swift @@ -13,8 +13,15 @@ struct DeveloperSettingsView: View { @AppSettings(\.developerSettings.lspBinaries) var lspBinaries + @AppSettings(\.developerSettings.showInternalDevelopmentInspector) + var showInternalDevelopmentInspector + var body: some View { SettingsForm { + Section { + Toggle("Show Internal Development Inspector", isOn: $showInternalDevelopmentInspector) + } + Section { KeyValueTable( items: $lspBinaries, diff --git a/CodeEdit/Features/Settings/Pages/DeveloperSettings/Models/DeveloperSettings.swift b/CodeEdit/Features/Settings/Pages/DeveloperSettings/Models/DeveloperSettings.swift index cd142f36bd..f52f075385 100644 --- a/CodeEdit/Features/Settings/Pages/DeveloperSettings/Models/DeveloperSettings.swift +++ b/CodeEdit/Features/Settings/Pages/DeveloperSettings/Models/DeveloperSettings.swift @@ -15,7 +15,8 @@ extension SettingsData { [ "Developer", "Language Server Protocol", - "LSP Binaries" + "LSP Binaries", + "Show Internal Development Inspector" ] .map { NSLocalizedString($0, comment: "") } } @@ -23,6 +24,9 @@ extension SettingsData { /// A dictionary that stores a file type and a path to an LSP binary var lspBinaries: [String: String] = [:] + /// Toggle for showing the internal development inspector + var showInternalDevelopmentInspector: Bool = false + /// Default initializer init() {} @@ -34,6 +38,11 @@ extension SettingsData { [String: String].self, forKey: .lspBinaries ) ?? [:] + + self.showInternalDevelopmentInspector = try container.decodeIfPresent( + Bool.self, + forKey: .showInternalDevelopmentInspector + ) ?? false } } } diff --git a/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlSettingsView.swift b/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlSettingsView.swift index 22f0304cac..14ee02523f 100644 --- a/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlSettingsView.swift +++ b/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlSettingsView.swift @@ -53,7 +53,7 @@ struct SourceControlSettingsView: View { """) .font(.callout) } icon: { - FeatureIcon(symbol: Image(symbol: "vault"), color: Color(.systemBlue), size: 26) + FeatureIcon(symbol: "vault", color: Color(.systemBlue), size: 26) } } .controlSize(.large) diff --git a/CodeEdit/Features/Settings/Views/FeatureIcon.swift b/CodeEdit/Features/Settings/Views/FeatureIcon.swift deleted file mode 100644 index b7ca98e4eb..0000000000 --- a/CodeEdit/Features/Settings/Views/FeatureIcon.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// FeatureIcon.swift -// CodeEdit -// -// Created by Austin Condiff on 12/2/24. -// - -import SwiftUI - -struct FeatureIcon: View { - private let symbol: Image - private let color: Color - private let size: CGFloat - - init( - symbol: Image?, - color: Color?, - size: CGFloat? - ) { - self.symbol = symbol ?? Image(systemName: "exclamationmark.triangle") - self.color = color ?? .white - self.size = size ?? 20 - } - - var body: some View { - Group { - symbol - .resizable() - .aspectRatio(contentMode: .fit) - } - .shadow(color: Color(NSColor.black).opacity(0.25), radius: size / 40, y: size / 40) - .padding(size / 8) - .foregroundColor(.white) - .frame(width: size, height: size) - .background( - RoundedRectangle( - cornerRadius: size / 4, - style: .continuous - ) - .fill(color.gradient) - .shadow(color: Color(NSColor.black).opacity(0.25), radius: size / 40, y: size / 40) - ) - } -} diff --git a/CodeEdit/Features/Settings/Views/SettingsPageView.swift b/CodeEdit/Features/Settings/Views/SettingsPageView.swift index e1c96460b1..caf1c46e40 100644 --- a/CodeEdit/Features/Settings/Views/SettingsPageView.swift +++ b/CodeEdit/Features/Settings/Views/SettingsPageView.swift @@ -16,15 +16,14 @@ struct SettingsPageView: View { self.searchText = searchText } - var symbol: Image? { + private var iconName: String { switch page.icon { - case .system(let name): - Image(systemName: name) - case .symbol(let name): - Image(symbol: name) + case .system(let name), .symbol(let name): + return name case .asset(let name): - Image(name) - case .none: nil + return name + case .none: + return "questionmark.circle" // fallback icon } } @@ -34,7 +33,11 @@ struct SettingsPageView: View { page.name.rawValue.highlightOccurrences(self.searchText) .padding(.leading, 2) } icon: { - FeatureIcon(symbol: symbol, color: page.baseColor, size: 20) + if case .asset(let name) = page.icon { + FeatureIcon(image: Image(name), size: 20) + } else { + FeatureIcon(symbol: iconName, color: page.baseColor, size: 20) + } } } } diff --git a/CodeEdit/WorkspaceView.swift b/CodeEdit/WorkspaceView.swift index 3a7cf0ddff..3bcdbd4dd8 100644 --- a/CodeEdit/WorkspaceView.swift +++ b/CodeEdit/WorkspaceView.swift @@ -103,6 +103,9 @@ struct WorkspaceView: View { } .accessibilityElement(children: .contain) } + .overlay(alignment: .topTrailing) { + NotificationPanelView() + } .onChange(of: focusedEditor) { newValue in /// update active tab group only if the new one is not the same with it. if let newValue, editorManager.activeEditor != newValue { diff --git a/CodeEditUI/src/Preferences/ViewOffsetPreferenceKey.swift b/CodeEditUI/src/Preferences/ViewOffsetPreferenceKey.swift new file mode 100644 index 0000000000..831ccc8b9b --- /dev/null +++ b/CodeEditUI/src/Preferences/ViewOffsetPreferenceKey.swift @@ -0,0 +1,10 @@ +import SwiftUI + +/// Tracks scroll offset in scrollable views +public struct ViewOffsetPreferenceKey: PreferenceKey { + public typealias Value = CGFloat + public static var defaultValue = CGFloat.zero + public static func reduce(value: inout Value, nextValue: () -> Value) { + value += nextValue() + } +}