diff --git a/AGENTS.md b/AGENTS.md index 21645e4d2..c6bd79b0e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,13 +18,6 @@ A file for [guiding coding agents](https://agents.md/). - macOS app: `macos/` - GTK (Linux and FreeBSD) app: `src/apprt/gtk` -## macOS App - -- Do not use `xcodebuild` -- Use `zig build` to build the macOS app and any shared Zig code -- Use `zig build run` to build and run the macOS app -- Run Xcode tests using `zig build test` - ## Issue and PR Guidelines - Never create an issue. diff --git a/macos/AGENTS.md b/macos/AGENTS.md index 6321808b8..50e91781d 100644 --- a/macos/AGENTS.md +++ b/macos/AGENTS.md @@ -1,3 +1,9 @@ # macOS Ghostty Application - Use `swiftlint` for formatting and linting Swift code. +- If code outside of this directory is modified, use + `zig build -Demit-macos-app=false` before building the macOS app to update + the underlying Ghostty library. +- Use `xcodebuild` to build the macOS app, do not use `zig build` + (except to build the underlying library as mentioned above). +- Run unit tests directly with `xcodebuild` diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 028d4506c..bcd9a0ffa 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -243,6 +243,12 @@ class AppDelegate: NSObject, name: .ghosttyBellDidRing, object: nil ) + NotificationCenter.default.addObserver( + self, + selector: #selector(terminalWindowHasBell(_:)), + name: .terminalWindowBellDidChangeNotification, + object: nil + ) NotificationCenter.default.addObserver( self, selector: #selector(ghosttyNewWindow(_:)), @@ -327,9 +333,6 @@ class AppDelegate: NSObject, // If we're back manually then clear the hidden state because macOS handles it. self.hiddenState = nil - // Clear the dock badge when the app becomes active - self.setDockBadge(nil) - // First launch stuff if !applicationHasBecomeActive { applicationHasBecomeActive = true @@ -777,18 +780,20 @@ class AppDelegate: NSObject, if ghostty.config.bellFeatures.contains(.attention) { // Bounce the dock icon if we're not focused. NSApp.requestUserAttention(.informationalRequest) - - // Handle setting the dock badge based on permissions - ghosttyUpdateBadgeForBell() } } - private func ghosttyUpdateBadgeForBell() { + @objc private func terminalWindowHasBell(_ notification: Notification) { + guard notification.object is BaseTerminalController else { return } + syncDockBadge() + } + + private func syncDockBadge() { let center = UNUserNotificationCenter.current() center.getNotificationSettings { settings in switch settings.authorizationStatus { case .authorized: - // Already authorized, check badge setting and set if enabled + // If we're authorized and allow badges, then set the badge. if settings.badgeSetting == .enabled { DispatchQueue.main.async { self.setDockBadge() @@ -842,7 +847,12 @@ class AppDelegate: NSObject, _ = TerminalController.newTab(ghostty, from: window, withBaseConfig: config) } - private func setDockBadge(_ label: String? = "•") { + private func setDockBadge() { + let bellCount = NSApp.windows + .compactMap { $0.windowController as? BaseTerminalController } + .reduce(0) { $0 + ($1.bell ? 1 : 0) } + let wantsBadge = ghostty.config.bellFeatures.contains(.attention) && bellCount > 0 + let label = wantsBadge ? (bellCount > 99 ? "99+" : String(bellCount)) : nil NSApp.dockTile.badgeLabel = label NSApp.dockTile.display() } @@ -887,6 +897,9 @@ class AppDelegate: NSObject, syncMenuShortcuts(config) TerminalController.all.forEach { $0.relabelTabs() } + // Update our badge since config can change what we show. + syncDockBadge() + // Config could change window appearance. We wrap this in an async queue because when // this is called as part of application launch it can deadlock with an internal // AppKit mutex on the appearance. diff --git a/macos/Sources/Features/Splits/SplitTree.swift b/macos/Sources/Features/Splits/SplitTree.swift index 86932b1bb..30caae0da 100644 --- a/macos/Sources/Features/Splits/SplitTree.swift +++ b/macos/Sources/Features/Splits/SplitTree.swift @@ -1,4 +1,5 @@ import AppKit +import Combine /// SplitTree represents a tree of views that can be divided. struct SplitTree { @@ -1215,6 +1216,57 @@ extension SplitTree: Collection { } } +// MARK: SplitTree Combine + +extension SplitTree { + /// Builds a publisher that emits current values for all leaf views keyed by view ID. + /// + /// The returned publisher emits a full `[ViewType.ID: Value]` snapshot whenever any leaf view + /// publishes through the provided publisher key path. + func valuesPublisher( + valueKeyPath: KeyPath, + publisherKeyPath: KeyPath.Publisher> + ) -> AnyPublisher<[ViewType.ID: Value], Never> { + // Flatten the split tree into a list of current leaf views. + let views = map { $0 } + guard !views.isEmpty else { + // If there are no leaves, immediately publish an empty snapshot. + // `Just([:])` keeps the return type simple and makes downstream usage easy. + return Just([:]).eraseToAnyPublisher() + } + + // Capture each view's current value up front. + // We key by `ViewType.ID` so updates can replace the correct entry later. + // This avoids waiting for all views to emit before consumers see data. + let initial = Dictionary(uniqueKeysWithValues: views.map { view in + (view.id, view[keyPath: valueKeyPath]) + }) + + // Build one publisher per view from the requested key path. + // Each emission is mapped into `(id, value)` so we know which entry changed. + // `MergeMany` combines all per-view streams into a single update stream. + let updates = Publishers.MergeMany(views.map { view in + view[keyPath: publisherKeyPath] + .map { (view.id, $0) } + .eraseToAnyPublisher() + }) + + return updates + // Accumulate updates into a full "latest value per ID" dictionary. + // This turns incremental events into complete state snapshots. + .scan(initial) { state, update in + var state = state + state[update.0] = update.1 + return state + } + // Emit the initial snapshot first so subscribers always get a + // complete value dictionary immediately upon subscription. + .prepend(initial) + // Hide implementation details and expose a stable API type. + .eraseToAnyPublisher() + } +} + // MARK: Structural Identity extension SplitTree.Node { diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 9f65d35ce..51d6a263d 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -51,6 +51,9 @@ class BaseTerminalController: NSWindowController, /// Set if the terminal view should show the update overlay. @Published var updateOverlayIsVisible: Bool = false + /// True when any surface in this controller currently has an active bell. + @Published private(set) var bell: Bool = false + /// Whether the terminal surface should focus when the mouse is over it. var focusFollowsMouse: Bool { self.derivedConfig.focusFollowsMouse @@ -83,6 +86,9 @@ class BaseTerminalController: NSWindowController, /// The cancellables related to our focused surface. private var focusedSurfaceCancellables: Set = [] + /// Cancellable for aggregating bell state across all surfaces in this controller. + private var bellStateCancellable: AnyCancellable? + /// An override title for the tab/window set by the user via prompt_tab_title. /// When set, this takes precedence over the computed title from the terminal. var titleOverride: String? { @@ -135,6 +141,9 @@ class BaseTerminalController: NSWindowController, guard let ghostty_app = ghostty.app else { preconditionFailure("app must be loaded") } self.surfaceTree = tree ?? .init(view: Ghostty.SurfaceView(ghostty_app, baseConfig: base)) + // Setup our bell state for the window + setupBellNotificationPublisher() + // Setup our notifications for behaviors let center = NotificationCenter.default center.addObserver( @@ -1200,6 +1209,17 @@ class BaseTerminalController: NSWindowController, func windowWillClose(_ notification: Notification) { guard let window else { return } + // Emit a final bell-state transition so any observers can clear state + // without separately tracking NSWindow lifecycle events. + if bell { + bell = false + NotificationCenter.default.post( + name: .terminalWindowBellDidChangeNotification, + object: self, + userInfo: [Notification.Name.terminalWindowHasBellKey: false] + ) + } + // I don't know if this is required anymore. We previously had a ref cycle between // the view and the window so we had to nil this out to break it but I think this // may now be resolved. We should verify that no memory leaks and we can remove this. @@ -1470,3 +1490,57 @@ extension BaseTerminalController: NSMenuItemValidation { appliedColorScheme = scheme } } + +// MARK: Combine Methods + +extension BaseTerminalController { + /// Publishes an app-wide notification whenever this terminal window's aggregate + /// bell state changes. + private func setupBellNotificationPublisher() { + bellStateCancellable = surfaceValuesPublisher(valueKeyPath: \.bell, publisherKeyPath: \.$bell) + .map { $0.values.contains(true) } + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] hasBell in + guard let self else { return } + bell = hasBell + NotificationCenter.default.post( + name: .terminalWindowBellDidChangeNotification, + object: self, + userInfo: [Notification.Name.terminalWindowHasBellKey: hasBell] + ) + } + } + + /// Creates a publisher for values on all surfaces in this controller's tree. + /// + /// The publisher emits a dictionary of surface IDs to values whenever the tree changes + /// or any surface publishes a new value for the key path. + func surfaceValuesPublisher( + valueKeyPath: KeyPath, + publisherKeyPath: KeyPath.Publisher> + ) -> AnyPublisher<[Ghostty.SurfaceView.ID: Value], Never> { + // `surfaceTree` can be replaced entirely when splits are added/removed/closed. + // For each tree snapshot we build a fresh publisher that watches all surfaces + // in that snapshot. + $surfaceTree + .map { tree in + tree.valuesPublisher( + valueKeyPath: valueKeyPath, + publisherKeyPath: publisherKeyPath + ) + } + // Keep only the latest tree publisher active. This automatically cancels + // subscriptions for old/removed surfaces when the tree changes. + .switchToLatest() + .eraseToAnyPublisher() + } +} + +// MARK: Notifications + +extension Notification.Name { + /// Terminal window aggregate bell state changed. + static let terminalWindowBellDidChangeNotification = Notification.Name("com.mitchellh.ghostty.terminalWindowBellDidChange") + static let terminalWindowHasBellKey = terminalWindowBellDidChangeNotification.rawValue + ".hasBell" +} diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index 0ca43e78d..9276c9914 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -477,7 +477,9 @@ pub fn add( .freetype = true, .@"backend-metal" = target.result.os.tag.isDarwin(), .@"backend-osx" = target.result.os.tag == .macos, - .@"backend-opengl3" = target.result.os.tag != .macos, + // OpenGL3 backend should only be built on non-Apple targets. + // Apple platforms use Metal (and macOS may also use the OSX backend). + .@"backend-opengl3" = !target.result.os.tag.isDarwin(), })) |dep| { step.root_module.addImport("dcimgui", dep.module("dcimgui")); step.linkLibrary(dep.artifact("dcimgui"));