Fix terminal zoom inheritance for new splits/surfaces/workspaces (#384)
* Fix terminal Cmd zoom routing for Ghostty focus descendants (#383) * Inherit new terminal zoom from last terminal context Prefer pane-selected terminal as Ghostty config inheritance source when creating splits/new terminals, then focused/fallback terminals. This preserves runtime zoom/font size when opening the next terminal. * Fix terminal zoom inheritance across split/tab/workspace creation
This commit is contained in:
parent
c1822fdaac
commit
6598a38fe3
5 changed files with 551 additions and 51 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<CChar>, UnsafeMutablePointer<CChar>)] = []
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<CTFont>.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<UUID> = []
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue