diff --git a/TablePro/Core/Events/AppEvents.swift b/TablePro/Core/Events/AppEvents.swift index d451aca23..59b65d95a 100644 --- a/TablePro/Core/Events/AppEvents.swift +++ b/TablePro/Core/Events/AppEvents.swift @@ -14,6 +14,16 @@ final class AppEvents { let connectionStatusChanged = PassthroughSubject() + let editorSettingsChanged = PassthroughSubject() + + let dataGridSettingsChanged = PassthroughSubject() + + let aiSettingsChanged = PassthroughSubject() + + let terminalSettingsChanged = PassthroughSubject() + + let accessibilityTextSizeChanged = PassthroughSubject() + private init() {} } diff --git a/TablePro/Core/Services/Infrastructure/SettingsNotifications.swift b/TablePro/Core/Services/Infrastructure/SettingsNotifications.swift deleted file mode 100644 index f3e0d98b3..000000000 --- a/TablePro/Core/Services/Infrastructure/SettingsNotifications.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// SettingsNotifications.swift -// TablePro -// -// Notification names for settings changes that require AppKit bridging. -// SwiftUI views observe @Observable AppSettingsManager directly instead. -// - -import Foundation - -extension Notification.Name { - /// Posted when data grid settings change (row height, date format, etc.) - /// Used by AppKit components that cannot observe @Observable directly. - static let dataGridSettingsDidChange = Notification.Name("dataGridSettingsDidChange") - - /// Posted when editor settings change (font, line numbers, etc.) - /// Used by AppKit components that cannot observe @Observable directly. - static let editorSettingsDidChange = Notification.Name("editorSettingsDidChange") - - /// Posted when the system accessibility text size preference changes. - /// Observers should reload fonts via ThemeEngine.shared.reloadFontCaches(). - static let accessibilityTextSizeDidChange = Notification.Name("accessibilityTextSizeDidChange") - - /// Posted when terminal settings change (font, theme, cursor, etc.) - /// Used by terminal views to live-update configuration. - static let terminalSettingsDidChange = Notification.Name("terminalSettingsDidChange") - - /// Posted when AI settings change (active provider, inline suggestions toggle, etc.) - /// Used by editor coordinators to re-resolve inline suggestion sources. - static let aiSettingsDidChange = Notification.Name("aiSettingsDidChange") -} diff --git a/TablePro/Core/Storage/AppSettingsManager.swift b/TablePro/Core/Storage/AppSettingsManager.swift index 52289520f..35c29cec2 100644 --- a/TablePro/Core/Storage/AppSettingsManager.swift +++ b/TablePro/Core/Storage/AppSettingsManager.swift @@ -1,4 +1,5 @@ import AppKit +import Combine import Foundation import Observation import os @@ -44,7 +45,7 @@ final class AppSettingsManager { wordWrap: editor.wordWrap ) - notifyChange(.editorSettingsDidChange) + AppEvents.shared.editorSettingsChanged.send(()) SyncChangeTracker.shared.markDirty(.settings, id: "editor") } } @@ -64,7 +65,7 @@ final class AppSettingsManager { storage.saveDataGrid(validated) DateFormattingService.shared.updateFormat(validated.dateFormat) - notifyChange(.dataGridSettingsDidChange) + AppEvents.shared.dataGridSettingsChanged.send(()) SyncChangeTracker.shared.markDirty(.settings, id: "dataGrid") } } @@ -106,7 +107,7 @@ final class AppSettingsManager { didSet { storage.saveAI(ai) SyncChangeTracker.shared.markDirty(.settings, id: "ai") - notifyChange(.aiSettingsDidChange) + AppEvents.shared.aiSettingsChanged.send(()) let hadCopilot = oldValue.providers.contains(where: { $0.type == .copilot }) let hasCopilot = ai.providers.contains(where: { $0.type == .copilot }) if hasCopilot != hadCopilot { @@ -131,7 +132,7 @@ final class AppSettingsManager { var terminal: TerminalSettings { didSet { storage.saveTerminal(terminal) - notifyChange(.terminalSettingsDidChange) + AppEvents.shared.terminalSettingsChanged.send(()) SyncChangeTracker.shared.markDirty(.settings, id: "terminal") } } @@ -207,10 +208,6 @@ final class AppSettingsManager { } } - private func notifyChange(_ notification: Notification.Name) { - NotificationCenter.default.post(name: notification, object: self) - } - /// Auto-pick the first configured provider as active when nothing is selected. /// Avoids a "AI suddenly stopped working" upgrade UX when older settings JSON /// (with multiple providers and no activeProviderID concept) is loaded. @@ -240,7 +237,7 @@ final class AppSettingsManager { lastAccessibilityScale = newScale Self.logger.debug("Accessibility text size changed, scale: \(newScale, format: .fixed(precision: 2))") ThemeEngine.shared.reloadFontCaches() - NotificationCenter.default.post(name: .accessibilityTextSizeDidChange, object: self) + AppEvents.shared.accessibilityTextSizeChanged.send(()) } } } diff --git a/TablePro/Core/Terminal/TerminalSessionState.swift b/TablePro/Core/Terminal/TerminalSessionState.swift index aa7e1cebd..eea44fe25 100644 --- a/TablePro/Core/Terminal/TerminalSessionState.swift +++ b/TablePro/Core/Terminal/TerminalSessionState.swift @@ -3,6 +3,7 @@ // TablePro // +import Combine import Foundation import GhosttyTerminal import GhosttyTheme @@ -24,7 +25,7 @@ final class TerminalSessionState: Identifiable { var exitCode: Int32 = 0 var error: String? - @ObservationIgnored private var settingsObserver: NSObjectProtocol? + @ObservationIgnored private var settingsCancellable: AnyCancellable? init(connectionId: UUID, databaseType: DatabaseType) { self.id = UUID() @@ -36,9 +37,6 @@ final class TerminalSessionState: Identifiable { } deinit { - if let settingsObserver { - NotificationCenter.default.removeObserver(settingsObserver) - } // TerminalProcessManager.deinit handles source cancellation, fd close, and child kill // via nonisolated(unsafe) fields (see Issue 5 fix). Releasing our strong reference // here triggers that cleanup if no other references remain. @@ -151,13 +149,11 @@ final class TerminalSessionState: Identifiable { } private func observeSettingsChanges() { - settingsObserver = NotificationCenter.default.addObserver( - forName: .terminalSettingsDidChange, - object: nil, - queue: .main - ) { [weak self] _ in - self?.applySettingsToTerminal() - } + settingsCancellable = AppEvents.shared.terminalSettingsChanged + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.applySettingsToTerminal() + } } // MARK: - Private diff --git a/TablePro/Theme/ThemeEngine.swift b/TablePro/Theme/ThemeEngine.swift index 2add9bf55..b6657e765 100644 --- a/TablePro/Theme/ThemeEngine.swift +++ b/TablePro/Theme/ThemeEngine.swift @@ -391,7 +391,7 @@ internal final class ThemeEngine { lastAccessibilityScale = newScale Self.logger.debug("Accessibility text size changed, scale: \(newScale, format: .fixed(precision: 2))") reloadFontCaches() - NotificationCenter.default.post(name: .accessibilityTextSizeDidChange, object: self) + AppEvents.shared.accessibilityTextSizeChanged.send(()) } } } diff --git a/TablePro/Views/Editor/SQLEditorCoordinator.swift b/TablePro/Views/Editor/SQLEditorCoordinator.swift index 99b5c0652..76ead3fe4 100644 --- a/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -9,6 +9,7 @@ import AppKit import CodeEditSourceEditor import CodeEditTextView +import Combine import Observation import os @@ -30,8 +31,8 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { @ObservationIgnored private var aiChatInlineSource: AIChatInlineSource? @ObservationIgnored private var copilotDocumentSync: CopilotDocumentSync? @ObservationIgnored private var copilotInlineSource: CopilotInlineSource? - @ObservationIgnored private var editorSettingsObserver: NSObjectProtocol? - @ObservationIgnored private var aiSettingsObserver: NSObjectProtocol? + @ObservationIgnored private var editorSettingsCancellable: AnyCancellable? + @ObservationIgnored private var aiSettingsCancellable: AnyCancellable? @ObservationIgnored private var windowKeyObserver: NSObjectProtocol? @ObservationIgnored private var lastInlineSourceKind: InlineSourceKind = .off /// Debounce work item for frame-change notification to avoid @@ -71,12 +72,6 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { } deinit { - if let observer = editorSettingsObserver { - NotificationCenter.default.removeObserver(observer) - } - if let observer = aiSettingsObserver { - NotificationCenter.default.removeObserver(observer) - } if let observer = windowKeyObserver { NotificationCenter.default.removeObserver(observer) } @@ -84,14 +79,8 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { } private func cleanupMonitors() { - if let observer = editorSettingsObserver { - NotificationCenter.default.removeObserver(observer) - editorSettingsObserver = nil - } - if let observer = aiSettingsObserver { - NotificationCenter.default.removeObserver(observer) - aiSettingsObserver = nil - } + editorSettingsCancellable = nil + aiSettingsCancellable = nil if let observer = windowKeyObserver { NotificationCenter.default.removeObserver(observer) windowKeyObserver = nil @@ -450,23 +439,19 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { // MARK: - Editor Settings Observer private func installEditorSettingsObserver(controller: TextViewController) { - editorSettingsObserver = NotificationCenter.default.addObserver( - forName: .editorSettingsDidChange, - object: nil, - queue: .main - ) { [weak self, weak controller] _ in - guard let self, let controller else { return } - self.handleVimSettingsChange(controller: controller) - self.handleInlineProviderChange() - self.vimCursorManager?.updatePosition() - } - aiSettingsObserver = NotificationCenter.default.addObserver( - forName: .aiSettingsDidChange, - object: nil, - queue: .main - ) { [weak self] _ in - self?.handleInlineProviderChange() - } + editorSettingsCancellable = AppEvents.shared.editorSettingsChanged + .receive(on: RunLoop.main) + .sink { [weak self, weak controller] _ in + guard let self, let controller else { return } + self.handleVimSettingsChange(controller: controller) + self.handleInlineProviderChange() + self.vimCursorManager?.updatePosition() + } + aiSettingsCancellable = AppEvents.shared.aiSettingsChanged + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.handleInlineProviderChange() + } } private func handleInlineProviderChange() { diff --git a/TablePro/Views/Editor/SQLEditorView.swift b/TablePro/Views/Editor/SQLEditorView.swift index 2f6ecad3a..2de8db562 100644 --- a/TablePro/Views/Editor/SQLEditorView.swift +++ b/TablePro/Views/Editor/SQLEditorView.swift @@ -108,7 +108,10 @@ struct SQLEditorView: View { .onChange(of: AppSettingsManager.shared.editor) { editorConfiguration = Self.makeConfiguration() } - .onReceive(NotificationCenter.default.publisher(for: .accessibilityTextSizeDidChange)) { _ in + .onReceive(AppEvents.shared.accessibilityTextSizeChanged) { _ in + editorConfiguration = Self.makeConfiguration() + } + .onReceive(AppEvents.shared.themeChanged) { _ in editorConfiguration = Self.makeConfiguration() } .onAppear { diff --git a/TablePro/Views/Results/Cells/DataGridCellRegistry.swift b/TablePro/Views/Results/Cells/DataGridCellRegistry.swift index 697f5a64f..c167d52ff 100644 --- a/TablePro/Views/Results/Cells/DataGridCellRegistry.swift +++ b/TablePro/Views/Results/Cells/DataGridCellRegistry.swift @@ -4,6 +4,7 @@ // import AppKit +import Combine import Foundation @MainActor @@ -12,27 +13,17 @@ final class DataGridCellRegistry { weak var textFieldDelegate: NSTextFieldDelegate? private(set) var nullDisplayString: String - private var settingsObserver: NSObjectProtocol? + private var settingsCancellable: AnyCancellable? private let rowNumberCellIdentifier = NSUserInterfaceItemIdentifier("RowNumberCellView") init() { nullDisplayString = AppSettingsManager.shared.dataGrid.nullDisplay - settingsObserver = NotificationCenter.default.addObserver( - forName: .dataGridSettingsDidChange, - object: nil, - queue: .main - ) { [weak self] _ in - Task { @MainActor [weak self] in + settingsCancellable = AppEvents.shared.dataGridSettingsChanged + .receive(on: RunLoop.main) + .sink { [weak self] _ in self?.nullDisplayString = AppSettingsManager.shared.dataGrid.nullDisplay } - } - } - - deinit { - if let observer = settingsObserver { - NotificationCenter.default.removeObserver(observer) - } } func resolveKind( diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 8e28f8a87..512962ebd 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -92,7 +92,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData let tableRowsController = TableRowsController() var overlayEditor: CellOverlayEditor? - var settingsObserver: NSObjectProtocol? + var settingsCancellable: AnyCancellable? var themeCancellable: AnyCancellable? private var lastDataGridSettings: DataGridSettings @@ -136,14 +136,9 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData observeThemeChanges() - settingsObserver = NotificationCenter.default.addObserver( - forName: .dataGridSettingsDidChange, - object: nil, - queue: .main - ) { [weak self] _ in - guard let self else { return } - - Task { @MainActor [weak self] in + settingsCancellable = AppEvents.shared.dataGridSettingsChanged + .receive(on: RunLoop.main) + .sink { [weak self] _ in guard let self, let tableView = self.tableView else { return } let settings = AppSettingsManager.shared.dataGrid let prev = self.lastDataGridSettings @@ -176,7 +171,6 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData } } } - } } func observeThemeChanges() { @@ -224,9 +218,6 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData private(set) var teardownObserver: NSObjectProtocol? deinit { - if let observer = settingsObserver { - NotificationCenter.default.removeObserver(observer) - } if let observer = teardownObserver { NotificationCenter.default.removeObserver(observer) } diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index e016b8055..d55e67e72 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -359,10 +359,7 @@ struct DataGridView: NSViewRepresentable { static func dismantleNSView(_ nsView: NSScrollView, coordinator: TableViewCoordinator) { coordinator.overlayEditor?.dismiss(commit: false) coordinator.persistColumnLayoutToStorage() - if let observer = coordinator.settingsObserver { - NotificationCenter.default.removeObserver(observer) - coordinator.settingsObserver = nil - } + coordinator.settingsCancellable = nil coordinator.themeCancellable = nil coordinator.tableRowsController.detach() }