From 5aac83d950477c0b6ea553372fa0f6c5758e4c6e Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Tue, 11 Feb 2025 09:53:07 -0600 Subject: [PATCH 01/21] Added global notifications system --- CodeEdit.xcodeproj/project.pbxproj | 54 +++++- CodeEdit/AppDelegate.swift | 52 +++-- .../About/Views/BlurButtonStyle.swift | 45 +++-- .../Views/FeatureIcon.swift | 0 .../CodeEditWindowController+Toolbar.swift | 7 + .../CodeEditWindowControllerExtensions.swift | 1 + .../Documents/Views/WindowContentView.swift | 1 + .../FileInspector/FileInspectorView.swift | 26 +++ .../Views/FileInspector.swift | 13 ++ .../Notifications/Models/CENotification.swift | 38 ++++ .../Notifications/NotificationManager.swift | 183 ++++++++++++++++++ .../Views/NotificationBannerEnvironment.swift | 21 ++ .../Views/NotificationBannerView.swift | 129 ++++++++++++ .../Views/NotificationListView.swift | 41 ++++ .../Views/NotificationOverlayView.swift | 36 ++++ .../Views/NotificationToolbarItem.swift | 31 +++ CodeEdit/WorkspaceView.swift | 3 + 17 files changed, 651 insertions(+), 30 deletions(-) rename CodeEdit/Features/{Settings => CodeEditUI}/Views/FeatureIcon.swift (100%) create mode 100644 CodeEdit/Features/Documents/Views/WindowContentView.swift create mode 100644 CodeEdit/Features/InspectorSidebar/Views/FileInspector.swift create mode 100644 CodeEdit/Features/Notifications/Models/CENotification.swift create mode 100644 CodeEdit/Features/Notifications/NotificationManager.swift create mode 100644 CodeEdit/Features/Notifications/Views/NotificationBannerEnvironment.swift create mode 100644 CodeEdit/Features/Notifications/Views/NotificationBannerView.swift create mode 100644 CodeEdit/Features/Notifications/Views/NotificationListView.swift create mode 100644 CodeEdit/Features/Notifications/Views/NotificationOverlayView.swift create mode 100644 CodeEdit/Features/Notifications/Views/NotificationToolbarItem.swift diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index 15d4f127db..1bd77a3e88 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -580,6 +580,13 @@ 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 */; }; + B68DE5E12D5A61E5009A43EF /* NotificationListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68DE5DA2D5A61E5009A43EF /* NotificationListView.swift */; }; + B68DE5E22D5A61E5009A43EF /* NotificationToolbarItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68DE5DB2D5A61E5009A43EF /* NotificationToolbarItem.swift */; }; + B68DE5E32D5A61E5009A43EF /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68DE5DD2D5A61E5009A43EF /* NotificationManager.swift */; }; + B68DE5E52D5A7988009A43EF /* NotificationOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68DE5E42D5A7988009A43EF /* NotificationOverlayView.swift */; }; + B68DE5E72D5A7D62009A43EF /* NotificationBannerEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68DE5E62D5A7D62009A43EF /* NotificationBannerEnvironment.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 */; }; @@ -1270,6 +1277,13 @@ 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 = ""; }; + B68DE5DA2D5A61E5009A43EF /* NotificationListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationListView.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 /* NotificationOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationOverlayView.swift; sourceTree = ""; }; + B68DE5E62D5A7D62009A43EF /* NotificationBannerEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationBannerEnvironment.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 = ""; }; @@ -1761,6 +1775,7 @@ 58A5DF9D29339F6400D1BD5D /* Keybindings */, 30B087FB2C0D53080063A882 /* LSP */, 287776EA27E350A100D46668 /* NavigatorArea */, + B68DE5DE2D5A61E5009A43EF /* Notifications */, 5878DAA0291AE76700DD95A3 /* OpenQuickly */, 58798210292D92370085B254 /* Search */, B67B270029D7868000FB9301 /* Settings */, @@ -2130,6 +2145,7 @@ 587B9D8F29300ABD00AC7927 /* ToolbarBranchPicker.swift */, B6DCDAC52CCDE2B90099FBF9 /* InstantPopoverModifier.swift */, 2897E1C62979A29200741E32 /* TrackableScrollView.swift */, + B696A7E52CFE20C40048CFE1 /* FeatureIcon.swift */, B60718302B15A9A3009CDAB4 /* CEOutlineGroup.swift */, ); path = Views; @@ -3547,6 +3563,36 @@ path = Settings; sourceTree = ""; }; + B68DE5D82D5A61E5009A43EF /* Models */ = { + isa = PBXGroup; + children = ( + B68DE5D72D5A61E5009A43EF /* CENotification.swift */, + ); + path = Models; + sourceTree = ""; + }; + B68DE5DC2D5A61E5009A43EF /* Views */ = { + isa = PBXGroup; + children = ( + B68DE5E62D5A7D62009A43EF /* NotificationBannerEnvironment.swift */, + B68DE5D92D5A61E5009A43EF /* NotificationBannerView.swift */, + B68DE5DA2D5A61E5009A43EF /* NotificationListView.swift */, + B68DE5DB2D5A61E5009A43EF /* NotificationToolbarItem.swift */, + B68DE5E42D5A7988009A43EF /* NotificationOverlayView.swift */, + ); + path = Views; + sourceTree = ""; + }; + B68DE5DE2D5A61E5009A43EF /* Notifications */ = { + isa = PBXGroup; + children = ( + B68DE5D82D5A61E5009A43EF /* Models */, + B68DE5DC2D5A61E5009A43EF /* Views */, + B68DE5DD2D5A61E5009A43EF /* NotificationManager.swift */, + ); + path = Notifications; + sourceTree = ""; + }; B6966A262C2F673A00259C2D /* Views */ = { isa = PBXGroup; children = ( @@ -3643,7 +3689,6 @@ B6CF632A29E5436C0085880A /* Views */ = { isa = PBXGroup; children = ( - B696A7E52CFE20C40048CFE1 /* FeatureIcon.swift */, B67DBB932CD5FBE2007F4F18 /* GlobPatternListItem.swift */, B67DBB912CD5EAA4007F4F18 /* GlobPatternList.swift */, B6041F4C29D7A4E9000F3454 /* SettingsPageView.swift */, @@ -4065,6 +4110,11 @@ 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 */, + B68DE5E12D5A61E5009A43EF /* NotificationListView.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 */, @@ -4183,6 +4233,7 @@ B6B2D7A12CE8797B00379967 /* GitConfigExtensions.swift in Sources */, 587B9E7329301D8F00AC7927 /* GitRouter.swift in Sources */, 6C2C156129B4F52F00EA60A5 /* SplitViewModifiers.swift in Sources */, + B68DE5E52D5A7988009A43EF /* NotificationOverlayView.swift in Sources */, 61A53A812B4449F00093BF8A /* WorkspaceDocument+Index.swift in Sources */, 66AF6CE22BF17CC300D83C9D /* StatusBarViewModel.swift in Sources */, 30CB648D2C12680F00CC8A9E /* LSPService+Events.swift in Sources */, @@ -4384,6 +4435,7 @@ 6C578D8729CD345900DC73B2 /* ExtensionSceneView.swift in Sources */, 617DB3D02C25AFAE00B58BFE /* TaskNotificationHandler.swift in Sources */, 77EF6C052C57DE4B00984B69 /* URL+ResouceValues.swift in Sources */, + B68DE5E72D5A7D62009A43EF /* NotificationBannerEnvironment.swift in Sources */, B640A9A129E2188F00715F20 /* View+NavigationBarBackButtonVisible.swift in Sources */, 587B9E7929301D8F00AC7927 /* GitHubIssueRouter.swift in Sources */, B67700F92D2A2662004FD61F /* WorkspacePanelView.swift in Sources */, diff --git a/CodeEdit/AppDelegate.swift b/CodeEdit/AppDelegate.swift index 7945d1e715..906a1831ab 100644 --- a/CodeEdit/AppDelegate.swift +++ b/CodeEdit/AppDelegate.swift @@ -25,10 +25,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { checkForFilesToOpen() NSApp.closeWindow(.welcome, .about) - + + // Add test notification + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + NotificationManager.shared.post( + icon: "bell.badge", + title: "Welcome to CodeEdit", + description: "This is a test notification to demonstrate the notification system.", + actionButtonTitle: "Learn More", + action: { + print("Action button clicked!") + } + ) + } + DispatchQueue.main.async { var needToHandleOpen = true - + // If no windows were reopened by NSQuitAlwaysKeepsWindows, do default behavior. // Non-WindowGroup SwiftUI Windows are still in NSApp.windows when they are closed, // So we need to think about those. @@ -60,30 +73,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { } func applicationWillTerminate(_ aNotification: Notification) { - + } - + func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { true } - + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { guard flag else { handleOpen() return false } - + /// Check if all windows are either miniaturized or not visible. /// If so, attempt to find the first miniaturized window and deminiaturize it. guard sender.windows.allSatisfy({ $0.isMiniaturized || !$0.isVisible }) else { return false } sender.windows.first(where: { $0.isMiniaturized })?.deminiaturize(sender) return false } - + func applicationShouldOpenUntitledFile(_ sender: NSApplication) -> Bool { false } - + func handleOpen() { let behavior = Settings.shared.preferences.general.reopenBehavior switch behavior { @@ -97,7 +110,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { CodeEditDocumentController.shared.newDocument(self) } } - + /// Handle urls with the form `codeedit://file/{filepath}:{line}:{column}` func application(_ application: NSApplication, open urls: [URL]) { for url in urls { @@ -105,7 +118,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { let filePath = URL(fileURLWithPath: String(file[0])) let line = file.count > 1 ? Int(file[1]) ?? 0 : 0 let column = file.count > 2 ? Int(file[2]) ?? 1 : 1 - + CodeEditDocumentController.shared .openDocument(withContentsOf: filePath, display: true) { document, _, error in if let error { @@ -117,10 +130,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { cursorPositions: [CursorPosition(line: line, column: column > 0 ? column : 1)] ) } + // Add notification when workspace is opened via URL + if let workspaceDoc = document as? WorkspaceDocument { + NotificationManager.shared.post( + icon: "folder.badge.plus", + title: "Workspace Opened", + description: "Successfully opened workspace: \(workspaceDoc.fileURL?.lastPathComponent ?? "")", + actionButtonTitle: "View Files", + action: { + // Ensure the workspace window is frontmost + workspaceDoc.windowControllers.first?.window?.makeKeyAndOrderFront(nil) + } + ) + } } } } - + /// Defers the application terminate message until we've finished cleanup. /// /// All paths _must_ call `NSApplication.shared.reply(toApplicationShouldTerminate: true)` as soon as possible. @@ -138,9 +164,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { let projects: [String] = CodeEditDocumentController.shared.documents .compactMap { ($0 as? WorkspaceDocument)?.fileURL?.path } - + UserDefaults.standard.set(projects, forKey: AppDelegate.recoverWorkspacesKey) - + let areAllDocumentsClean = CodeEditDocumentController.shared.documents.allSatisfy { !$0.isDocumentEdited } guard areAllDocumentsClean else { CodeEditDocumentController.shared.closeAllDocuments( diff --git a/CodeEdit/Features/About/Views/BlurButtonStyle.swift b/CodeEdit/Features/About/Views/BlurButtonStyle.swift index 768f841310..e1c137c4f9 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,39 @@ 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) + .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) + if !isSecondary { + 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/Settings/Views/FeatureIcon.swift b/CodeEdit/Features/CodeEditUI/Views/FeatureIcon.swift similarity index 100% rename from CodeEdit/Features/Settings/Views/FeatureIcon.swift rename to CodeEdit/Features/CodeEditUI/Views/FeatureIcon.swift diff --git a/CodeEdit/Features/Documents/Controllers/CodeEditWindowController+Toolbar.swift b/CodeEdit/Features/Documents/Controllers/CodeEditWindowController+Toolbar.swift index b0d612b3e2..4edc3722d8 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,11 @@ extension CodeEditWindowController { strongWidth ]) + toolbarItem.view = view + return toolbarItem + case .notificationItem: + let toolbarItem = NSToolbarItem(itemIdentifier: .notificationItem) + let view = NSHostingView(rootView: NotificationToolbarItem()) 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/Views/WindowContentView.swift b/CodeEdit/Features/Documents/Views/WindowContentView.swift new file mode 100644 index 0000000000..8d1c8b69c3 --- /dev/null +++ b/CodeEdit/Features/Documents/Views/WindowContentView.swift @@ -0,0 +1 @@ + diff --git a/CodeEdit/Features/InspectorArea/FileInspector/FileInspectorView.swift b/CodeEdit/Features/InspectorArea/FileInspector/FileInspectorView.swift index d11f75fbd8..f6d950c706 100644 --- a/CodeEdit/Features/InspectorArea/FileInspector/FileInspectorView.swift +++ b/CodeEdit/Features/InspectorArea/FileInspector/FileInspectorView.swift @@ -59,6 +59,14 @@ struct FileInspectorView: View { widthOptions wrapLinesToggle } + Section { + Button("Add Test Notification") { + addTestNotification() + } + Button("Add Test Notification After Delay") { + addTestNotificationAfterDelay() + } + } } } else { NoSelectionInspectorView() @@ -81,6 +89,24 @@ struct FileInspectorView: View { } } + func addTestNotification () { + NotificationManager.shared.post( + icon: "bell", + title: "New Notification Created", + description: "Successfully created new notification", + actionButtonTitle: "Action", + action: { + print("Action taken") + } + ) + } + + func addTestNotificationAfterDelay () { + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + addTestNotification() + } + } + @ViewBuilder private var fileNameField: some View { if let file { TextField("Name", text: $fileName) diff --git a/CodeEdit/Features/InspectorSidebar/Views/FileInspector.swift b/CodeEdit/Features/InspectorSidebar/Views/FileInspector.swift new file mode 100644 index 0000000000..7682d39143 --- /dev/null +++ b/CodeEdit/Features/InspectorSidebar/Views/FileInspector.swift @@ -0,0 +1,13 @@ +struct FileInspector: View { + var body: some View { + List { + Section("Testing") { + Button("Test Notification (3s)") { + NotificationManager.shared.testNotification() + } + .buttonStyle(.borderless) + } + } + .listStyle(.inset) + } +} \ No newline at end of file diff --git a/CodeEdit/Features/Notifications/Models/CENotification.swift b/CodeEdit/Features/Notifications/Models/CENotification.swift new file mode 100644 index 0000000000..6622989be3 --- /dev/null +++ b/CodeEdit/Features/Notifications/Models/CENotification.swift @@ -0,0 +1,38 @@ +import SwiftUI + +struct CENotification: Identifiable, Equatable { + let id: UUID + let icon: String // SF Symbol name + let title: String + let description: String + let actionButtonTitle: String + let action: () -> Void + let isSticky: Bool + var isRead: Bool + let timestamp: Date + + init( + id: UUID = UUID(), + icon: String, + title: String, + description: String, + actionButtonTitle: String, + action: @escaping () -> Void, + isSticky: Bool = false, + isRead: Bool = false + ) { + 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.swift b/CodeEdit/Features/Notifications/NotificationManager.swift new file mode 100644 index 0000000000..abd035c686 --- /dev/null +++ b/CodeEdit/Features/Notifications/NotificationManager.swift @@ -0,0 +1,183 @@ +import SwiftUI +import Combine +import UserNotifications + +final class NotificationManager: NSObject, ObservableObject { + static let shared = NotificationManager() + + @Published private(set) var notifications: [CENotification] = [] + @Published private(set) var activeNotification: CENotification? + + private var timer: Timer? + private let displayDuration: TimeInterval = 5.0 + private var isPaused: Bool = false + private var isAppActive: Bool = true + + private override init() { + super.init() + + // Request notification permissions + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in } + + // Set up notification center delegate + UNUserNotificationCenter.current().delegate = self + + // Observe app active state + NotificationCenter.default.addObserver( + self, + selector: #selector(applicationDidBecomeActive), + name: NSApplication.didBecomeActiveNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(applicationDidResignActive), + name: NSApplication.didResignActiveNotification, + object: nil + ) + } + + @objc + private func applicationDidBecomeActive() { + isAppActive = true + } + + @objc + private func applicationDidResignActive() { + isAppActive = false + } + + var unreadCount: Int { + notifications.filter { !$0.isRead }.count + } + + var hasActiveNotification: Bool { + activeNotification != nil + } + + func post( + icon: String, + title: String, + description: String, + actionButtonTitle: String, + action: @escaping () -> Void, + isSticky: Bool = false + ) { + let notification = CENotification( + icon: icon, + title: title, + description: description, + actionButtonTitle: actionButtonTitle, + action: action, + isSticky: isSticky + ) + + DispatchQueue.main.async { [weak self] in + self?.notifications.append(notification) + + if self?.isAppActive == true { + self?.showTemporaryNotification(notification) + } else { + self?.showSystemNotification(notification) + } + } + } + + private func showSystemNotification(_ notification: CENotification) { + let content = UNMutableNotificationContent() + content.title = notification.title + content.body = notification.description + content.userInfo = ["id": notification.id.uuidString] + + let request = UNNotificationRequest( + identifier: notification.id.uuidString, + content: content, + trigger: nil + ) + + UNUserNotificationCenter.current().add(request) + } + + private func showTemporaryNotification(_ notification: CENotification) { + activeNotification = notification + + guard !notification.isSticky else { return } + + startHideTimer() + } + + private func startHideTimer() { + timer?.invalidate() + timer = nil + + guard !isPaused else { return } + + timer = Timer.scheduledTimer(withTimeInterval: displayDuration, repeats: false) { [weak self] _ in + self?.hideActiveNotification() + } + } + + func pauseTimer() { + isPaused = true + timer?.invalidate() + timer = nil + } + + func resumeTimer() { + isPaused = false + if activeNotification != nil && !activeNotification!.isSticky { + startHideTimer() + } + } + + func hideActiveNotification() { + activeNotification = nil + timer?.invalidate() + timer = nil + } + + func dismissNotification(_ notification: CENotification) { + if activeNotification?.id == notification.id { + hideActiveNotification() + } + notifications.removeAll(where: { $0.id == notification.id }) + } + + func markAsRead(_ notification: CENotification) { + if let index = notifications.firstIndex(where: { $0.id == notification.id }) { + notifications[index].isRead = true + } + } + + func handleSystemNotificationResponse(id: String) { + if let uuid = UUID(uuidString: id), + let notification = notifications.first(where: { $0.id == uuid }) { + notification.action() + dismissNotification(notification) + } + } +} + +extension NotificationManager: UNUserNotificationCenterDelegate { + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + if let id = response.notification.request.content.userInfo["id"] as? String { + DispatchQueue.main.async { + self.handleSystemNotificationResponse(id: id) + } + } + completionHandler() + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + // Don't show system notifications when app is active + completionHandler([]) + } +} diff --git a/CodeEdit/Features/Notifications/Views/NotificationBannerEnvironment.swift b/CodeEdit/Features/Notifications/Views/NotificationBannerEnvironment.swift new file mode 100644 index 0000000000..4f08356228 --- /dev/null +++ b/CodeEdit/Features/Notifications/Views/NotificationBannerEnvironment.swift @@ -0,0 +1,21 @@ +import SwiftUI + +struct IsOverlayKey: EnvironmentKey { + static let defaultValue: Bool = false +} + +struct IsSingleListItemKey: EnvironmentKey { + static let defaultValue: Bool = false +} + +extension EnvironmentValues { + var isOverlay: Bool { + get { self[IsOverlayKey.self] } + set { self[IsOverlayKey.self] = newValue } + } + + var isSingleListItem: Bool { + get { self[IsSingleListItemKey.self] } + set { self[IsSingleListItemKey.self] = newValue } + } +} diff --git a/CodeEdit/Features/Notifications/Views/NotificationBannerView.swift b/CodeEdit/Features/Notifications/Views/NotificationBannerView.swift new file mode 100644 index 0000000000..da9f7e59a6 --- /dev/null +++ b/CodeEdit/Features/Notifications/Views/NotificationBannerView.swift @@ -0,0 +1,129 @@ +import SwiftUI + +struct NotificationBannerView: View { + let notification: CENotification + let namespace: Namespace.ID + let onDismiss: () -> Void + let onAction: () -> Void + + @Environment(\.isOverlay) private var isOverlay + @Environment(\.isSingleListItem) private var isSingleListItem + @State private var offset: CGFloat = 0 + @State private var opacity: CGFloat = 1 + @State private var isHovering = false + + private let dismissThreshold: CGFloat = 100 + + private var cornerRadius: CGFloat { + isOverlay ? 10 : 6 + } + + private var shouldShowBackground: Bool { + isOverlay || !isSingleListItem + } + + private var content: some View { + VStack(spacing: 10) { + HStack(alignment: .top, spacing: 10) { + FeatureIcon(symbol: Image(systemName: notification.icon), color: Color(.systemBlue), size: 26) + VStack(alignment: .leading, spacing: 1) { + Text(notification.title) + .font(.headline) + .fontWeight(.medium) + .padding(.top, -2) + Text(notification.description) + .font(.callout) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + 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) + } + } + .padding(10) + .matchedGeometryEffect(id: "content-\(notification.id)", in: namespace) + } + + private var backgroundContainer: some View { + RoundedRectangle(cornerRadius: cornerRadius) + .fill(.regularMaterial) + .matchedGeometryEffect(id: "background-\(notification.id)", in: namespace) + } + + private var borderOverlay: some View { + RoundedRectangle(cornerRadius: cornerRadius) + .stroke(Color(nsColor: .separatorColor), lineWidth: 2) + .matchedGeometryEffect(id: "border-\(notification.id)", in: namespace) + } + + private var dragGesture: some Gesture { + DragGesture(minimumDistance: 2) + .onChanged { value in + if value.translation.width > 0 { + offset = value.translation.width + opacity = 1 - (offset / dismissThreshold) + } + } + .onEnded { value in + let velocity = value.predictedEndLocation.x - value.location.x + + if offset > dismissThreshold || velocity > 100 { + withAnimation(.easeOut(duration: 0.2)) { + offset = NSScreen.main?.frame.width ?? 1000 + opacity = 0 + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + onDismiss() + } + } else { + withAnimation(.easeOut(duration: 0.2)) { + offset = 0 + opacity = 1 + } + } + } + } + + var body: some View { + VStack { + if shouldShowBackground { + content + .background(backgroundContainer) + .overlay(borderOverlay) + .cornerRadius(cornerRadius) + .shadow( + color: Color(.black.withAlphaComponent(0.2)), + radius: 5, + x: 0, + y: 2 + ) + } else { + content + } + } + .frame(width: 300) + .offset(x: offset) + .opacity(opacity) + .simultaneousGesture(dragGesture) + .onHover { hovering in + isHovering = hovering + if hovering { + NotificationManager.shared.pauseTimer() + } else { + NotificationManager.shared.resumeTimer() + } + } + } +} diff --git a/CodeEdit/Features/Notifications/Views/NotificationListView.swift b/CodeEdit/Features/Notifications/Views/NotificationListView.swift new file mode 100644 index 0000000000..d141b0082e --- /dev/null +++ b/CodeEdit/Features/Notifications/Views/NotificationListView.swift @@ -0,0 +1,41 @@ +import SwiftUI + +struct NotificationListView: View { + @ObservedObject private var notificationManager = NotificationManager.shared + @Namespace private var animation + + var body: some View { + ScrollView { + VStack(spacing: 10) { + if notificationManager.notifications.isEmpty { + Text("No notifications") + .foregroundColor(.secondary) + .padding() + } else { + ForEach(notificationManager.notifications) { notification in + NotificationBannerView( + notification: notification, + namespace: animation, + onDismiss: { + withAnimation(.easeInOut(duration: 0.2)) { + notificationManager.dismissNotification(notification) + } + }, + onAction: { + withAnimation(.easeInOut(duration: 0.2)) { + notification.action() + notificationManager.dismissNotification(notification) + } + } + ) + .environment(\.isOverlay, false) + .environment(\.isSingleListItem, notificationManager.notifications.count == 1) + .transition(.opacity.combined(with: .move(edge: .trailing))) + } + } + } + .padding(notificationManager.notifications.count == 1 ? 0 : 10) + .animation(.easeInOut(duration: 0.2), value: notificationManager.notifications) + } + } +} diff --git a/CodeEdit/Features/Notifications/Views/NotificationOverlayView.swift b/CodeEdit/Features/Notifications/Views/NotificationOverlayView.swift new file mode 100644 index 0000000000..c6a8217974 --- /dev/null +++ b/CodeEdit/Features/Notifications/Views/NotificationOverlayView.swift @@ -0,0 +1,36 @@ +import SwiftUI + +struct NotificationOverlayView: View { + @ObservedObject private var notificationManager = NotificationManager.shared + @Namespace private var animation + @Environment(\.controlActiveState) private var controlActiveState + + var body: some View { + VStack(spacing: 10) { + ForEach(Array([notificationManager.activeNotification].compactMap { $0 }), id: \.id) { notification in + if controlActiveState == .active || controlActiveState == .key { + NotificationBannerView( + notification: notification, + namespace: animation, + onDismiss: { + notificationManager.dismissNotification(notification) + }, + onAction: { + notification.action() + notificationManager.dismissNotification(notification) + } + ) + .environment(\.isOverlay, true) + .transition( + .asymmetric( + insertion: .opacity.combined(with: .move(edge: .bottom)), + removal: .opacity.combined(with: .move(edge: .top)) + ) + ) + } + } + } + .padding(8) + .animation(.easeInOut(duration: 0.2), value: notificationManager.activeNotification?.id) + } +} diff --git a/CodeEdit/Features/Notifications/Views/NotificationToolbarItem.swift b/CodeEdit/Features/Notifications/Views/NotificationToolbarItem.swift new file mode 100644 index 0000000000..42e7848439 --- /dev/null +++ b/CodeEdit/Features/Notifications/Views/NotificationToolbarItem.swift @@ -0,0 +1,31 @@ +import SwiftUI + +struct NotificationToolbarItem: View { + @ObservedObject private var notificationManager = NotificationManager.shared + @Environment(\.controlActiveState) + private var controlActiveState + @State private var showingPopover = false + + var body: some View { + if notificationManager.unreadCount > 0 { + Button { + if notificationManager.hasActiveNotification { + notificationManager.hideActiveNotification() + } + showingPopover.toggle() + } label: { + HStack(spacing: 4) { + Image(systemName: "bell.badge.fill") + .symbolRenderingMode(.palette) + .foregroundStyle(controlActiveState == .inactive ? .secondary : Color.accentColor, .primary) + Text("\(notificationManager.unreadCount)") + .monospacedDigit() + } + } + .popover(isPresented: $showingPopover, arrowEdge: .bottom) { + NotificationListView() + } + .transition(.opacity.animation(.none)) + } + } +} diff --git a/CodeEdit/WorkspaceView.swift b/CodeEdit/WorkspaceView.swift index 3a7cf0ddff..4f0f64d848 100644 --- a/CodeEdit/WorkspaceView.swift +++ b/CodeEdit/WorkspaceView.swift @@ -52,6 +52,9 @@ struct WorkspaceView: View { focus: $focusedEditor ) .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay(alignment: .topTrailing) { + NotificationOverlayView() + } .onChange(of: geo.size.height) { newHeight in editorsHeight = newHeight } From 779e3a645e23949790666fdff464c85d947fc075 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Tue, 11 Feb 2025 12:46:33 -0600 Subject: [PATCH 02/21] Refactored FeatureIcon and notifications to support custom images --- CodeEdit/AppDelegate.swift | 26 +++--- .../CodeEditUI/Views/FeatureIcon.swift | 84 +++++++++++++----- .../FileInspector/FileInspectorView.swift | 5 +- .../Notifications/Models/CENotification.swift | 41 ++++++++- .../Notifications/NotificationManager.swift | 88 ++++++++++++++++++- .../Views/NotificationBannerEnvironment.swift | 7 ++ .../Views/NotificationBannerView.swift | 21 ++++- .../Views/NotificationListView.swift | 7 ++ .../Views/NotificationOverlayView.swift | 7 ++ .../Views/NotificationToolbarItem.swift | 7 ++ .../AccountsSettingsAccountLink.swift | 9 +- .../AccountsSettingsProviderRow.swift | 6 +- .../AccountsSettingsSigninView.swift | 7 +- .../SourceControlSettingsView.swift | 2 +- .../Settings/Views/SettingsPageView.swift | 19 ++-- 15 files changed, 264 insertions(+), 72 deletions(-) diff --git a/CodeEdit/AppDelegate.swift b/CodeEdit/AppDelegate.swift index 906a1831ab..81eb1987e3 100644 --- a/CodeEdit/AppDelegate.swift +++ b/CodeEdit/AppDelegate.swift @@ -25,11 +25,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { checkForFilesToOpen() NSApp.closeWindow(.welcome, .about) - + // Add test notification DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { NotificationManager.shared.post( - icon: "bell.badge", + iconSymbol: "bell.badge", title: "Welcome to CodeEdit", description: "This is a test notification to demonstrate the notification system.", actionButtonTitle: "Learn More", @@ -38,10 +38,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { } ) } - + DispatchQueue.main.async { var needToHandleOpen = true - + // If no windows were reopened by NSQuitAlwaysKeepsWindows, do default behavior. // Non-WindowGroup SwiftUI Windows are still in NSApp.windows when they are closed, // So we need to think about those. @@ -73,13 +73,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { } func applicationWillTerminate(_ aNotification: Notification) { - + } - + func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { true } - + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { guard flag else { handleOpen() @@ -92,11 +92,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { sender.windows.first(where: { $0.isMiniaturized })?.deminiaturize(sender) return false } - + func applicationShouldOpenUntitledFile(_ sender: NSApplication) -> Bool { false } - + func handleOpen() { let behavior = Settings.shared.preferences.general.reopenBehavior switch behavior { @@ -110,7 +110,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { CodeEditDocumentController.shared.newDocument(self) } } - + /// Handle urls with the form `codeedit://file/{filepath}:{line}:{column}` func application(_ application: NSApplication, open urls: [URL]) { for url in urls { @@ -118,7 +118,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { let filePath = URL(fileURLWithPath: String(file[0])) let line = file.count > 1 ? Int(file[1]) ?? 0 : 0 let column = file.count > 2 ? Int(file[2]) ?? 1 : 1 - + CodeEditDocumentController.shared .openDocument(withContentsOf: filePath, display: true) { document, _, error in if let error { @@ -133,7 +133,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { // Add notification when workspace is opened via URL if let workspaceDoc = document as? WorkspaceDocument { NotificationManager.shared.post( - icon: "folder.badge.plus", + iconSymbol: "folder.badge.plus", title: "Workspace Opened", description: "Successfully opened workspace: \(workspaceDoc.fileURL?.lastPathComponent ?? "")", actionButtonTitle: "View Files", @@ -146,7 +146,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { } } } - + /// Defers the application terminate message until we've finished cleanup. /// /// All paths _must_ call `NSApplication.shared.reply(toApplicationShouldTerminate: true)` as soon as possible. diff --git a/CodeEdit/Features/CodeEditUI/Views/FeatureIcon.swift b/CodeEdit/Features/CodeEditUI/Views/FeatureIcon.swift index b7ca98e4eb..41e31f7fe5 100644 --- a/CodeEdit/Features/CodeEditUI/Views/FeatureIcon.swift +++ b/CodeEdit/Features/CodeEditUI/Views/FeatureIcon.swift @@ -6,39 +6,77 @@ // import SwiftUI +import CodeEditSymbols struct FeatureIcon: View { - private let symbol: Image - private let color: Color + private let content: IconContent + private let color: Color? private let size: CGFloat init( - symbol: Image?, - color: Color?, - size: CGFloat? + symbol: String, + color: Color? = nil, + size: CGFloat? = nil ) { - self.symbol = symbol ?? Image(systemName: "exclamationmark.triangle") - self.color = color ?? .white + self.content = .symbol(symbol) + self.color = color ?? .accentColor self.size = size ?? 20 } - var body: some View { - Group { - symbol - .resizable() - .aspectRatio(contentMode: .fit) + 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) } - .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 + } + + var body: some View { + RoundedRectangle(cornerRadius: size / 4, style: .continuous) + .fill(background) + .overlay { + switch content { + case .symbol(let name): + getSafeImage(named: name) + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(.white) + .padding(size / 8) + case .image(let 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 ) - .fill(color.gradient) - .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: + return AnyShapeStyle((color ?? .accentColor).gradient) + case .image: + return AnyShapeStyle(.regularMaterial) + } + } +} + +private enum IconContent { + case symbol(String) + case image(Image) } diff --git a/CodeEdit/Features/InspectorArea/FileInspector/FileInspectorView.swift b/CodeEdit/Features/InspectorArea/FileInspector/FileInspectorView.swift index f6d950c706..c3d45afa1c 100644 --- a/CodeEdit/Features/InspectorArea/FileInspector/FileInspectorView.swift +++ b/CodeEdit/Features/InspectorArea/FileInspector/FileInspectorView.swift @@ -91,7 +91,8 @@ struct FileInspectorView: View { func addTestNotification () { NotificationManager.shared.post( - icon: "bell", + iconSymbol: "bell", + iconColor: .red, title: "New Notification Created", description: "Successfully created new notification", actionButtonTitle: "Action", @@ -100,7 +101,7 @@ struct FileInspectorView: View { } ) } - + func addTestNotificationAfterDelay () { DispatchQueue.main.asyncAfter(deadline: .now() + 3) { addTestNotification() diff --git a/CodeEdit/Features/Notifications/Models/CENotification.swift b/CodeEdit/Features/Notifications/Models/CENotification.swift index 6622989be3..b19e6da13d 100644 --- a/CodeEdit/Features/Notifications/Models/CENotification.swift +++ b/CodeEdit/Features/Notifications/Models/CENotification.swift @@ -1,8 +1,16 @@ +// +// CENotification.swift +// CodeEdit +// +// Created by Austin Condiff on 2/10/24. +// + +import Foundation import SwiftUI struct CENotification: Identifiable, Equatable { let id: UUID - let icon: String // SF Symbol name + let icon: IconType let title: String let description: String let actionButtonTitle: String @@ -11,9 +19,36 @@ struct CENotification: Identifiable, Equatable { var isRead: Bool let timestamp: Date + enum IconType { + case symbol(name: String, color: Color?) + case image(Image) + } + + 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.id = id + self.icon = .symbol(name: iconSymbol, color: iconColor) + self.title = title + self.description = description + self.actionButtonTitle = actionButtonTitle + self.action = action + self.isSticky = isSticky + self.isRead = isRead + self.timestamp = Date() + } + init( id: UUID = UUID(), - icon: String, + iconImage: Image, title: String, description: String, actionButtonTitle: String, @@ -22,7 +57,7 @@ struct CENotification: Identifiable, Equatable { isRead: Bool = false ) { self.id = id - self.icon = icon + self.icon = .image(iconImage) self.title = title self.description = description self.actionButtonTitle = actionButtonTitle diff --git a/CodeEdit/Features/Notifications/NotificationManager.swift b/CodeEdit/Features/Notifications/NotificationManager.swift index abd035c686..569f2e5c15 100644 --- a/CodeEdit/Features/Notifications/NotificationManager.swift +++ b/CodeEdit/Features/Notifications/NotificationManager.swift @@ -1,11 +1,28 @@ +// +// 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: +/// - Displaying temporary notifications in the app UI +/// - Managing notification persistence +/// - Handling system notifications when app is in background +/// - Tracking notification read status 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] = [] + + /// Currently displayed notification in the overlay @Published private(set) var activeNotification: CENotification? private var timer: Timer? @@ -47,16 +64,65 @@ final class NotificationManager: NSObject, ObservableObject { isAppActive = false } + /// Number of unread notifications var unreadCount: Int { notifications.filter { !$0.isRead }.count } + /// Whether there is currently a notification being displayed in the overlay var hasActiveNotification: Bool { activeNotification != nil } + /// 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 + ) + + DispatchQueue.main.async { [weak self] in + self?.notifications.append(notification) + + if self?.isAppActive == true { + self?.showTemporaryNotification(notification) + } else { + self?.showSystemNotification(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( - icon: String, + iconImage: Image, title: String, description: String, actionButtonTitle: String, @@ -64,7 +130,7 @@ final class NotificationManager: NSObject, ObservableObject { isSticky: Bool = false ) { let notification = CENotification( - icon: icon, + iconImage: iconImage, title: title, description: description, actionButtonTitle: actionButtonTitle, @@ -83,21 +149,23 @@ final class NotificationManager: NSObject, ObservableObject { } } + /// Shows a notification in macOS Notification Center when app is in background private func showSystemNotification(_ notification: CENotification) { let content = UNMutableNotificationContent() content.title = notification.title content.body = notification.description content.userInfo = ["id": notification.id.uuidString] - + let request = UNNotificationRequest( identifier: notification.id.uuidString, content: content, trigger: nil ) - + UNUserNotificationCenter.current().add(request) } + /// Shows a notification in the app's overlay UI private func showTemporaryNotification(_ notification: CENotification) { activeNotification = notification @@ -106,6 +174,7 @@ final class NotificationManager: NSObject, ObservableObject { startHideTimer() } + /// Starts the timer to automatically hide non-sticky notifications private func startHideTimer() { timer?.invalidate() timer = nil @@ -117,12 +186,14 @@ final class NotificationManager: NSObject, ObservableObject { } } + /// Pauses the auto-hide timer (used when hovering over notification) func pauseTimer() { isPaused = true timer?.invalidate() timer = nil } + /// Resumes the auto-hide timer func resumeTimer() { isPaused = false if activeNotification != nil && !activeNotification!.isSticky { @@ -130,12 +201,15 @@ final class NotificationManager: NSObject, ObservableObject { } } + /// Hides the currently active notification func hideActiveNotification() { activeNotification = nil timer?.invalidate() timer = nil } + /// Dismisses a specific notification + /// - Parameter notification: The notification to dismiss func dismissNotification(_ notification: CENotification) { if activeNotification?.id == notification.id { hideActiveNotification() @@ -143,12 +217,16 @@ final class NotificationManager: NSObject, ObservableObject { notifications.removeAll(where: { $0.id == notification.id }) } + /// 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 } } + /// Handles response from system notification + /// - Parameter id: ID of the notification that was interacted with func handleSystemNotificationResponse(id: String) { if let uuid = UUID(uuidString: id), let notification = notifications.first(where: { $0.id == uuid }) { @@ -158,6 +236,8 @@ final class NotificationManager: NSObject, ObservableObject { } } +// MARK: - UNUserNotificationCenterDelegate + extension NotificationManager: UNUserNotificationCenterDelegate { func userNotificationCenter( _ center: UNUserNotificationCenter, diff --git a/CodeEdit/Features/Notifications/Views/NotificationBannerEnvironment.swift b/CodeEdit/Features/Notifications/Views/NotificationBannerEnvironment.swift index 4f08356228..f30d06a12a 100644 --- a/CodeEdit/Features/Notifications/Views/NotificationBannerEnvironment.swift +++ b/CodeEdit/Features/Notifications/Views/NotificationBannerEnvironment.swift @@ -1,3 +1,10 @@ +// +// NotificationBannerEnvironment.swift +// CodeEdit +// +// Created by Austin Condiff on 2/10/24. +// + import SwiftUI struct IsOverlayKey: EnvironmentKey { diff --git a/CodeEdit/Features/Notifications/Views/NotificationBannerView.swift b/CodeEdit/Features/Notifications/Views/NotificationBannerView.swift index da9f7e59a6..2a70e82a4d 100644 --- a/CodeEdit/Features/Notifications/Views/NotificationBannerView.swift +++ b/CodeEdit/Features/Notifications/Views/NotificationBannerView.swift @@ -1,3 +1,10 @@ +// +// NotificationBannerView.swift +// CodeEdit +// +// Created by Austin Condiff on 2/10/24. +// + import SwiftUI struct NotificationBannerView: View { @@ -25,7 +32,19 @@ struct NotificationBannerView: View { private var content: some View { VStack(spacing: 10) { HStack(alignment: .top, spacing: 10) { - FeatureIcon(symbol: Image(systemName: notification.icon), color: Color(.systemBlue), size: 26) + switch notification.icon { + case .symbol(let name, let color): + FeatureIcon( + symbol: name, + color: color ?? Color(.systemBlue), + size: 26 + ) + case .image(let image): + FeatureIcon( + image: image, + size: 26 + ) + } VStack(alignment: .leading, spacing: 1) { Text(notification.title) .font(.headline) diff --git a/CodeEdit/Features/Notifications/Views/NotificationListView.swift b/CodeEdit/Features/Notifications/Views/NotificationListView.swift index d141b0082e..d579c9bb3f 100644 --- a/CodeEdit/Features/Notifications/Views/NotificationListView.swift +++ b/CodeEdit/Features/Notifications/Views/NotificationListView.swift @@ -1,3 +1,10 @@ +// +// NotificationListView.swift +// CodeEdit +// +// Created by Austin Condiff on 2/10/24. +// + import SwiftUI struct NotificationListView: View { diff --git a/CodeEdit/Features/Notifications/Views/NotificationOverlayView.swift b/CodeEdit/Features/Notifications/Views/NotificationOverlayView.swift index c6a8217974..77c5f3ed33 100644 --- a/CodeEdit/Features/Notifications/Views/NotificationOverlayView.swift +++ b/CodeEdit/Features/Notifications/Views/NotificationOverlayView.swift @@ -1,3 +1,10 @@ +// +// NotificationOverlayView.swift +// CodeEdit +// +// Created by Austin Condiff on 2/10/24. +// + import SwiftUI struct NotificationOverlayView: View { diff --git a/CodeEdit/Features/Notifications/Views/NotificationToolbarItem.swift b/CodeEdit/Features/Notifications/Views/NotificationToolbarItem.swift index 42e7848439..b623362818 100644 --- a/CodeEdit/Features/Notifications/Views/NotificationToolbarItem.swift +++ b/CodeEdit/Features/Notifications/Views/NotificationToolbarItem.swift @@ -1,3 +1,10 @@ +// +// NotificationToolbarItem.swift +// CodeEdit +// +// Created by Austin Condiff on 2/10/24. +// + import SwiftUI struct NotificationToolbarItem: View { 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/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/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) + } } } } From 69883305bb6243096621005d2f944642268d3d1f Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Tue, 11 Feb 2025 16:02:46 -0600 Subject: [PATCH 03/21] Fixed SwiftLint errors --- CodeEdit/AppDelegate.swift | 12 ++--- .../CodeEditUI/Views/FeatureIcon.swift | 22 ++++++++-- .../FileInspector/FileInspectorView.swift | 2 +- .../Notifications/Models/CENotification.swift | 34 +++++++++++--- .../Notifications/NotificationManager.swift | 44 ++++++++++++++++++- .../Views/NotificationBannerView.swift | 29 ++++++++---- .../Views/NotificationOverlayView.swift | 5 ++- 7 files changed, 123 insertions(+), 25 deletions(-) diff --git a/CodeEdit/AppDelegate.swift b/CodeEdit/AppDelegate.swift index 81eb1987e3..115e2e8bc1 100644 --- a/CodeEdit/AppDelegate.swift +++ b/CodeEdit/AppDelegate.swift @@ -29,10 +29,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { // Add test notification DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { NotificationManager.shared.post( - iconSymbol: "bell.badge", + iconText: "👋", + iconTextColor: .white, + iconColor: .indigo, title: "Welcome to CodeEdit", description: "This is a test notification to demonstrate the notification system.", - actionButtonTitle: "Learn More", + actionButtonTitle: "Learn More...", action: { print("Action button clicked!") } @@ -85,7 +87,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { handleOpen() return false } - + /// Check if all windows are either miniaturized or not visible. /// If so, attempt to find the first miniaturized window and deminiaturize it. guard sender.windows.allSatisfy({ $0.isMiniaturized || !$0.isVisible }) else { return false } @@ -164,9 +166,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { let projects: [String] = CodeEditDocumentController.shared.documents .compactMap { ($0 as? WorkspaceDocument)?.fileURL?.path } - + UserDefaults.standard.set(projects, forKey: AppDelegate.recoverWorkspacesKey) - + let areAllDocumentsClean = CodeEditDocumentController.shared.documents.allSatisfy { !$0.isDocumentEdited } guard areAllDocumentsClean else { CodeEditDocumentController.shared.closeAllDocuments( diff --git a/CodeEdit/Features/CodeEditUI/Views/FeatureIcon.swift b/CodeEdit/Features/CodeEditUI/Views/FeatureIcon.swift index 41e31f7fe5..33d0ae09cf 100644 --- a/CodeEdit/Features/CodeEditUI/Views/FeatureIcon.swift +++ b/CodeEdit/Features/CodeEditUI/Views/FeatureIcon.swift @@ -23,6 +23,17 @@ struct FeatureIcon: View { 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 @@ -45,13 +56,17 @@ struct FeatureIcon: View { .fill(background) .overlay { switch content { - case .symbol(let name): + case let .symbol(name): getSafeImage(named: name) .resizable() .aspectRatio(contentMode: .fit) .foregroundColor(.white) .padding(size / 8) - case .image(let image): + 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) @@ -68,7 +83,7 @@ struct FeatureIcon: View { private var background: AnyShapeStyle { switch content { - case .symbol: + case .symbol, .text: return AnyShapeStyle((color ?? .accentColor).gradient) case .image: return AnyShapeStyle(.regularMaterial) @@ -78,5 +93,6 @@ struct FeatureIcon: View { private enum IconContent { case symbol(String) + case text(String, textColor: Color?) case image(Image) } diff --git a/CodeEdit/Features/InspectorArea/FileInspector/FileInspectorView.swift b/CodeEdit/Features/InspectorArea/FileInspector/FileInspectorView.swift index c3d45afa1c..832104c9fe 100644 --- a/CodeEdit/Features/InspectorArea/FileInspector/FileInspectorView.swift +++ b/CodeEdit/Features/InspectorArea/FileInspector/FileInspectorView.swift @@ -91,7 +91,7 @@ struct FileInspectorView: View { func addTestNotification () { NotificationManager.shared.post( - iconSymbol: "bell", + iconSymbol: "bell.badge.fill", iconColor: .red, title: "New Notification Created", description: "Successfully created new notification", diff --git a/CodeEdit/Features/Notifications/Models/CENotification.swift b/CodeEdit/Features/Notifications/Models/CENotification.swift index b19e6da13d..4fff58be11 100644 --- a/CodeEdit/Features/Notifications/Models/CENotification.swift +++ b/CodeEdit/Features/Notifications/Models/CENotification.swift @@ -18,12 +18,13 @@ struct CENotification: Identifiable, Equatable { let isSticky: Bool var isRead: Bool let timestamp: Date - + enum IconType { case symbol(name: String, color: Color?) case image(Image) + case text(String, backgroundColor: Color?, textColor: Color?) } - + init( id: UUID = UUID(), iconSymbol: String, @@ -45,7 +46,30 @@ struct CENotification: Identifiable, Equatable { self.isRead = isRead self.timestamp = Date() } - + + 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.id = id + self.icon = .text(iconText, backgroundColor: iconColor, textColor: iconTextColor) + self.title = title + self.description = description + self.actionButtonTitle = actionButtonTitle + self.action = action + self.isSticky = isSticky + self.isRead = isRead + self.timestamp = Date() + } + init( id: UUID = UUID(), iconImage: Image, @@ -66,8 +90,8 @@ struct CENotification: Identifiable, Equatable { self.isRead = isRead self.timestamp = Date() } - + static func == (lhs: CENotification, rhs: CENotification) -> Bool { lhs.id == rhs.id } -} +} diff --git a/CodeEdit/Features/Notifications/NotificationManager.swift b/CodeEdit/Features/Notifications/NotificationManager.swift index 569f2e5c15..c745fb987f 100644 --- a/CodeEdit/Features/Notifications/NotificationManager.swift +++ b/CodeEdit/Features/Notifications/NotificationManager.swift @@ -30,7 +30,7 @@ final class NotificationManager: NSObject, ObservableObject { private var isPaused: Bool = false private var isAppActive: Bool = true - private override init() { + override private init() { super.init() // Request notification permissions @@ -149,6 +149,48 @@ final class NotificationManager: NSObject, ObservableObject { } } + /// 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 + ) + + DispatchQueue.main.async { [weak self] in + self?.notifications.append(notification) + + if self?.isAppActive == true { + self?.showTemporaryNotification(notification) + } else { + self?.showSystemNotification(notification) + } + } + } + /// Shows a notification in macOS Notification Center when app is in background private func showSystemNotification(_ notification: CENotification) { let content = UNMutableNotificationContent() diff --git a/CodeEdit/Features/Notifications/Views/NotificationBannerView.swift b/CodeEdit/Features/Notifications/Views/NotificationBannerView.swift index 2a70e82a4d..30bbc6801d 100644 --- a/CodeEdit/Features/Notifications/Views/NotificationBannerView.swift +++ b/CodeEdit/Features/Notifications/Views/NotificationBannerView.swift @@ -8,17 +8,21 @@ import SwiftUI struct NotificationBannerView: View { + @Environment(\.isOverlay) + private var isOverlay + + @Environment(\.isSingleListItem) + private var isSingleListItem + let notification: CENotification let namespace: Namespace.ID let onDismiss: () -> Void let onAction: () -> Void - @Environment(\.isOverlay) private var isOverlay - @Environment(\.isSingleListItem) private var isSingleListItem @State private var offset: CGFloat = 0 @State private var opacity: CGFloat = 1 @State private var isHovering = false - + private let dismissThreshold: CGFloat = 100 private var cornerRadius: CGFloat { @@ -33,13 +37,20 @@ struct NotificationBannerView: View { VStack(spacing: 10) { HStack(alignment: .top, spacing: 10) { switch notification.icon { - case .symbol(let name, let color): + case let .symbol(name, color): FeatureIcon( symbol: name, color: color ?? Color(.systemBlue), size: 26 ) - case .image(let image): + 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 @@ -47,9 +58,9 @@ struct NotificationBannerView: View { } VStack(alignment: .leading, spacing: 1) { Text(notification.title) - .font(.headline) - .fontWeight(.medium) - .padding(.top, -2) + .font(.system(size: 12)) + .fontWeight(.semibold) + .padding(.top, -3) Text(notification.description) .font(.callout) .foregroundColor(.secondary) @@ -97,7 +108,7 @@ struct NotificationBannerView: View { } .onEnded { value in let velocity = value.predictedEndLocation.x - value.location.x - + if offset > dismissThreshold || velocity > 100 { withAnimation(.easeOut(duration: 0.2)) { offset = NSScreen.main?.frame.width ?? 1000 diff --git a/CodeEdit/Features/Notifications/Views/NotificationOverlayView.swift b/CodeEdit/Features/Notifications/Views/NotificationOverlayView.swift index 77c5f3ed33..ce69e2e5b4 100644 --- a/CodeEdit/Features/Notifications/Views/NotificationOverlayView.swift +++ b/CodeEdit/Features/Notifications/Views/NotificationOverlayView.swift @@ -8,9 +8,12 @@ import SwiftUI struct NotificationOverlayView: View { + @Environment(\.controlActiveState) + private var controlActiveState + @ObservedObject private var notificationManager = NotificationManager.shared + @Namespace private var animation - @Environment(\.controlActiveState) private var controlActiveState var body: some View { VStack(spacing: 10) { From 9b524bdcf585c5497c9665f370caf8c6664f4947 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Tue, 11 Feb 2025 22:14:55 -0600 Subject: [PATCH 04/21] Fixed SwiftLint errors --- CodeEdit/AppDelegate.swift | 15 +-------------- .../Documents/Views/WindowContentView.swift | 1 - .../InspectorSidebar/Views/FileInspector.swift | 2 +- .../Notifications/NotificationManager.swift | 8 ++++---- .../Views/NotificationBannerEnvironment.swift | 2 +- 5 files changed, 7 insertions(+), 21 deletions(-) delete mode 100644 CodeEdit/Features/Documents/Views/WindowContentView.swift diff --git a/CodeEdit/AppDelegate.swift b/CodeEdit/AppDelegate.swift index 115e2e8bc1..720783c37a 100644 --- a/CodeEdit/AppDelegate.swift +++ b/CodeEdit/AppDelegate.swift @@ -27,7 +27,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { NSApp.closeWindow(.welcome, .about) // Add test notification - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { NotificationManager.shared.post( iconText: "👋", iconTextColor: .white, @@ -132,19 +132,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { cursorPositions: [CursorPosition(line: line, column: column > 0 ? column : 1)] ) } - // Add notification when workspace is opened via URL - if let workspaceDoc = document as? WorkspaceDocument { - NotificationManager.shared.post( - iconSymbol: "folder.badge.plus", - title: "Workspace Opened", - description: "Successfully opened workspace: \(workspaceDoc.fileURL?.lastPathComponent ?? "")", - actionButtonTitle: "View Files", - action: { - // Ensure the workspace window is frontmost - workspaceDoc.windowControllers.first?.window?.makeKeyAndOrderFront(nil) - } - ) - } } } } diff --git a/CodeEdit/Features/Documents/Views/WindowContentView.swift b/CodeEdit/Features/Documents/Views/WindowContentView.swift deleted file mode 100644 index 8d1c8b69c3..0000000000 --- a/CodeEdit/Features/Documents/Views/WindowContentView.swift +++ /dev/null @@ -1 +0,0 @@ - diff --git a/CodeEdit/Features/InspectorSidebar/Views/FileInspector.swift b/CodeEdit/Features/InspectorSidebar/Views/FileInspector.swift index 7682d39143..270c0a6485 100644 --- a/CodeEdit/Features/InspectorSidebar/Views/FileInspector.swift +++ b/CodeEdit/Features/InspectorSidebar/Views/FileInspector.swift @@ -10,4 +10,4 @@ struct FileInspector: View { } .listStyle(.inset) } -} \ No newline at end of file +} diff --git a/CodeEdit/Features/Notifications/NotificationManager.swift b/CodeEdit/Features/Notifications/NotificationManager.swift index c745fb987f..4de71bb9d0 100644 --- a/CodeEdit/Features/Notifications/NotificationManager.swift +++ b/CodeEdit/Features/Notifications/NotificationManager.swift @@ -104,7 +104,7 @@ final class NotificationManager: NSObject, ObservableObject { DispatchQueue.main.async { [weak self] in self?.notifications.append(notification) - + if self?.isAppActive == true { self?.showTemporaryNotification(notification) } else { @@ -182,7 +182,7 @@ final class NotificationManager: NSObject, ObservableObject { DispatchQueue.main.async { [weak self] in self?.notifications.append(notification) - + if self?.isAppActive == true { self?.showTemporaryNotification(notification) } else { @@ -197,13 +197,13 @@ final class NotificationManager: NSObject, ObservableObject { content.title = notification.title content.body = notification.description content.userInfo = ["id": notification.id.uuidString] - + let request = UNNotificationRequest( identifier: notification.id.uuidString, content: content, trigger: nil ) - + UNUserNotificationCenter.current().add(request) } diff --git a/CodeEdit/Features/Notifications/Views/NotificationBannerEnvironment.swift b/CodeEdit/Features/Notifications/Views/NotificationBannerEnvironment.swift index f30d06a12a..33d4bd1e26 100644 --- a/CodeEdit/Features/Notifications/Views/NotificationBannerEnvironment.swift +++ b/CodeEdit/Features/Notifications/Views/NotificationBannerEnvironment.swift @@ -20,7 +20,7 @@ extension EnvironmentValues { get { self[IsOverlayKey.self] } set { self[IsOverlayKey.self] = newValue } } - + var isSingleListItem: Bool { get { self[IsSingleListItemKey.self] } set { self[IsSingleListItemKey.self] = newValue } From 99041cfb859fb42adeeb4a247a9cd7b8eb13dc76 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Tue, 11 Feb 2025 22:28:12 -0600 Subject: [PATCH 05/21] Deleted unused file --- .../InspectorSidebar/Views/FileInspector.swift | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 CodeEdit/Features/InspectorSidebar/Views/FileInspector.swift diff --git a/CodeEdit/Features/InspectorSidebar/Views/FileInspector.swift b/CodeEdit/Features/InspectorSidebar/Views/FileInspector.swift deleted file mode 100644 index 270c0a6485..0000000000 --- a/CodeEdit/Features/InspectorSidebar/Views/FileInspector.swift +++ /dev/null @@ -1,13 +0,0 @@ -struct FileInspector: View { - var body: some View { - List { - Section("Testing") { - Button("Test Notification (3s)") { - NotificationManager.shared.testNotification() - } - .buttonStyle(.borderless) - } - } - .listStyle(.inset) - } -} From 13d3bca8c0e24beb148b03bd39f610106dd92159 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Wed, 12 Feb 2025 13:13:30 -0600 Subject: [PATCH 06/21] Added sticky notifications. Allowed multiple notifications to overlay UI. Improved aesthetics of notification banner --- .../About/Views/BlurButtonStyle.swift | 13 +- .../FileInspector/FileInspectorView.swift | 33 +---- .../Notifications/NotificationManager.swift | 77 +++++++----- .../Views/NotificationBannerView.swift | 113 +++++++++++++----- .../Views/NotificationListView.swift | 49 ++++++-- .../Views/NotificationOverlayView.swift | 4 +- .../Views/NotificationToolbarItem.swift | 11 +- 7 files changed, 194 insertions(+), 106 deletions(-) diff --git a/CodeEdit/Features/About/Views/BlurButtonStyle.swift b/CodeEdit/Features/About/Views/BlurButtonStyle.swift index e1c137c4f9..a86f21bcfe 100644 --- a/CodeEdit/Features/About/Views/BlurButtonStyle.swift +++ b/CodeEdit/Features/About/Views/BlurButtonStyle.swift @@ -39,7 +39,10 @@ struct BlurButtonStyle: ButtonStyle { case .dark: ZStack { Color.gray.opacity(0.001) - if !isSecondary { + if isSecondary { + Rectangle() + .fill(.regularMaterial) + } else { Rectangle() .fill(.regularMaterial) .blendMode(.plusLighter) @@ -50,11 +53,9 @@ struct BlurButtonStyle: ButtonStyle { case .light: ZStack { Color.gray.opacity(0.001) - if !isSecondary { - Rectangle() - .fill(.regularMaterial) - .blendMode(.darken) - } + Rectangle() + .fill(.regularMaterial) + .blendMode(.darken) Color.gray.opacity(isSecondary ? 0.05 : 0.15) .blendMode(.plusDarker) Color.gray.opacity(configuration.isPressed ? 0.10 : 0.00) diff --git a/CodeEdit/Features/InspectorArea/FileInspector/FileInspectorView.swift b/CodeEdit/Features/InspectorArea/FileInspector/FileInspectorView.swift index 832104c9fe..f4e2766d8d 100644 --- a/CodeEdit/Features/InspectorArea/FileInspector/FileInspectorView.swift +++ b/CodeEdit/Features/InspectorArea/FileInspector/FileInspectorView.swift @@ -59,14 +59,6 @@ struct FileInspectorView: View { widthOptions wrapLinesToggle } - Section { - Button("Add Test Notification") { - addTestNotification() - } - Button("Add Test Notification After Delay") { - addTestNotificationAfterDelay() - } - } } } else { NoSelectionInspectorView() @@ -89,36 +81,20 @@ struct FileInspectorView: View { } } - func addTestNotification () { - NotificationManager.shared.post( - iconSymbol: "bell.badge.fill", - iconColor: .red, - title: "New Notification Created", - description: "Successfully created new notification", - actionButtonTitle: "Action", - action: { - print("Action taken") - } - ) - } - - func addTestNotificationAfterDelay () { - DispatchQueue.main.asyncAfter(deadline: .now() + 3) { - addTestNotification() - } - } - @ViewBuilder private var fileNameField: some View { + @State var isValid: Bool = true + if let file { TextField("Name", text: $fileName) .background( - file.validateFileName(for: fileName) ? Color.clear : Color(errorRed) + isValid ? Color.clear : Color(errorRed) ) .onSubmit { if file.validateFileName(for: fileName) { let destinationURL = file.url .deletingLastPathComponent() .appendingPathComponent(fileName) + isValid = true DispatchQueue.main.async { [weak workspace] in do { if let newItem = try workspace?.workspaceFileManager?.move( @@ -136,6 +112,7 @@ struct FileInspectorView: View { } } } else { + isValid = false fileName = file.labelFileName() } } diff --git a/CodeEdit/Features/Notifications/NotificationManager.swift b/CodeEdit/Features/Notifications/NotificationManager.swift index 4de71bb9d0..8d67dbc575 100644 --- a/CodeEdit/Features/Notifications/NotificationManager.swift +++ b/CodeEdit/Features/Notifications/NotificationManager.swift @@ -22,13 +22,17 @@ final class NotificationManager: NSObject, ObservableObject { /// Collection of all notifications, both read and unread @Published private(set) var notifications: [CENotification] = [] - /// Currently displayed notification in the overlay + /// Currently displayed notifications in the overlay @Published private(set) var activeNotification: CENotification? + @Published private(set) var activeNotifications: [CENotification] = [] - private var timer: Timer? + private var timers: [UUID: Timer] = [:] private let displayDuration: TimeInterval = 5.0 private var isPaused: Bool = false private var isAppActive: Bool = true + private var hiddenStickyNotifications: [CENotification] = [] + private var hiddenNonStickyNotifications: [CENotification] = [] + private var dismissedNotificationIds: Set = [] // Track dismissed notifications override private init() { super.init() @@ -69,9 +73,9 @@ final class NotificationManager: NSObject, ObservableObject { notifications.filter { !$0.isRead }.count } - /// Whether there is currently a notification being displayed in the overlay + /// Whether there are currently notifications being displayed in the overlay var hasActiveNotification: Bool { - activeNotification != nil + !activeNotifications.isEmpty } /// Posts a new notification @@ -209,53 +213,53 @@ final class NotificationManager: NSObject, ObservableObject { /// Shows a notification in the app's overlay UI private func showTemporaryNotification(_ notification: CENotification) { - activeNotification = notification + activeNotifications.insert(notification, at: 0) // Add to start of array guard !notification.isSticky else { return } - startHideTimer() + startHideTimer(for: notification) } - /// Starts the timer to automatically hide non-sticky notifications - private func startHideTimer() { - timer?.invalidate() - timer = nil + /// Starts the timer to automatically hide a non-sticky notification + private func startHideTimer(for notification: CENotification) { + timers[notification.id]?.invalidate() + timers[notification.id] = nil guard !isPaused else { return } - timer = Timer.scheduledTimer(withTimeInterval: displayDuration, repeats: false) { [weak self] _ in - self?.hideActiveNotification() + timers[notification.id] = Timer.scheduledTimer( + withTimeInterval: displayDuration, + repeats: false + ) { [weak self] _ in + self?.hideNotification(notification) } } - /// Pauses the auto-hide timer (used when hovering over notification) + /// Pauses all auto-hide timers func pauseTimer() { isPaused = true - timer?.invalidate() - timer = nil + timers.values.forEach { $0.invalidate() } } - /// Resumes the auto-hide timer + /// Resumes all auto-hide timers func resumeTimer() { isPaused = false - if activeNotification != nil && !activeNotification!.isSticky { - startHideTimer() - } + activeNotifications + .filter { !$0.isSticky } + .forEach { startHideTimer(for: $0) } } - /// Hides the currently active notification - func hideActiveNotification() { - activeNotification = nil - timer?.invalidate() - timer = nil + /// Hides a specific notification + private func hideNotification(_ notification: CENotification) { + timers[notification.id]?.invalidate() + timers[notification.id] = nil + activeNotifications.removeAll(where: { $0.id == notification.id }) } /// Dismisses a specific notification - /// - Parameter notification: The notification to dismiss func dismissNotification(_ notification: CENotification) { - if activeNotification?.id == notification.id { - hideActiveNotification() - } + hideNotification(notification) + dismissedNotificationIds.insert(notification.id) // Track dismissed notification notifications.removeAll(where: { $0.id == notification.id }) } @@ -276,6 +280,23 @@ final class NotificationManager: NSObject, ObservableObject { dismissNotification(notification) } } + + /// Hides all notifications from the overlay view + func hideOverlayNotifications() { + dismissedNotificationIds.removeAll() // Clear dismissed tracking when hiding + hiddenStickyNotifications = activeNotifications.filter { $0.isSticky } + hiddenNonStickyNotifications = activeNotifications.filter { !$0.isSticky } + activeNotifications.removeAll() + } + + /// Restores only sticky notifications to the overlay + func restoreOverlayStickies() { + // Only restore sticky notifications that weren't dismissed + let nonDismissedStickies = hiddenStickyNotifications.filter { !dismissedNotificationIds.contains($0.id) } + activeNotifications.insert(contentsOf: nonDismissedStickies, at: 0) + hiddenStickyNotifications.removeAll() + dismissedNotificationIds.removeAll() // Clear tracking after restore + } } // MARK: - UNUserNotificationCenterDelegate diff --git a/CodeEdit/Features/Notifications/Views/NotificationBannerView.swift b/CodeEdit/Features/Notifications/Views/NotificationBannerView.swift index 30bbc6801d..d71887b228 100644 --- a/CodeEdit/Features/Notifications/Views/NotificationBannerView.swift +++ b/CodeEdit/Features/Notifications/Views/NotificationBannerView.swift @@ -14,6 +14,9 @@ struct NotificationBannerView: View { @Environment(\.isSingleListItem) private var isSingleListItem + @Environment(\.colorScheme) + private var colorScheme + let notification: CENotification let namespace: Namespace.ID let onDismiss: () -> Void @@ -66,36 +69,50 @@ struct NotificationBannerView: View { .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 + ) + ) } - 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) + 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) - .matchedGeometryEffect(id: "content-\(notification.id)", in: namespace) } private var backgroundContainer: some View { RoundedRectangle(cornerRadius: cornerRadius) .fill(.regularMaterial) - .matchedGeometryEffect(id: "background-\(notification.id)", in: namespace) } private var borderOverlay: some View { RoundedRectangle(cornerRadius: cornerRadius) .stroke(Color(nsColor: .separatorColor), lineWidth: 2) - .matchedGeometryEffect(id: "border-\(notification.id)", in: namespace) } private var dragGesture: some Gesture { @@ -128,19 +145,52 @@ struct NotificationBannerView: View { var body: some View { VStack { - if shouldShowBackground { - content - .background(backgroundContainer) - .overlay(borderOverlay) - .cornerRadius(cornerRadius) - .shadow( - color: Color(.black.withAlphaComponent(0.2)), - radius: 5, - x: 0, - y: 2 - ) - } else { - content + content + .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") + .font(.system(size: 10)) + .foregroundColor(.secondary) + .frame(width: 20, height: 20, alignment: .center) + .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 + ) + } + .buttonStyle(.borderless) + .padding(.top, -5) + .padding(.leading, -5) + .transition(.opacity) } } .frame(width: 300) @@ -148,7 +198,10 @@ struct NotificationBannerView: View { .opacity(opacity) .simultaneousGesture(dragGesture) .onHover { hovering in - isHovering = hovering + withAnimation(.easeOut(duration: 0.2)) { + isHovering = hovering + } + if hovering { NotificationManager.shared.pauseTimer() } else { diff --git a/CodeEdit/Features/Notifications/Views/NotificationListView.swift b/CodeEdit/Features/Notifications/Views/NotificationListView.swift index d579c9bb3f..8e9f0753a9 100644 --- a/CodeEdit/Features/Notifications/Views/NotificationListView.swift +++ b/CodeEdit/Features/Notifications/Views/NotificationListView.swift @@ -8,9 +8,22 @@ import SwiftUI struct NotificationListView: View { + @Environment(\.dismiss) + private var dismiss + @ObservedObject private var notificationManager = NotificationManager.shared + @Namespace private var animation + private var sortedNotifications: [CENotification] { + notificationManager.notifications.sorted { first, second in + if first.isSticky == second.isSticky { + return first.timestamp > second.timestamp + } + return first.isSticky && !second.isSticky + } + } + var body: some View { ScrollView { VStack(spacing: 10) { @@ -19,30 +32,48 @@ struct NotificationListView: View { .foregroundColor(.secondary) .padding() } else { - ForEach(notificationManager.notifications) { notification in + ForEach(sortedNotifications) { notification in NotificationBannerView( notification: notification, namespace: animation, onDismiss: { - withAnimation(.easeInOut(duration: 0.2)) { - notificationManager.dismissNotification(notification) + if !notification.isSticky && notificationManager.notifications.count == 1 { + dismiss() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + withAnimation(.easeInOut(duration: 0.2)) { + notificationManager.dismissNotification(notification) + } + } + } else { + withAnimation(.easeInOut(duration: 0.2)) { + notificationManager.dismissNotification(notification) + } } }, onAction: { - withAnimation(.easeInOut(duration: 0.2)) { - notification.action() - notificationManager.dismissNotification(notification) + if !notification.isSticky && notificationManager.notifications.count == 1 { + dismiss() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + notification.action() + withAnimation(.easeInOut(duration: 0.2)) { + notificationManager.dismissNotification(notification) + } + } + } else { + withAnimation(.easeInOut(duration: 0.2)) { + notification.action() + notificationManager.dismissNotification(notification) + } } } ) .environment(\.isOverlay, false) .environment(\.isSingleListItem, notificationManager.notifications.count == 1) - .transition(.opacity.combined(with: .move(edge: .trailing))) + .transition(.opacity) } } } - .padding(notificationManager.notifications.count == 1 ? 0 : 10) - .animation(.easeInOut(duration: 0.2), value: notificationManager.notifications) + .padding(10) } } } diff --git a/CodeEdit/Features/Notifications/Views/NotificationOverlayView.swift b/CodeEdit/Features/Notifications/Views/NotificationOverlayView.swift index ce69e2e5b4..8c998652ef 100644 --- a/CodeEdit/Features/Notifications/Views/NotificationOverlayView.swift +++ b/CodeEdit/Features/Notifications/Views/NotificationOverlayView.swift @@ -17,7 +17,7 @@ struct NotificationOverlayView: View { var body: some View { VStack(spacing: 10) { - ForEach(Array([notificationManager.activeNotification].compactMap { $0 }), id: \.id) { notification in + ForEach(notificationManager.activeNotifications, id: \.id) { notification in if controlActiveState == .active || controlActiveState == .key { NotificationBannerView( notification: notification, @@ -41,6 +41,6 @@ struct NotificationOverlayView: View { } } .padding(8) - .animation(.easeInOut(duration: 0.2), value: notificationManager.activeNotification?.id) + .animation(.easeInOut(duration: 0.2), value: notificationManager.activeNotifications) } } diff --git a/CodeEdit/Features/Notifications/Views/NotificationToolbarItem.swift b/CodeEdit/Features/Notifications/Views/NotificationToolbarItem.swift index b623362818..6063ccbb49 100644 --- a/CodeEdit/Features/Notifications/Views/NotificationToolbarItem.swift +++ b/CodeEdit/Features/Notifications/Views/NotificationToolbarItem.swift @@ -16,9 +16,8 @@ struct NotificationToolbarItem: View { var body: some View { if notificationManager.unreadCount > 0 { Button { - if notificationManager.hasActiveNotification { - notificationManager.hideActiveNotification() - } + // Hide all notifications from overlay + notificationManager.hideOverlayNotifications() showingPopover.toggle() } label: { HStack(spacing: 4) { @@ -32,6 +31,12 @@ struct NotificationToolbarItem: View { .popover(isPresented: $showingPopover, arrowEdge: .bottom) { NotificationListView() } + .onChange(of: showingPopover) { isShowing in + if !isShowing { + // Restore only sticky notifications when popover closes + notificationManager.restoreOverlayStickies() + } + } .transition(.opacity.animation(.none)) } } From 2471175a1174bf011613ebc2debe956f34ca374b Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Thu, 13 Feb 2025 15:10:07 -0600 Subject: [PATCH 07/21] Streamlined UI getting rid of the notifications popover in favor of the notification overlay UI previously just used for temporary notifications. --- CodeEdit.xcodeproj/project.pbxproj | 8 - .../FileInspector/FileInspectorView.swift | 27 ++++ .../Notifications/Models/CENotification.swift | 1 + .../Notifications/NotificationManager.swift | 145 +++++++++++++----- .../Views/NotificationBannerEnvironment.swift | 28 ---- .../Views/NotificationBannerView.swift | 125 +++++++-------- .../Views/NotificationListView.swift | 79 ---------- .../Views/NotificationOverlayView.swift | 65 ++++++-- .../Views/NotificationToolbarItem.swift | 15 +- 9 files changed, 250 insertions(+), 243 deletions(-) delete mode 100644 CodeEdit/Features/Notifications/Views/NotificationBannerEnvironment.swift delete mode 100644 CodeEdit/Features/Notifications/Views/NotificationListView.swift diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index 1bd77a3e88..e04514acd3 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -582,11 +582,9 @@ 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 */; }; - B68DE5E12D5A61E5009A43EF /* NotificationListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68DE5DA2D5A61E5009A43EF /* NotificationListView.swift */; }; B68DE5E22D5A61E5009A43EF /* NotificationToolbarItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68DE5DB2D5A61E5009A43EF /* NotificationToolbarItem.swift */; }; B68DE5E32D5A61E5009A43EF /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68DE5DD2D5A61E5009A43EF /* NotificationManager.swift */; }; B68DE5E52D5A7988009A43EF /* NotificationOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68DE5E42D5A7988009A43EF /* NotificationOverlayView.swift */; }; - B68DE5E72D5A7D62009A43EF /* NotificationBannerEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68DE5E62D5A7D62009A43EF /* NotificationBannerEnvironment.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 */; }; @@ -1279,11 +1277,9 @@ 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 = ""; }; - B68DE5DA2D5A61E5009A43EF /* NotificationListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationListView.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 /* NotificationOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationOverlayView.swift; sourceTree = ""; }; - B68DE5E62D5A7D62009A43EF /* NotificationBannerEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationBannerEnvironment.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 = ""; }; @@ -3574,9 +3570,7 @@ B68DE5DC2D5A61E5009A43EF /* Views */ = { isa = PBXGroup; children = ( - B68DE5E62D5A7D62009A43EF /* NotificationBannerEnvironment.swift */, B68DE5D92D5A61E5009A43EF /* NotificationBannerView.swift */, - B68DE5DA2D5A61E5009A43EF /* NotificationListView.swift */, B68DE5DB2D5A61E5009A43EF /* NotificationToolbarItem.swift */, B68DE5E42D5A7988009A43EF /* NotificationOverlayView.swift */, ); @@ -4112,7 +4106,6 @@ 04BA7C1C2AE2D84100584E1C /* GitClient+Commit.swift in Sources */, B68DE5DF2D5A61E5009A43EF /* CENotification.swift in Sources */, B68DE5E02D5A61E5009A43EF /* NotificationBannerView.swift in Sources */, - B68DE5E12D5A61E5009A43EF /* NotificationListView.swift in Sources */, B68DE5E22D5A61E5009A43EF /* NotificationToolbarItem.swift in Sources */, B68DE5E32D5A61E5009A43EF /* NotificationManager.swift in Sources */, B65B10EC2B073913002852CF /* CEContentUnavailableView.swift in Sources */, @@ -4435,7 +4428,6 @@ 6C578D8729CD345900DC73B2 /* ExtensionSceneView.swift in Sources */, 617DB3D02C25AFAE00B58BFE /* TaskNotificationHandler.swift in Sources */, 77EF6C052C57DE4B00984B69 /* URL+ResouceValues.swift in Sources */, - B68DE5E72D5A7D62009A43EF /* NotificationBannerEnvironment.swift in Sources */, B640A9A129E2188F00715F20 /* View+NavigationBarBackButtonVisible.swift in Sources */, 587B9E7929301D8F00AC7927 /* GitHubIssueRouter.swift in Sources */, B67700F92D2A2662004FD61F /* WorkspacePanelView.swift in Sources */, diff --git a/CodeEdit/Features/InspectorArea/FileInspector/FileInspectorView.swift b/CodeEdit/Features/InspectorArea/FileInspector/FileInspectorView.swift index f4e2766d8d..b644c6c894 100644 --- a/CodeEdit/Features/InspectorArea/FileInspector/FileInspectorView.swift +++ b/CodeEdit/Features/InspectorArea/FileInspector/FileInspectorView.swift @@ -59,6 +59,33 @@ struct FileInspectorView: View { widthOptions wrapLinesToggle } + Section("Test Notifications") { + Button("Add Test Notification") { + NotificationManager.shared.post( + iconSymbol: "bell.badge.fill", + iconColor: .red, + title: "Test Notification", + description: "This is a test notification", + actionButtonTitle: "Action", + action: { + print("Test notification action triggered") + } + ) + } + Button("Add Sticky Notification") { + NotificationManager.shared.post( + iconSymbol: "pin.fill", + iconColor: .orange, + title: "Sticky Notification", + description: "This notification will stay until dismissed", + actionButtonTitle: "Acknowledge", + action: { + print("Sticky notification acknowledged") + }, + isSticky: true + ) + } + } } } else { NoSelectionInspectorView() diff --git a/CodeEdit/Features/Notifications/Models/CENotification.swift b/CodeEdit/Features/Notifications/Models/CENotification.swift index 4fff58be11..ded63b6b34 100644 --- a/CodeEdit/Features/Notifications/Models/CENotification.swift +++ b/CodeEdit/Features/Notifications/Models/CENotification.swift @@ -18,6 +18,7 @@ struct CENotification: Identifiable, Equatable { let isSticky: Bool var isRead: Bool let timestamp: Date + var isBeingDismissed: Bool = false enum IconType { case symbol(name: String, color: Color?) diff --git a/CodeEdit/Features/Notifications/NotificationManager.swift b/CodeEdit/Features/Notifications/NotificationManager.swift index 8d67dbc575..73130f6afb 100644 --- a/CodeEdit/Features/Notifications/NotificationManager.swift +++ b/CodeEdit/Features/Notifications/NotificationManager.swift @@ -23,16 +23,40 @@ final class NotificationManager: NSObject, ObservableObject { @Published private(set) var notifications: [CENotification] = [] /// Currently displayed notifications in the overlay - @Published private(set) var activeNotification: CENotification? @Published private(set) var activeNotifications: [CENotification] = [] private var timers: [UUID: Timer] = [:] private let displayDuration: TimeInterval = 5.0 private var isPaused: Bool = false private var isAppActive: Bool = true - private var hiddenStickyNotifications: [CENotification] = [] - private var hiddenNonStickyNotifications: [CENotification] = [] - private var dismissedNotificationIds: Set = [] // Track dismissed notifications + + /// Whether notifications were manually shown via toolbar + @Published private(set) var isManuallyShown: Bool = false + + /// Set of hidden notification IDs + private var hiddenNotificationIds: Set = [] + + /// Whether any non-sticky notifications are currently hidden + private var hasHiddenNotifications: Bool { + activeNotifications.contains { notification in + !notification.isSticky && !isNotificationVisible(notification) + } + } + + /// Whether a notification should be visible in the overlay + 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 isManuallyShown { + return true // Show all notifications when manually shown + } + // Otherwise, show if not hidden and has active timer + return !hiddenNotificationIds.contains(notification.id) && timers[notification.id] != nil + } override private init() { super.init() @@ -61,6 +85,16 @@ final class NotificationManager: NSObject, ObservableObject { @objc private func applicationDidBecomeActive() { isAppActive = true + + // Show any pending notifications in the overlay + notifications + .filter { notification in + // Only show notifications that aren't already in the overlay + !activeNotifications.contains { $0.id == notification.id } + } + .forEach { notification in + showTemporaryNotification(notification) + } } @objc @@ -103,7 +137,8 @@ final class NotificationManager: NSObject, ObservableObject { description: description, actionButtonTitle: actionButtonTitle, action: action, - isSticky: isSticky + isSticky: isSticky, + isRead: false // Always start as unread ) DispatchQueue.main.async { [weak self] in @@ -213,11 +248,37 @@ final class NotificationManager: NSObject, ObservableObject { /// Shows a notification in the app's overlay UI private func showTemporaryNotification(_ notification: CENotification) { - activeNotifications.insert(notification, at: 0) // Add to start of array - - guard !notification.isSticky else { return } + withAnimation(.easeInOut(duration: 0.3)) { + insertNotification(notification) + hiddenNotificationIds.remove(notification.id) // Ensure new notification is visible + // Only start timer if notifications aren't manually shown + if !isManuallyShown && !notification.isSticky { + startHideTimer(for: notification) + } + } + } - startHideTimer(for: notification) + /// 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) + } + } } /// Starts the timer to automatically hide a non-sticky notification @@ -231,7 +292,14 @@ final class NotificationManager: NSObject, ObservableObject { withTimeInterval: displayDuration, repeats: false ) { [weak self] _ in - self?.hideNotification(notification) + guard let self = self else { return } + self.timers[notification.id] = nil + + withAnimation(.easeInOut(duration: 0.3)) { + // Hide this specific notification + self.hiddenNotificationIds.insert(notification.id) + self.objectWillChange.send() + } } } @@ -244,23 +312,29 @@ final class NotificationManager: NSObject, ObservableObject { /// Resumes all auto-hide timers func resumeTimer() { isPaused = false + // Only restart timers for notifications that are currently visible activeNotifications - .filter { !$0.isSticky } + .filter { !$0.isSticky && isNotificationVisible($0) } .forEach { startHideTimer(for: $0) } } - /// Hides a specific notification - private func hideNotification(_ notification: CENotification) { - timers[notification.id]?.invalidate() - timers[notification.id] = nil - activeNotifications.removeAll(where: { $0.id == notification.id }) - } - /// Dismisses a specific notification func dismissNotification(_ notification: CENotification) { - hideNotification(notification) - dismissedNotificationIds.insert(notification.id) // Track dismissed notification + timers[notification.id]?.invalidate() + timers[notification.id] = nil + hiddenNotificationIds.remove(notification.id) + + if let index = activeNotifications.firstIndex(where: { $0.id == notification.id }) { + activeNotifications[index].isBeingDismissed = true + } + + withAnimation(.easeOut(duration: 0.2)) { + activeNotifications.removeAll(where: { $0.id == notification.id }) + } notifications.removeAll(where: { $0.id == notification.id }) + + // Mark as read when dismissed + markAsRead(notification) } /// Marks a notification as read @@ -281,21 +355,22 @@ final class NotificationManager: NSObject, ObservableObject { } } - /// Hides all notifications from the overlay view - func hideOverlayNotifications() { - dismissedNotificationIds.removeAll() // Clear dismissed tracking when hiding - hiddenStickyNotifications = activeNotifications.filter { $0.isSticky } - hiddenNonStickyNotifications = activeNotifications.filter { !$0.isSticky } - activeNotifications.removeAll() - } - - /// Restores only sticky notifications to the overlay - func restoreOverlayStickies() { - // Only restore sticky notifications that weren't dismissed - let nonDismissedStickies = hiddenStickyNotifications.filter { !dismissedNotificationIds.contains($0.id) } - activeNotifications.insert(contentsOf: nonDismissedStickies, at: 0) - hiddenStickyNotifications.removeAll() - dismissedNotificationIds.removeAll() // Clear tracking after restore + /// Toggles visibility of notifications in the overlay + func toggleNotificationsVisibility() { + withAnimation(.easeInOut(duration: 0.3)) { + if hasHiddenNotifications || !isManuallyShown { + // Show all notifications + isManuallyShown = true + hiddenNotificationIds.removeAll() // Clear all hidden states + } else { + // Hide all non-sticky notifications + isManuallyShown = false + activeNotifications + .filter { !$0.isSticky } + .forEach { hiddenNotificationIds.insert($0.id) } + } + objectWillChange.send() + } } } diff --git a/CodeEdit/Features/Notifications/Views/NotificationBannerEnvironment.swift b/CodeEdit/Features/Notifications/Views/NotificationBannerEnvironment.swift deleted file mode 100644 index 33d4bd1e26..0000000000 --- a/CodeEdit/Features/Notifications/Views/NotificationBannerEnvironment.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// NotificationBannerEnvironment.swift -// CodeEdit -// -// Created by Austin Condiff on 2/10/24. -// - -import SwiftUI - -struct IsOverlayKey: EnvironmentKey { - static let defaultValue: Bool = false -} - -struct IsSingleListItemKey: EnvironmentKey { - static let defaultValue: Bool = false -} - -extension EnvironmentValues { - var isOverlay: Bool { - get { self[IsOverlayKey.self] } - set { self[IsOverlayKey.self] = newValue } - } - - var isSingleListItem: Bool { - get { self[IsSingleListItemKey.self] } - set { self[IsSingleListItemKey.self] = newValue } - } -} diff --git a/CodeEdit/Features/Notifications/Views/NotificationBannerView.swift b/CodeEdit/Features/Notifications/Views/NotificationBannerView.swift index d71887b228..cfc009b073 100644 --- a/CodeEdit/Features/Notifications/Views/NotificationBannerView.swift +++ b/CodeEdit/Features/Notifications/Views/NotificationBannerView.swift @@ -8,17 +8,12 @@ import SwiftUI struct NotificationBannerView: View { - @Environment(\.isOverlay) - private var isOverlay - - @Environment(\.isSingleListItem) - private var isSingleListItem - @Environment(\.colorScheme) private var colorScheme + @ObservedObject private var notificationManager = NotificationManager.shared + let notification: CENotification - let namespace: Namespace.ID let onDismiss: () -> Void let onAction: () -> Void @@ -28,15 +23,57 @@ struct NotificationBannerView: View { private let dismissThreshold: CGFloat = 100 - private var cornerRadius: CGFloat { - isOverlay ? 10 : 6 + let cornerRadius: CGFloat = 10 + + private var backgroundContainer: some View { + RoundedRectangle(cornerRadius: cornerRadius) + .fill(.regularMaterial) } - private var shouldShowBackground: Bool { - isOverlay || !isSingleListItem + private var borderOverlay: some View { + RoundedRectangle(cornerRadius: cornerRadius) + .stroke(Color(nsColor: .separatorColor), lineWidth: 2) } - private var content: some View { + private var dragGesture: some Gesture { + DragGesture(minimumDistance: 2) + .onChanged { value in + if value.translation.width > 0 { + offset = value.translation.width + opacity = 1 - (offset / dismissThreshold) + } + } + .onEnded { value in + let velocity = value.predictedEndLocation.x - value.location.x + + if offset > dismissThreshold || velocity > 100 { + withAnimation(.easeOut(duration: 0.2)) { + offset = NSScreen.main?.frame.width ?? 1000 + opacity = 0 + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + onDismiss() + } + } else { + withAnimation(.easeOut(duration: 0.2)) { + offset = 0 + opacity = 1 + } + } + } + } + + private var xOffset: CGFloat { + if offset > 0 { + return offset + } + if !notificationManager.isNotificationVisible(notification) && !notification.isBeingDismissed { + return 350 // Width of banner + padding + } + return 0 + } + + var body: some View { VStack(spacing: 10) { HStack(alignment: .top, spacing: 10) { switch notification.icon { @@ -103,59 +140,15 @@ struct NotificationBannerView: View { } } .padding(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) - } - - private var dragGesture: some Gesture { - DragGesture(minimumDistance: 2) - .onChanged { value in - if value.translation.width > 0 { - offset = value.translation.width - opacity = 1 - (offset / dismissThreshold) - } - } - .onEnded { value in - let velocity = value.predictedEndLocation.x - value.location.x - - if offset > dismissThreshold || velocity > 100 { - withAnimation(.easeOut(duration: 0.2)) { - offset = NSScreen.main?.frame.width ?? 1000 - opacity = 0 - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - onDismiss() - } - } else { - withAnimation(.easeOut(duration: 0.2)) { - offset = 0 - opacity = 1 - } - } - } - } - - var body: some View { - VStack { - content - .background(backgroundContainer) - .overlay(borderOverlay) - .cornerRadius(cornerRadius) - .shadow( - color: Color(.black.withAlphaComponent(colorScheme == .dark ? 0.2 : 0.1)), - radius: 5, - x: 0, - y: 2 - ) - } + .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: { @@ -194,7 +187,7 @@ struct NotificationBannerView: View { } } .frame(width: 300) - .offset(x: offset) + .offset(x: xOffset) .opacity(opacity) .simultaneousGesture(dragGesture) .onHover { hovering in diff --git a/CodeEdit/Features/Notifications/Views/NotificationListView.swift b/CodeEdit/Features/Notifications/Views/NotificationListView.swift deleted file mode 100644 index 8e9f0753a9..0000000000 --- a/CodeEdit/Features/Notifications/Views/NotificationListView.swift +++ /dev/null @@ -1,79 +0,0 @@ -// -// NotificationListView.swift -// CodeEdit -// -// Created by Austin Condiff on 2/10/24. -// - -import SwiftUI - -struct NotificationListView: View { - @Environment(\.dismiss) - private var dismiss - - @ObservedObject private var notificationManager = NotificationManager.shared - - @Namespace private var animation - - private var sortedNotifications: [CENotification] { - notificationManager.notifications.sorted { first, second in - if first.isSticky == second.isSticky { - return first.timestamp > second.timestamp - } - return first.isSticky && !second.isSticky - } - } - - var body: some View { - ScrollView { - VStack(spacing: 10) { - if notificationManager.notifications.isEmpty { - Text("No notifications") - .foregroundColor(.secondary) - .padding() - } else { - ForEach(sortedNotifications) { notification in - NotificationBannerView( - notification: notification, - namespace: animation, - onDismiss: { - if !notification.isSticky && notificationManager.notifications.count == 1 { - dismiss() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - withAnimation(.easeInOut(duration: 0.2)) { - notificationManager.dismissNotification(notification) - } - } - } else { - withAnimation(.easeInOut(duration: 0.2)) { - notificationManager.dismissNotification(notification) - } - } - }, - onAction: { - if !notification.isSticky && notificationManager.notifications.count == 1 { - dismiss() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - notification.action() - withAnimation(.easeInOut(duration: 0.2)) { - notificationManager.dismissNotification(notification) - } - } - } else { - withAnimation(.easeInOut(duration: 0.2)) { - notification.action() - notificationManager.dismissNotification(notification) - } - } - } - ) - .environment(\.isOverlay, false) - .environment(\.isSingleListItem, notificationManager.notifications.count == 1) - .transition(.opacity) - } - } - } - .padding(10) - } - } -} diff --git a/CodeEdit/Features/Notifications/Views/NotificationOverlayView.swift b/CodeEdit/Features/Notifications/Views/NotificationOverlayView.swift index 8c998652ef..3ac064dab6 100644 --- a/CodeEdit/Features/Notifications/Views/NotificationOverlayView.swift +++ b/CodeEdit/Features/Notifications/Views/NotificationOverlayView.swift @@ -13,34 +13,73 @@ struct NotificationOverlayView: View { @ObservedObject private var notificationManager = NotificationManager.shared - @Namespace private var animation + // ID for the top anchor + private let topID = "top" - var body: some View { - VStack(spacing: 10) { + // Fixed width for notifications + private let notificationWidth: CGFloat = 320 // 300 + 10 padding on each side + + var notifications: some View { + VStack(spacing: 8) { ForEach(notificationManager.activeNotifications, id: \.id) { notification in if controlActiveState == .active || controlActiveState == .key { NotificationBannerView( notification: notification, - namespace: animation, onDismiss: { notificationManager.dismissNotification(notification) }, onAction: { notification.action() notificationManager.dismissNotification(notification) + // Only hide if manually shown + if notificationManager.isManuallyShown { + notificationManager.toggleNotificationsVisibility() + } } ) - .environment(\.isOverlay, true) - .transition( - .asymmetric( - insertion: .opacity.combined(with: .move(edge: .bottom)), - removal: .opacity.combined(with: .move(edge: .top)) - ) - ) + .transition(.asymmetric( + insertion: .move(edge: .trailing), + removal: .opacity + )) + } + } + } + .padding(10) + } + + var body: some View { + ViewThatFits(in: .vertical) { + notifications + .border(.red) + GeometryReader { geometry in + HStack { + Spacer() // Push content to trailing edge + ScrollViewReader { proxy in + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 0) { + // Invisible anchor view at the top to scroll back to when closed + Color.clear.frame(height: 0).id(topID) + notifications + } + .padding(.bottom, 30) // Account for the status bar + } + .frame(width: notificationWidth) + .frame(maxHeight: geometry.size.height) + .scrollDisabled(!notificationManager.isManuallyShown) + .onChange(of: notificationManager.isManuallyShown) { isShown in + if !isShown { + // 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) + } + } + } + } + } } + .animation(.easeInOut(duration: 0.3), value: notificationManager.activeNotifications) } } - .padding(8) - .animation(.easeInOut(duration: 0.2), value: notificationManager.activeNotifications) } } diff --git a/CodeEdit/Features/Notifications/Views/NotificationToolbarItem.swift b/CodeEdit/Features/Notifications/Views/NotificationToolbarItem.swift index 6063ccbb49..00600a140e 100644 --- a/CodeEdit/Features/Notifications/Views/NotificationToolbarItem.swift +++ b/CodeEdit/Features/Notifications/Views/NotificationToolbarItem.swift @@ -11,14 +11,11 @@ struct NotificationToolbarItem: View { @ObservedObject private var notificationManager = NotificationManager.shared @Environment(\.controlActiveState) private var controlActiveState - @State private var showingPopover = false var body: some View { if notificationManager.unreadCount > 0 { Button { - // Hide all notifications from overlay - notificationManager.hideOverlayNotifications() - showingPopover.toggle() + notificationManager.toggleNotificationsVisibility() } label: { HStack(spacing: 4) { Image(systemName: "bell.badge.fill") @@ -28,16 +25,6 @@ struct NotificationToolbarItem: View { .monospacedDigit() } } - .popover(isPresented: $showingPopover, arrowEdge: .bottom) { - NotificationListView() - } - .onChange(of: showingPopover) { isShowing in - if !isShowing { - // Restore only sticky notifications when popover closes - notificationManager.restoreOverlayStickies() - } - } - .transition(.opacity.animation(.none)) } } } From b91877e6c1985acd418c349fa30d7c5e7fde4c56 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Fri, 14 Feb 2025 17:40:09 -0600 Subject: [PATCH 08/21] Added a close button click state, allows clicking under scroll view. --- .../FileInspector/FileInspectorView.swift | 27 ---- .../Notifications/NotificationManager.swift | 11 +- .../Views/NotificationBannerView.swift | 111 +++++++--------- .../Views/NotificationOverlayView.swift | 125 +++++++++++------- CodeEdit/WorkspaceView.swift | 6 +- 5 files changed, 140 insertions(+), 140 deletions(-) diff --git a/CodeEdit/Features/InspectorArea/FileInspector/FileInspectorView.swift b/CodeEdit/Features/InspectorArea/FileInspector/FileInspectorView.swift index b644c6c894..f4e2766d8d 100644 --- a/CodeEdit/Features/InspectorArea/FileInspector/FileInspectorView.swift +++ b/CodeEdit/Features/InspectorArea/FileInspector/FileInspectorView.swift @@ -59,33 +59,6 @@ struct FileInspectorView: View { widthOptions wrapLinesToggle } - Section("Test Notifications") { - Button("Add Test Notification") { - NotificationManager.shared.post( - iconSymbol: "bell.badge.fill", - iconColor: .red, - title: "Test Notification", - description: "This is a test notification", - actionButtonTitle: "Action", - action: { - print("Test notification action triggered") - } - ) - } - Button("Add Sticky Notification") { - NotificationManager.shared.post( - iconSymbol: "pin.fill", - iconColor: .orange, - title: "Sticky Notification", - description: "This notification will stay until dismissed", - actionButtonTitle: "Acknowledge", - action: { - print("Sticky notification acknowledged") - }, - isSticky: true - ) - } - } } } else { NoSelectionInspectorView() diff --git a/CodeEdit/Features/Notifications/NotificationManager.swift b/CodeEdit/Features/Notifications/NotificationManager.swift index 73130f6afb..eea8f38192 100644 --- a/CodeEdit/Features/Notifications/NotificationManager.swift +++ b/CodeEdit/Features/Notifications/NotificationManager.swift @@ -323,16 +323,21 @@ final class NotificationManager: NSObject, ObservableObject { timers[notification.id]?.invalidate() timers[notification.id] = nil hiddenNotificationIds.remove(notification.id) - + if let index = activeNotifications.firstIndex(where: { $0.id == notification.id }) { activeNotifications[index].isBeingDismissed = true } - + withAnimation(.easeOut(duration: 0.2)) { activeNotifications.removeAll(where: { $0.id == notification.id }) + + // If this was the last notification and they were manually shown, hide the panel + if activeNotifications.isEmpty && isManuallyShown { + isManuallyShown = false + } } notifications.removeAll(where: { $0.id == notification.id }) - + // Mark as read when dismissed markAsRead(notification) } diff --git a/CodeEdit/Features/Notifications/Views/NotificationBannerView.swift b/CodeEdit/Features/Notifications/Views/NotificationBannerView.swift index cfc009b073..3448a284f6 100644 --- a/CodeEdit/Features/Notifications/Views/NotificationBannerView.swift +++ b/CodeEdit/Features/Notifications/Views/NotificationBannerView.swift @@ -7,6 +7,31 @@ import SwiftUI +struct CloseButtonStyle: 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 + ) + } +} + struct NotificationBannerView: View { @Environment(\.colorScheme) private var colorScheme @@ -17,12 +42,8 @@ struct NotificationBannerView: View { let onDismiss: () -> Void let onAction: () -> Void - @State private var offset: CGFloat = 0 - @State private var opacity: CGFloat = 1 @State private var isHovering = false - private let dismissThreshold: CGFloat = 100 - let cornerRadius: CGFloat = 10 private var backgroundContainer: some View { @@ -35,44 +56,6 @@ struct NotificationBannerView: View { .stroke(Color(nsColor: .separatorColor), lineWidth: 2) } - private var dragGesture: some Gesture { - DragGesture(minimumDistance: 2) - .onChanged { value in - if value.translation.width > 0 { - offset = value.translation.width - opacity = 1 - (offset / dismissThreshold) - } - } - .onEnded { value in - let velocity = value.predictedEndLocation.x - value.location.x - - if offset > dismissThreshold || velocity > 100 { - withAnimation(.easeOut(duration: 0.2)) { - offset = NSScreen.main?.frame.width ?? 1000 - opacity = 0 - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - onDismiss() - } - } else { - withAnimation(.easeOut(duration: 0.2)) { - offset = 0 - opacity = 1 - } - } - } - } - - private var xOffset: CGFloat { - if offset > 0 { - return offset - } - if !notificationManager.isNotificationVisible(notification) && !notification.isBeingDismissed { - return 350 // Width of banner + padding - } - return 0 - } - var body: some View { VStack(spacing: 10) { HStack(alignment: .top, spacing: 10) { @@ -164,32 +147,27 @@ struct NotificationBannerView: View { if !notification.isSticky && isHovering { Button(action: onDismiss) { Image(systemName: "xmark") - .font(.system(size: 10)) - .foregroundColor(.secondary) - .frame(width: 20, height: 20, alignment: .center) - .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 - ) } - .buttonStyle(.borderless) + .buttonStyle(CloseButtonStyle()) .padding(.top, -5) .padding(.leading, -5) .transition(.opacity) } } .frame(width: 300) - .offset(x: xOffset) - .opacity(opacity) - .simultaneousGesture(dragGesture) + .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 @@ -203,3 +181,14 @@ struct NotificationBannerView: View { } } } + +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/NotificationOverlayView.swift b/CodeEdit/Features/Notifications/Views/NotificationOverlayView.swift index 3ac064dab6..49e8331e86 100644 --- a/CodeEdit/Features/Notifications/Views/NotificationOverlayView.swift +++ b/CodeEdit/Features/Notifications/Views/NotificationOverlayView.swift @@ -19,67 +19,100 @@ struct NotificationOverlayView: View { // 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 + } + } + var notifications: some View { VStack(spacing: 8) { - ForEach(notificationManager.activeNotifications, id: \.id) { notification in - if controlActiveState == .active || controlActiveState == .key { - NotificationBannerView( - notification: notification, - onDismiss: { - notificationManager.dismissNotification(notification) - }, - onAction: { - notification.action() - notificationManager.dismissNotification(notification) - // Only hide if manually shown - if notificationManager.isManuallyShown { - notificationManager.toggleNotificationsVisibility() - } + ForEach( + notificationManager.activeNotifications.filter { + notificationManager.isNotificationVisible($0) + }, + id: \.id + ) { notification in + NotificationBannerView( + notification: notification, + onDismiss: { + notificationManager.dismissNotification(notification) + }, + onAction: { + notification.action() + notificationManager.dismissNotification(notification) + // Only hide if manually shown + if notificationManager.isManuallyShown { + notificationManager.toggleNotificationsVisibility() } - ) - .transition(.asymmetric( - insertion: .move(edge: .trailing), - removal: .opacity - )) - } + } + ) } } .padding(10) } - var body: some View { - ViewThatFits(in: .vertical) { - notifications - .border(.red) - GeometryReader { geometry in - HStack { - Spacer() // Push content to trailing edge - ScrollViewReader { proxy in - ScrollView(.vertical, showsIndicators: false) { - VStack(spacing: 0) { - // Invisible anchor view at the top to scroll back to when closed - Color.clear.frame(height: 0).id(topID) - notifications - } - .padding(.bottom, 30) // Account for the status bar + var notificationsWithScrollView: some View { + GeometryReader { geometry in + HStack { + Spacer() // Push content to trailing edge + ScrollViewReader { proxy in + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .trailing, spacing: 0) { + // Invisible anchor view at the top to scroll back to when closed + Color.clear.frame(height: 0).id(topID) + notifications } - .frame(width: notificationWidth) - .frame(maxHeight: geometry.size.height) - .scrollDisabled(!notificationManager.isManuallyShown) - .onChange(of: notificationManager.isManuallyShown) { isShown in - if !isShown { - // 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) - } + .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) + .onChange(of: geometry.size.height) { newValue in + updateOverflow(contentHeight: contentHeight, containerHeight: newValue) + } + .onChange(of: notificationManager.isManuallyShown) { isShown in + if !isShown { + // 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( + notificationManager.activeNotifications + .contains { notificationManager.isNotificationVisible($0) } + ) } - .animation(.easeInOut(duration: 0.3), value: notificationManager.activeNotifications) } } } + + var body: some View { + Group { + if #available(macOS 14.0, *) { + notificationsWithScrollView + .scrollClipDisabled(true) + } else { + notificationsWithScrollView + } + } + .opacity(controlActiveState == .active || controlActiveState == .key ? 1 : 0) + .offset(x: controlActiveState == .active || controlActiveState == .key ? 0 : 350) + .animation(.easeInOut(duration: 0.2), value: controlActiveState) + } } diff --git a/CodeEdit/WorkspaceView.swift b/CodeEdit/WorkspaceView.swift index 4f0f64d848..45b1306f2a 100644 --- a/CodeEdit/WorkspaceView.swift +++ b/CodeEdit/WorkspaceView.swift @@ -52,9 +52,6 @@ struct WorkspaceView: View { focus: $focusedEditor ) .frame(maxWidth: .infinity, maxHeight: .infinity) - .overlay(alignment: .topTrailing) { - NotificationOverlayView() - } .onChange(of: geo.size.height) { newHeight in editorsHeight = newHeight } @@ -106,6 +103,9 @@ struct WorkspaceView: View { } .accessibilityElement(children: .contain) } + .overlay(alignment: .topTrailing) { + NotificationOverlayView() + } .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 { From d3d26d111aa4783886e6e7059eea1bf1bea5abb3 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Fri, 14 Feb 2025 17:56:10 -0600 Subject: [PATCH 09/21] Fix SwiftLint errors by splitting up NotificationManager --- CodeEdit.xcodeproj/project.pbxproj | 8 +++ .../NotificationManager+Delegate.swift | 25 ++++++++ .../NotificationManager+System.swift | 28 +++++++++ .../Notifications/NotificationManager.swift | 57 ------------------- 4 files changed, 61 insertions(+), 57 deletions(-) create mode 100644 CodeEdit/Features/Notifications/NotificationManager+Delegate.swift create mode 100644 CodeEdit/Features/Notifications/NotificationManager+System.swift diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index e04514acd3..0f960f28b0 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -558,6 +558,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 */; }; @@ -1253,6 +1255,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 = ""; }; @@ -3583,6 +3587,8 @@ B68DE5D82D5A61E5009A43EF /* Models */, B68DE5DC2D5A61E5009A43EF /* Views */, B68DE5DD2D5A61E5009A43EF /* NotificationManager.swift */, + B66460572D600E9500EC1411 /* NotificationManager+Delegate.swift */, + B66460582D600E9500EC1411 /* NotificationManager+System.swift */, ); path = Notifications; sourceTree = ""; @@ -4525,6 +4531,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 */, diff --git a/CodeEdit/Features/Notifications/NotificationManager+Delegate.swift b/CodeEdit/Features/Notifications/NotificationManager+Delegate.swift new file mode 100644 index 0000000000..89317b5fa8 --- /dev/null +++ b/CodeEdit/Features/Notifications/NotificationManager+Delegate.swift @@ -0,0 +1,25 @@ +import UserNotifications + +extension NotificationManager: UNUserNotificationCenterDelegate { + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + if let id = response.notification.request.content.userInfo["id"] as? String { + DispatchQueue.main.async { + self.handleSystemNotificationResponse(id: id) + } + } + completionHandler() + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + // Don't show system notifications when app is active + completionHandler([]) + } +} \ No newline at end of file diff --git a/CodeEdit/Features/Notifications/NotificationManager+System.swift b/CodeEdit/Features/Notifications/NotificationManager+System.swift new file mode 100644 index 0000000000..3b6f22de34 --- /dev/null +++ b/CodeEdit/Features/Notifications/NotificationManager+System.swift @@ -0,0 +1,28 @@ +import SwiftUI + +extension NotificationManager { + /// Shows a notification in macOS Notification Center when app is in background + func showSystemNotification(_ notification: CENotification) { + let content = UNMutableNotificationContent() + content.title = notification.title + content.body = notification.description + content.userInfo = ["id": notification.id.uuidString] + + let request = UNNotificationRequest( + identifier: notification.id.uuidString, + content: content, + trigger: nil + ) + + UNUserNotificationCenter.current().add(request) + } + + /// 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) + } + } +} \ No newline at end of file diff --git a/CodeEdit/Features/Notifications/NotificationManager.swift b/CodeEdit/Features/Notifications/NotificationManager.swift index eea8f38192..8c80c01073 100644 --- a/CodeEdit/Features/Notifications/NotificationManager.swift +++ b/CodeEdit/Features/Notifications/NotificationManager.swift @@ -107,11 +107,6 @@ final class NotificationManager: NSObject, ObservableObject { notifications.filter { !$0.isRead }.count } - /// Whether there are currently notifications being displayed in the overlay - var hasActiveNotification: Bool { - !activeNotifications.isEmpty - } - /// Posts a new notification /// - Parameters: /// - iconSymbol: SF Symbol or CodeEditSymbol name for the notification icon @@ -230,22 +225,6 @@ final class NotificationManager: NSObject, ObservableObject { } } - /// Shows a notification in macOS Notification Center when app is in background - private func showSystemNotification(_ notification: CENotification) { - let content = UNMutableNotificationContent() - content.title = notification.title - content.body = notification.description - content.userInfo = ["id": notification.id.uuidString] - - let request = UNNotificationRequest( - identifier: notification.id.uuidString, - content: content, - trigger: nil - ) - - UNUserNotificationCenter.current().add(request) - } - /// Shows a notification in the app's overlay UI private func showTemporaryNotification(_ notification: CENotification) { withAnimation(.easeInOut(duration: 0.3)) { @@ -350,16 +329,6 @@ final class NotificationManager: NSObject, ObservableObject { } } - /// Handles response from system notification - /// - Parameter id: ID of the notification that was interacted with - func handleSystemNotificationResponse(id: String) { - if let uuid = UUID(uuidString: id), - let notification = notifications.first(where: { $0.id == uuid }) { - notification.action() - dismissNotification(notification) - } - } - /// Toggles visibility of notifications in the overlay func toggleNotificationsVisibility() { withAnimation(.easeInOut(duration: 0.3)) { @@ -378,29 +347,3 @@ final class NotificationManager: NSObject, ObservableObject { } } } - -// MARK: - UNUserNotificationCenterDelegate - -extension NotificationManager: UNUserNotificationCenterDelegate { - func userNotificationCenter( - _ center: UNUserNotificationCenter, - didReceive response: UNNotificationResponse, - withCompletionHandler completionHandler: @escaping () -> Void - ) { - if let id = response.notification.request.content.userInfo["id"] as? String { - DispatchQueue.main.async { - self.handleSystemNotificationResponse(id: id) - } - } - completionHandler() - } - - func userNotificationCenter( - _ center: UNUserNotificationCenter, - willPresent notification: UNNotification, - withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void - ) { - // Don't show system notifications when app is active - completionHandler([]) - } -} From a4846e9f029e0873d77c9c06c17a3330e81901d9 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Fri, 14 Feb 2025 18:01:51 -0600 Subject: [PATCH 10/21] Fixed SwiftLint errors --- .../Notifications/NotificationManager+Delegate.swift | 9 ++++++++- .../Notifications/NotificationManager+System.swift | 10 +++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/CodeEdit/Features/Notifications/NotificationManager+Delegate.swift b/CodeEdit/Features/Notifications/NotificationManager+Delegate.swift index 89317b5fa8..7e85bfc3d9 100644 --- a/CodeEdit/Features/Notifications/NotificationManager+Delegate.swift +++ b/CodeEdit/Features/Notifications/NotificationManager+Delegate.swift @@ -1,3 +1,10 @@ +// +// NotificationManager+Delegate.swift +// CodeEdit +// +// Created by Austin Condiff on 2/14/24. +// + import UserNotifications extension NotificationManager: UNUserNotificationCenterDelegate { @@ -22,4 +29,4 @@ extension NotificationManager: UNUserNotificationCenterDelegate { // Don't show system notifications when app is active completionHandler([]) } -} \ No newline at end of file +} diff --git a/CodeEdit/Features/Notifications/NotificationManager+System.swift b/CodeEdit/Features/Notifications/NotificationManager+System.swift index 3b6f22de34..4374fb03c2 100644 --- a/CodeEdit/Features/Notifications/NotificationManager+System.swift +++ b/CodeEdit/Features/Notifications/NotificationManager+System.swift @@ -1,4 +1,12 @@ +// +// NotificationManager+System.swift +// CodeEdit +// +// Created by Austin Condiff on 2/14/24. +// + import SwiftUI +import UserNotifications extension NotificationManager { /// Shows a notification in macOS Notification Center when app is in background @@ -25,4 +33,4 @@ extension NotificationManager { dismissNotification(notification) } } -} \ No newline at end of file +} From 83490073204204a37a9c8eb96f35b78464ae5f88 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Sat, 15 Feb 2025 01:30:59 -0600 Subject: [PATCH 11/21] Remove test notification --- CodeEdit/AppDelegate.swift | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/CodeEdit/AppDelegate.swift b/CodeEdit/AppDelegate.swift index 720783c37a..7945d1e715 100644 --- a/CodeEdit/AppDelegate.swift +++ b/CodeEdit/AppDelegate.swift @@ -26,21 +26,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { NSApp.closeWindow(.welcome, .about) - // Add test notification - DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { - NotificationManager.shared.post( - iconText: "👋", - iconTextColor: .white, - iconColor: .indigo, - title: "Welcome to CodeEdit", - description: "This is a test notification to demonstrate the notification system.", - actionButtonTitle: "Learn More...", - action: { - print("Action button clicked!") - } - ) - } - DispatchQueue.main.async { var needToHandleOpen = true From 85f46567d9da25a2b5d0bf9f9fd477464b60525e Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Tue, 18 Feb 2025 13:03:41 -0600 Subject: [PATCH 12/21] Improve notification handling and workspace panel behavior - Add system notification action button that, when clicked, focuses CodeEdit, runs the action, and dismisses the corresponding CodeEdit notification. - Dismissing a CodeEdit notification now also dismisses its corresponding system notification, and vice versa. - The notification panel in a workspace now closes when clicking outside of it, behaving like other menus. - Refactored notification state management: Moved display-related state from `NotificationService` to a dedicated view model to ensure notification panels remain independent across workspaces. --- CodeEdit.xcodeproj/project.pbxproj | 12 + CodeEdit/AppDelegate.swift | 15 + .../CodeEditWindowController+Toolbar.swift | 6 +- .../WorkspaceDocument/WorkspaceDocument.swift | 15 + .../FileInspector/FileInspectorView.swift | 121 ++++++++ .../NotificationManager+Delegate.swift | 45 ++- .../NotificationManager+System.swift | 16 +- .../Notifications/NotificationManager.swift | 261 +++++------------ .../NotificationOverlayViewModel.swift | 265 ++++++++++++++++++ .../Views/DismissTransition.swift | 12 + .../Views/NotificationBannerView.swift | 5 +- .../Views/NotificationOverlayView.swift | 94 +++++-- .../Views/NotificationToolbarItem.swift | 9 +- 13 files changed, 647 insertions(+), 229 deletions(-) create mode 100644 CodeEdit/Features/Notifications/ViewModels/NotificationOverlayViewModel.swift create mode 100644 CodeEdit/Features/Notifications/Views/DismissTransition.swift diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index 0f960f28b0..65c6b18c6b 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -595,6 +595,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 /* NotificationOverlayViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B69970302D63E5C700BB132D /* NotificationOverlayViewModel.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 */; }; @@ -1292,6 +1293,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 /* NotificationOverlayViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationOverlayViewModel.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 = ""; }; @@ -3585,6 +3587,7 @@ isa = PBXGroup; children = ( B68DE5D82D5A61E5009A43EF /* Models */, + B69970312D63E5C700BB132D /* ViewModels */, B68DE5DC2D5A61E5009A43EF /* Views */, B68DE5DD2D5A61E5009A43EF /* NotificationManager.swift */, B66460572D600E9500EC1411 /* NotificationManager+Delegate.swift */, @@ -3609,6 +3612,14 @@ path = Views; sourceTree = ""; }; + B69970312D63E5C700BB132D /* ViewModels */ = { + isa = PBXGroup; + children = ( + B69970302D63E5C700BB132D /* NotificationOverlayViewModel.swift */, + ); + path = ViewModels; + sourceTree = ""; + }; B69D3EDC2C5E856F005CF43A /* Views */ = { isa = PBXGroup; children = ( @@ -4232,6 +4243,7 @@ B6B2D7A12CE8797B00379967 /* GitConfigExtensions.swift in Sources */, 587B9E7329301D8F00AC7927 /* GitRouter.swift in Sources */, 6C2C156129B4F52F00EA60A5 /* SplitViewModifiers.swift in Sources */, + B69970322D63E5C700BB132D /* NotificationOverlayViewModel.swift in Sources */, B68DE5E52D5A7988009A43EF /* NotificationOverlayView.swift in Sources */, 61A53A812B4449F00093BF8A /* WorkspaceDocument+Index.swift in Sources */, 66AF6CE22BF17CC300D83C9D /* StatusBarViewModel.swift in Sources */, diff --git a/CodeEdit/AppDelegate.swift b/CodeEdit/AppDelegate.swift index 7945d1e715..720783c37a 100644 --- a/CodeEdit/AppDelegate.swift +++ b/CodeEdit/AppDelegate.swift @@ -26,6 +26,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { NSApp.closeWindow(.welcome, .about) + // Add test notification + DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { + NotificationManager.shared.post( + iconText: "👋", + iconTextColor: .white, + iconColor: .indigo, + title: "Welcome to CodeEdit", + description: "This is a test notification to demonstrate the notification system.", + actionButtonTitle: "Learn More...", + action: { + print("Action button clicked!") + } + ) + } + DispatchQueue.main.async { var needToHandleOpen = true diff --git a/CodeEdit/Features/Documents/Controllers/CodeEditWindowController+Toolbar.swift b/CodeEdit/Features/Documents/Controllers/CodeEditWindowController+Toolbar.swift index 4edc3722d8..e7d5f02822 100644 --- a/CodeEdit/Features/Documents/Controllers/CodeEditWindowController+Toolbar.swift +++ b/CodeEdit/Features/Documents/Controllers/CodeEditWindowController+Toolbar.swift @@ -179,7 +179,11 @@ extension CodeEditWindowController { return toolbarItem case .notificationItem: let toolbarItem = NSToolbarItem(itemIdentifier: .notificationItem) - let view = NSHostingView(rootView: NotificationToolbarItem()) + 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/WorkspaceDocument/WorkspaceDocument.swift b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift index aaac6e235a..fbdfd60285 100644 --- a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift +++ b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift @@ -43,11 +43,26 @@ final class WorkspaceDocument: NSDocument, ObservableObject, NSToolbarDelegate { var workspaceSettingsManager: CEWorkspaceSettings? var taskNotificationHandler: TaskNotificationHandler = TaskNotificationHandler() + @Published var notificationOverlay = NotificationOverlayViewModel() + private var notificationOverlaySubscription: AnyCancellable? + private var cancellables = Set() + override init() { + super.init() + + // Observe changes to notification overlay + notificationOverlaySubscription = notificationOverlay.objectWillChange + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.objectWillChange.send() + } + } + deinit { cancellables.forEach { $0.cancel() } NotificationCenter.default.removeObserver(self) + notificationOverlaySubscription?.cancel() } func getFromWorkspaceState(_ key: WorkspaceStateKey) -> Any? { diff --git a/CodeEdit/Features/InspectorArea/FileInspector/FileInspectorView.swift b/CodeEdit/Features/InspectorArea/FileInspector/FileInspectorView.swift index f4e2766d8d..be02ceffb5 100644 --- a/CodeEdit/Features/InspectorArea/FileInspector/FileInspectorView.swift +++ b/CodeEdit/Features/InspectorArea/FileInspector/FileInspectorView.swift @@ -59,6 +59,71 @@ struct FileInspectorView: View { widthOptions wrapLinesToggle } + Section("Test Notifications") { + Button("Add Notification") { + let (iconSymbol, iconColor) = randomSymbolAndColor() + NotificationManager.shared.post( + iconSymbol: iconSymbol, + iconColor: iconColor, + title: "Test Notification", + description: "This is a test notification.", + actionButtonTitle: "Action", + action: { + print("Test notification action triggered") + } + ) + } + Button("Add Sticky Notification") { + NotificationManager.shared.post( + iconSymbol: "pin.fill", + iconColor: .orange, + title: "Sticky Notification", + description: "This notification will stay until dismissed.", + actionButtonTitle: "Acknowledge", + action: { + print("Sticky notification acknowledged") + }, + isSticky: true + ) + } + Button("Add Image Notification") { + NotificationManager.shared.post( + iconImage: randomImage(), + title: "Test Notification with Image", + description: "This is a test notification with a custom image.", + actionButtonTitle: "Action", + action: { + print("Test notification action triggered") + } + ) + } + Button("Add Text Notification") { + NotificationManager.shared.post( + iconText: randomLetter(), + iconTextColor: .white, + iconColor: randomColor(), + title: "Text Notification", + description: "This is a test notification with text.", + actionButtonTitle: "Acknowledge", + action: { + print("Test notification action triggered") + } + ) + } + Button("Add Emoji Notification") { + NotificationManager.shared.post( + iconText: randomEmoji(), + iconTextColor: .white, + iconColor: randomColor(), + title: "Emoji Notification", + description: "This is a test notification with an emoji.", + actionButtonTitle: "Acknowledge", + action: { + print("Test notification action triggered") + } + ) + } + } } } else { NoSelectionInspectorView() @@ -81,6 +146,62 @@ struct FileInspectorView: View { } } + func randomColor() -> Color { + let colors: [Color] = [ + .red, .orange, .yellow, .green, .mint, .cyan, + .teal, .blue, .indigo, .purple, .pink, .gray + ] + return colors.randomElement() ?? .black + } + + func randomSymbolAndColor() -> (String, Color) { + let symbols: [(String, Color)] = [ + ("bell.fill", .red), + ("bell.badge.fill", .red), + ("exclamationmark.triangle.fill", .orange), + ("info.circle.fill", .blue), + ("checkmark.seal.fill", .green), + ("xmark.octagon.fill", .red), + ("bubble.left.fill", .teal), + ("envelope.fill", .blue), + ("phone.fill", .green), + ("megaphone.fill", .pink), + ("clock.fill", .gray), + ("calendar", .red), + ("flag.fill", .green), + ("bookmark.fill", .orange), + ("bolt.fill", .indigo), + ("shield.lefthalf.fill", .red), + ("gift.fill", .purple), + ("heart.fill", .pink), + ("star.fill", .orange), + ("curlybraces", .cyan), + ] + return symbols.randomElement() ?? ("bell.fill", .red) + } + + func randomEmoji() -> String { + let emoji: [String] = [ + "🔔", "🚨", "⚠️", "👋", "😍", "😎", "😘", "😜", "😝", "😀", "😁", + "😂", "🤣", "😃", "😄", "😅", "😆", "😇", "😉", "😊", "😋", "😌" + ] + return emoji.randomElement() ?? "🔔" + } + + func randomImage() -> Image { + let images: [Image] = [ + Image("GitHubIcon"), + Image("BitBucketIcon"), + Image("GitLabIcon") + ] + return images.randomElement() ?? Image("GitHubIcon") + } + + func randomLetter() -> String { + let letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".map { String($0) } + return letters.randomElement() ?? "A" + } + @ViewBuilder private var fileNameField: some View { @State var isValid: Bool = true diff --git a/CodeEdit/Features/Notifications/NotificationManager+Delegate.swift b/CodeEdit/Features/Notifications/NotificationManager+Delegate.swift index 7e85bfc3d9..967023db1f 100644 --- a/CodeEdit/Features/Notifications/NotificationManager+Delegate.swift +++ b/CodeEdit/Features/Notifications/NotificationManager+Delegate.swift @@ -5,6 +5,7 @@ // Created by Austin Condiff on 2/14/24. // +import AppKit import UserNotifications extension NotificationManager: UNUserNotificationCenterDelegate { @@ -13,11 +14,24 @@ extension NotificationManager: UNUserNotificationCenterDelegate { didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void ) { - if let id = response.notification.request.content.userInfo["id"] as? String { - DispatchQueue.main.async { - self.handleSystemNotificationResponse(id: id) + 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() } @@ -26,7 +40,28 @@ extension NotificationManager: UNUserNotificationCenterDelegate { willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void ) { - // Don't show system notifications when app is active - completionHandler([]) + 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 index 4374fb03c2..24522d7c4b 100644 --- a/CodeEdit/Features/Notifications/NotificationManager+System.swift +++ b/CodeEdit/Features/Notifications/NotificationManager+System.swift @@ -5,16 +5,19 @@ // Created by Austin Condiff on 2/14/24. // -import SwiftUI +import Foundation import UserNotifications extension NotificationManager { - /// Shows a notification in macOS Notification Center when app is in background + /// Shows a system notification when app is in background func showSystemNotification(_ notification: CENotification) { let content = UNMutableNotificationContent() content.title = notification.title content.body = notification.description - content.userInfo = ["id": notification.id.uuidString] + + if !notification.actionButtonTitle.isEmpty { + content.categoryIdentifier = "ACTIONABLE" + } let request = UNNotificationRequest( identifier: notification.id.uuidString, @@ -25,6 +28,13 @@ extension NotificationManager { 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), diff --git a/CodeEdit/Features/Notifications/NotificationManager.swift b/CodeEdit/Features/Notifications/NotificationManager.swift index 8c80c01073..48a5d079e1 100644 --- a/CodeEdit/Features/Notifications/NotificationManager.swift +++ b/CodeEdit/Features/Notifications/NotificationManager.swift @@ -11,10 +11,9 @@ import UserNotifications /// Manages the application's notification system, handling both in-app notifications and system notifications. /// This class is responsible for: -/// - Displaying temporary notifications in the app UI /// - Managing notification persistence -/// - Handling system notifications when app is in background /// - Tracking notification read status +/// - Broadcasting notifications to workspaces final class NotificationManager: NSObject, ObservableObject { /// Shared instance for accessing the notification manager static let shared = NotificationManager() @@ -22,91 +21,18 @@ final class NotificationManager: NSObject, ObservableObject { /// Collection of all notifications, both read and unread @Published private(set) var notifications: [CENotification] = [] - /// Currently displayed notifications in the overlay - @Published private(set) var activeNotifications: [CENotification] = [] - - private var timers: [UUID: Timer] = [:] - private let displayDuration: TimeInterval = 5.0 - private var isPaused: Bool = false private var isAppActive: Bool = true - /// Whether notifications were manually shown via toolbar - @Published private(set) var isManuallyShown: Bool = false - - /// Set of hidden notification IDs - private var hiddenNotificationIds: Set = [] - - /// Whether any non-sticky notifications are currently hidden - private var hasHiddenNotifications: Bool { - activeNotifications.contains { notification in - !notification.isSticky && !isNotificationVisible(notification) - } - } - - /// Whether a notification should be visible in the overlay - 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 isManuallyShown { - return true // Show all notifications when manually shown - } - // Otherwise, show if not hidden and has active timer - return !hiddenNotificationIds.contains(notification.id) && timers[notification.id] != nil - } - - override private init() { - super.init() - - // Request notification permissions - UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in } - - // Set up notification center delegate - UNUserNotificationCenter.current().delegate = self - - // Observe app active state - NotificationCenter.default.addObserver( - self, - selector: #selector(applicationDidBecomeActive), - name: NSApplication.didBecomeActiveNotification, - object: nil - ) - NotificationCenter.default.addObserver( - self, - selector: #selector(applicationDidResignActive), - name: NSApplication.didResignActiveNotification, - object: nil - ) - } - - @objc - private func applicationDidBecomeActive() { - isAppActive = true - - // Show any pending notifications in the overlay - notifications - .filter { notification in - // Only show notifications that aren't already in the overlay - !activeNotifications.contains { $0.id == notification.id } - } - .forEach { notification in - showTemporaryNotification(notification) - } - } - - @objc - private func applicationDidResignActive() { - isAppActive = false - } - /// Number of unread notifications var unreadCount: Int { notifications.filter { !$0.isRead }.count } + /// Whether there are currently notifications being displayed in the overlay + var hasActiveNotification: Bool { + !notifications.isEmpty + } + /// Posts a new notification /// - Parameters: /// - iconSymbol: SF Symbol or CodeEditSymbol name for the notification icon @@ -139,9 +65,14 @@ final class NotificationManager: NSObject, ObservableObject { DispatchQueue.main.async { [weak self] in self?.notifications.append(notification) - if self?.isAppActive == true { - self?.showTemporaryNotification(notification) - } else { + // 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) } } @@ -175,9 +106,14 @@ final class NotificationManager: NSObject, ObservableObject { DispatchQueue.main.async { [weak self] in self?.notifications.append(notification) - if self?.isAppActive == true { - self?.showTemporaryNotification(notification) - } else { + // 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) } } @@ -217,108 +153,31 @@ final class NotificationManager: NSObject, ObservableObject { DispatchQueue.main.async { [weak self] in self?.notifications.append(notification) - if self?.isAppActive == true { - self?.showTemporaryNotification(notification) - } else { - self?.showSystemNotification(notification) - } - } - } - - /// Shows a notification in the app's overlay UI - private func showTemporaryNotification(_ notification: CENotification) { - withAnimation(.easeInOut(duration: 0.3)) { - insertNotification(notification) - hiddenNotificationIds.remove(notification.id) // Ensure new notification is visible - // Only start timer if notifications aren't manually shown - if !isManuallyShown && !notification.isSticky { - startHideTimer(for: notification) - } - } - } - - /// 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) - } - } - } - - /// Starts the timer to automatically hide a non-sticky notification - private func startHideTimer(for notification: CENotification) { - timers[notification.id]?.invalidate() - timers[notification.id] = nil - - guard !isPaused else { return } + // Always notify workspaces of new notification + NotificationCenter.default.post( + name: .init("NewNotificationAdded"), + object: notification + ) - timers[notification.id] = Timer.scheduledTimer( - withTimeInterval: displayDuration, - repeats: false - ) { [weak self] _ in - guard let self = self else { return } - self.timers[notification.id] = nil - - withAnimation(.easeInOut(duration: 0.3)) { - // Hide this specific notification - self.hiddenNotificationIds.insert(notification.id) - self.objectWillChange.send() + // Additionally show system notification when app is in background + if self?.isAppActive != true { + self?.showSystemNotification(notification) } } } - /// 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) } - } - /// Dismisses a specific notification func dismissNotification(_ notification: CENotification) { - timers[notification.id]?.invalidate() - timers[notification.id] = nil - hiddenNotificationIds.remove(notification.id) - - if let index = activeNotifications.firstIndex(where: { $0.id == notification.id }) { - activeNotifications[index].isBeingDismissed = true - } - - withAnimation(.easeOut(duration: 0.2)) { - activeNotifications.removeAll(where: { $0.id == notification.id }) - - // If this was the last notification and they were manually shown, hide the panel - if activeNotifications.isEmpty && isManuallyShown { - isManuallyShown = false - } - } notifications.removeAll(where: { $0.id == notification.id }) - - // Mark as read when dismissed markAsRead(notification) + + // Remove system notification if it exists + removeSystemNotification(notification) + + NotificationCenter.default.post( + name: .init("NotificationDismissed"), + object: notification + ) } /// Marks a notification as read @@ -329,21 +188,33 @@ final class NotificationManager: NSObject, ObservableObject { } } - /// Toggles visibility of notifications in the overlay - func toggleNotificationsVisibility() { - withAnimation(.easeInOut(duration: 0.3)) { - if hasHiddenNotifications || !isManuallyShown { - // Show all notifications - isManuallyShown = true - hiddenNotificationIds.removeAll() // Clear all hidden states - } else { - // Hide all non-sticky notifications - isManuallyShown = false - activeNotifications - .filter { !$0.isSticky } - .forEach { hiddenNotificationIds.insert($0.id) } - } - objectWillChange.send() - } + 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 } } diff --git a/CodeEdit/Features/Notifications/ViewModels/NotificationOverlayViewModel.swift b/CodeEdit/Features/Notifications/ViewModels/NotificationOverlayViewModel.swift new file mode 100644 index 0000000000..0cb5161988 --- /dev/null +++ b/CodeEdit/Features/Notifications/ViewModels/NotificationOverlayViewModel.swift @@ -0,0 +1,265 @@ +// +// NotificationOverlayViewModel.swift +// CodeEdit +// +// Created by Austin Condiff on 2/14/24. +// + +import SwiftUI + +final class NotificationOverlayViewModel: ObservableObject { + /// Currently displayed notifications in the overlay + @Published private(set) var activeNotifications: [CENotification] = [] + + /// Whether notifications were manually shown via toolbar + @Published private(set) var isManuallyShown: 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 + + /// Whether any non-sticky notifications are currently hidden + private var hasHiddenNotifications: Bool { + activeNotifications.contains { notification in + !notification.isSticky && !isNotificationVisible(notification) + } + } + + @Published var scrolledToTop: Bool = true + + /// Whether a notification should be visible in the overlay + 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 isManuallyShown { + return true // Show all notifications when manually shown + } + return !hiddenNotificationIds.contains(notification.id) + } + + /// Handles focus changes for the notification overlay + func handleFocusChange(isFocused: Bool) { + if !isFocused { + // Only hide if manually shown and focus is completely lost + if isManuallyShown { + toggleNotificationsVisibility() + } + } + } + + /// Toggles visibility of notifications in the overlay + func toggleNotificationsVisibility() { + if isManuallyShown { + if !scrolledToTop { + // Just set isManuallyShown to false to trigger the offset animation + withAnimation(.easeInOut(duration: 0.3)) { + isManuallyShown = 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)) { + isManuallyShown = true + hiddenNotificationIds.removeAll() + objectWillChange.send() + } + } + } + + private func hideNotifications() { + withAnimation(.easeInOut(duration: 0.3)) { + self.isManuallyShown = 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 && !isManuallyShown 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 !isManuallyShown && !notification.isSticky { + startHideTimer(for: notification) + } + } + } + + /// Dismisses a specific notification + func dismissNotification(_ notification: CENotification) { + // 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 }) { + 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.isManuallyShown { + self.isManuallyShown = 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 && isManuallyShown { + isManuallyShown = 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 index 3448a284f6..fbe42e9093 100644 --- a/CodeEdit/Features/Notifications/Views/NotificationBannerView.swift +++ b/CodeEdit/Features/Notifications/Views/NotificationBannerView.swift @@ -36,6 +36,7 @@ struct NotificationBannerView: View { @Environment(\.colorScheme) private var colorScheme + @EnvironmentObject private var workspace: WorkspaceDocument @ObservedObject private var notificationManager = NotificationManager.shared let notification: CENotification @@ -174,9 +175,9 @@ struct NotificationBannerView: View { } if hovering { - NotificationManager.shared.pauseTimer() + workspace.notificationOverlay.pauseTimer() } else { - NotificationManager.shared.resumeTimer() + workspace.notificationOverlay.resumeTimer() } } } diff --git a/CodeEdit/Features/Notifications/Views/NotificationOverlayView.swift b/CodeEdit/Features/Notifications/Views/NotificationOverlayView.swift index 49e8331e86..a520ebdbfd 100644 --- a/CodeEdit/Features/Notifications/Views/NotificationOverlayView.swift +++ b/CodeEdit/Features/Notifications/Views/NotificationOverlayView.swift @@ -8,10 +8,12 @@ import SwiftUI struct NotificationOverlayView: 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" @@ -21,6 +23,7 @@ struct NotificationOverlayView: View { @State private var hasOverflow: Bool = false @State private var contentHeight: CGFloat = 0.0 + @State private var scrollOffset: CGFloat = 0 private func updateOverflow(contentHeight: CGFloat, containerHeight: CGFloat) { if !hasOverflow && contentHeight > containerHeight { @@ -31,41 +34,56 @@ struct NotificationOverlayView: View { } var notifications: some View { - VStack(spacing: 8) { - ForEach( - notificationManager.activeNotifications.filter { - notificationManager.isNotificationVisible($0) - }, - id: \.id - ) { notification in + let visibleNotifications = workspace.notificationOverlay.activeNotifications.filter { + workspace.notificationOverlay.isNotificationVisible($0) + } + + return VStack(spacing: 8) { + ForEach(visibleNotifications, id: \.id) { notification in NotificationBannerView( notification: notification, onDismiss: { - notificationManager.dismissNotification(notification) + workspace.notificationOverlay.dismissNotification(notification) }, onAction: { notification.action() - notificationManager.dismissNotification(notification) - // Only hide if manually shown - if notificationManager.isManuallyShown { - notificationManager.toggleNotificationsVisibility() + workspace.notificationOverlay.dismissNotification(notification) + if workspace.notificationOverlay.isManuallyShown { + workspace.notificationOverlay.toggleNotificationsVisibility() } } ) } } .padding(10) + .animation(.easeInOut(duration: 0.3), value: visibleNotifications) } var notificationsWithScrollView: some View { GeometryReader { geometry in HStack { - Spacer() // Push content to trailing edge + Spacer() ScrollViewReader { proxy in ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .trailing, spacing: 0) { - // Invisible anchor view at the top to scroll back to when closed - Color.clear.frame(height: 0).id(topID) + 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.notificationOverlay.scrolledToTop { + workspace.notificationOverlay.scrolledToTop = true + } else if $0 > 0.0 && workspace.notificationOverlay.scrolledToTop { + workspace.notificationOverlay.scrolledToTop = false + } + } notifications } .background( @@ -80,12 +98,16 @@ struct NotificationOverlayView: View { .frame(maxWidth: notificationWidth, alignment: .trailing) .frame(height: min(geometry.size.height, contentHeight)) .scrollDisabled(!hasOverflow) + .coordinateSpace(name: "scroll") + .onChange(of: isFocused) { newValue in + workspace.notificationOverlay.handleFocusChange(isFocused: newValue) + } .onChange(of: geometry.size.height) { newValue in updateOverflow(contentHeight: contentHeight, containerHeight: newValue) } - .onChange(of: notificationManager.isManuallyShown) { isShown in - if !isShown { - // Delay scroll animation until after notifications are hidden + .onChange(of: workspace.notificationOverlay.isManuallyShown) { isShown in + if !isShown && !workspace.notificationOverlay.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) @@ -94,8 +116,8 @@ struct NotificationOverlayView: View { } } .allowsHitTesting( - notificationManager.activeNotifications - .contains { notificationManager.isNotificationVisible($0) } + workspace.notificationOverlay.activeNotifications + .contains { workspace.notificationOverlay.isNotificationVisible($0) } ) } } @@ -107,12 +129,42 @@ struct NotificationOverlayView: View { if #available(macOS 14.0, *) { notificationsWithScrollView .scrollClipDisabled(true) + .focusable() + .focusEffectDisabled() + .focused($isFocused) + .onChange(of: workspace.notificationOverlay.isManuallyShown) { isShown in + if isShown { + isFocused = true + } + } + .onChange(of: controlActiveState) { newState in + if newState != .active && newState != .key && workspace.notificationOverlay.isManuallyShown { + // Delay hiding notifications to match animation timing + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + workspace.notificationOverlay.toggleNotificationsVisibility() + } + } + } } else { notificationsWithScrollView } } .opacity(controlActiveState == .active || controlActiveState == .key ? 1 : 0) - .offset(x: controlActiveState == .active || controlActiveState == .key ? 0 : 350) + .offset( + x: (controlActiveState == .active || controlActiveState == .key) && + (workspace.notificationOverlay.isManuallyShown || workspace.notificationOverlay.scrolledToTop) + ? 0 + : 350 + ) + .animation(.easeInOut(duration: 0.3), value: workspace.notificationOverlay.isManuallyShown) + .animation(.easeInOut(duration: 0.3), value: workspace.notificationOverlay.scrolledToTop) .animation(.easeInOut(duration: 0.2), value: controlActiveState) } } + +struct ScrollOffsetPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() + } +} diff --git a/CodeEdit/Features/Notifications/Views/NotificationToolbarItem.swift b/CodeEdit/Features/Notifications/Views/NotificationToolbarItem.swift index 00600a140e..fbdcd58e47 100644 --- a/CodeEdit/Features/Notifications/Views/NotificationToolbarItem.swift +++ b/CodeEdit/Features/Notifications/Views/NotificationToolbarItem.swift @@ -8,14 +8,19 @@ 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 { - if notificationManager.unreadCount > 0 { + let visibleNotifications = workspace.notificationOverlay.activeNotifications.filter { + !workspace.notificationOverlay.hiddenNotificationIds.contains($0.id) + } + + if notificationManager.unreadCount > 0 || !visibleNotifications.isEmpty { Button { - notificationManager.toggleNotificationsVisibility() + workspace.notificationOverlay.toggleNotificationsVisibility() } label: { HStack(spacing: 4) { Image(systemName: "bell.badge.fill") From 721389cab30896996795e9bf7afa8f6262d25f28 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Tue, 18 Feb 2025 13:58:37 -0600 Subject: [PATCH 13/21] Fixed SwiftLint and PR issues --- CodeEdit.xcodeproj/project.pbxproj | 26 +++- .../{Views => Styles}/IconButtonStyle.swift | 0 .../{Views => Styles}/IconToggleStyle.swift | 0 .../MenuWithButtonStyle.swift | 0 .../Styles/OverlayButtonStyle.swift | 29 ++++ .../Views/ScrollOffsetPreferenceKey.swift | 10 ++ .../Views/SegmentedControlImproved.swift | 145 ------------------ .../Notifications/NotificationManager.swift | 71 ++++----- .../NotificationOverlayViewModel.swift | 7 - .../Views/NotificationOverlayView.swift | 14 +- .../Preferences/ViewOffsetPreferenceKey.swift | 10 ++ 11 files changed, 97 insertions(+), 215 deletions(-) rename CodeEdit/Features/CodeEditUI/{Views => Styles}/IconButtonStyle.swift (100%) rename CodeEdit/Features/CodeEditUI/{Views => Styles}/IconToggleStyle.swift (100%) rename CodeEdit/Features/CodeEditUI/{Views => Styles}/MenuWithButtonStyle.swift (100%) create mode 100644 CodeEdit/Features/CodeEditUI/Styles/OverlayButtonStyle.swift create mode 100644 CodeEdit/Features/CodeEditUI/Views/ScrollOffsetPreferenceKey.swift delete mode 100644 CodeEdit/Features/CodeEditUI/Views/SegmentedControlImproved.swift create mode 100644 CodeEditUI/src/Preferences/ViewOffsetPreferenceKey.swift diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index 65c6b18c6b..9cd6aaf596 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,8 @@ 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 */; }; 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 */; }; @@ -1154,7 +1155,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 = ""; }; @@ -1220,6 +1220,8 @@ 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 = ""; }; 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 = ""; }; @@ -2117,6 +2119,7 @@ 587B9D7529300ABD00AC7927 /* CodeEditUI */ = { isa = PBXGroup; children = ( + B616EA8A2D651B0A00DF9029 /* Styles */, 587B9D8629300ABD00AC7927 /* Views */, ); path = CodeEditUI; @@ -2132,17 +2135,14 @@ 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 */, @@ -3271,6 +3271,17 @@ path = Views; sourceTree = ""; }; + B616EA8A2D651B0A00DF9029 /* Styles */ = { + isa = PBXGroup; + children = ( + B67DB0F82AFDF638002DC647 /* IconButtonStyle.swift */, + B67DB0FB2AFDF71F002DC647 /* IconToggleStyle.swift */, + B616EA862D651ADA00DF9029 /* OverlayButtonStyle.swift */, + 611028C72C8DC7F100DFD845 /* MenuWithButtonStyle.swift */, + ); + path = Styles; + sourceTree = ""; + }; B61DA9DD29D929BF00BF4A43 /* Pages */ = { isa = PBXGroup; children = ( @@ -4297,7 +4308,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 */, @@ -4648,6 +4658,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/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..4739cc8f43 --- /dev/null +++ b/CodeEdit/Features/CodeEditUI/Styles/OverlayButtonStyle.swift @@ -0,0 +1,29 @@ +import SwiftUI + +/// A button style for overlay buttons (like close, action buttons in notifications) +struct OverlayButtonStyle: ButtonStyle { + @Environment(\.isEnabled) private var isEnabled + @Environment(\.controlActiveState) private var controlActive + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .foregroundColor( + isEnabled + ? (configuration.isPressed + ? .primary.opacity(0.3) + : (controlActive == .inactive + ? .primary.opacity(0.5) + : .primary.opacity(0.7))) + : .primary.opacity(0.3) + ) + .padding(4) + .contentShape(Rectangle()) + } +} + +extension ButtonStyle where Self == OverlayButtonStyle { + /// A button style for overlay buttons + static var overlay: OverlayButtonStyle { + OverlayButtonStyle() + } +} 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/Notifications/NotificationManager.swift b/CodeEdit/Features/Notifications/NotificationManager.swift index 48a5d079e1..7573a85156 100644 --- a/CodeEdit/Features/Notifications/NotificationManager.swift +++ b/CodeEdit/Features/Notifications/NotificationManager.swift @@ -59,23 +59,10 @@ final class NotificationManager: NSObject, ObservableObject { actionButtonTitle: actionButtonTitle, action: action, isSticky: isSticky, - isRead: false // Always start as unread + isRead: false ) - 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) - } - } + postNotification(notification) } /// Posts a new notification @@ -103,20 +90,7 @@ final class NotificationManager: NSObject, ObservableObject { isSticky: isSticky ) - 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) - } - } + postNotification(notification) } /// Posts a new notification @@ -150,20 +124,7 @@ final class NotificationManager: NSObject, ObservableObject { isSticky: isSticky ) - 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) - } - } + postNotification(notification) } /// Dismisses a specific notification @@ -208,13 +169,33 @@ final class NotificationManager: NSObject, ObservableObject { ) } - @objc private func handleAppDidBecomeActive() { + @objc + private func handleAppDidBecomeActive() { isAppActive = true // Remove any system notifications when app becomes active UNUserNotificationCenter.current().removeAllDeliveredNotifications() } - @objc private func handleAppDidResignActive() { + @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/NotificationOverlayViewModel.swift b/CodeEdit/Features/Notifications/ViewModels/NotificationOverlayViewModel.swift index 0cb5161988..efcb349eaa 100644 --- a/CodeEdit/Features/Notifications/ViewModels/NotificationOverlayViewModel.swift +++ b/CodeEdit/Features/Notifications/ViewModels/NotificationOverlayViewModel.swift @@ -28,13 +28,6 @@ final class NotificationOverlayViewModel: ObservableObject { private var notificationManager = NotificationManager.shared - /// Whether any non-sticky notifications are currently hidden - private var hasHiddenNotifications: Bool { - activeNotifications.contains { notification in - !notification.isSticky && !isNotificationVisible(notification) - } - } - @Published var scrolledToTop: Bool = true /// Whether a notification should be visible in the overlay diff --git a/CodeEdit/Features/Notifications/Views/NotificationOverlayView.swift b/CodeEdit/Features/Notifications/Views/NotificationOverlayView.swift index a520ebdbfd..7275a63e5e 100644 --- a/CodeEdit/Features/Notifications/Views/NotificationOverlayView.swift +++ b/CodeEdit/Features/Notifications/Views/NotificationOverlayView.swift @@ -23,7 +23,6 @@ struct NotificationOverlayView: View { @State private var hasOverflow: Bool = false @State private var contentHeight: CGFloat = 0.0 - @State private var scrollOffset: CGFloat = 0 private func updateOverflow(contentHeight: CGFloat, containerHeight: CGFloat) { if !hasOverflow && contentHeight > containerHeight { @@ -33,12 +32,12 @@ struct NotificationOverlayView: View { } } - var notifications: some View { + @ViewBuilder var notifications: some View { let visibleNotifications = workspace.notificationOverlay.activeNotifications.filter { workspace.notificationOverlay.isNotificationVisible($0) } - return VStack(spacing: 8) { + VStack(spacing: 8) { ForEach(visibleNotifications, id: \.id) { notification in NotificationBannerView( notification: notification, @@ -59,7 +58,7 @@ struct NotificationOverlayView: View { .animation(.easeInOut(duration: 0.3), value: visibleNotifications) } - var notificationsWithScrollView: some View { + @ViewBuilder var notificationsWithScrollView: some View { GeometryReader { geometry in HStack { Spacer() @@ -161,10 +160,3 @@ struct NotificationOverlayView: View { .animation(.easeInOut(duration: 0.2), value: controlActiveState) } } - -struct ScrollOffsetPreferenceKey: PreferenceKey { - static var defaultValue: CGFloat = 0 - static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { - value = nextValue() - } -} 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() + } +} From b839c1aeb195687ab49f9ffdcd75b8fb1d69ed93 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Tue, 18 Feb 2025 14:26:29 -0600 Subject: [PATCH 14/21] Added an Internal Development Inspector for debug purposes. This inspector can be enabled with a setting found in the hidden developer settings page. --- CodeEdit.xcodeproj/project.pbxproj | 12 ++ .../InternalDevelopmentInspectorView.swift | 130 ++++++++++++++++++ .../InspectorArea/Models/InspectorTab.swift | 7 + .../Views/InspectorAreaView.swift | 34 +++-- .../DeveloperSettingsView.swift | 7 + .../Models/DeveloperSettings.swift | 11 +- 6 files changed, 190 insertions(+), 11 deletions(-) create mode 100644 CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentInspectorView.swift diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index 9cd6aaf596..1a9a2e852b 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -528,6 +528,7 @@ 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 */; }; 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 */; }; @@ -1222,6 +1223,7 @@ 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 = ""; }; 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 = ""; }; @@ -3282,6 +3284,14 @@ path = Styles; sourceTree = ""; }; + B616EA8C2D65238900DF9029 /* InternalDevelopmentInspector */ = { + isa = PBXGroup; + children = ( + B616EA8B2D65238900DF9029 /* InternalDevelopmentInspectorView.swift */, + ); + path = InternalDevelopmentInspector; + sourceTree = ""; + }; B61DA9DD29D929BF00BF4A43 /* Pages */ = { isa = PBXGroup; children = ( @@ -3765,6 +3775,7 @@ 3026F50B2AC006A10061227E /* ViewModels */, B67660672AA972B000CD56B0 /* FileInspector */, B67660662AA9726F00CD56B0 /* HistoryInspector */, + B616EA8C2D65238900DF9029 /* InternalDevelopmentInspector */, 20EBB50B280C382800F3A5DA /* Models */, 20EBB4FF280C325000F3A5DA /* Views */, ); @@ -4183,6 +4194,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 */, diff --git a/CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentInspectorView.swift b/CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentInspectorView.swift new file mode 100644 index 0000000000..0c126a53ea --- /dev/null +++ b/CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentInspectorView.swift @@ -0,0 +1,130 @@ +import SwiftUI + +struct InternalDevelopmentInspectorView: View { + var body: some View { + Form { + Section("Test Notifications") { + Button("Add Notification") { + let (iconSymbol, iconColor) = randomSymbolAndColor() + NotificationManager.shared.post( + iconSymbol: iconSymbol, + iconColor: iconColor, + title: "Test Notification", + description: "This is a test notification.", + actionButtonTitle: "Action", + action: { + print("Test notification action triggered") + } + ) + } + Button("Add Sticky Notification") { + NotificationManager.shared.post( + iconSymbol: "pin.fill", + iconColor: .orange, + title: "Sticky Notification", + description: "This notification will stay until dismissed.", + actionButtonTitle: "Acknowledge", + action: { + print("Sticky notification acknowledged") + }, + isSticky: true + ) + } + Button("Add Image Notification") { + NotificationManager.shared.post( + iconImage: randomImage(), + title: "Test Notification with Image", + description: "This is a test notification with a custom image.", + actionButtonTitle: "Action", + action: { + print("Test notification action triggered") + } + ) + } + Button("Add Text Notification") { + NotificationManager.shared.post( + iconText: randomLetter(), + iconTextColor: .white, + iconColor: randomColor(), + title: "Text Notification", + description: "This is a test notification with text.", + actionButtonTitle: "Acknowledge", + action: { + print("Test notification action triggered") + } + ) + } + Button("Add Emoji Notification") { + NotificationManager.shared.post( + iconText: randomEmoji(), + iconTextColor: .white, + iconColor: randomColor(), + title: "Emoji Notification", + description: "This is a test notification with an emoji.", + actionButtonTitle: "Acknowledge", + action: { + print("Test notification action triggered") + } + ) + } + } + } + } + + // Helper functions moved from FileInspectorView + private func randomColor() -> Color { + let colors: [Color] = [ + .red, .orange, .yellow, .green, .mint, .cyan, + .teal, .blue, .indigo, .purple, .pink, .gray + ] + return colors.randomElement() ?? .black + } + + private func randomSymbolAndColor() -> (String, Color) { + let symbols: [(String, Color)] = [ + ("bell.fill", .red), + ("bell.badge.fill", .red), + ("exclamationmark.triangle.fill", .orange), + ("info.circle.fill", .blue), + ("checkmark.seal.fill", .green), + ("xmark.octagon.fill", .red), + ("bubble.left.fill", .teal), + ("envelope.fill", .blue), + ("phone.fill", .green), + ("megaphone.fill", .pink), + ("clock.fill", .gray), + ("calendar", .red), + ("flag.fill", .green), + ("bookmark.fill", .orange), + ("bolt.fill", .indigo), + ("shield.lefthalf.fill", .red), + ("gift.fill", .purple), + ("heart.fill", .pink), + ("star.fill", .orange), + ("curlybraces", .cyan), + ] + return symbols.randomElement() ?? ("bell.fill", .red) + } + + private func randomEmoji() -> String { + let emoji: [String] = [ + "🔔", "🚨", "⚠️", "👋", "😍", "😎", "😘", "😜", "😝", "😀", "😁", + "😂", "🤣", "😃", "😄", "😅", "😆", "😇", "😉", "😊", "😋", "😌" + ] + return emoji.randomElement() ?? "🔔" + } + + private func randomImage() -> Image { + let images: [Image] = [ + Image("GitHubIcon"), + Image("BitBucketIcon"), + Image("GitLabIcon") + ] + return images.randomElement() ?? Image("GitHubIcon") + } + + 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..8dda0467b6 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() + } - 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 + private func updateTabs() { + var tabs: [InspectorTab] = [.file, .gitHistory] + + if showInternalDevelopmentInspector { + tabs.append(.internalDevelopment) + } + + 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/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..1d116ba247 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 } } } From c292c7d10c3e681e9f011e7774652a97539c0454 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Wed, 19 Feb 2025 10:28:02 -0600 Subject: [PATCH 15/21] Fixed animation glitch when taking action on a notification while notification panel is presented. Removed notification tests and made the notification test in the dev inspector more configurable. --- CodeEdit.xcodeproj/project.pbxproj | 4 + CodeEdit/AppDelegate.swift | 15 -- .../FileInspector/FileInspectorView.swift | 121 ---------- .../InternalDevelopmentInspectorView.swift | 130 +---------- ...InternalDevelopmentNotificationsView.swift | 206 ++++++++++++++++++ .../NotificationOverlayViewModel.swift | 9 +- .../Views/NotificationOverlayView.swift | 4 +- 7 files changed, 229 insertions(+), 260 deletions(-) create mode 100644 CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentNotificationsView.swift diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index 1a9a2e852b..54f199d2f4 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -529,6 +529,7 @@ 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 */; }; @@ -1224,6 +1225,7 @@ 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 = ""; }; @@ -3288,6 +3290,7 @@ isa = PBXGroup; children = ( B616EA8B2D65238900DF9029 /* InternalDevelopmentInspectorView.swift */, + B616EA8E2D662E9800DF9029 /* InternalDevelopmentNotificationsView.swift */, ); path = InternalDevelopmentInspector; sourceTree = ""; @@ -4472,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 */, diff --git a/CodeEdit/AppDelegate.swift b/CodeEdit/AppDelegate.swift index 720783c37a..7945d1e715 100644 --- a/CodeEdit/AppDelegate.swift +++ b/CodeEdit/AppDelegate.swift @@ -26,21 +26,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { NSApp.closeWindow(.welcome, .about) - // Add test notification - DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { - NotificationManager.shared.post( - iconText: "👋", - iconTextColor: .white, - iconColor: .indigo, - title: "Welcome to CodeEdit", - description: "This is a test notification to demonstrate the notification system.", - actionButtonTitle: "Learn More...", - action: { - print("Action button clicked!") - } - ) - } - DispatchQueue.main.async { var needToHandleOpen = true diff --git a/CodeEdit/Features/InspectorArea/FileInspector/FileInspectorView.swift b/CodeEdit/Features/InspectorArea/FileInspector/FileInspectorView.swift index be02ceffb5..f4e2766d8d 100644 --- a/CodeEdit/Features/InspectorArea/FileInspector/FileInspectorView.swift +++ b/CodeEdit/Features/InspectorArea/FileInspector/FileInspectorView.swift @@ -59,71 +59,6 @@ struct FileInspectorView: View { widthOptions wrapLinesToggle } - Section("Test Notifications") { - Button("Add Notification") { - let (iconSymbol, iconColor) = randomSymbolAndColor() - NotificationManager.shared.post( - iconSymbol: iconSymbol, - iconColor: iconColor, - title: "Test Notification", - description: "This is a test notification.", - actionButtonTitle: "Action", - action: { - print("Test notification action triggered") - } - ) - } - Button("Add Sticky Notification") { - NotificationManager.shared.post( - iconSymbol: "pin.fill", - iconColor: .orange, - title: "Sticky Notification", - description: "This notification will stay until dismissed.", - actionButtonTitle: "Acknowledge", - action: { - print("Sticky notification acknowledged") - }, - isSticky: true - ) - } - Button("Add Image Notification") { - NotificationManager.shared.post( - iconImage: randomImage(), - title: "Test Notification with Image", - description: "This is a test notification with a custom image.", - actionButtonTitle: "Action", - action: { - print("Test notification action triggered") - } - ) - } - Button("Add Text Notification") { - NotificationManager.shared.post( - iconText: randomLetter(), - iconTextColor: .white, - iconColor: randomColor(), - title: "Text Notification", - description: "This is a test notification with text.", - actionButtonTitle: "Acknowledge", - action: { - print("Test notification action triggered") - } - ) - } - Button("Add Emoji Notification") { - NotificationManager.shared.post( - iconText: randomEmoji(), - iconTextColor: .white, - iconColor: randomColor(), - title: "Emoji Notification", - description: "This is a test notification with an emoji.", - actionButtonTitle: "Acknowledge", - action: { - print("Test notification action triggered") - } - ) - } - } } } else { NoSelectionInspectorView() @@ -146,62 +81,6 @@ struct FileInspectorView: View { } } - func randomColor() -> Color { - let colors: [Color] = [ - .red, .orange, .yellow, .green, .mint, .cyan, - .teal, .blue, .indigo, .purple, .pink, .gray - ] - return colors.randomElement() ?? .black - } - - func randomSymbolAndColor() -> (String, Color) { - let symbols: [(String, Color)] = [ - ("bell.fill", .red), - ("bell.badge.fill", .red), - ("exclamationmark.triangle.fill", .orange), - ("info.circle.fill", .blue), - ("checkmark.seal.fill", .green), - ("xmark.octagon.fill", .red), - ("bubble.left.fill", .teal), - ("envelope.fill", .blue), - ("phone.fill", .green), - ("megaphone.fill", .pink), - ("clock.fill", .gray), - ("calendar", .red), - ("flag.fill", .green), - ("bookmark.fill", .orange), - ("bolt.fill", .indigo), - ("shield.lefthalf.fill", .red), - ("gift.fill", .purple), - ("heart.fill", .pink), - ("star.fill", .orange), - ("curlybraces", .cyan), - ] - return symbols.randomElement() ?? ("bell.fill", .red) - } - - func randomEmoji() -> String { - let emoji: [String] = [ - "🔔", "🚨", "⚠️", "👋", "😍", "😎", "😘", "😜", "😝", "😀", "😁", - "😂", "🤣", "😃", "😄", "😅", "😆", "😇", "😉", "😊", "😋", "😌" - ] - return emoji.randomElement() ?? "🔔" - } - - func randomImage() -> Image { - let images: [Image] = [ - Image("GitHubIcon"), - Image("BitBucketIcon"), - Image("GitLabIcon") - ] - return images.randomElement() ?? Image("GitHubIcon") - } - - func randomLetter() -> String { - let letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".map { String($0) } - return letters.randomElement() ?? "A" - } - @ViewBuilder private var fileNameField: some View { @State var isValid: Bool = true diff --git a/CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentInspectorView.swift b/CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentInspectorView.swift index 0c126a53ea..0906bbcbfb 100644 --- a/CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentInspectorView.swift +++ b/CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentInspectorView.swift @@ -1,130 +1,16 @@ +// +// InternalDevelopmentInspectorView.swift +// CodeEdit +// +// Created by Austin Condiff on 2/19/24. +// + import SwiftUI struct InternalDevelopmentInspectorView: View { var body: some View { Form { - Section("Test Notifications") { - Button("Add Notification") { - let (iconSymbol, iconColor) = randomSymbolAndColor() - NotificationManager.shared.post( - iconSymbol: iconSymbol, - iconColor: iconColor, - title: "Test Notification", - description: "This is a test notification.", - actionButtonTitle: "Action", - action: { - print("Test notification action triggered") - } - ) - } - Button("Add Sticky Notification") { - NotificationManager.shared.post( - iconSymbol: "pin.fill", - iconColor: .orange, - title: "Sticky Notification", - description: "This notification will stay until dismissed.", - actionButtonTitle: "Acknowledge", - action: { - print("Sticky notification acknowledged") - }, - isSticky: true - ) - } - Button("Add Image Notification") { - NotificationManager.shared.post( - iconImage: randomImage(), - title: "Test Notification with Image", - description: "This is a test notification with a custom image.", - actionButtonTitle: "Action", - action: { - print("Test notification action triggered") - } - ) - } - Button("Add Text Notification") { - NotificationManager.shared.post( - iconText: randomLetter(), - iconTextColor: .white, - iconColor: randomColor(), - title: "Text Notification", - description: "This is a test notification with text.", - actionButtonTitle: "Acknowledge", - action: { - print("Test notification action triggered") - } - ) - } - Button("Add Emoji Notification") { - NotificationManager.shared.post( - iconText: randomEmoji(), - iconTextColor: .white, - iconColor: randomColor(), - title: "Emoji Notification", - description: "This is a test notification with an emoji.", - actionButtonTitle: "Acknowledge", - action: { - print("Test notification action triggered") - } - ) - } - } + InternalDevelopmentNotificationsView() } } - - // Helper functions moved from FileInspectorView - private func randomColor() -> Color { - let colors: [Color] = [ - .red, .orange, .yellow, .green, .mint, .cyan, - .teal, .blue, .indigo, .purple, .pink, .gray - ] - return colors.randomElement() ?? .black - } - - private func randomSymbolAndColor() -> (String, Color) { - let symbols: [(String, Color)] = [ - ("bell.fill", .red), - ("bell.badge.fill", .red), - ("exclamationmark.triangle.fill", .orange), - ("info.circle.fill", .blue), - ("checkmark.seal.fill", .green), - ("xmark.octagon.fill", .red), - ("bubble.left.fill", .teal), - ("envelope.fill", .blue), - ("phone.fill", .green), - ("megaphone.fill", .pink), - ("clock.fill", .gray), - ("calendar", .red), - ("flag.fill", .green), - ("bookmark.fill", .orange), - ("bolt.fill", .indigo), - ("shield.lefthalf.fill", .red), - ("gift.fill", .purple), - ("heart.fill", .pink), - ("star.fill", .orange), - ("curlybraces", .cyan), - ] - return symbols.randomElement() ?? ("bell.fill", .red) - } - - private func randomEmoji() -> String { - let emoji: [String] = [ - "🔔", "🚨", "⚠️", "👋", "😍", "😎", "😘", "😜", "😝", "😀", "😁", - "😂", "🤣", "😃", "😄", "😅", "😆", "😇", "😉", "😊", "😋", "😌" - ] - return emoji.randomElement() ?? "🔔" - } - - private func randomImage() -> Image { - let images: [Image] = [ - Image("GitHubIcon"), - Image("BitBucketIcon"), - Image("GitLabIcon") - ] - return images.randomElement() ?? Image("GitHubIcon") - } - - private func randomLetter() -> String { - let letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".map { String($0) } - return letters.randomElement() ?? "A" - } } diff --git a/CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentNotificationsView.swift b/CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentNotificationsView.swift new file mode 100644 index 0000000000..5909f4129f --- /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/Notifications/ViewModels/NotificationOverlayViewModel.swift b/CodeEdit/Features/Notifications/ViewModels/NotificationOverlayViewModel.swift index efcb349eaa..47e5b1565a 100644 --- a/CodeEdit/Features/Notifications/ViewModels/NotificationOverlayViewModel.swift +++ b/CodeEdit/Features/Notifications/ViewModels/NotificationOverlayViewModel.swift @@ -181,7 +181,7 @@ final class NotificationOverlayViewModel: ObservableObject { } /// Dismisses a specific notification - func dismissNotification(_ notification: CENotification) { + func dismissNotification(_ notification: CENotification, disableAnimation: Bool = false) { // Clean up timers timers[notification.id]?.invalidate() timers[notification.id] = nil @@ -189,6 +189,13 @@ final class NotificationOverlayViewModel: ObservableObject { // 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 diff --git a/CodeEdit/Features/Notifications/Views/NotificationOverlayView.swift b/CodeEdit/Features/Notifications/Views/NotificationOverlayView.swift index 7275a63e5e..f1a3e105a4 100644 --- a/CodeEdit/Features/Notifications/Views/NotificationOverlayView.swift +++ b/CodeEdit/Features/Notifications/Views/NotificationOverlayView.swift @@ -46,9 +46,11 @@ struct NotificationOverlayView: View { }, onAction: { notification.action() - workspace.notificationOverlay.dismissNotification(notification) if workspace.notificationOverlay.isManuallyShown { workspace.notificationOverlay.toggleNotificationsVisibility() + workspace.notificationOverlay.dismissNotification(notification, disableAnimation: true) + } else { + workspace.notificationOverlay.dismissNotification(notification) } } ) From cd1bf2dd76d2cb3055c655230becfa688d5b2a1f Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Wed, 19 Feb 2025 10:48:11 -0600 Subject: [PATCH 16/21] Changed variable name isManuallyShown to isPresented, and notificationOverlay to notificationPanel. Fixed SwiftLint errors. --- CodeEdit.xcodeproj/project.pbxproj | 16 +++--- .../Styles/OverlayButtonStyle.swift | 9 ++-- .../WorkspaceDocument/WorkspaceDocument.swift | 10 ++-- .../Views/InspectorAreaView.swift | 4 +- .../Notifications/NotificationManager.swift | 5 -- ...swift => NotificationPanelViewModel.swift} | 42 ++++++++-------- .../Views/NotificationBannerView.swift | 4 +- ...View.swift => NotificationPanelView.swift} | 50 +++++++++---------- .../Views/NotificationToolbarItem.swift | 6 +-- CodeEdit/WorkspaceView.swift | 2 +- 10 files changed, 73 insertions(+), 75 deletions(-) rename CodeEdit/Features/Notifications/ViewModels/{NotificationOverlayViewModel.swift => NotificationPanelViewModel.swift} (89%) rename CodeEdit/Features/Notifications/Views/{NotificationOverlayView.swift => NotificationPanelView.swift} (75%) diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index 54f199d2f4..6a26fcfb40 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -589,7 +589,7 @@ 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 /* NotificationOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68DE5E42D5A7988009A43EF /* NotificationOverlayView.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 */; }; @@ -598,7 +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 /* NotificationOverlayViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B69970302D63E5C700BB132D /* NotificationOverlayViewModel.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 */; }; @@ -1290,7 +1290,7 @@ 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 /* NotificationOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationOverlayView.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 = ""; }; @@ -1299,7 +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 /* NotificationOverlayViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationOverlayViewModel.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 = ""; }; @@ -3601,8 +3601,8 @@ isa = PBXGroup; children = ( B68DE5D92D5A61E5009A43EF /* NotificationBannerView.swift */, + B68DE5E42D5A7988009A43EF /* NotificationPanelView.swift */, B68DE5DB2D5A61E5009A43EF /* NotificationToolbarItem.swift */, - B68DE5E42D5A7988009A43EF /* NotificationOverlayView.swift */, ); path = Views; sourceTree = ""; @@ -3639,7 +3639,7 @@ B69970312D63E5C700BB132D /* ViewModels */ = { isa = PBXGroup; children = ( - B69970302D63E5C700BB132D /* NotificationOverlayViewModel.swift */, + B69970302D63E5C700BB132D /* NotificationPanelViewModel.swift */, ); path = ViewModels; sourceTree = ""; @@ -4269,8 +4269,8 @@ B6B2D7A12CE8797B00379967 /* GitConfigExtensions.swift in Sources */, 587B9E7329301D8F00AC7927 /* GitRouter.swift in Sources */, 6C2C156129B4F52F00EA60A5 /* SplitViewModifiers.swift in Sources */, - B69970322D63E5C700BB132D /* NotificationOverlayViewModel.swift in Sources */, - B68DE5E52D5A7988009A43EF /* NotificationOverlayView.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 */, diff --git a/CodeEdit/Features/CodeEditUI/Styles/OverlayButtonStyle.swift b/CodeEdit/Features/CodeEditUI/Styles/OverlayButtonStyle.swift index 4739cc8f43..bd5c86cf28 100644 --- a/CodeEdit/Features/CodeEditUI/Styles/OverlayButtonStyle.swift +++ b/CodeEdit/Features/CodeEditUI/Styles/OverlayButtonStyle.swift @@ -2,9 +2,12 @@ import SwiftUI /// A button style for overlay buttons (like close, action buttons in notifications) struct OverlayButtonStyle: ButtonStyle { - @Environment(\.isEnabled) private var isEnabled - @Environment(\.controlActiveState) private var controlActive - + @Environment(\.isEnabled) + private var isEnabled + + @Environment(\.controlActiveState) + private var controlActive + func makeBody(configuration: Configuration) -> some View { configuration.label .foregroundColor( diff --git a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift index fbdfd60285..06b27fab18 100644 --- a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift +++ b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift @@ -43,16 +43,16 @@ final class WorkspaceDocument: NSDocument, ObservableObject, NSToolbarDelegate { var workspaceSettingsManager: CEWorkspaceSettings? var taskNotificationHandler: TaskNotificationHandler = TaskNotificationHandler() - @Published var notificationOverlay = NotificationOverlayViewModel() - private var notificationOverlaySubscription: AnyCancellable? + @Published var notificationPanel = NotificationPanelViewModel() + private var notificationPanelSubscription: AnyCancellable? private var cancellables = Set() override init() { super.init() - // Observe changes to notification overlay - notificationOverlaySubscription = notificationOverlay.objectWillChange + // Observe changes to notification panel + notificationPanelSubscription = notificationPanel.objectWillChange .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.objectWillChange.send() @@ -62,7 +62,7 @@ final class WorkspaceDocument: NSDocument, ObservableObject, NSToolbarDelegate { deinit { cancellables.forEach { $0.cancel() } NotificationCenter.default.removeObserver(self) - notificationOverlaySubscription?.cancel() + notificationPanelSubscription?.cancel() } func getFromWorkspaceState(_ key: WorkspaceStateKey) -> Any? { diff --git a/CodeEdit/Features/InspectorArea/Views/InspectorAreaView.swift b/CodeEdit/Features/InspectorArea/Views/InspectorAreaView.swift index 8dda0467b6..ecfe7df841 100644 --- a/CodeEdit/Features/InspectorArea/Views/InspectorAreaView.swift +++ b/CodeEdit/Features/InspectorArea/Views/InspectorAreaView.swift @@ -26,11 +26,11 @@ struct InspectorAreaView: View { private func updateTabs() { var tabs: [InspectorTab] = [.file, .gitHistory] - + if showInternalDevelopmentInspector { tabs.append(.internalDevelopment) } - + viewModel.tabItems = tabs + extensionManager .extensions .map { ext in diff --git a/CodeEdit/Features/Notifications/NotificationManager.swift b/CodeEdit/Features/Notifications/NotificationManager.swift index 7573a85156..e270514189 100644 --- a/CodeEdit/Features/Notifications/NotificationManager.swift +++ b/CodeEdit/Features/Notifications/NotificationManager.swift @@ -28,11 +28,6 @@ final class NotificationManager: NSObject, ObservableObject { notifications.filter { !$0.isRead }.count } - /// Whether there are currently notifications being displayed in the overlay - var hasActiveNotification: Bool { - !notifications.isEmpty - } - /// Posts a new notification /// - Parameters: /// - iconSymbol: SF Symbol or CodeEditSymbol name for the notification icon diff --git a/CodeEdit/Features/Notifications/ViewModels/NotificationOverlayViewModel.swift b/CodeEdit/Features/Notifications/ViewModels/NotificationPanelViewModel.swift similarity index 89% rename from CodeEdit/Features/Notifications/ViewModels/NotificationOverlayViewModel.swift rename to CodeEdit/Features/Notifications/ViewModels/NotificationPanelViewModel.swift index 47e5b1565a..9343856b92 100644 --- a/CodeEdit/Features/Notifications/ViewModels/NotificationOverlayViewModel.swift +++ b/CodeEdit/Features/Notifications/ViewModels/NotificationPanelViewModel.swift @@ -1,5 +1,5 @@ // -// NotificationOverlayViewModel.swift +// NotificationPanelViewModel.swift // CodeEdit // // Created by Austin Condiff on 2/14/24. @@ -7,12 +7,12 @@ import SwiftUI -final class NotificationOverlayViewModel: ObservableObject { - /// Currently displayed notifications in the overlay +final class NotificationPanelViewModel: ObservableObject { + /// Currently displayed notifications in the panel @Published private(set) var activeNotifications: [CENotification] = [] - /// Whether notifications were manually shown via toolbar - @Published private(set) var isManuallyShown: Bool = false + /// 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 = [] @@ -30,7 +30,7 @@ final class NotificationOverlayViewModel: ObservableObject { @Published var scrolledToTop: Bool = true - /// Whether a notification should be visible in the overlay + /// Whether a notification should be visible in the panel func isNotificationVisible(_ notification: CENotification) -> Bool { if notification.isBeingDismissed { return true // Always show notifications being dismissed @@ -38,29 +38,29 @@ final class NotificationOverlayViewModel: ObservableObject { if notification.isSticky { return true // Always show sticky notifications } - if isManuallyShown { + if isPresented { return true // Show all notifications when manually shown } return !hiddenNotificationIds.contains(notification.id) } - /// Handles focus changes for the notification overlay + /// Handles focus changes for the notification panel func handleFocusChange(isFocused: Bool) { if !isFocused { // Only hide if manually shown and focus is completely lost - if isManuallyShown { + if isPresented { toggleNotificationsVisibility() } } } - /// Toggles visibility of notifications in the overlay + /// Toggles visibility of notifications in the panel func toggleNotificationsVisibility() { - if isManuallyShown { + if isPresented { if !scrolledToTop { - // Just set isManuallyShown to false to trigger the offset animation + // Just set isPresented to false to trigger the offset animation withAnimation(.easeInOut(duration: 0.3)) { - isManuallyShown = false + isPresented = false } // After the slide-out animation, hide notifications @@ -82,7 +82,7 @@ final class NotificationOverlayViewModel: ObservableObject { } } else { withAnimation(.easeInOut(duration: 0.3)) { - isManuallyShown = true + isPresented = true hiddenNotificationIds.removeAll() objectWillChange.send() } @@ -91,7 +91,7 @@ final class NotificationOverlayViewModel: ObservableObject { private func hideNotifications() { withAnimation(.easeInOut(duration: 0.3)) { - self.isManuallyShown = false + self.isPresented = false self.activeNotifications .filter { !$0.isSticky } .forEach { self.hiddenNotificationIds.insert($0.id) } @@ -101,7 +101,7 @@ final class NotificationOverlayViewModel: ObservableObject { /// Starts the timer to automatically hide a notification func startHideTimer(for notification: CENotification) { - guard !notification.isSticky && !isManuallyShown else { return } + guard !notification.isSticky && !isPresented else { return } timers[notification.id]?.invalidate() timers[notification.id] = nil @@ -174,7 +174,7 @@ final class NotificationOverlayViewModel: ObservableObject { withAnimation(.easeInOut(duration: 0.3)) { insertNotification(notification) hiddenNotificationIds.remove(notification.id) - if !isManuallyShown && !notification.isSticky { + if !isPresented && !notification.isSticky { startHideTimer(for: notification) } } @@ -204,8 +204,8 @@ final class NotificationOverlayViewModel: ObservableObject { 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.isManuallyShown { - self.isManuallyShown = false + if self.activeNotifications.isEmpty && self.isPresented { + self.isPresented = false } } @@ -257,8 +257,8 @@ final class NotificationOverlayViewModel: ObservableObject { activeNotifications.removeAll(where: { $0.id == ceNotification.id }) // If this was the last notification and they were manually shown, hide the panel - if activeNotifications.isEmpty && isManuallyShown { - isManuallyShown = false + if activeNotifications.isEmpty && isPresented { + isPresented = false } } } diff --git a/CodeEdit/Features/Notifications/Views/NotificationBannerView.swift b/CodeEdit/Features/Notifications/Views/NotificationBannerView.swift index fbe42e9093..63020693c2 100644 --- a/CodeEdit/Features/Notifications/Views/NotificationBannerView.swift +++ b/CodeEdit/Features/Notifications/Views/NotificationBannerView.swift @@ -175,9 +175,9 @@ struct NotificationBannerView: View { } if hovering { - workspace.notificationOverlay.pauseTimer() + workspace.notificationPanel.pauseTimer() } else { - workspace.notificationOverlay.resumeTimer() + workspace.notificationPanel.resumeTimer() } } } diff --git a/CodeEdit/Features/Notifications/Views/NotificationOverlayView.swift b/CodeEdit/Features/Notifications/Views/NotificationPanelView.swift similarity index 75% rename from CodeEdit/Features/Notifications/Views/NotificationOverlayView.swift rename to CodeEdit/Features/Notifications/Views/NotificationPanelView.swift index f1a3e105a4..c2c764eeb9 100644 --- a/CodeEdit/Features/Notifications/Views/NotificationOverlayView.swift +++ b/CodeEdit/Features/Notifications/Views/NotificationPanelView.swift @@ -1,5 +1,5 @@ // -// NotificationOverlayView.swift +// NotificationPanelView.swift // CodeEdit // // Created by Austin Condiff on 2/10/24. @@ -7,7 +7,7 @@ import SwiftUI -struct NotificationOverlayView: View { +struct NotificationPanelView: View { @EnvironmentObject private var workspace: WorkspaceDocument @Environment(\.controlActiveState) private var controlActiveState @@ -33,8 +33,8 @@ struct NotificationOverlayView: View { } @ViewBuilder var notifications: some View { - let visibleNotifications = workspace.notificationOverlay.activeNotifications.filter { - workspace.notificationOverlay.isNotificationVisible($0) + let visibleNotifications = workspace.notificationPanel.activeNotifications.filter { + workspace.notificationPanel.isNotificationVisible($0) } VStack(spacing: 8) { @@ -42,15 +42,15 @@ struct NotificationOverlayView: View { NotificationBannerView( notification: notification, onDismiss: { - workspace.notificationOverlay.dismissNotification(notification) + workspace.notificationPanel.dismissNotification(notification) }, onAction: { notification.action() - if workspace.notificationOverlay.isManuallyShown { - workspace.notificationOverlay.toggleNotificationsVisibility() - workspace.notificationOverlay.dismissNotification(notification, disableAnimation: true) + if workspace.notificationPanel.isPresented { + workspace.notificationPanel.toggleNotificationsVisibility() + workspace.notificationPanel.dismissNotification(notification, disableAnimation: true) } else { - workspace.notificationOverlay.dismissNotification(notification) + workspace.notificationPanel.dismissNotification(notification) } } ) @@ -79,10 +79,10 @@ struct NotificationOverlayView: View { } ) .onPreferenceChange(ViewOffsetKey.self) { - if $0 <= 0.0 && !workspace.notificationOverlay.scrolledToTop { - workspace.notificationOverlay.scrolledToTop = true - } else if $0 > 0.0 && workspace.notificationOverlay.scrolledToTop { - workspace.notificationOverlay.scrolledToTop = false + if $0 <= 0.0 && !workspace.notificationPanel.scrolledToTop { + workspace.notificationPanel.scrolledToTop = true + } else if $0 > 0.0 && workspace.notificationPanel.scrolledToTop { + workspace.notificationPanel.scrolledToTop = false } } notifications @@ -101,13 +101,13 @@ struct NotificationOverlayView: View { .scrollDisabled(!hasOverflow) .coordinateSpace(name: "scroll") .onChange(of: isFocused) { newValue in - workspace.notificationOverlay.handleFocusChange(isFocused: newValue) + workspace.notificationPanel.handleFocusChange(isFocused: newValue) } .onChange(of: geometry.size.height) { newValue in updateOverflow(contentHeight: contentHeight, containerHeight: newValue) } - .onChange(of: workspace.notificationOverlay.isManuallyShown) { isShown in - if !isShown && !workspace.notificationOverlay.scrolledToTop { + .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)) { @@ -117,8 +117,8 @@ struct NotificationOverlayView: View { } } .allowsHitTesting( - workspace.notificationOverlay.activeNotifications - .contains { workspace.notificationOverlay.isNotificationVisible($0) } + workspace.notificationPanel.activeNotifications + .contains { workspace.notificationPanel.isNotificationVisible($0) } ) } } @@ -133,16 +133,16 @@ struct NotificationOverlayView: View { .focusable() .focusEffectDisabled() .focused($isFocused) - .onChange(of: workspace.notificationOverlay.isManuallyShown) { isShown in - if isShown { + .onChange(of: workspace.notificationPanel.isPresented) { isPresented in + if isPresented { isFocused = true } } .onChange(of: controlActiveState) { newState in - if newState != .active && newState != .key && workspace.notificationOverlay.isManuallyShown { + if newState != .active && newState != .key && workspace.notificationPanel.isPresented { // Delay hiding notifications to match animation timing DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - workspace.notificationOverlay.toggleNotificationsVisibility() + workspace.notificationPanel.toggleNotificationsVisibility() } } } @@ -153,12 +153,12 @@ struct NotificationOverlayView: View { .opacity(controlActiveState == .active || controlActiveState == .key ? 1 : 0) .offset( x: (controlActiveState == .active || controlActiveState == .key) && - (workspace.notificationOverlay.isManuallyShown || workspace.notificationOverlay.scrolledToTop) + (workspace.notificationPanel.isPresented || workspace.notificationPanel.scrolledToTop) ? 0 : 350 ) - .animation(.easeInOut(duration: 0.3), value: workspace.notificationOverlay.isManuallyShown) - .animation(.easeInOut(duration: 0.3), value: workspace.notificationOverlay.scrolledToTop) + .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 index fbdcd58e47..9729330ddd 100644 --- a/CodeEdit/Features/Notifications/Views/NotificationToolbarItem.swift +++ b/CodeEdit/Features/Notifications/Views/NotificationToolbarItem.swift @@ -14,13 +14,13 @@ struct NotificationToolbarItem: View { private var controlActiveState var body: some View { - let visibleNotifications = workspace.notificationOverlay.activeNotifications.filter { - !workspace.notificationOverlay.hiddenNotificationIds.contains($0.id) + let visibleNotifications = workspace.notificationPanel.activeNotifications.filter { + !workspace.notificationPanel.hiddenNotificationIds.contains($0.id) } if notificationManager.unreadCount > 0 || !visibleNotifications.isEmpty { Button { - workspace.notificationOverlay.toggleNotificationsVisibility() + workspace.notificationPanel.toggleNotificationsVisibility() } label: { HStack(spacing: 4) { Image(systemName: "bell.badge.fill") diff --git a/CodeEdit/WorkspaceView.swift b/CodeEdit/WorkspaceView.swift index 45b1306f2a..3bcdbd4dd8 100644 --- a/CodeEdit/WorkspaceView.swift +++ b/CodeEdit/WorkspaceView.swift @@ -104,7 +104,7 @@ struct WorkspaceView: View { .accessibilityElement(children: .contain) } .overlay(alignment: .topTrailing) { - NotificationOverlayView() + NotificationPanelView() } .onChange(of: focusedEditor) { newValue in /// update active tab group only if the new one is not the same with it. From d2060e7731952b937726c966b5856ee6f5b92bd2 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Wed, 19 Feb 2025 10:50:01 -0600 Subject: [PATCH 17/21] SwiftLint error fix --- .../InternalDevelopmentNotificationsView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentNotificationsView.swift b/CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentNotificationsView.swift index 5909f4129f..5334880996 100644 --- a/CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentNotificationsView.swift +++ b/CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentNotificationsView.swift @@ -198,7 +198,7 @@ struct InternalDevelopmentNotificationsView: View { } } } - + private func randomLetter() -> String { let letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".map { String($0) } return letters.randomElement() ?? "A" From dc33a4eac9e23e8a50e7dff21d5abd4f166253dd Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Wed, 19 Feb 2025 11:00:04 -0600 Subject: [PATCH 18/21] SwiftLint fixes --- .../Pages/DeveloperSettings/Models/DeveloperSettings.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CodeEdit/Features/Settings/Pages/DeveloperSettings/Models/DeveloperSettings.swift b/CodeEdit/Features/Settings/Pages/DeveloperSettings/Models/DeveloperSettings.swift index 1d116ba247..f52f075385 100644 --- a/CodeEdit/Features/Settings/Pages/DeveloperSettings/Models/DeveloperSettings.swift +++ b/CodeEdit/Features/Settings/Pages/DeveloperSettings/Models/DeveloperSettings.swift @@ -38,7 +38,7 @@ extension SettingsData { [String: String].self, forKey: .lspBinaries ) ?? [:] - + self.showInternalDevelopmentInspector = try container.decodeIfPresent( Bool.self, forKey: .showInternalDevelopmentInspector From 4815194fc67257e342e1a48e24f4d39bba680a0b Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Wed, 19 Feb 2025 13:28:14 -0600 Subject: [PATCH 19/21] Refactored CENotification to use delegated inits. --- .../Notifications/Models/CENotification.swift | 62 +++++++++++++------ 1 file changed, 43 insertions(+), 19 deletions(-) diff --git a/CodeEdit/Features/Notifications/Models/CENotification.swift b/CodeEdit/Features/Notifications/Models/CENotification.swift index ded63b6b34..f71d39a49e 100644 --- a/CodeEdit/Features/Notifications/Models/CENotification.swift +++ b/CodeEdit/Features/Notifications/Models/CENotification.swift @@ -37,15 +37,16 @@ struct CENotification: Identifiable, Equatable { isSticky: Bool = false, isRead: Bool = false ) { - self.id = id - self.icon = .symbol(name: iconSymbol, color: iconColor) - self.title = title - self.description = description - self.actionButtonTitle = actionButtonTitle - self.action = action - self.isSticky = isSticky - self.isRead = isRead - self.timestamp = Date() + self.init( + id: id, + icon: .symbol(name: iconSymbol, color: iconColor), + title: title, + description: description, + actionButtonTitle: actionButtonTitle, + action: action, + isSticky: isSticky, + isRead: isRead + ) } init( @@ -60,15 +61,16 @@ struct CENotification: Identifiable, Equatable { isSticky: Bool = false, isRead: Bool = false ) { - self.id = id - self.icon = .text(iconText, backgroundColor: iconColor, textColor: iconTextColor) - self.title = title - self.description = description - self.actionButtonTitle = actionButtonTitle - self.action = action - self.isSticky = isSticky - self.isRead = isRead - self.timestamp = Date() + self.init( + id: id, + icon: .text(iconText, backgroundColor: iconColor, textColor: iconTextColor), + title: title, + description: description, + actionButtonTitle: actionButtonTitle, + action: action, + isSticky: isSticky, + isRead: isRead + ) } init( @@ -80,9 +82,31 @@ struct CENotification: Identifiable, Equatable { 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 = .image(iconImage) + self.icon = icon self.title = title self.description = description self.actionButtonTitle = actionButtonTitle From 749c7163d6ef51d8f0a2b20af40efba8bcdf40a0 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Wed, 19 Feb 2025 21:51:37 -0600 Subject: [PATCH 20/21] Moved CloseButtonStyle into CodeEditUI and renamed it OverlayButtonStyle --- .../TaskNotificationHandler.swift | 2 +- .../Styles/OverlayButtonStyle.swift | 32 ++++++++++--------- .../FileInspector/FileInspectorView.swift | 6 +--- .../Views/NotificationBannerView.swift | 27 +--------------- 4 files changed, 20 insertions(+), 47 deletions(-) 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/Styles/OverlayButtonStyle.swift b/CodeEdit/Features/CodeEditUI/Styles/OverlayButtonStyle.swift index bd5c86cf28..7e9b962c68 100644 --- a/CodeEdit/Features/CodeEditUI/Styles/OverlayButtonStyle.swift +++ b/CodeEdit/Features/CodeEditUI/Styles/OverlayButtonStyle.swift @@ -2,25 +2,27 @@ import SwiftUI /// A button style for overlay buttons (like close, action buttons in notifications) struct OverlayButtonStyle: ButtonStyle { - @Environment(\.isEnabled) - private var isEnabled - - @Environment(\.controlActiveState) - private var controlActive + @Environment(\.colorScheme) + private var colorScheme func makeBody(configuration: Configuration) -> some View { configuration.label - .foregroundColor( - isEnabled - ? (configuration.isPressed - ? .primary.opacity(0.3) - : (controlActive == .inactive - ? .primary.opacity(0.5) - : .primary.opacity(0.7))) - : .primary.opacity(0.3) + .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 ) - .padding(4) - .contentShape(Rectangle()) } } diff --git a/CodeEdit/Features/InspectorArea/FileInspector/FileInspectorView.swift b/CodeEdit/Features/InspectorArea/FileInspector/FileInspectorView.swift index f4e2766d8d..50cbd983b3 100644 --- a/CodeEdit/Features/InspectorArea/FileInspector/FileInspectorView.swift +++ b/CodeEdit/Features/InspectorArea/FileInspector/FileInspectorView.swift @@ -82,19 +82,16 @@ struct FileInspectorView: View { } @ViewBuilder private var fileNameField: some View { - @State var isValid: Bool = true - if let file { TextField("Name", text: $fileName) .background( - isValid ? Color.clear : Color(errorRed) + fileName != file.fileName() && !file.validateFileName(for: fileName) ? Color(errorRed) : Color.clear ) .onSubmit { if file.validateFileName(for: fileName) { let destinationURL = file.url .deletingLastPathComponent() .appendingPathComponent(fileName) - isValid = true DispatchQueue.main.async { [weak workspace] in do { if let newItem = try workspace?.workspaceFileManager?.move( @@ -112,7 +109,6 @@ struct FileInspectorView: View { } } } else { - isValid = false fileName = file.labelFileName() } } diff --git a/CodeEdit/Features/Notifications/Views/NotificationBannerView.swift b/CodeEdit/Features/Notifications/Views/NotificationBannerView.swift index 63020693c2..11a90696ac 100644 --- a/CodeEdit/Features/Notifications/Views/NotificationBannerView.swift +++ b/CodeEdit/Features/Notifications/Views/NotificationBannerView.swift @@ -7,31 +7,6 @@ import SwiftUI -struct CloseButtonStyle: 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 - ) - } -} - struct NotificationBannerView: View { @Environment(\.colorScheme) private var colorScheme @@ -149,7 +124,7 @@ struct NotificationBannerView: View { Button(action: onDismiss) { Image(systemName: "xmark") } - .buttonStyle(CloseButtonStyle()) + .buttonStyle(.overlay) .padding(.top, -5) .padding(.leading, -5) .transition(.opacity) From 5e6c6085fc8b84008ff37d5763b35c768112cf6c Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Wed, 19 Feb 2025 21:59:29 -0600 Subject: [PATCH 21/21] Using cancelables so workspace cleanup is more concise --- .../Documents/WorkspaceDocument/WorkspaceDocument.swift | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift index 06b27fab18..1b96e4fca2 100644 --- a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift +++ b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift @@ -44,25 +44,23 @@ final class WorkspaceDocument: NSDocument, ObservableObject, NSToolbarDelegate { var taskNotificationHandler: TaskNotificationHandler = TaskNotificationHandler() @Published var notificationPanel = NotificationPanelViewModel() - private var notificationPanelSubscription: AnyCancellable? - private var cancellables = Set() override init() { super.init() // Observe changes to notification panel - notificationPanelSubscription = notificationPanel.objectWillChange + 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) - notificationPanelSubscription?.cancel() } func getFromWorkspaceState(_ key: WorkspaceStateKey) -> Any? {