diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 0d671e90..11fca42f 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -194,6 +194,54 @@ func shouldRouteTerminalFontZoomShortcutToGhostty( return browserZoomShortcutAction(flags: flags, chars: chars, keyCode: keyCode) != nil } +func cmuxOwningGhosttyView(for responder: NSResponder?) -> GhosttyNSView? { + guard let responder else { return nil } + if let ghosttyView = responder as? GhosttyNSView { + return ghosttyView + } + + if let view = responder as? NSView, + let ghosttyView = cmuxOwningGhosttyView(for: view) { + return ghosttyView + } + + if let textView = responder as? NSTextView, + let delegateView = textView.delegate as? NSView, + let ghosttyView = cmuxOwningGhosttyView(for: delegateView) { + return ghosttyView + } + + var current = responder.nextResponder + while let next = current { + if let ghosttyView = next as? GhosttyNSView { + return ghosttyView + } + if let view = next as? NSView, + let ghosttyView = cmuxOwningGhosttyView(for: view) { + return ghosttyView + } + current = next.nextResponder + } + + return nil +} + +private func cmuxOwningGhosttyView(for view: NSView) -> GhosttyNSView? { + if let ghosttyView = view as? GhosttyNSView { + return ghosttyView + } + + var current: NSView? = view.superview + while let candidate = current { + if let ghosttyView = candidate as? GhosttyNSView { + return ghosttyView + } + current = candidate.superview + } + + return nil +} + #if DEBUG func browserZoomShortcutTraceCandidate( flags: NSEvent.ModifierFlags, @@ -2300,7 +2348,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // When the terminal has active IME composition (e.g. Korean, Japanese, Chinese // input), don't intercept key events — let them flow through to the input method. - if let ghosttyView = NSApp.keyWindow?.firstResponder as? GhosttyNSView, + if let ghosttyView = cmuxOwningGhosttyView(for: NSApp.keyWindow?.firstResponder), ghosttyView.hasMarkedText() { return false } @@ -2345,7 +2393,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // (e.g., split that doesn't properly blur the address bar). If the first responder // is a terminal surface, the address bar can't be focused. if browserAddressBarFocusedPanelId != nil, - NSApp.keyWindow?.firstResponder is GhosttyNSView { + cmuxOwningGhosttyView(for: NSApp.keyWindow?.firstResponder) != nil { #if DEBUG dlog("handleCustomShortcut: clearing stale browserAddressBarFocusedPanelId") #endif @@ -4441,7 +4489,8 @@ private extension NSWindow { // Command shortcuts when the terminal is focused — the local event monitor // (handleCustomShortcut) already handles app-level shortcuts, and anything // remaining should be menu items. - if let ghosttyView = self.firstResponder as? GhosttyNSView { + let firstResponderGhosttyView = cmuxOwningGhosttyView(for: self.firstResponder) + if let ghosttyView = firstResponderGhosttyView { // If the IME is composing, don't intercept key events — let them flow // through normal AppKit event dispatch so the input method can process them. if ghosttyView.hasMarkedText() { @@ -4484,7 +4533,7 @@ private extension NSWindow { // When the terminal is focused, skip the full NSWindow.performKeyEquivalent // (which walks the SwiftUI content view hierarchy) and dispatch Command-key // events directly to the main menu. This avoids the broken SwiftUI focus path. - if self.firstResponder is GhosttyNSView, + if firstResponderGhosttyView != nil, event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command), let mainMenu = NSApp.mainMenu { let consumedByMenu = mainMenu.performKeyEquivalent(with: event) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 1acbb97e..0e01b157 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1616,6 +1616,13 @@ final class TerminalSurface: Identifiable, ObservableObject { surfaceCallbackContext = callbackContext surfaceConfig.scale_factor = scaleFactors.layer surfaceConfig.context = surfaceContext +#if DEBUG + let templateFontText = String(format: "%.2f", surfaceConfig.font_size) + dlog( + "zoom.create surface=\(id.uuidString.prefix(5)) context=\(cmuxSurfaceContextName(surfaceContext)) " + + "templateFont=\(templateFontText)" + ) +#endif var envVars: [ghostty_env_var_s] = [] var envStorage: [(UnsafeMutablePointer, UnsafeMutablePointer)] = [] defer { @@ -1761,6 +1768,7 @@ final class TerminalSurface: Identifiable, ObservableObject { #endif return } + guard let createdSurface = surface else { return } // For vsync-driven rendering, Ghostty needs to know which display we're on so it can // start a CVDisplayLink with the right refresh rate. If we don't set this early, the @@ -1772,21 +1780,48 @@ final class TerminalSurface: Identifiable, ObservableObject { if let screen = view.window?.screen ?? NSScreen.main, let displayID = screen.displayID, displayID != 0 { - ghostty_surface_set_display_id(surface, displayID) + ghostty_surface_set_display_id(createdSurface, displayID) } - ghostty_surface_set_content_scale(surface, scaleFactors.x, scaleFactors.y) + ghostty_surface_set_content_scale(createdSurface, scaleFactors.x, scaleFactors.y) let wpx = UInt32((view.bounds.width * scaleFactors.x).rounded(.toNearestOrAwayFromZero)) let hpx = UInt32((view.bounds.height * scaleFactors.y).rounded(.toNearestOrAwayFromZero)) if wpx > 0, hpx > 0 { - ghostty_surface_set_size(surface, wpx, hpx) + ghostty_surface_set_size(createdSurface, wpx, hpx) lastPixelWidth = wpx lastPixelHeight = hpx lastXScale = scaleFactors.x lastYScale = scaleFactors.y } + // Some GhosttyKit builds can drop inherited font_size during post-create + // config/scale reconciliation. If runtime points don't match the inherited + // template points, re-apply via binding action so all creation paths + // (new surface, split, new workspace) preserve zoom from the source terminal. + if let inheritedFontPoints = configTemplate?.font_size, + inheritedFontPoints > 0 { + let currentFontPoints = cmuxCurrentSurfaceFontSizePoints(createdSurface) + let shouldReapply = { + guard let currentFontPoints else { return true } + return abs(currentFontPoints - inheritedFontPoints) > 0.05 + }() + if shouldReapply { + let action = String(format: "set_font_size:%.3f", inheritedFontPoints) + _ = performBindingAction(action) + } + } + flushPendingTextIfNeeded() + +#if DEBUG + let runtimeFontText = cmuxCurrentSurfaceFontSizePoints(createdSurface).map { + String(format: "%.2f", $0) + } ?? "nil" + dlog( + "zoom.create.done surface=\(id.uuidString.prefix(5)) context=\(cmuxSurfaceContextName(surfaceContext)) " + + "runtimeFont=\(runtimeFontText)" + ) +#endif } func updateSize(width: CGFloat, height: CGFloat, xScale: CGFloat, yScale: CGFloat, layerScale: CGFloat) { diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index f9c6b17d..0e38e366 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -753,9 +753,15 @@ class TabManager: ObservableObject { @discardableResult func addWorkspace(workingDirectory overrideWorkingDirectory: String? = nil, select: Bool = true) -> Workspace { let workingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory) ?? preferredWorkingDirectoryForNewTab() + let inheritedConfig = inheritedTerminalConfigForNewWorkspace() let ordinal = Self.nextPortOrdinal Self.nextPortOrdinal += 1 - let newWorkspace = Workspace(title: "Terminal \(tabs.count + 1)", workingDirectory: workingDirectory, portOrdinal: ordinal) + let newWorkspace = Workspace( + title: "Terminal \(tabs.count + 1)", + workingDirectory: workingDirectory, + portOrdinal: ordinal, + configTemplate: inheritedConfig + ) wireClosedBrowserTracking(for: newWorkspace) let insertIndex = newTabInsertIndex() if insertIndex >= 0 && insertIndex <= tabs.count { @@ -785,6 +791,36 @@ class TabManager: ObservableObject { @discardableResult func addTab(select: Bool = true) -> Workspace { addWorkspace(select: select) } + func terminalPanelForWorkspaceConfigInheritanceSource() -> TerminalPanel? { + guard let workspace = selectedWorkspace else { return nil } + if let focusedTerminal = workspace.focusedTerminalPanel { + return focusedTerminal + } + if let rememberedTerminal = workspace.lastRememberedTerminalPanelForConfigInheritance() { + return rememberedTerminal + } + if let focusedPaneId = workspace.bonsplitController.focusedPaneId, + let paneTerminal = workspace.terminalPanelForConfigInheritance(inPane: focusedPaneId) { + return paneTerminal + } + return workspace.terminalPanelForConfigInheritance() + } + + private func inheritedTerminalConfigForNewWorkspace() -> ghostty_surface_config_s? { + if let sourceSurface = terminalPanelForWorkspaceConfigInheritanceSource()?.surface.surface { + return cmuxInheritedSurfaceConfig( + sourceSurface: sourceSurface, + context: GHOSTTY_SURFACE_CONTEXT_TAB + ) + } + if let fallbackFontPoints = selectedWorkspace?.lastRememberedTerminalFontPointsForConfigInheritance() { + var config = ghostty_surface_config_new() + config.font_size = fallbackFontPoints + return config + } + return nil + } + private func normalizedWorkingDirectory(_ directory: String?) -> String? { guard let directory else { return nil } let normalized = normalizeDirectory(directory) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index a0838d0d..9697b271 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -3,6 +3,58 @@ import SwiftUI import AppKit import Bonsplit import Combine +import CoreText + +func cmuxSurfaceContextName(_ context: ghostty_surface_context_e) -> String { + switch context { + case GHOSTTY_SURFACE_CONTEXT_WINDOW: + return "window" + case GHOSTTY_SURFACE_CONTEXT_TAB: + return "tab" + case GHOSTTY_SURFACE_CONTEXT_SPLIT: + return "split" + default: + return "unknown(\(context))" + } +} + +func cmuxCurrentSurfaceFontSizePoints(_ surface: ghostty_surface_t) -> Float? { + guard let quicklookFont = ghostty_surface_quicklook_font(surface) else { + return nil + } + + let ctFont = Unmanaged.fromOpaque(quicklookFont).takeRetainedValue() + let points = Float(CTFontGetSize(ctFont)) + guard points > 0 else { return nil } + return points +} + +func cmuxInheritedSurfaceConfig( + sourceSurface: ghostty_surface_t, + context: ghostty_surface_context_e +) -> ghostty_surface_config_s { + let inherited = ghostty_surface_inherited_config(sourceSurface, context) + var config = inherited + + // Make runtime zoom inheritance explicit, even when Ghostty's + // inherit-font-size config is disabled. + let runtimePoints = cmuxCurrentSurfaceFontSizePoints(sourceSurface) + if let points = runtimePoints { + config.font_size = points + } + +#if DEBUG + let inheritedText = String(format: "%.2f", inherited.font_size) + let runtimeText = runtimePoints.map { String(format: "%.2f", $0) } ?? "nil" + let finalText = String(format: "%.2f", config.font_size) + dlog( + "zoom.inherit context=\(cmuxSurfaceContextName(context)) " + + "inherited=\(inheritedText) runtime=\(runtimeText) final=\(finalText)" + ) +#endif + + return config +} struct SidebarStatusEntry { let key: String @@ -261,6 +313,15 @@ final class Workspace: Identifiable, ObservableObject { /// When true, suppresses auto-creation in didSplitPane (programmatic splits handle their own panels) private var isProgrammaticSplit = false + /// Last terminal panel used as an inheritance source (typically last focused terminal). + private var lastTerminalConfigInheritancePanelId: UUID? + /// Last known terminal font points from inheritance sources. Used as fallback when + /// no live terminal surface is currently available. + private var lastTerminalConfigInheritanceFontPoints: Float? + /// Per-panel inherited zoom lineage. Descendants reuse this root value unless + /// a panel is explicitly re-zoomed by the user. + private var terminalInheritanceFontPointsByPanelId: [UUID: Float] = [:] + /// Callback used by TabManager to capture recently closed browser panels for Cmd+Shift+T restore. var onClosedBrowserPanel: ((ClosedBrowserPanelRestoreSnapshot) -> Void)? @@ -376,7 +437,12 @@ final class Workspace: Identifiable, ObservableObject { } } - init(title: String = "Terminal", workingDirectory: String? = nil, portOrdinal: Int = 0) { + init( + title: String = "Terminal", + workingDirectory: String? = nil, + portOrdinal: Int = 0, + configTemplate: ghostty_surface_config_s? = nil + ) { self.id = UUID() self.portOrdinal = portOrdinal self.processTitle = title @@ -414,11 +480,13 @@ final class Workspace: Identifiable, ObservableObject { let terminalPanel = TerminalPanel( workspaceId: id, context: GHOSTTY_SURFACE_CONTEXT_TAB, + configTemplate: configTemplate, workingDirectory: hasWorkingDirectory ? trimmedWorkingDirectory : nil, portOrdinal: portOrdinal ) panels[terminalPanel.id] = terminalPanel panelTitles[terminalPanel.id] = terminalPanel.displayTitle + seedTerminalInheritanceFontPoints(panelId: terminalPanel.id, configTemplate: configTemplate) // Create initial tab in bonsplit and store the mapping var initialTabId: TabID? @@ -919,6 +987,169 @@ final class Workspace: Identifiable, ObservableObject { // MARK: - Panel Operations + private func seedTerminalInheritanceFontPoints( + panelId: UUID, + configTemplate: ghostty_surface_config_s? + ) { + guard let fontPoints = configTemplate?.font_size, fontPoints > 0 else { return } + terminalInheritanceFontPointsByPanelId[panelId] = fontPoints + lastTerminalConfigInheritanceFontPoints = fontPoints + } + + private func resolvedTerminalInheritanceFontPoints( + for terminalPanel: TerminalPanel, + sourceSurface: ghostty_surface_t, + inheritedConfig: ghostty_surface_config_s + ) -> Float? { + let runtimePoints = cmuxCurrentSurfaceFontSizePoints(sourceSurface) + if let rooted = terminalInheritanceFontPointsByPanelId[terminalPanel.id], rooted > 0 { + if let runtimePoints, abs(runtimePoints - rooted) > 0.05 { + // Runtime zoom changed after lineage was seeded (manual zoom on descendant); + // treat runtime as the new root for future descendants. + return runtimePoints + } + return rooted + } + if inheritedConfig.font_size > 0 { + return inheritedConfig.font_size + } + return runtimePoints + } + + private func rememberTerminalConfigInheritanceSource(_ terminalPanel: TerminalPanel) { + lastTerminalConfigInheritancePanelId = terminalPanel.id + if let sourceSurface = terminalPanel.surface.surface, + let runtimePoints = cmuxCurrentSurfaceFontSizePoints(sourceSurface) { + let existing = terminalInheritanceFontPointsByPanelId[terminalPanel.id] + if existing == nil || abs((existing ?? runtimePoints) - runtimePoints) > 0.05 { + terminalInheritanceFontPointsByPanelId[terminalPanel.id] = runtimePoints + } + lastTerminalConfigInheritanceFontPoints = + terminalInheritanceFontPointsByPanelId[terminalPanel.id] ?? runtimePoints + } + } + + func lastRememberedTerminalPanelForConfigInheritance() -> TerminalPanel? { + guard let panelId = lastTerminalConfigInheritancePanelId else { return nil } + return terminalPanel(for: panelId) + } + + func lastRememberedTerminalFontPointsForConfigInheritance() -> Float? { + lastTerminalConfigInheritanceFontPoints + } + + /// Candidate terminal panels used as the source when creating inherited Ghostty config. + /// Preference order: + /// 1) explicitly preferred terminal panel (when the caller has one), + /// 2) selected terminal in the target pane, + /// 3) currently focused terminal in the workspace, + /// 4) last remembered terminal source, + /// 5) first terminal tab in the target pane, + /// 6) deterministic workspace fallback. + private func terminalPanelConfigInheritanceCandidates( + preferredPanelId: UUID? = nil, + inPane preferredPaneId: PaneID? = nil + ) -> [TerminalPanel] { + var candidates: [TerminalPanel] = [] + var seen: Set = [] + + func appendCandidate(_ panel: TerminalPanel?) { + guard let panel, seen.insert(panel.id).inserted else { return } + candidates.append(panel) + } + + if let preferredPanelId, + let terminalPanel = terminalPanel(for: preferredPanelId) { + appendCandidate(terminalPanel) + } + + if let preferredPaneId, + let selectedSurfaceId = bonsplitController.selectedTab(inPane: preferredPaneId)?.id, + let selectedPanelId = panelIdFromSurfaceId(selectedSurfaceId), + let selectedTerminalPanel = terminalPanel(for: selectedPanelId) { + appendCandidate(selectedTerminalPanel) + } + + if let focusedTerminalPanel { + appendCandidate(focusedTerminalPanel) + } + + if let rememberedTerminalPanel = lastRememberedTerminalPanelForConfigInheritance() { + appendCandidate(rememberedTerminalPanel) + } + + if let preferredPaneId { + for tab in bonsplitController.tabs(inPane: preferredPaneId) { + guard let panelId = panelIdFromSurfaceId(tab.id), + let terminalPanel = terminalPanel(for: panelId) else { continue } + appendCandidate(terminalPanel) + } + } + + for terminalPanel in panels.values + .compactMap({ $0 as? TerminalPanel }) + .sorted(by: { $0.id.uuidString < $1.id.uuidString }) { + appendCandidate(terminalPanel) + } + + return candidates + } + + /// Picks the first terminal panel candidate used as the inheritance source. + func terminalPanelForConfigInheritance( + preferredPanelId: UUID? = nil, + inPane preferredPaneId: PaneID? = nil + ) -> TerminalPanel? { + terminalPanelConfigInheritanceCandidates( + preferredPanelId: preferredPanelId, + inPane: preferredPaneId + ).first + } + + private func inheritedTerminalConfig( + preferredPanelId: UUID? = nil, + inPane preferredPaneId: PaneID? = nil + ) -> ghostty_surface_config_s? { + // Walk candidates in priority order and use the first panel with a live surface. + // This avoids returning nil when the top candidate exists but is not attached yet. + for terminalPanel in terminalPanelConfigInheritanceCandidates( + preferredPanelId: preferredPanelId, + inPane: preferredPaneId + ) { + guard let sourceSurface = terminalPanel.surface.surface else { continue } + var config = cmuxInheritedSurfaceConfig( + sourceSurface: sourceSurface, + context: GHOSTTY_SURFACE_CONTEXT_SPLIT + ) + if let rootedFontPoints = resolvedTerminalInheritanceFontPoints( + for: terminalPanel, + sourceSurface: sourceSurface, + inheritedConfig: config + ), rootedFontPoints > 0 { + config.font_size = rootedFontPoints + terminalInheritanceFontPointsByPanelId[terminalPanel.id] = rootedFontPoints + } + rememberTerminalConfigInheritanceSource(terminalPanel) + if config.font_size > 0 { + lastTerminalConfigInheritanceFontPoints = config.font_size + } + return config + } + + if let fallbackFontPoints = lastTerminalConfigInheritanceFontPoints { + var config = ghostty_surface_config_new() + config.font_size = fallbackFontPoints +#if DEBUG + dlog( + "zoom.inherit fallback=lastKnownFont context=split font=\(String(format: "%.2f", fallbackFontPoints))" + ) +#endif + return config + } + + return nil + } + /// Create a new split with a terminal panel @discardableResult func newTerminalSplit( @@ -927,22 +1158,6 @@ final class Workspace: Identifiable, ObservableObject { insertFirst: Bool = false, focus: Bool = true ) -> TerminalPanel? { - // Get inherited config from the source terminal when possible. - // If the split is initiated from a non-terminal panel (for example browser), - // fall back to any terminal in the workspace. - let inheritedConfig: ghostty_surface_config_s? = { - if let sourceTerminal = terminalPanel(for: panelId), - let existing = sourceTerminal.surface.surface { - return ghostty_surface_inherited_config(existing, GHOSTTY_SURFACE_CONTEXT_SPLIT) - } - if let fallbackSurface = panels.values - .compactMap({ ($0 as? TerminalPanel)?.surface.surface }) - .first { - return ghostty_surface_inherited_config(fallbackSurface, GHOSTTY_SURFACE_CONTEXT_SPLIT) - } - return nil - }() - // Find the pane containing the source panel guard let sourceTabId = surfaceIdFromPanelId(panelId) else { return nil } var sourcePaneId: PaneID? @@ -955,6 +1170,7 @@ final class Workspace: Identifiable, ObservableObject { } guard let paneId = sourcePaneId else { return nil } + let inheritedConfig = inheritedTerminalConfig(preferredPanelId: panelId, inPane: paneId) // Create the new terminal panel. let newPanel = TerminalPanel( @@ -965,6 +1181,7 @@ final class Workspace: Identifiable, ObservableObject { ) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle + seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) // Pre-generate the bonsplit tab ID so we can install the panel mapping before bonsplit // mutates layout state (avoids transient "Empty Panel" flashes during split). @@ -989,6 +1206,7 @@ final class Workspace: Identifiable, ObservableObject { panels.removeValue(forKey: newPanel.id) panelTitles.removeValue(forKey: newPanel.id) surfaceIdToPanelId.removeValue(forKey: newTab.id) + terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) return nil } @@ -1024,16 +1242,7 @@ final class Workspace: Identifiable, ObservableObject { func newTerminalSurface(inPane paneId: PaneID, focus: Bool? = nil) -> TerminalPanel? { let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) - // Get an existing terminal panel to inherit config from - let inheritedConfig: ghostty_surface_config_s? = { - for panel in panels.values { - if let terminalPanel = panel as? TerminalPanel, - let surface = terminalPanel.surface.surface { - return ghostty_surface_inherited_config(surface, GHOSTTY_SURFACE_CONTEXT_SPLIT) - } - } - return nil - }() + let inheritedConfig = inheritedTerminalConfig(inPane: paneId) // Create new terminal panel let newPanel = TerminalPanel( @@ -1044,6 +1253,7 @@ final class Workspace: Identifiable, ObservableObject { ) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle + seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) // Create tab in bonsplit guard let newTabId = bonsplitController.createTab( @@ -1056,6 +1266,7 @@ final class Workspace: Identifiable, ObservableObject { ) else { panels.removeValue(forKey: newPanel.id) panelTitles.removeValue(forKey: newPanel.id) + terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) return nil } @@ -1819,14 +2030,19 @@ final class Workspace: Identifiable, ObservableObject { /// Create a new terminal panel (used when replacing the last panel) @discardableResult func createReplacementTerminalPanel() -> TerminalPanel { + let inheritedConfig = inheritedTerminalConfig( + preferredPanelId: focusedPanelId, + inPane: bonsplitController.focusedPaneId + ) let newPanel = TerminalPanel( workspaceId: id, context: GHOSTTY_SURFACE_CONTEXT_TAB, - configTemplate: nil, + configTemplate: inheritedConfig, portOrdinal: portOrdinal ) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle + seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) // Create tab in bonsplit if let newTabId = bonsplitController.createTab( @@ -2100,6 +2316,9 @@ extension Workspace: BonsplitDelegate { } panel.focus() + if let terminalPanel = panel as? TerminalPanel { + rememberTerminalConfigInheritanceSource(terminalPanel) + } let isManuallyUnread = manualUnreadPanelIds.contains(panelId) let markedAt = manualUnreadMarkedAt[panelId] if Self.shouldClearManualUnread( @@ -2327,6 +2546,10 @@ extension Workspace: BonsplitDelegate { panelSubscriptions.removeValue(forKey: panelId) surfaceTTYNames.removeValue(forKey: panelId) PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId) + terminalInheritanceFontPointsByPanelId.removeValue(forKey: panelId) + if lastTerminalConfigInheritancePanelId == panelId { + lastTerminalConfigInheritancePanelId = nil + } // Keep the workspace invariant: always retain at least one real panel. // This prevents runtime close callbacks from ever collapsing into a tabless workspace. @@ -2519,15 +2742,7 @@ extension Workspace: BonsplitDelegate { // Keep the existing placeholder tab identity and replace only the panel mapping. // This avoids an extra create+close tab churn that can transiently render an // empty pane during drag-to-split of a single-tab pane. - let inheritedConfig: ghostty_surface_config_s? = { - for panel in panels.values { - if let terminalPanel = panel as? TerminalPanel, - let surface = terminalPanel.surface.surface { - return ghostty_surface_inherited_config(surface, GHOSTTY_SURFACE_CONTEXT_SPLIT) - } - } - return nil - }() + let inheritedConfig = inheritedTerminalConfig(inPane: originalPane) let replacementPanel = TerminalPanel( workspaceId: id, @@ -2537,6 +2752,7 @@ extension Workspace: BonsplitDelegate { ) panels[replacementPanel.id] = replacementPanel panelTitles[replacementPanel.id] = replacementPanel.displayTitle + seedTerminalInheritanceFontPoints(panelId: replacementPanel.id, configTemplate: inheritedConfig) surfaceIdToPanelId[replacementTab.id] = replacementPanel.id bonsplitController.updateTab( @@ -2579,7 +2795,7 @@ extension Workspace: BonsplitDelegate { // Get the focused terminal in the original pane to inherit config from guard let sourceTabId = controller.selectedTab(inPane: originalPane)?.id, let sourcePanelId = panelIdFromSurfaceId(sourceTabId), - let sourcePanel = terminalPanel(for: sourcePanelId) else { return } + terminalPanel(for: sourcePanelId) != nil else { return } #if DEBUG dlog( @@ -2588,11 +2804,10 @@ extension Workspace: BonsplitDelegate { ) #endif - let inheritedConfig: ghostty_surface_config_s? = if let existing = sourcePanel.surface.surface { - ghostty_surface_inherited_config(existing, GHOSTTY_SURFACE_CONTEXT_SPLIT) - } else { - nil - } + let inheritedConfig = inheritedTerminalConfig( + preferredPanelId: sourcePanelId, + inPane: originalPane + ) let newPanel = TerminalPanel( workspaceId: id, @@ -2602,6 +2817,7 @@ extension Workspace: BonsplitDelegate { ) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle + seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) guard let newTabId = bonsplitController.createTab( title: newPanel.displayTitle, @@ -2613,6 +2829,7 @@ extension Workspace: BonsplitDelegate { ) else { panels.removeValue(forKey: newPanel.id) panelTitles.removeValue(forKey: newPanel.id) + terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) return } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 59208945..c12d2c08 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -1238,6 +1238,10 @@ final class BrowserZoomShortcutActionTests: XCTestCase { browserZoomShortcutAction(flags: [.command, .shift], chars: "+", keyCode: 24), .zoomIn ) + XCTAssertEqual( + browserZoomShortcutAction(flags: [.command], chars: "+", keyCode: 30), + .zoomIn + ) } func testZoomOutSupportsMinusAndUnderscoreVariants() { @@ -1316,6 +1320,30 @@ final class BrowserZoomShortcutRoutingPolicyTests: XCTestCase { } } +final class GhosttyResponderResolutionTests: XCTestCase { + private final class FocusProbeView: NSView { + override var acceptsFirstResponder: Bool { true } + } + + func testResolvesGhosttyViewFromDescendantResponder() { + let ghosttyView = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 200, height: 120)) + let descendant = FocusProbeView(frame: NSRect(x: 0, y: 0, width: 40, height: 40)) + ghosttyView.addSubview(descendant) + + XCTAssertTrue(cmuxOwningGhosttyView(for: descendant) === ghosttyView) + } + + func testResolvesGhosttyViewFromGhosttyResponder() { + let ghosttyView = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 200, height: 120)) + XCTAssertTrue(cmuxOwningGhosttyView(for: ghosttyView) === ghosttyView) + } + + func testReturnsNilForUnrelatedResponder() { + let view = FocusProbeView(frame: NSRect(x: 0, y: 0, width: 40, height: 40)) + XCTAssertNil(cmuxOwningGhosttyView(for: view)) + } +} + final class CommandPaletteKeyboardNavigationTests: XCTestCase { func testArrowKeysMoveSelectionWithoutModifiers() { XCTAssertEqual( @@ -2313,6 +2341,141 @@ final class TabManagerSurfaceCreationTests: XCTestCase { } } +@MainActor +final class WorkspaceTerminalConfigInheritanceSelectionTests: XCTestCase { + func testPrefersSelectedTerminalInTargetPaneOverFocusedTerminalElsewhere() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let leftPanelId = workspace.focusedPanelId, + let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal), + let leftPaneId = workspace.paneId(forPanelId: leftPanelId) else { + XCTFail("Expected workspace split setup to succeed") + return + } + + // Programmatic split focuses the new right panel by default. + XCTAssertEqual(workspace.focusedPanelId, rightPanel.id) + + let sourcePanel = workspace.terminalPanelForConfigInheritance(inPane: leftPaneId) + XCTAssertEqual( + sourcePanel?.id, + leftPanelId, + "Expected inheritance to use the selected terminal in the target pane" + ) + } + + func testFallsBackToAnotherTerminalInPaneWhenSelectedTabIsBrowser() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let terminalPanelId = workspace.focusedPanelId, + let paneId = workspace.paneId(forPanelId: terminalPanelId), + let browserPanel = workspace.newBrowserSurface(inPane: paneId, focus: true) else { + XCTFail("Expected workspace browser setup to succeed") + return + } + + XCTAssertEqual(workspace.focusedPanelId, browserPanel.id) + + let sourcePanel = workspace.terminalPanelForConfigInheritance(inPane: paneId) + XCTAssertEqual( + sourcePanel?.id, + terminalPanelId, + "Expected inheritance to fall back to a terminal in the pane when browser is selected" + ) + } + + func testPreferredTerminalPanelWinsWhenProvided() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let terminalPanelId = workspace.focusedPanelId else { + XCTFail("Expected selected workspace with a terminal panel") + return + } + + let sourcePanel = workspace.terminalPanelForConfigInheritance(preferredPanelId: terminalPanelId) + XCTAssertEqual(sourcePanel?.id, terminalPanelId) + } + + func testPrefersLastFocusedTerminalWhenBrowserFocusedInDifferentPane() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let leftTerminalPanelId = workspace.focusedPanelId, + let rightTerminalPanel = workspace.newTerminalSplit(from: leftTerminalPanelId, orientation: .horizontal), + let rightPaneId = workspace.paneId(forPanelId: rightTerminalPanel.id) else { + XCTFail("Expected split setup to succeed") + return + } + + workspace.focusPanel(leftTerminalPanelId) + _ = workspace.newBrowserSurface(inPane: rightPaneId, focus: true) + XCTAssertNotEqual(workspace.focusedPanelId, leftTerminalPanelId) + + let sourcePanel = workspace.terminalPanelForConfigInheritance(inPane: rightPaneId) + XCTAssertEqual( + sourcePanel?.id, + leftTerminalPanelId, + "Expected inheritance to prefer last focused terminal when browser is focused in another pane" + ) + } +} + +@MainActor +final class TabManagerWorkspaceConfigInheritanceSourceTests: XCTestCase { + func testUsesFocusedTerminalWhenTerminalIsFocused() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let terminalPanelId = workspace.focusedPanelId else { + XCTFail("Expected selected workspace with focused terminal") + return + } + + let sourcePanel = manager.terminalPanelForWorkspaceConfigInheritanceSource() + XCTAssertEqual(sourcePanel?.id, terminalPanelId) + } + + func testFallsBackToTerminalWhenBrowserIsFocused() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let terminalPanelId = workspace.focusedPanelId, + let paneId = workspace.paneId(forPanelId: terminalPanelId), + let browserPanel = workspace.newBrowserSurface(inPane: paneId, focus: true) else { + XCTFail("Expected selected workspace setup to succeed") + return + } + + XCTAssertEqual(workspace.focusedPanelId, browserPanel.id) + + let sourcePanel = manager.terminalPanelForWorkspaceConfigInheritanceSource() + XCTAssertEqual( + sourcePanel?.id, + terminalPanelId, + "Expected new workspace inheritance source to resolve to the pane terminal when browser is focused" + ) + } + + func testPrefersLastFocusedTerminalAcrossPanesWhenBrowserIsFocused() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let leftTerminalPanelId = workspace.focusedPanelId, + let rightTerminalPanel = workspace.newTerminalSplit(from: leftTerminalPanelId, orientation: .horizontal), + let rightPaneId = workspace.paneId(forPanelId: rightTerminalPanel.id) else { + XCTFail("Expected split setup to succeed") + return + } + + workspace.focusPanel(leftTerminalPanelId) + _ = workspace.newBrowserSurface(inPane: rightPaneId, focus: true) + XCTAssertNotEqual(workspace.focusedPanelId, leftTerminalPanelId) + + let sourcePanel = manager.terminalPanelForWorkspaceConfigInheritanceSource() + XCTAssertEqual( + sourcePanel?.id, + leftTerminalPanelId, + "Expected workspace inheritance source to use last focused terminal across panes" + ) + } +} + @MainActor final class TabManagerReopenClosedBrowserFocusTests: XCTestCase { func testReopenFromDifferentWorkspaceFocusesReopenedBrowser() {