diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/MobileViewportFontFitState.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/MobileViewportFontFitState.swift new file mode 100644 index 000000000000..0a5b4aa5ae27 --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/MobileViewportFontFitState.swift @@ -0,0 +1,14 @@ +/// Temporary runtime font state owned by mobile viewport fitting. +struct MobileViewportFontFitState: Equatable { + var baseRuntimePointSize: Float32 + var fittedRuntimePointSize: Float32 + + func matchesFittedRuntimePointSize(_ runtimePointSize: Float32) -> Bool { + abs(runtimePointSize - fittedRuntimePointSize) <= 0.05 + } + + mutating func rebase(to runtimePointSize: Float32) { + baseRuntimePointSize = runtimePointSize + fittedRuntimePointSize = runtimePointSize + } +} diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+FontSizeLineage.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+FontSizeLineage.swift new file mode 100644 index 000000000000..c2132aec494a --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+FontSizeLineage.swift @@ -0,0 +1,108 @@ +public import CmuxTerminalCore +internal import GhosttyKit + +extension TerminalSurface { + /// Captures the current font size and its surface-local ownership state. + /// + /// Live Ghostty state is authoritative. When the runtime is unavailable, + /// the last captured lineage survives hibernation and session restoration. + /// + /// - Returns: Current font-size lineage, or nil before a size is known. + @MainActor + public func fontSizeLineageSnapshot() -> TerminalFontSizeLineage? { + guard let runtimeSurface = liveSurfaceForGhosttyAccess( + reason: "fontSizeLineage.snapshot" + ) else { + return lastKnownFontSizeLineage + } + guard let runtimePoints = GhosttySurfaceRuntimeProbe.currentSurfaceFontSizePoints( + runtimeSurface + ) else { + return lastKnownFontSizeLineage + } + + return recordObservedFontSizeLineage( + runtimePoints: runtimePoints, + isExplicitOverride: ghostty_surface_font_size_adjusted(runtimeSurface), + globalFontMagnificationPercent: globalFontMagnificationPercent() + ) + } + + /// Reconciles observed runtime points with durable surface ownership. + /// + /// A live value matching the active mobile fit is temporary and leaves the + /// pre-fit lineage unchanged. A different live value came from outside the + /// fitter, so it becomes the new durable base and restore point. + @MainActor + func recordObservedFontSizeLineage( + runtimePoints: Float32, + isExplicitOverride: Bool, + globalFontMagnificationPercent: Int + ) -> TerminalFontSizeLineage? { + guard runtimePoints.isFinite, runtimePoints > 0 else { + return lastKnownFontSizeLineage + } + if var fitState = mobileViewportFontFitState { + guard !isExplicitOverride + || !fitState.matchesFittedRuntimePointSize(runtimePoints) else { + return lastKnownFontSizeLineage + } + fitState.rebase(to: runtimePoints) + mobileViewportFontFitState = fitState + } + + let lineage = TerminalFontSizeLineage( + basePoints: CmuxSurfaceConfigTemplate.baseFontSize( + fromRuntimePoints: runtimePoints, + percent: globalFontMagnificationPercent + ), + isExplicitOverride: isExplicitOverride + ) + recordCurrentFontSizeLineage(lineage) + return lineage + } + + /// Records live font-size lineage for hibernation and split inheritance. + /// + /// A non-explicit value is retained as the last known split-inheritance + /// value, while separately recording that this surface must follow current + /// config when its own runtime is recreated. + @MainActor + func recordCurrentFontSizeLineage(_ lineage: TerminalFontSizeLineage) { + guard lastKnownFontSizeLineage != lineage else { return } + lastKnownFontSizeLineage = lineage + onFontSizeLineageChanged?(lineage) + } + + /// Resolves the Swift-owned template used to create this surface's runtime. + /// + /// Initial non-explicit lineage seeds the first native runtime. After a + /// native lifetime, non-explicit lineage remains available to descendants + /// but must not seed this surface again because Cmd+0 and ordinary unzoomed + /// terminals follow the then-current terminal config. + @MainActor + func runtimeCreationConfigTemplate() -> CmuxSurfaceConfigTemplate { + var template = configTemplate ?? CmuxSurfaceConfigTemplate() + if lastKnownFontSizeLineage?.isExplicitOverride == false, + runtimeSurfaceGeneration > 0 { + template.fontSizeLineage = nil + } else if let lastKnownFontSizeLineage { + template.fontSizeLineage = lastKnownFontSizeLineage + } + return template + } + + /// Returns the explicit unscaled font override to persist in a session snapshot. + /// + /// Nil means the terminal follows the current config and should not pin a + /// font size across relaunches. + @MainActor + public func sessionFontSizeOverrideBasePoints() -> Float32? { + guard let lineage = fontSizeLineageSnapshot(), + lineage.isExplicitOverride, + TerminalFontSizePolicy().acceptsPersistedBasePoints(lineage.basePoints) else { + return nil + } + return lineage.basePoints + } +} diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+MobileViewportFit.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+MobileViewportFit.swift index 3a1626267a9a..04fa336037b6 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+MobileViewportFit.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+MobileViewportFit.swift @@ -176,10 +176,10 @@ extension TerminalSurface { paneHost.setMobileViewportBorder(size: nil, drawRight: false, drawBottom: false) guard let surface = liveSurfaceForGhosttyAccess(reason: "clearMobileViewportLimit") else { - mobileFitBaseFontPointSize = nil - mobileFittedFontPointSize = nil + mobileViewportFontFitState = nil return false } + _ = fontSizeLineageSnapshot() let fontRestored = restoreMobileViewportFitFontIfNeeded() let uncappedWidth = lastUncappedPixelWidth let uncappedHeight = lastUncappedPixelHeight @@ -229,8 +229,9 @@ extension TerminalSurface { let grantedRows = max(1, mobileViewportCellLimit.rows) let paneWidth = max(1, Int(width)) let paneHeight = max(1, Int(height)) + _ = fontSizeLineageSnapshot() let baseFont = resolvedMobileViewportBaseFontPointSize(surface: surface) - var currentFont = mobileFittedFontPointSize + var currentFont = mobileViewportFontFitState?.fittedRuntimePointSize ?? GhosttySurfaceRuntimeProbe.currentSurfaceFontSizePoints(surface) ?? baseFont var measurement = mobileViewportMeasurement(surface: surface) @@ -247,11 +248,7 @@ extension TerminalSurface { for _ in 0..<3 { let fontFloor = min(baseFont, MobileViewportFitGeometry.defaultFontFloorPointSize) if abs(targetFont - currentFont) >= 0.25 { - if mobileFitBaseFontPointSize == nil { - mobileFitBaseFontPointSize = baseFont - } - if applyMobileViewportFontPointSize(targetFont) { - mobileFittedFontPointSize = targetFont + if applyMobileViewportFontPointSize(targetFont, baseFont: baseFont) { currentFont = targetFont fontChanged = true measurement = mobileViewportMeasurement(surface: surface) @@ -276,11 +273,7 @@ extension TerminalSurface { guard abs(nextTarget - currentFont) > 0.001 else { break } - if mobileFitBaseFontPointSize == nil { - mobileFitBaseFontPointSize = baseFont - } - if applyMobileViewportFontPointSize(nextTarget) { - mobileFittedFontPointSize = nextTarget + if applyMobileViewportFontPointSize(nextTarget, baseFont: baseFont) { currentFont = nextTarget fontChanged = true measurement = mobileViewportMeasurement(surface: surface) @@ -301,14 +294,10 @@ extension TerminalSurface { // This force-to-floor step can be the first font change of the fit // (every earlier apply may have been skipped or broken out of), so // it must capture the restore point like the loop branches do. - if mobileFitBaseFontPointSize == nil { - mobileFitBaseFontPointSize = baseFont - } - guard applyMobileViewportFontPointSize(fontFloor) else { + guard applyMobileViewportFontPointSize(fontFloor, baseFont: baseFont) else { let fallback = geometry.cappedFallbackGrant(grantedColumns: grantedColumns, grantedRows: grantedRows) return .fallback(width: fallback.width, height: fallback.height, columns: fallback.columns, rows: fallback.rows, grant: appliedBox, baseFont: baseFont, currentFont: currentFont, fontChanged: fontChanged) } - mobileFittedFontPointSize = fontFloor currentFont = fontFloor fontChanged = true measurement = mobileViewportMeasurement(surface: surface) @@ -397,8 +386,8 @@ extension TerminalSurface { @MainActor private func resolvedMobileViewportBaseFontPointSize(surface: ghostty_surface_t) -> Float { - if let mobileFitBaseFontPointSize { - return mobileFitBaseFontPointSize + if let mobileViewportFontFitState { + return mobileViewportFontFitState.baseRuntimePointSize } if let current = GhosttySurfaceRuntimeProbe.currentSurfaceFontSizePoints(surface), current.isFinite, @@ -415,26 +404,49 @@ extension TerminalSurface { @discardableResult @MainActor private func restoreMobileViewportFitFontIfNeeded() -> Bool { - guard mobileFittedFontPointSize != nil, - let baseFont = mobileFitBaseFontPointSize else { - mobileFitBaseFontPointSize = nil - mobileFittedFontPointSize = nil + guard mobileViewportFontFitState != nil else { return false } - guard applyMobileViewportFontPointSize(baseFont) else { + let restored: Bool + if let lineage = lastKnownFontSizeLineage, + lineage.isExplicitOverride { + // Lineage stores unscaled base points, so restoration intentionally + // reapplies the current global magnification. + let runtimePoints = CmuxSurfaceConfigTemplate.runtimeFontSize( + fromBasePoints: lineage.basePoints, + percent: globalFontMagnificationPercent() + ) + restored = performMobileViewportFontPointSizeAction(runtimePoints) + } else { + restored = performInternalBindingAction("reset_font_size") + } + guard restored else { // Keep the fit state when the binding action fails so a later // clear or fit pass can retry; dropping it here would leave the // pane at the shrunken font with no way back to the base size. return false } - mobileFitBaseFontPointSize = nil - mobileFittedFontPointSize = nil + mobileViewportFontFitState = nil return true } @MainActor @discardableResult - private func applyMobileViewportFontPointSize(_ points: Float) -> Bool { + private func applyMobileViewportFontPointSize(_ points: Float, baseFont: Float) -> Bool { + guard performMobileViewportFontPointSizeAction(points) else { return false } + if mobileViewportFontFitState == nil { + mobileViewportFontFitState = MobileViewportFontFitState( + baseRuntimePointSize: baseFont, + fittedRuntimePointSize: points + ) + } else { + mobileViewportFontFitState?.fittedRuntimePointSize = points + } + return true + } + + @MainActor + private func performMobileViewportFontPointSizeAction(_ points: Float) -> Bool { let action = String(format: "set_font_size:%.3f", points) return performInternalBindingAction(action) } diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift index 4843b17f68a5..86e5336fcd3f 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift @@ -301,6 +301,8 @@ extension TerminalSurface { /// agent-hibernation resume. @MainActor public func suspendRuntimeSurfaceForAgentHibernation(reason: String) { + _ = fontSizeLineageSnapshot() + mobileViewportFontFitState = nil runtimeSurfaceSuspendedForAgentHibernation = true backgroundSurfaceStartQueued = false backgroundSurfaceStartSource = .normal @@ -609,21 +611,18 @@ extension TerminalSurface { // wrapping at Ghostty's default grid. flushPendingRemoteOutput(to: createdSurface) - // Some GhosttyKit builds can drop inherited font_size during post-create - // config/scale reconciliation. Re-apply runtime points so all creation - // paths preserve zoom from the source terminal. - if let inheritedBaseFontPoints = configTemplate?.fontSize, - inheritedBaseFontPoints > 0 { + // Some GhosttyKit builds can drop explicit font_size during post-create + // config/scale reconciliation. Re-apply explicit runtime points so + // Ghostty retains surface-local ownership; otherwise Cmd+0 could not + // clear the restored override for the next snapshot. Non-explicit + // lineage intentionally reconciles to the current terminal config. + if let inheritedFontSizeLineage = lastKnownFontSizeLineage, + inheritedFontSizeLineage.isExplicitOverride, + inheritedFontSizeLineage.basePoints > 0 { + let inheritedBaseFontPoints = inheritedFontSizeLineage.basePoints let inheritedRuntimeFontPoints = CmuxSurfaceConfigTemplate.runtimeFontSize(fromBasePoints: inheritedBaseFontPoints, percent: globalFontMagnificationPercent()) - let currentFontPoints = GhosttySurfaceRuntimeProbe.currentSurfaceFontSizePoints(createdSurface) - let shouldReapply = { - guard let currentFontPoints else { return true } - return abs(currentFontPoints - inheritedRuntimeFontPoints) > 0.05 - }() - if shouldReapply { - let action = String(format: "set_font_size:%.3f", inheritedRuntimeFontPoints) - _ = performInternalBindingAction(action) - } + let action = String(format: "set_font_size:%.3f", inheritedRuntimeFontPoints) + _ = performInternalBindingAction(action) } // Re-apply the desired focus state after creation so the live runtime diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeSurfaceCreation.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeSurfaceCreation.swift index 1e212d43e85e..a57bf862fab9 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeSurfaceCreation.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeSurfaceCreation.swift @@ -18,7 +18,7 @@ extension TerminalSurface { scaleFactors: (x: CGFloat, y: CGFloat, layer: CGFloat), claudeShim: ClaudeCommandShim? ) -> (createdSurface: ghostty_surface_t?, runtimeInitialInput: String?) { - var baseConfig = configTemplate ?? CmuxSurfaceConfigTemplate() + var baseConfig = runtimeCreationConfigTemplate() var surfaceConfig = ghostty_surface_config_new() let magnificationPercent = globalFontMagnificationPercent() surfaceConfig.font_size = CmuxSurfaceConfigTemplate.runtimeFontSize( diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift index 097f29cabb06..38aaaa1cc91a 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift @@ -150,6 +150,7 @@ public final class TerminalSurface: Identifiable, ObservableObject { let portOrdinal: Int let surfaceContext: ghostty_surface_context_e let configTemplate: CmuxSurfaceConfigTemplate? + var lastKnownFontSizeLineage: TerminalFontSizeLineage? let workingDirectory: String? /// The command to run instead of the default shell, if any. @@ -184,6 +185,8 @@ public final class TerminalSurface: Identifiable, ObservableObject { /// Remote tmux manual-I/O resize and runtime-readiness hooks. @MainActor public var onManualSizeApplied: (@MainActor (TerminalSurfaceRawSizingSample) -> Void)? @MainActor public var onRuntimeReady: (@MainActor () -> Void)? + /// Called after durable font-size lineage changes. + @MainActor public var onFontSizeLineageChanged: (@MainActor (TerminalFontSizeLineage) -> Void)? @MainActor var manualSizeReportPendingWindowAttach = false /// For MANUAL-I/O remote tmux display surfaces: whether to suppress /// ghostty primary-screen reflow on resize. @@ -231,10 +234,8 @@ public final class TerminalSurface: Identifiable, ObservableObject { /// the pinned grid and clips or letterboxes the difference — the same /// answer tmux gives a client whose size disagrees with the window. var assignedGrid: (columns: Int, rows: Int)? - /// Runtime font size to restore when mobile viewport fitting clears. - var mobileFitBaseFontPointSize: Float? - /// Last runtime font size applied by mobile viewport fitting. - var mobileFittedFontPointSize: Float? + /// Temporary runtime font-size ownership while a mobile viewport is fitted. + var mobileViewportFontFitState: MobileViewportFontFitState? // Debug metadata is read from debug/CLI paths off the main thread; the // lock is the sanctioned carve-out for tiny values shared with // synchronous off-isolation readers. @@ -474,6 +475,7 @@ public final class TerminalSurface: Identifiable, ObservableObject { self.tabId = tabId self.surfaceContext = context self.configTemplate = configTemplate + self.lastKnownFontSizeLineage = configTemplate?.fontSizeLineage self.workingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines) self.portOrdinal = portOrdinal self.initialCommand = initialCommand.flatMap { diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceFontSizeLineageTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceFontSizeLineageTests.swift new file mode 100644 index 000000000000..96db0f17ad49 --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceFontSizeLineageTests.swift @@ -0,0 +1,95 @@ +import AppKit +import CmuxTerminalCore +import GhosttyKit +import Testing +@testable import CmuxTerminal + +@MainActor +@Suite struct TerminalSurfaceFontSizeLineageTests { + @Test func initialNonExplicitTemplateSeedsFirstRuntimeCreation() { + var template = CmuxSurfaceConfigTemplate() + template.setFontSize(19, isExplicitOverride: false) + let surface = makeSurface(configTemplate: template) + + #expect(surface.runtimeSurfaceGeneration == 0) + #expect( + surface.runtimeCreationConfigTemplate().fontSizeLineage + == template.fontSizeLineage + ) + } + + @Test func nonExplicitObservedLineageDoesNotSeedRuntimeRecreation() { + var template = CmuxSurfaceConfigTemplate() + template.setFontSize(19, isExplicitOverride: true) + let surface = makeSurface(configTemplate: template) + surface.surface = UnsafeMutableRawPointer(bitPattern: 0x7540) + surface.surface = nil + + surface.recordCurrentFontSizeLineage( + TerminalFontSizeLineage(basePoints: 12, isExplicitOverride: false) + ) + + #expect(surface.runtimeSurfaceGeneration == 2) + #expect(surface.runtimeCreationConfigTemplate().fontSizeLineage == nil) + } + + @Test func oversizedExplicitLineageIsNotPersisted() { + let surface = makeSurface(configTemplate: CmuxSurfaceConfigTemplate()) + surface.recordCurrentFontSizeLineage( + TerminalFontSizeLineage(basePoints: 511, isExplicitOverride: true) + ) + + #expect(surface.sessionFontSizeOverrideBasePoints() == nil) + } + + @Test func maximumExplicitLineageIsPersisted() { + let surface = makeSurface(configTemplate: CmuxSurfaceConfigTemplate()) + surface.recordCurrentFontSizeLineage( + TerminalFontSizeLineage(basePoints: 510, isExplicitOverride: true) + ) + + #expect(surface.sessionFontSizeOverrideBasePoints() == 510) + } + + private func makeSurface( + configTemplate: CmuxSurfaceConfigTemplate + ) -> TerminalSurface { + let nativeView = FakeTerminalSurfaceNativeView( + frame: NSRect(x: 0, y: 0, width: 800, height: 600) + ) + let paneHost = FakeTerminalSurfacePaneHost(surfaceView: nativeView) + return TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: configTemplate, + runtimeSpawnPolicy: .pacedSessionRestore, + dependencies: TerminalSurfaceRuntimeDependencies( + registry: FakeSurfaceRegistry(), + engine: FakeTerminalEngine(), + viewProvider: FakeTerminalSurfaceViewProvider( + surfaceView: nativeView, + paneHost: paneHost + ), + spawnPolicy: FakeSpawnPolicyProvider(), + byteTee: FakeTerminalByteTee(), + rendererRealization: FakeRendererRealizationScheduler(), + hibernationRecorder: FakeHibernationRecorder(), + runtimeTeardown: TerminalSurfaceRuntimeTeardownCoordinator(), + restoreSpawnScheduler: TerminalSurfaceRestoreSpawnScheduler( + interSpawnDelay: .zero + ), + runtimeFilesystem: TerminalSurfaceRuntimeFilesystem( + claudeCommandShimTemporaryDirectory: URL( + fileURLWithPath: "/tmp/cmux-terminal-tests", + isDirectory: true + ), + installClaudeCommandShim: { _, _, _ in nil }, + isExecutableFile: { _ in false } + ), + sessionPortBase: 40_000, + sessionPortRangeSize: 100, + scrollbackReplayEnvironmentKey: "CMUX_TEST_SCROLLBACK_REPLAY" + ) + ) + } +} diff --git a/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c b/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c index 1812857753be..a52a1aa7423d 100644 --- a/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c +++ b/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c @@ -133,6 +133,14 @@ void ghostty_surface_binding_action(void) {} void ghostty_surface_config_new(void) {} void ghostty_surface_free(void) {} void ghostty_surface_free_text(void) {} +float ghostty_surface_font_size(void *surface) { + (void)surface; + return 0; +} +bool ghostty_surface_font_size_adjusted(void *surface) { + (void)surface; + return false; +} uint64_t ghostty_surface_foreground_pid(void *surface) { (void)surface; return cmux_test_foreground_pid; diff --git a/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h b/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h index 1a89dcec7544..f3d7fb84ffde 100644 --- a/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h +++ b/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h @@ -36,6 +36,8 @@ void ghostty_surface_binding_action(void); void ghostty_surface_config_new(void); void ghostty_surface_free(void); void ghostty_surface_free_text(void); +float ghostty_surface_font_size(void *surface); +bool ghostty_surface_font_size_adjusted(void *surface); uint64_t ghostty_surface_foreground_pid(void *surface); void ghostty_surface_has_selection(void); void ghostty_surface_key(void); diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Interop/GhosttySurfaceRuntimeProbe.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Interop/GhosttySurfaceRuntimeProbe.swift index ba838b89f463..685b13ffad7e 100644 --- a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Interop/GhosttySurfaceRuntimeProbe.swift +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Interop/GhosttySurfaceRuntimeProbe.swift @@ -1,4 +1,3 @@ -public import CoreText public import Darwin public import GhosttyKit @@ -45,20 +44,16 @@ public struct GhosttySurfaceRuntimeProbe { /// The current runtime font size of a live surface, in points. /// /// - Parameter surface: The runtime surface to read. - /// - Returns: The QuickLook font size in points, or nil when the surface - /// pointer is stale or the runtime reports no font. + /// - Returns: The live app-thread-owned font size in points, or nil when + /// the surface pointer is stale or the runtime reports no font. + @MainActor public static func currentSurfaceFontSizePoints(_ surface: ghostty_surface_t) -> Float? { guard surfacePointerAppearsLive(surface) else { return nil } - guard let quicklookFont = ghostty_surface_quicklook_font(surface) else { - return nil - } - - let ctFont = Unmanaged.fromOpaque(quicklookFont).takeUnretainedValue() - let points = Float(CTFontGetSize(ctFont)) - guard points > 0 else { return nil } + let points = ghostty_surface_font_size(surface) + guard points.isFinite, points > 0 else { return nil } return points } diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/CmuxSurfaceConfigTemplate.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/CmuxSurfaceConfigTemplate.swift index 7af547672a87..101ff174164b 100644 --- a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/CmuxSurfaceConfigTemplate.swift +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/CmuxSurfaceConfigTemplate.swift @@ -7,8 +7,28 @@ public import GhosttyKit /// command, environment, initial input) either empty for a fresh surface or /// copied from a source surface's inherited C config when splitting. public struct CmuxSurfaceConfigTemplate: Sendable { + /// The font-size lineage applied to the new surface, or nil for the runtime default. + public var fontSizeLineage: TerminalFontSizeLineage? = nil + /// The unscaled base font size in points; `0` means the runtime default. - public var fontSize: Float32 = 0 + /// + /// Assigning a valid size to a fresh template creates an explicit override. + /// Assigning through this property later preserves the lineage's existing + /// ownership. Use ``setFontSize(_:isExplicitOverride:)`` for inherited or + /// observed sizes that should keep following terminal config. + public var fontSize: Float32 { + get { fontSizeLineage?.basePoints ?? 0 } + set { + guard TerminalFontSizePolicy().acceptsPersistedBasePoints(newValue) else { + fontSizeLineage = nil + return + } + fontSizeLineage = TerminalFontSizeLineage( + basePoints: newValue, + isExplicitOverride: fontSizeLineage?.isExplicitOverride ?? true + ) + } + } /// The working directory the spawned shell starts in. public var workingDirectory: String? @@ -28,6 +48,26 @@ public struct CmuxSurfaceConfigTemplate: Sendable { /// Creates an empty template (runtime defaults for every field). public init() {} + /// Sets the template font size and records whether it is a surface-local override. + /// + /// Values outside the persistable base-font range clear the font-size + /// lineage and restore runtime-default behavior. + /// + /// - Parameters: + /// - basePoints: The unscaled font size in points. + /// - isExplicitOverride: Whether the new surface should retain this size + /// independently of later terminal config changes. + public mutating func setFontSize(_ basePoints: Float32, isExplicitOverride: Bool) { + guard TerminalFontSizePolicy().acceptsPersistedBasePoints(basePoints) else { + fontSizeLineage = nil + return + } + fontSizeLineage = TerminalFontSizeLineage( + basePoints: basePoints, + isExplicitOverride: isExplicitOverride + ) + } + /// Creates a template from a ghostty inherited surface config. /// /// - Parameters: @@ -39,9 +79,12 @@ public struct CmuxSurfaceConfigTemplate: Sendable { cConfig: ghostty_surface_config_s, globalFontMagnificationPercent: Int = 100 ) { - fontSize = Self.baseFontSize( - fromRuntimePoints: cConfig.font_size, - percent: globalFontMagnificationPercent + setFontSize( + Self.baseFontSize( + fromRuntimePoints: cConfig.font_size, + percent: globalFontMagnificationPercent + ), + isExplicitOverride: false ) if let workingDirectory = cConfig.working_directory { self.workingDirectory = String(cString: workingDirectory, encoding: .utf8) @@ -83,7 +126,7 @@ public struct CmuxSurfaceConfigTemplate: Sendable { guard runtimePoints.isFinite, runtimePoints > 0 else { return runtimePoints } let scale = Float32(GlobalFontMagnification.scale(for: percent)) guard scale > 0 else { return runtimePoints } - return max(1, runtimePoints / scale) + return runtimePoints / scale } /// Converts an unscaled base font size into the runtime point size Ghostty @@ -100,9 +143,11 @@ public struct CmuxSurfaceConfigTemplate: Sendable { /// - Parameters: /// - basePoints: The unscaled base point size. /// - percent: The global magnification percent used for the conversion. - /// - Returns: The runtime point size for `percent`. + /// - Returns: The runtime point size for `percent`, clamped to Ghostty's + /// supported native range, or zero to preserve runtime-default behavior. public static func runtimeFontSize(fromBasePoints basePoints: Float32, percent: Int) -> Float32 { - guard basePoints.isFinite, basePoints > 0 else { return basePoints } - return max(1, basePoints * Float32(GlobalFontMagnification.scale(for: percent))) + guard basePoints.isFinite, basePoints > 0 else { return 0 } + let scaledPoints = basePoints * Float32(GlobalFontMagnification.scale(for: percent)) + return TerminalFontSizePolicy().clampedRuntimePoints(scaledPoints) } } diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/TerminalFontSizeCreationPolicy.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/TerminalFontSizeCreationPolicy.swift new file mode 100644 index 000000000000..c34dc0e22bb4 --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/TerminalFontSizeCreationPolicy.swift @@ -0,0 +1,39 @@ +/// Selects how a new terminal surface obtains its initial font-size lineage. +public enum TerminalFontSizeCreationPolicy: Equatable, Sendable { + /// Preserves the inherited terminal configuration, including its font-size lineage. + case inherit + + /// Restores an explicit persisted font-size override or clears inherited lineage. + /// + /// A missing value or one outside ``TerminalFontSizePolicy``'s persistable + /// base range clears inherited lineage so the surface follows current config. + /// + /// - Parameter overrideBasePoints: The persisted unscaled base font size, or + /// `nil` when the restored surface had no explicit override. + case sessionRestore(overrideBasePoints: Float32?) + + /// Applies the creation policy while preserving unrelated inherited configuration. + /// + /// - Parameter inheritedConfig: The configuration inherited from the selected + /// terminal, or `nil` when no inheritable configuration is available. + /// - Returns: The configuration for the new terminal, or `nil` when no template + /// is needed. + public func applying( + to inheritedConfig: CmuxSurfaceConfigTemplate? + ) -> CmuxSurfaceConfigTemplate? { + switch self { + case .inherit: + return inheritedConfig + case .sessionRestore(let overrideBasePoints): + guard let overrideBasePoints, + TerminalFontSizePolicy().acceptsPersistedBasePoints(overrideBasePoints) else { + guard var template = inheritedConfig else { return nil } + template.fontSizeLineage = nil + return template + } + var template = inheritedConfig ?? CmuxSurfaceConfigTemplate() + template.setFontSize(overrideBasePoints, isExplicitOverride: true) + return template + } + } +} diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/TerminalFontSizeLineage.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/TerminalFontSizeLineage.swift new file mode 100644 index 000000000000..f1e679bef89d --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/TerminalFontSizeLineage.swift @@ -0,0 +1,23 @@ +/// A terminal font size together with whether it is a surface-local override. +/// +/// Keeping provenance beside the point size lets inherited and restored +/// surfaces preserve explicit zoom while terminals at the config default keep +/// following later config changes. +public struct TerminalFontSizeLineage: Equatable, Sendable { + /// The unscaled font size in points at 100% global magnification. + public var basePoints: Float32 + + /// Whether the size came from an explicit surface-local zoom. + public var isExplicitOverride: Bool + + /// Creates font-size lineage for a terminal surface. + /// + /// - Parameters: + /// - basePoints: The unscaled font size in points. + /// - isExplicitOverride: Whether the surface owns this size instead of + /// following the current terminal config. + public init(basePoints: Float32, isExplicitOverride: Bool) { + self.basePoints = basePoints + self.isExplicitOverride = isExplicitOverride + } +} diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/TerminalFontSizePolicy.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/TerminalFontSizePolicy.swift new file mode 100644 index 000000000000..c9e336d2be13 --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/TerminalFontSizePolicy.swift @@ -0,0 +1,52 @@ +internal import CmuxFoundation + +/// Validates persisted terminal font sizes and clamps values sent to Ghostty. +/// +/// Ghostty accepts runtime point sizes from 1 through 255. Persisted values +/// are stored before global magnification, so the largest valid base value is +/// 510 points: 255 runtime points at the minimum supported 50% magnification. +public struct TerminalFontSizePolicy: Sendable { + /// The minimum runtime point size accepted by Ghostty. + public static let minimumRuntimePoints: Float32 = 1 + + /// The maximum runtime point size accepted by Ghostty. + public static let maximumRuntimePoints: Float32 = 255 + + /// The largest persisted base size that can produce a valid Ghostty size. + public static let maximumPersistedBasePoints: Float32 = + maximumRuntimePoints * Float32(GlobalFontMagnification.defaultPercent) + / Float32(GlobalFontMagnification.minimumPercent) + + /// Creates the fixed terminal font-size policy. + public init() {} + + /// Returns whether a base size can be restored under supported magnification. + /// + /// - Parameter basePoints: The unscaled persisted font size in points. + /// - Returns: True when the size is finite, positive, and no greater than + /// ``maximumPersistedBasePoints``. + public func acceptsPersistedBasePoints(_ basePoints: Float32) -> Bool { + basePoints.isFinite + && basePoints > 0 + && basePoints <= Self.maximumPersistedBasePoints + } + + /// Clamps a scaled point size to Ghostty's native runtime range. + /// + /// Non-finite positive values use the maximum; NaN and negative values use + /// the minimum so this function always returns a native-safe value. + /// + /// - Parameter runtimePoints: The magnified size intended for Ghostty. + /// - Returns: A finite value in the closed 1 through 255 point range. + public func clampedRuntimePoints(_ runtimePoints: Float32) -> Float32 { + guard runtimePoints.isFinite else { + return runtimePoints == .infinity + ? Self.maximumRuntimePoints + : Self.minimumRuntimePoints + } + return min( + Self.maximumRuntimePoints, + max(Self.minimumRuntimePoints, runtimePoints) + ) + } +} diff --git a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/SurfaceConfigTemplateFontSizeTests.swift b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/SurfaceConfigTemplateFontSizeTests.swift index 999dd66b3f81..2df0091d172f 100644 --- a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/SurfaceConfigTemplateFontSizeTests.swift +++ b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/SurfaceConfigTemplateFontSizeTests.swift @@ -1,7 +1,34 @@ import Testing import CmuxTerminalCore +import GhosttyKit @Suite struct SurfaceConfigTemplateFontSizeTests { + @Test func freshFontSizeAssignmentClaimsExplicitOwnership() { + var template = CmuxSurfaceConfigTemplate() + + template.fontSize = 13 + + #expect(template.fontSizeLineage == TerminalFontSizeLineage( + basePoints: 13, + isExplicitOverride: true + )) + } + + @Test func inheritedCConfigFontSizeRemainsNonExplicit() { + var cConfig = ghostty_surface_config_s() + cConfig.font_size = 24 + + let template = CmuxSurfaceConfigTemplate( + cConfig: cConfig, + globalFontMagnificationPercent: 200 + ) + + #expect(template.fontSizeLineage == TerminalFontSizeLineage( + basePoints: 12, + isExplicitOverride: false + )) + } + @Test func convertsRuntimeFontSizeToBasePoints() { let basePoints = CmuxSurfaceConfigTemplate.baseFontSize(fromRuntimePoints: 24, percent: 200) @@ -14,10 +41,124 @@ import CmuxTerminalCore #expect(abs(runtimePoints - 24) < 0.001) } + @Test(arguments: [50, 100, 200]) + func runtimeDefaultFontSizePreservesZeroSentinel(percent: Int) { + let defaultBasePoints = CmuxSurfaceConfigTemplate().fontSize + + #expect(defaultBasePoints == 0) + #expect( + CmuxSurfaceConfigTemplate.runtimeFontSize( + fromBasePoints: defaultBasePoints, + percent: percent + ) == 0 + ) + #expect( + CmuxSurfaceConfigTemplate.runtimeFontSize( + fromBasePoints: 0, + percent: percent + ) == 0 + ) + } + + @Test(arguments: [50, 100, 200]) + func invalidBaseFontSizeUsesRuntimeDefaultSentinel(percent: Int) { + let invalidBasePoints: [Float32] = [ + -1, + .nan, + .infinity, + -.infinity, + ] + + for basePoints in invalidBasePoints { + #expect( + CmuxSurfaceConfigTemplate.runtimeFontSize( + fromBasePoints: basePoints, + percent: percent + ) == 0 + ) + } + } + @Test func inheritedRuntimeFontSizeRoundTripsWithoutCompounding() { let basePoints = CmuxSurfaceConfigTemplate.baseFontSize(fromRuntimePoints: 24, percent: 200) let runtimePoints = CmuxSurfaceConfigTemplate.runtimeFontSize(fromBasePoints: basePoints, percent: 200) #expect(abs(runtimePoints - 24) < 0.001) } + + @Test func retainsExplicitFontSizeOwnershipWhileChangingPoints() { + var template = CmuxSurfaceConfigTemplate() + template.setFontSize(9, isExplicitOverride: true) + + template.fontSize = 11 + + #expect(template.fontSizeLineage == TerminalFontSizeLineage( + basePoints: 11, + isExplicitOverride: true + )) + } + + @Test func invalidFontSizeClearsLineage() { + var template = CmuxSurfaceConfigTemplate() + template.setFontSize(9, isExplicitOverride: true) + + template.fontSize = 0 + + #expect(template.fontSizeLineage == nil) + } + + @Test(arguments: [Float32(511), Float32.greatestFiniteMagnitude]) + func oversizedFontSizeClearsLineage(basePoints: Float32) { + var propertyTemplate = CmuxSurfaceConfigTemplate() + propertyTemplate.fontSize = basePoints + + var methodTemplate = CmuxSurfaceConfigTemplate() + methodTemplate.setFontSize(basePoints, isExplicitOverride: true) + + #expect(propertyTemplate.fontSizeLineage == nil) + #expect(methodTemplate.fontSizeLineage == nil) + } + + @Test func maximumPersistableBaseFontSizeIsAccepted() { + var template = CmuxSurfaceConfigTemplate() + + template.setFontSize(510, isExplicitOverride: true) + + #expect(template.fontSizeLineage == TerminalFontSizeLineage( + basePoints: 510, + isExplicitOverride: true + )) + } + + @Test func runtimeFontSizeClampsToGhosttyMaximumAtIncreasedMagnification() { + let runtimePoints = CmuxSurfaceConfigTemplate.runtimeFontSize( + fromBasePoints: 510, + percent: 200 + ) + + #expect(runtimePoints == 255) + } + + @Test func maximumRuntimeFontConvertsToMaximumPersistableBaseAtMinimumMagnification() { + let basePoints = CmuxSurfaceConfigTemplate.baseFontSize( + fromRuntimePoints: 255, + percent: 50 + ) + + #expect(basePoints == 510) + } + + @Test func minimumRuntimeFontRoundTripsAtIncreasedMagnification() { + let basePoints = CmuxSurfaceConfigTemplate.baseFontSize( + fromRuntimePoints: 1, + percent: 200 + ) + let restoredRuntimePoints = CmuxSurfaceConfigTemplate.runtimeFontSize( + fromBasePoints: basePoints, + percent: 200 + ) + + #expect(basePoints == 0.5) + #expect(restoredRuntimePoints == 1) + } } diff --git a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalFontSizeCreationPolicyTests.swift b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalFontSizeCreationPolicyTests.swift new file mode 100644 index 000000000000..faa5c8d1771b --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalFontSizeCreationPolicyTests.swift @@ -0,0 +1,86 @@ +import CmuxTerminalCore +import Testing + +@Suite struct TerminalFontSizeCreationPolicyTests { + @Test func inheritPreservesConfiguration() throws { + var inherited = CmuxSurfaceConfigTemplate() + inherited.setFontSize(13, isExplicitOverride: true) + inherited.workingDirectory = "/tmp/inherited" + inherited.command = "echo inherited" + inherited.environmentVariables = ["CMUX_TEST": "inherited"] + inherited.initialInput = "pwd\n" + inherited.waitAfterCommand = true + + let applied = try #require( + TerminalFontSizeCreationPolicy.inherit.applying(to: inherited) + ) + + #expect(applied.fontSizeLineage == inherited.fontSizeLineage) + #expect(applied.workingDirectory == inherited.workingDirectory) + #expect(applied.command == inherited.command) + #expect(applied.environmentVariables == inherited.environmentVariables) + #expect(applied.initialInput == inherited.initialInput) + #expect(applied.waitAfterCommand == inherited.waitAfterCommand) + } + + @Test func sessionRestoreAppliesExplicitOverride() throws { + var inherited = CmuxSurfaceConfigTemplate() + inherited.setFontSize(11, isExplicitOverride: false) + + let applied = try #require( + TerminalFontSizeCreationPolicy.sessionRestore(overrideBasePoints: 15) + .applying(to: inherited) + ) + + #expect(applied.fontSizeLineage == TerminalFontSizeLineage( + basePoints: 15, + isExplicitOverride: true + )) + } + + @Test(arguments: [ + nil, + Float32.zero, + -1, + Float32.nan, + Float32.infinity, + 511, + Float32.greatestFiniteMagnitude, + ] as [Float32?]) + func invalidSessionRestoreClearsOnlyFontLineage( + overrideBasePoints: Float32? + ) throws { + var inherited = CmuxSurfaceConfigTemplate() + inherited.setFontSize(13, isExplicitOverride: true) + inherited.workingDirectory = "/tmp/restored" + inherited.command = "echo restored" + inherited.environmentVariables = ["CMUX_TEST": "restored"] + inherited.initialInput = "ls\n" + inherited.waitAfterCommand = true + + let applied = try #require( + TerminalFontSizeCreationPolicy.sessionRestore( + overrideBasePoints: overrideBasePoints + ).applying(to: inherited) + ) + + #expect(applied.fontSizeLineage == nil) + #expect(applied.workingDirectory == inherited.workingDirectory) + #expect(applied.command == inherited.command) + #expect(applied.environmentVariables == inherited.environmentVariables) + #expect(applied.initialInput == inherited.initialInput) + #expect(applied.waitAfterCommand == inherited.waitAfterCommand) + } + + @Test func sessionRestoreAcceptsMaximumPersistableBaseFontSize() throws { + let applied = try #require( + TerminalFontSizeCreationPolicy.sessionRestore(overrideBasePoints: 510) + .applying(to: nil) + ) + + #expect(applied.fontSizeLineage == TerminalFontSizeLineage( + basePoints: 510, + isExplicitOverride: true + )) + } +} diff --git a/Packages/macOS/CmuxTerminalCore/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c b/Packages/macOS/CmuxTerminalCore/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c index cb844529aa39..c424519fba10 100644 --- a/Packages/macOS/CmuxTerminalCore/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c +++ b/Packages/macOS/CmuxTerminalCore/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c @@ -32,6 +32,11 @@ GHOSTTY_RUNTIME_TEST_STUB_WEAK bool ghostty_surface_clear_selection(void *surfac return false; } +GHOSTTY_RUNTIME_TEST_STUB_WEAK float ghostty_surface_font_size(void *surface) { + (void)surface; + return 0; +} + GHOSTTY_RUNTIME_TEST_STUB_WEAK void *ghostty_surface_quicklook_font(void *surface) { (void)surface; return 0; diff --git a/Packages/macOS/CmuxTerminalCore/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h b/Packages/macOS/CmuxTerminalCore/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h index a7e18b1d88cb..292acfda50fa 100644 --- a/Packages/macOS/CmuxTerminalCore/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h +++ b/Packages/macOS/CmuxTerminalCore/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h @@ -24,6 +24,8 @@ GHOSTTY_RUNTIME_TEST_STUB_WEAK void *ghostty_surface_new_with_scrollback_limit( GHOSTTY_RUNTIME_TEST_STUB_WEAK bool ghostty_surface_clear_selection(void *surface); +GHOSTTY_RUNTIME_TEST_STUB_WEAK float ghostty_surface_font_size(void *surface); + GHOSTTY_RUNTIME_TEST_STUB_WEAK void *ghostty_surface_quicklook_font(void *surface); GHOSTTY_RUNTIME_TEST_STUB_WEAK void *ghostty_config_new(void); diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 8fd0d5e6e828..ec550fb2346e 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -2759,8 +2759,10 @@ class GhosttyApp { width: CGFloat(action.action.cell_size.width), height: CGFloat(action.action.cell_size.height) ) + let terminalSurface = surfaceView.terminalSurface DispatchQueue.main.async { surfaceView.cellSize = cellSize + _ = terminalSurface?.fontSizeLineageSnapshot() NotificationCenter.default.post( name: .ghosttyDidUpdateCellSize, object: surfaceView, diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index f7993517ce5f..c886e9673d7a 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -1378,6 +1378,8 @@ enum SurfaceResumeBindingScriptStore { struct SessionTerminalPanelSnapshot: Codable, Sendable { var workingDirectory: String? + /// Explicit, unscaled surface font override. Nil follows the current config. + var fontSize: Float? var scrollback: String? var agent: SessionRestorableAgentSnapshot? var tmuxStartCommand: String? @@ -1392,6 +1394,7 @@ struct SessionTerminalPanelSnapshot: Codable, Sendable { init( workingDirectory: String? = nil, + fontSize: Float? = nil, scrollback: String? = nil, agent: SessionRestorableAgentSnapshot? = nil, tmuxStartCommand: String? = nil, @@ -1403,6 +1406,7 @@ struct SessionTerminalPanelSnapshot: Codable, Sendable { wasAgentRunning: Bool? = nil ) { self.workingDirectory = workingDirectory + self.fontSize = fontSize self.scrollback = scrollback self.agent = agent self.tmuxStartCommand = tmuxStartCommand diff --git a/Sources/SessionPersistencePolicy+CrashStorage.swift b/Sources/SessionPersistencePolicy+CrashStorage.swift index e12e5297054d..d7403e8ec888 100644 --- a/Sources/SessionPersistencePolicy+CrashStorage.swift +++ b/Sources/SessionPersistencePolicy+CrashStorage.swift @@ -1,4 +1,5 @@ import Foundation +import CmuxTerminalCore extension SessionPersistencePolicy { static func defaultCmuxCrashDirectoryURL( @@ -249,7 +250,11 @@ extension SessionPersistencePolicy { } private static func terminalCarriesRestorableUserState(_ terminal: SessionTerminalPanelSnapshot) -> Bool { - !isNilOrBlank(terminal.scrollback) || terminal.textBoxDraft != nil + if let fontSize = terminal.fontSize, + TerminalFontSizePolicy().acceptsPersistedBasePoints(fontSize) { + return true + } + return !isNilOrBlank(terminal.scrollback) || terminal.textBoxDraft != nil } private static func adjustedSelectedWorkspaceIndex( diff --git a/Sources/TabManager+DetachedWorkspace.swift b/Sources/TabManager+DetachedWorkspace.swift index a978cde4c7b3..3111dc07cd80 100644 --- a/Sources/TabManager+DetachedWorkspace.swift +++ b/Sources/TabManager+DetachedWorkspace.swift @@ -1,5 +1,6 @@ import Foundation import CmuxSettings +import CmuxTerminalCore extension TabManager { struct WorkspaceCreationTabSnapshot { @@ -18,7 +19,7 @@ extension TabManager { let selectedTabId: UUID? let selectedTabWasPinned: Bool let preferredWorkingDirectory: String? - let inheritedTerminalFontPoints: Float? + let inheritedTerminalFontSizeLineage: TerminalFontSizeLineage? } @discardableResult @@ -36,12 +37,14 @@ extension TabManager { return withExtendedLifetime((capturedTabs, sourceWorkspace, detached.panel)) { let inheritedDirectory = implicitWorkingDirectoryForNewWorkspace(from: sourceWorkspace) - let font = inheritedTerminalFontPointsForNewWorkspace(workspace: sourceWorkspace) + let fontSizeLineage = inheritedTerminalFontSizeLineageForNewWorkspace( + workspace: sourceWorkspace + ) let snapshot = workspaceCreationSnapshotLite( currentTabs: capturedTabs, currentSelectedTabId: capturedSelectedTabId, preferredWorkingDirectory: inheritedDirectory, - inheritedTerminalFontPoints: font + inheritedTerminalFontSizeLineage: fontSizeLineage ) didCaptureWorkspaceCreationSnapshot() #if DEBUG @@ -51,7 +54,7 @@ extension TabManager { sentryBreadcrumb("workspace.create.fromDetachedSurface", data: ["tabCount": nextTabCount]) let inheritedConfig = workspaceCreationConfigTemplate( - inheritedTerminalFontPoints: snapshot.inheritedTerminalFontPoints + inheritedTerminalFontSizeLineage: snapshot.inheritedTerminalFontSizeLineage ) let plannedInsertIndex = detachedWorkspaceInsertIndex( insertionIndexOverride: insertionIndexOverride, diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 917d4beffe28..c82119d15453 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -1064,12 +1064,14 @@ class TabManager: ObservableObject { let dir = inheritWorkingDirectory ? implicitWorkingDirectoryForNewWorkspace(from: sourceWorkspace) : nil - let font = inheritedTerminalFontPointsForNewWorkspace(workspace: sourceWorkspace) + let fontSizeLineage = inheritedTerminalFontSizeLineageForNewWorkspace( + workspace: sourceWorkspace + ) let snapshot = workspaceCreationSnapshotLite( currentTabs: capturedTabs, currentSelectedTabId: capturedSelectedTabId, preferredWorkingDirectory: dir, - inheritedTerminalFontPoints: font + inheritedTerminalFontSizeLineage: fontSizeLineage ) didCaptureWorkspaceCreationSnapshot() #if DEBUG @@ -1080,7 +1082,7 @@ class TabManager: ObservableObject { let explicitWorkingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory) let workingDirectory = explicitWorkingDirectory ?? snapshot.preferredWorkingDirectory let inheritedConfig = workspaceCreationConfigTemplate( - inheritedTerminalFontPoints: snapshot.inheritedTerminalFontPoints + inheritedTerminalFontSizeLineage: snapshot.inheritedTerminalFontSizeLineage ) // Resolve placement against the pre-creation snapshot before Workspace init // boots terminal state. The ssh/new-workspace path can otherwise crash while @@ -1316,14 +1318,14 @@ class TabManager: ObservableObject { } /// Build a snapshot using pre-extracted value-type data. The caller is responsible - /// for obtaining `preferredWorkingDirectory` and `inheritedTerminalFontPoints` through + /// for obtaining `preferredWorkingDirectory` and font-size lineage through /// `self` (where `self.tabs` keeps all Workspace objects alive) so that no local /// Workspace references are needed here. func workspaceCreationSnapshotLite( currentTabs: [Workspace], currentSelectedTabId: UUID?, preferredWorkingDirectory: String?, - inheritedTerminalFontPoints: Float? + inheritedTerminalFontSizeLineage: TerminalFontSizeLineage? ) -> WorkspaceCreationSnapshot { var tabSnapshots: [WorkspaceCreationTabSnapshot] = [] tabSnapshots.reserveCapacity(currentTabs.count) @@ -1345,7 +1347,7 @@ class TabManager: ObservableObject { selectedTabId: currentSelectedTabId, selectedTabWasPinned: selectedTabSnapshot?.isPinned ?? false, preferredWorkingDirectory: preferredWorkingDirectory, - inheritedTerminalFontPoints: inheritedTerminalFontPoints + inheritedTerminalFontSizeLineage: inheritedTerminalFontSizeLineage ) } @@ -1354,7 +1356,7 @@ class TabManager: ObservableObject { currentTabs: tabs, currentSelectedTabId: selectedTabId, preferredWorkingDirectory: preferredWorkingDirectoryForNewTab(), - inheritedTerminalFontPoints: inheritedTerminalFontPointsForNewWorkspace() + inheritedTerminalFontSizeLineage: inheritedTerminalFontSizeLineageForNewWorkspace() ) } @@ -1414,54 +1416,59 @@ class TabManager: ObservableObject { inheritedTerminalConfigForNewWorkspace(workspace: selectedWorkspace) } - private func cachedInheritedTerminalFontPointsForNewWorkspace( + private func cachedInheritedTerminalFontSizeLineageForNewWorkspace( workspace: Workspace? - ) -> Float? { + ) -> TerminalFontSizeLineage? { guard let workspace else { return nil } - // New workspace creation only seeds font size into a fresh Swift-owned template. + // New workspace creation only seeds font lineage into a fresh Swift-owned template. // Avoid reading live panel/surface state here; the arm64 Nightly Cmd+N crash path // was repeatedly dereferencing pointer-backed terminal objects while preparing the // new workspace. The workspace already caches the rooted font lineage we need. return withExtendedLifetime(workspace) { - guard let fontPoints = workspace.lastRememberedTerminalFontPointsForConfigInheritance(), - fontPoints > 0 else { + guard let lineage = workspace.lastRememberedTerminalFontSizeLineageForConfigInheritance(), + TerminalFontSizePolicy().acceptsPersistedBasePoints(lineage.basePoints) else { return nil } - return fontPoints + return lineage } } func inheritedTerminalConfigForNewWorkspace( workspace: Workspace? ) -> CmuxSurfaceConfigTemplate? { - guard let fontPoints = cachedInheritedTerminalFontPointsForNewWorkspace(workspace: workspace) else { + guard let fontSizeLineage = cachedInheritedTerminalFontSizeLineageForNewWorkspace( + workspace: workspace + ) else { return nil } var config = CmuxSurfaceConfigTemplate() - config.fontSize = fontPoints + config.fontSizeLineage = fontSizeLineage return config } - private func inheritedTerminalFontPointsForNewWorkspace() -> Float? { - inheritedTerminalFontPointsForNewWorkspace(workspace: selectedWorkspace) + private func inheritedTerminalFontSizeLineageForNewWorkspace() -> TerminalFontSizeLineage? { + inheritedTerminalFontSizeLineageForNewWorkspace(workspace: selectedWorkspace) } - func inheritedTerminalFontPointsForNewWorkspace( + func inheritedTerminalFontSizeLineageForNewWorkspace( workspace: Workspace? - ) -> Float? { - cachedInheritedTerminalFontPointsForNewWorkspace(workspace: workspace) + ) -> TerminalFontSizeLineage? { + cachedInheritedTerminalFontSizeLineageForNewWorkspace(workspace: workspace) } func workspaceCreationConfigTemplate( - inheritedTerminalFontPoints: Float? + inheritedTerminalFontSizeLineage: TerminalFontSizeLineage? ) -> CmuxSurfaceConfigTemplate? { - guard let inheritedTerminalFontPoints, inheritedTerminalFontPoints > 0 else { + guard let inheritedTerminalFontSizeLineage, + TerminalFontSizePolicy().acceptsPersistedBasePoints( + inheritedTerminalFontSizeLineage.basePoints + ) else { return nil } // Rebuild a clean Swift-owned template instead of carrying over any pointer-backed // inherited config state from the source workspace. var config = CmuxSurfaceConfigTemplate() - config.fontSize = inheritedTerminalFontPoints + config.fontSizeLineage = inheritedTerminalFontSizeLineage return config } diff --git a/Sources/Workspace+PanelLifecycle.swift b/Sources/Workspace+PanelLifecycle.swift index fa03470c5807..5726a83c3559 100644 --- a/Sources/Workspace+PanelLifecycle.swift +++ b/Sources/Workspace+PanelLifecycle.swift @@ -477,10 +477,7 @@ extension Workspace { clearRestoredAgentSnapshot(panelId: panelId) invalidatedRestoredAgentFingerprintsByPanelId.removeValue(forKey: panelId) PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId) - terminalInheritanceFontPointsByPanelId.removeValue(forKey: panelId) - if lastTerminalConfigInheritancePanelId == panelId { - lastTerminalConfigInheritancePanelId = nil - } + removeTerminalConfigInheritanceSource(panelId: panelId) if clearSurfaceNotifications { AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: id, surfaceId: panelId) } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index ab91646a5a46..d0421503d4ce 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -7,6 +7,7 @@ import CmuxRemoteSession import CmuxRemoteWorkspace import CmuxWorkspaces import CmuxTerminal +import CmuxTerminalCore import SwiftUI import AppKit import CmuxFoundation @@ -622,6 +623,7 @@ extension Workspace { ) terminalSnapshot = SessionTerminalPanelSnapshot( workingDirectory: directory, + fontSize: terminalPanel.surface.sessionFontSizeOverrideBasePoints(), scrollback: resolvedScrollback, agent: effectiveRestorableAgent, tmuxStartCommand: restorableTmuxStartCommand, @@ -1556,7 +1558,10 @@ extension Workspace { runtimeSpawnPolicy: .pacedSessionRestore, remotePTYSessionID: restoredRemotePTYSessionID, suppressWorkspaceRemoteStartupCommand: suppressWorkspaceRemoteStartupCommand, - restoredSurfaceId: reusableSurfaceId + restoredSurfaceId: reusableSurfaceId, + terminalFontSizeCreationPolicy: .sessionRestore( + overrideBasePoints: snapshot.terminal?.fontSize + ) ) else { if let replayFileURL { try? FileManager.default.removeItem(at: replayFileURL) } return nil @@ -2245,12 +2250,10 @@ final class Workspace: Identifiable, ObservableObject { /// Last terminal panel used as an inheritance source (typically last focused terminal). 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. - var terminalInheritanceFontPointsByPanelId: [UUID: Float] = [:] + /// Last known terminal font lineage from an inheritance source. The + /// surface owns live lineage; this fallback only survives removal of the + /// last source panel. + private var lastTerminalConfigInheritanceFontSizeLineage: TerminalFontSizeLineage? /// Callback used by TabManager to capture recently closed browser panels for Cmd+Shift+T restore. var onClosedBrowserPanel: ((ClosedBrowserPanelRestoreSnapshot) -> Void)? @@ -3144,7 +3147,6 @@ final class Workspace: Identifiable, ObservableObject { ) panels[terminalPanel.id] = terminalPanel panelTitles[terminalPanel.id] = terminalPanel.displayTitle - seedTerminalInheritanceFontPoints(panelId: terminalPanel.id, configTemplate: configTemplate) // Create initial tab in bonsplit and store the mapping if let tabId = bonsplitController.createTab( @@ -3156,6 +3158,7 @@ final class Workspace: Identifiable, ObservableObject { ) { bindSurface(tabId, toPanelId: terminalPanel.id) initialTabId = tabId + rememberTerminalConfigInheritanceSource(terminalPanel) } } @@ -3692,6 +3695,15 @@ final class Workspace: Identifiable, ObservableObject { } private func configureTerminalPanel(_ terminalPanel: TerminalPanel) { + terminalPanel.surface.onFontSizeLineageChanged = { [weak self, weak terminalPanel] lineage in + guard let self, let terminalPanel, + self.lastTerminalConfigInheritancePanelId == terminalPanel.id, + let mountedPanel = self.panels[terminalPanel.id] as? TerminalPanel, + mountedPanel === terminalPanel else { + return + } + self.lastTerminalConfigInheritanceFontSizeLineage = lineage + } terminalPanel.onRequestWorkspacePaneFlash = { [weak self, weak terminalPanel] reason in guard let self, let terminalPanel else { return } self.triggerWorkspacePaneFlash(panelId: terminalPanel.id, reason: reason) @@ -6468,46 +6480,11 @@ final class Workspace: Identifiable, ObservableObject { // MARK: - Panel Operations - private func seedTerminalInheritanceFontPoints( - panelId: UUID, - configTemplate: CmuxSurfaceConfigTemplate? - ) { - guard let fontPoints = configTemplate?.fontSize, fontPoints > 0 else { return } - terminalInheritanceFontPointsByPanelId[panelId] = fontPoints - lastTerminalConfigInheritanceFontPoints = fontPoints - } - - private func resolvedTerminalInheritanceFontPoints( - for terminalPanel: TerminalPanel, - sourceSurface: ghostty_surface_t, - inheritedConfig: CmuxSurfaceConfigTemplate - ) -> Float? { - let runtimeBasePoints = cmuxCurrentSurfaceFontSizePoints(sourceSurface).map { CmuxSurfaceConfigTemplate.baseFontSize(fromRuntimePoints: $0, percent: GlobalFontMagnification.storedPercent) } - if let rooted = terminalInheritanceFontPointsByPanelId[terminalPanel.id], rooted > 0 { - if let runtimeBasePoints, abs(runtimeBasePoints - rooted) > 0.05 { - // Runtime zoom changed after lineage was seeded (manual zoom on descendant); - // treat runtime as the new root for future descendants. - return runtimeBasePoints - } - return rooted - } - if inheritedConfig.fontSize > 0 { - return inheritedConfig.fontSize - } - return runtimeBasePoints - } - - private func rememberTerminalConfigInheritanceSource(_ terminalPanel: TerminalPanel) { + func rememberTerminalConfigInheritanceSource(_ terminalPanel: TerminalPanel) { + guard let mountedPanel = panels[terminalPanel.id] as? TerminalPanel, + mountedPanel === terminalPanel else { return } lastTerminalConfigInheritancePanelId = terminalPanel.id - if let sourceSurface = terminalPanel.surface.surface, - let runtimePoints = cmuxCurrentSurfaceFontSizePoints(sourceSurface) { - let runtimeBasePoints = CmuxSurfaceConfigTemplate.baseFontSize(fromRuntimePoints: runtimePoints, percent: GlobalFontMagnification.storedPercent) - let existing = terminalInheritanceFontPointsByPanelId[terminalPanel.id] - if existing == nil || abs((existing ?? runtimeBasePoints) - runtimeBasePoints) > 0.05 { - terminalInheritanceFontPointsByPanelId[terminalPanel.id] = runtimeBasePoints - } - lastTerminalConfigInheritanceFontPoints = terminalInheritanceFontPointsByPanelId[terminalPanel.id] ?? runtimeBasePoints - } + lastTerminalConfigInheritanceFontSizeLineage = terminalPanel.surface.fontSizeLineageSnapshot() } func lastRememberedTerminalPanelForConfigInheritance() -> TerminalPanel? { @@ -6515,8 +6492,18 @@ final class Workspace: Identifiable, ObservableObject { return terminalPanel(for: panelId) } - func lastRememberedTerminalFontPointsForConfigInheritance() -> Float? { - lastTerminalConfigInheritanceFontPoints + func lastRememberedTerminalFontSizeLineageForConfigInheritance() -> TerminalFontSizeLineage? { + lastTerminalConfigInheritanceFontSizeLineage + } + + func removeTerminalConfigInheritanceSource(panelId: UUID) { + guard lastTerminalConfigInheritancePanelId == panelId else { return } + lastTerminalConfigInheritancePanelId = nil + lastTerminalConfigInheritanceFontSizeLineage = nil + + if let replacement = terminalPanelForConfigInheritance() { + rememberTerminalConfigInheritanceSource(replacement) + } } nonisolated private static func normalizedTerminalWorkingDirectory(_ workingDirectory: String?) -> String? { @@ -6750,34 +6737,39 @@ final class Workspace: Identifiable, ObservableObject { // ghostty_surface_inherited_config or cmuxCurrentSurfaceFontSizePoints // is still reading through the pointer. let surface = terminalPanel.surface - guard let sourceSurface = surface.surface else { continue } + let sourceFontSizeLineage = surface.fontSizeLineageSnapshot() + guard let sourceSurface = surface.surface else { + if let sourceFontSizeLineage { + var config = CmuxSurfaceConfigTemplate() + config.fontSizeLineage = sourceFontSizeLineage + lastTerminalConfigInheritancePanelId = terminalPanel.id + lastTerminalConfigInheritanceFontSizeLineage = sourceFontSizeLineage + return config + } + continue + } var config = cmuxInheritedSurfaceConfig( sourceSurface: sourceSurface, context: GHOSTTY_SURFACE_CONTEXT_SPLIT ) - if let rootedFontPoints = resolvedTerminalInheritanceFontPoints( - for: terminalPanel, - sourceSurface: sourceSurface, - inheritedConfig: config - ), rootedFontPoints > 0 { - config.fontSize = rootedFontPoints - terminalInheritanceFontPointsByPanelId[terminalPanel.id] = rootedFontPoints + if let sourceFontSizeLineage { + config.fontSizeLineage = sourceFontSizeLineage } // Prevent ARC from releasing panel/surface before the C calls above complete. withExtendedLifetime((terminalPanel, surface)) {} rememberTerminalConfigInheritanceSource(terminalPanel) - if config.fontSize > 0 { - lastTerminalConfigInheritanceFontPoints = config.fontSize + if let fontSizeLineage = config.fontSizeLineage { + lastTerminalConfigInheritanceFontSizeLineage = fontSizeLineage } return config } - if let fallbackFontPoints = lastTerminalConfigInheritanceFontPoints { + if let fallbackFontSizeLineage = lastTerminalConfigInheritanceFontSizeLineage { var config = CmuxSurfaceConfigTemplate() - config.fontSize = fallbackFontPoints + config.fontSizeLineage = fallbackFontSizeLineage #if DEBUG cmuxDebugLog( - "zoom.inherit fallback=lastKnownFont context=split font=\(String(format: "%.2f", fallbackFontPoints))" + "zoom.inherit fallback=lastKnownFont context=split font=\(String(format: "%.2f", fallbackFontSizeLineage.basePoints))" ) #endif return config @@ -6981,7 +6973,6 @@ final class Workspace: Identifiable, ObservableObject { if tracksRemoteTerminalSurface { trackRemoteTerminalSurface(newPanel.id) } - seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) #if DEBUG dlog( "split.timing workspace=\(id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " + @@ -7018,7 +7009,6 @@ final class Workspace: Identifiable, ObservableObject { if tracksRemoteTerminalSurface { untrackRemoteTerminalSurface(newPanel.id) } - terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) return nil } applyInitialSplitDividerPosition(initialDividerPosition, sourcePaneId: paneId, newPaneId: newPaneId) @@ -7049,6 +7039,7 @@ final class Workspace: Identifiable, ObservableObject { previousHostedView: previousHostedView ) } + rememberTerminalConfigInheritanceSource(newPanel) #if DEBUG dlog( "split.timing workspace=\(id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " + @@ -7085,6 +7076,7 @@ final class Workspace: Identifiable, ObservableObject { remotePTYSessionID: String? = nil, suppressWorkspaceRemoteStartupCommand: Bool = false, restoredSurfaceId: UUID? = nil, + terminalFontSizeCreationPolicy: TerminalFontSizeCreationPolicy = .inherit, inheritWorkingDirectoryFallback: Bool = false, workingDirectoryFallbackSourcePanelId: UUID? = nil, allowTextBoxFocusDefault: Bool = true @@ -7103,6 +7095,7 @@ final class Workspace: Identifiable, ObservableObject { remotePTYSessionID: remotePTYSessionID, suppressWorkspaceRemoteStartupCommand: suppressWorkspaceRemoteStartupCommand, restoredSurfaceId: restoredSurfaceId, + terminalFontSizeCreationPolicy: terminalFontSizeCreationPolicy, inheritWorkingDirectoryFallback: inheritWorkingDirectoryFallback, workingDirectoryFallbackSourcePanelId: workingDirectoryFallbackSourcePanelId, allowTextBoxFocusDefault: allowTextBoxFocusDefault @@ -7126,6 +7119,7 @@ final class Workspace: Identifiable, ObservableObject { remotePTYSessionID: String? = nil, suppressWorkspaceRemoteStartupCommand: Bool = false, restoredSurfaceId: UUID? = nil, + terminalFontSizeCreationPolicy: TerminalFontSizeCreationPolicy = .inherit, inheritWorkingDirectoryFallback: Bool = false, workingDirectoryFallbackSourcePanelId: UUID? = nil, allowTextBoxFocusDefault: Bool = true @@ -7174,6 +7168,7 @@ final class Workspace: Identifiable, ObservableObject { remotePTYSessionID: remotePTYSessionID, suppressWorkspaceRemoteStartupCommand: suppressWorkspaceRemoteStartupCommand, restoredSurfaceId: restoredSurfaceId, + terminalFontSizeCreationPolicy: terminalFontSizeCreationPolicy, inheritWorkingDirectoryFallback: inheritWorkingDirectoryFallback, workingDirectoryFallbackSourcePanelId: workingDirectoryFallbackSourcePanelId, allowTextBoxFocusDefault: allowTextBoxFocusDefault @@ -7195,6 +7190,7 @@ final class Workspace: Identifiable, ObservableObject { remotePTYSessionID: String?, suppressWorkspaceRemoteStartupCommand: Bool, restoredSurfaceId: UUID?, + terminalFontSizeCreationPolicy: TerminalFontSizeCreationPolicy, inheritWorkingDirectoryFallback: Bool, workingDirectoryFallbackSourcePanelId: UUID?, allowTextBoxFocusDefault: Bool @@ -7203,7 +7199,9 @@ final class Workspace: Identifiable, ObservableObject { let previousFocusedPanelId = focusedPanelId let previousHostedView = focusedTerminalPanel?.hostedView - var inheritedConfig = inheritedTerminalConfig(inPane: paneId) + var inheritedConfig = terminalFontSizeCreationPolicy.applying( + to: inheritedTerminalConfig(inPane: paneId) + ) let requestedInitialCommand = initialCommand?.trimmingCharacters(in: .whitespacesAndNewlines) let explicitInitialCommand = (requestedInitialCommand?.isEmpty == false) ? requestedInitialCommand : nil let remoteTerminalStartupCommand = suppressWorkspaceRemoteStartupCommand ? nil : remoteTerminalStartupCommand() @@ -7271,7 +7269,6 @@ final class Workspace: Identifiable, ObservableObject { if tracksRemoteTerminalSurface { trackRemoteTerminalSurface(newPanel.id) } - seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) // Create tab in bonsplit guard let newTabId = bonsplitController.createTab( title: newPanel.displayTitle, @@ -7288,7 +7285,6 @@ final class Workspace: Identifiable, ObservableObject { if tracksRemoteTerminalSurface { untrackRemoteTerminalSurface(newPanel.id) } - terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) return nil } @@ -7312,6 +7308,7 @@ final class Workspace: Identifiable, ObservableObject { } else { clearNonFocusSplitFocusReassert() } + rememberTerminalConfigInheritanceSource(newPanel) if autoRefreshMetadata { owningTabManager?.scheduleInitialWorkspaceGitMetadataRefreshIfPossible( @@ -7393,6 +7390,7 @@ final class Workspace: Identifiable, ObservableObject { return nil } bindSurface(newTabId, toPanelId: newPanel.id) + rememberTerminalConfigInheritanceSource(newPanel) return newPanel } if focus, let newPanel { @@ -7505,7 +7503,6 @@ final class Workspace: Identifiable, ObservableObject { configureNewTerminalPanel(replacementPanel) panels[pair.key] = replacementPanel panelTitles[pair.key] = replacementPanel.displayTitle - seedTerminalInheritanceFontPoints(panelId: pair.key, configTemplate: inheritedConfig) bonsplitController.updateTab( tabId, title: replacementPanel.displayTitle, @@ -7528,6 +7525,7 @@ final class Workspace: Identifiable, ObservableObject { } else { replacementPanel.unfocus() } + rememberTerminalConfigInheritanceSource(replacementPanel) owningTabManager?.scheduleInitialWorkspaceGitMetadataRefreshIfPossible( workspaceId: id, panelId: pair.key, @@ -7648,7 +7646,6 @@ final class Workspace: Identifiable, ObservableObject { pinnedPanelIds.insert(panelId) } bindSurface(tabId, toPanelId: panelId) - seedTerminalInheritanceFontPoints(panelId: panelId, configTemplate: inheritedConfig) let resolvedTitle = resolvedPanelTitle(panelId: panelId, fallback: replacementPanel.displayTitle) bonsplitController.updateTab( tabId, @@ -7674,6 +7671,7 @@ final class Workspace: Identifiable, ObservableObject { } else { replacementPanel.unfocus() } + rememberTerminalConfigInheritanceSource(replacementPanel) owningTabManager?.scheduleInitialWorkspaceGitMetadataRefreshIfPossible( workspaceId: id, @@ -8540,9 +8538,8 @@ final class Workspace: Identifiable, ObservableObject { debugSessionSnapshotSyntheticScrollbackByPanelId.removeAll(keepingCapacity: false) #endif pendingTerminalInputObserversByPanelId.removeAll(keepingCapacity: false) - terminalInheritanceFontPointsByPanelId.removeAll(keepingCapacity: false) lastTerminalConfigInheritancePanelId = nil - lastTerminalConfigInheritanceFontPoints = nil + lastTerminalConfigInheritanceFontSizeLineage = nil // Tear down the right-sidebar Dock's own panels (terminals/browsers) too, // but only if the Dock was ever opened for this workspace. _dockSplit?.closeAllPanels() @@ -9668,7 +9665,6 @@ final class Workspace: Identifiable, ObservableObject { if pendingRemoteDisconnect != nil { remoteDisconnectPlaceholderPanelIds.insert(newPanel.id) } - seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: replacementConfig) // Create tab in bonsplit if let newTabId = bonsplitController.createTab( @@ -9679,6 +9675,7 @@ final class Workspace: Identifiable, ObservableObject { isPinned: false ) { bindSurface(newTabId, toPanelId: newPanel.id) + rememberTerminalConfigInheritanceSource(newPanel) } return newPanel @@ -10786,7 +10783,6 @@ final class Workspace: Identifiable, ObservableObject { if startupCommand != nil { trackRemoteTerminalSurface(newPanel.id) } - seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) let newTab = Bonsplit.Tab( title: newPanel.displayTitle, @@ -10806,13 +10802,13 @@ final class Workspace: Identifiable, ObservableObject { if startupCommand != nil { untrackRemoteTerminalSurface(newPanel.id) } - terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) return nil } publishCmuxSplitCreated(newPaneId, sourcePaneId: paneId, orientation: orientation, surfaceId: newPanel.id, kind: "terminal", origin: "terminal_split", focused: true) bonsplitController.selectTab(newTab.id) newPanel.focus() + rememberTerminalConfigInheritanceSource(newPanel) return newPanel } @@ -12236,7 +12232,6 @@ extension Workspace: BonsplitDelegate { configureNewTerminalPanel(replacementPanel) panels[replacementPanel.id] = replacementPanel panelTitles[replacementPanel.id] = replacementPanel.displayTitle - seedTerminalInheritanceFontPoints(panelId: replacementPanel.id, configTemplate: inheritedConfig) bindSurface(replacementTab.id, toPanelId: replacementPanel.id) bonsplitController.updateTab( @@ -12251,6 +12246,7 @@ extension Workspace: BonsplitDelegate { isLoading: false, isPinned: false ) + rememberTerminalConfigInheritanceSource(replacementPanel) publishCmuxSurfaceCreated(replacementPanel.id, paneId: originalPane, kind: "terminal", origin: "placeholder_repair", focused: false) for extraPlaceholder in placeholderTabs.dropFirst() { @@ -12305,7 +12301,6 @@ extension Workspace: BonsplitDelegate { configureNewTerminalPanel(newPanel) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle - seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) guard let newTabId = bonsplitController.createTab( title: newPanel.displayTitle, @@ -12317,11 +12312,11 @@ extension Workspace: BonsplitDelegate { ) else { panels.removeValue(forKey: newPanel.id) panelTitles.removeValue(forKey: newPanel.id) - terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) return } bindSurface(newTabId, toPanelId: newPanel.id) + rememberTerminalConfigInheritanceSource(newPanel) normalizePinnedTabs(in: newPane) publishCmuxSplitCreated(newPane, sourcePaneId: originalPane, orientation: orientation, surfaceId: newPanel.id, kind: "terminal", origin: "ui_split", focused: true) #if DEBUG diff --git a/Sources/WorkspaceSurfaceConfig.swift b/Sources/WorkspaceSurfaceConfig.swift index 680732a94770..fd3d9030710a 100644 --- a/Sources/WorkspaceSurfaceConfig.swift +++ b/Sources/WorkspaceSurfaceConfig.swift @@ -17,10 +17,12 @@ func cmuxSurfacePointerAppearsLive(_ surface: ghostty_surface_t) -> Bool { GhosttySurfaceRuntimeProbe.surfacePointerAppearsLive(surface) } +@MainActor func cmuxCurrentSurfaceFontSizePoints(_ surface: ghostty_surface_t) -> Float? { GhosttySurfaceRuntimeProbe.currentSurfaceFontSizePoints(surface) } +@MainActor func cmuxInheritedSurfaceConfig( sourceSurface: ghostty_surface_t, context: ghostty_surface_context_e @@ -32,13 +34,16 @@ func cmuxInheritedSurfaceConfig( globalFontMagnificationPercent: percent ) - // Make runtime zoom inheritance explicit, even when Ghostty's - // inherit-font-size config is disabled. + // Capture runtime zoom for inheritance, even when Ghostty's inherit-font-size + // config is disabled, without claiming surface-local ownership. let runtimePoints = cmuxCurrentSurfaceFontSizePoints(sourceSurface) if let points = runtimePoints { - config.fontSize = CmuxSurfaceConfigTemplate.baseFontSize( - fromRuntimePoints: points, - percent: percent + config.setFontSize( + CmuxSurfaceConfigTemplate.baseFontSize( + fromRuntimePoints: points, + percent: percent + ), + isExplicitOverride: false ) } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index b04d3bdd081b..3da45a75dfed 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1860,6 +1860,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources EC010B01 /* TerminalCustomUploadRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC010B02 /* TerminalCustomUploadRunner.swift */; }; 0D56BE882EAD4B67AC44F96D /* TerminalDirectoryOpenSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B8E2E03F4A64C61B729CF19 /* TerminalDirectoryOpenSupport.swift */; }; E5C0F1A0E5C0F1A0E5C0F1A0 /* TerminalFindEscapeRouting.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5C0F1A1E5C0F1A1E5C0F1A1 /* TerminalFindEscapeRouting.swift */; }; + 851500000000000000000001 /* TerminalFontZoomSessionPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 851500000000000000000002 /* TerminalFontZoomSessionPersistenceTests.swift */; }; A5FB1303 /* TerminalForegroundCommandCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FB1304 /* TerminalForegroundCommandCapture.swift */; }; A5001542 /* TerminalImageTransfer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001544 /* TerminalImageTransfer.swift */; }; 859110000000000000000005 /* TerminalLinkOpenContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 859110000000000000000006 /* TerminalLinkOpenContainer.swift */; }; @@ -3989,6 +3990,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = EC010B02 /* TerminalCustomUploadRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalCustomUploadRunner.swift; sourceTree = ""; }; 6B8E2E03F4A64C61B729CF19 /* TerminalDirectoryOpenSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/TerminalDirectoryOpenSupport.swift; sourceTree = ""; }; E5C0F1A1E5C0F1A1E5C0F1A1 /* TerminalFindEscapeRouting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/TerminalFindEscapeRouting.swift; sourceTree = ""; }; + 851500000000000000000002 /* TerminalFontZoomSessionPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalFontZoomSessionPersistenceTests.swift; sourceTree = ""; }; A5FB1304 /* TerminalForegroundCommandCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalForegroundCommandCapture.swift; sourceTree = ""; }; A5001544 /* TerminalImageTransfer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalImageTransfer.swift; sourceTree = ""; }; 859110000000000000000006 /* TerminalLinkOpenContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalLinkOpenContainer.swift; sourceTree = ""; }; @@ -6020,6 +6022,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 812600000000000000000004 /* SessionRemoteWorkspaceMoshRestoreTests.swift */, F6572013A1B2C3D4E5F60718 /* SurfaceResumeBindingCodexUpdateCheckTests.swift */, F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */, + 851500000000000000000002 /* TerminalFontZoomSessionPersistenceTests.swift */, 806600000000000000000001 /* SessionRestorableAgentSnapshotPermissionModeTests.swift */, F17F00000000000000000002 /* FullWidthTabPersistenceTests.swift */, FD21AD844E5D4D50A6DFC442 /* AppDelegateWindowFrameReconcileTests.swift */, @@ -9013,6 +9016,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 8C4BBF2DEF6DF93F395A9EE7 /* TerminalControllerSocketSecurityTests.swift in Sources */, 9C1BEA3D2E6F49709A71C020 /* TerminalControllerTerminalTextTests.swift in Sources */, 6190C0126190C0126190C012 /* TerminalCopyOnSelectManagedConfigLayeringTests.swift in Sources */, + 851500000000000000000001 /* TerminalFontZoomSessionPersistenceTests.swift in Sources */, 859100000000000000000001 /* TerminalLinkOpenCoordinatorTests.swift in Sources */, A5C41103A1B2C3D4E5F60718 /* TerminalNotificationCallerTests.swift in Sources */, A5A5A507A1B2C3D4E5F60718 /* TerminalNotificationClearAllTests.swift in Sources */, diff --git a/cmuxTests/CrashDiagnosticSessionPolicyTests.swift b/cmuxTests/CrashDiagnosticSessionPolicyTests.swift index 6e6f0e8a63f7..b233648abbd8 100644 --- a/cmuxTests/CrashDiagnosticSessionPolicyTests.swift +++ b/cmuxTests/CrashDiagnosticSessionPolicyTests.swift @@ -202,6 +202,47 @@ struct CrashDiagnosticSessionPolicyTests { ]) } + @Test(arguments: [Float(13), Float(510)]) + func sessionSnapshotKeepsCrashWorkspaceWithValidExplicitFontSize(fontSize: Float) { + let crashDirectory = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".local/state/cmux/crash", isDirectory: true) + .path + let projectDirectory = "/tmp/cmux-project" + let snapshot = crashAndProjectSnapshot( + crashDirectory: crashDirectory, + projectDirectory: projectDirectory, + fontSize: fontSize + ) + + let pruned = SessionPersistencePolicy.pruningCmuxCrashDiagnosticWindows(from: snapshot) + + #expect(!pruned.removedAny) + #expect(pruned.snapshot?.windows.first?.tabManager.workspaces.map(\.currentDirectory) == [ + crashDirectory, + projectDirectory, + ]) + } + + @Test(arguments: [Float.zero, -1, .nan, .infinity, 511, .greatestFiniteMagnitude]) + func sessionSnapshotPrunesCrashWorkspaceWithInvalidFontSize(fontSize: Float) { + let crashDirectory = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".local/state/cmux/crash", isDirectory: true) + .path + let projectDirectory = "/tmp/cmux-project" + let snapshot = crashAndProjectSnapshot( + crashDirectory: crashDirectory, + projectDirectory: projectDirectory, + fontSize: fontSize + ) + + let pruned = SessionPersistencePolicy.pruningCmuxCrashDiagnosticWindows(from: snapshot) + + #expect(pruned.removedAny) + #expect(pruned.snapshot?.windows.first?.tabManager.workspaces.map(\.currentDirectory) == [ + projectDirectory, + ]) + } + @Test func sessionSnapshotPruningDoesNotResolveSymlinkedCrashDirectories() throws { let root = FileManager.default.temporaryDirectory @@ -360,6 +401,37 @@ struct CrashDiagnosticSessionPolicyTests { ) } + private func crashAndProjectSnapshot( + crashDirectory: String, + projectDirectory: String, + fontSize: Float + ) -> AppSessionSnapshot { + AppSessionSnapshot( + version: SessionSnapshotSchema.currentVersion, + createdAt: 10, + windows: [ + SessionWindowSnapshot( + frame: nil, + display: nil, + tabManager: SessionTabManagerSnapshot( + selectedWorkspaceIndex: 0, + workspaces: [ + terminalWorkspaceSnapshot( + currentDirectory: crashDirectory, + terminal: SessionTerminalPanelSnapshot( + workingDirectory: crashDirectory, + fontSize: fontSize + ) + ), + emptyWorkspaceSnapshot(currentDirectory: projectDirectory), + ] + ), + sidebar: SessionSidebarSnapshot(isVisible: true, selection: .tabs, width: nil) + ), + ] + ) + } + private func terminalPanelSnapshot( id: UUID, directory: String, diff --git a/cmuxTests/TerminalFontZoomSessionPersistenceTests.swift b/cmuxTests/TerminalFontZoomSessionPersistenceTests.swift new file mode 100644 index 000000000000..3523530fbd03 --- /dev/null +++ b/cmuxTests/TerminalFontZoomSessionPersistenceTests.swift @@ -0,0 +1,409 @@ +import Foundation +import Testing +import CmuxTerminalCore +@testable import CmuxTerminal + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite("Terminal font zoom session persistence") +struct TerminalFontZoomSessionPersistenceTests { + @Test("restored terminal zoom survives the next session capture") + func restoredZoomSurvivesRecapture() throws { + let workspace = Workspace() + let panelID = try #require(workspace.focusedPanelId) + let snapshot = workspace.sessionSnapshot(includeScrollback: false) + + let zoomedSnapshot = try snapshotBySettingTerminalFontSize( + 5.5, + panelID: panelID, + in: snapshot + ) + let restoredWorkspace = Workspace() + let restoredPanelIDs = restoredWorkspace.restoreSessionSnapshot(zoomedSnapshot) + let restoredPanelID = restoredPanelIDs[panelID] ?? panelID + let restoredPanel = try #require( + restoredWorkspace.panels[restoredPanelID] as? TerminalPanel + ) + let restoredLineage = try #require( + restoredPanel.surface.fontSizeLineageSnapshot() + ) + + #expect(restoredLineage.basePoints == 5.5) + #expect(restoredLineage.isExplicitOverride) + + let recapturedSnapshot = restoredWorkspace.sessionSnapshot(includeScrollback: false) + let recapturedFontSize = try terminalFontSize( + panelID: restoredPanelID, + in: recapturedSnapshot + ) + + #expect(recapturedFontSize == 5.5) + + let inheritedConfig = try #require( + TabManager().inheritedTerminalConfigForNewWorkspace(workspace: restoredWorkspace) + ) + #expect(inheritedConfig.fontSize == 5.5) + #expect(inheritedConfig.fontSizeLineage?.isExplicitOverride == true) + } + + @Test("unzoomed terminal keeps following config across session restore") + func unzoomedTerminalDoesNotPersistFontSize() throws { + let workspace = Workspace() + let panelID = try #require(workspace.focusedPanelId) + + let initialSnapshot = workspace.sessionSnapshot(includeScrollback: false) + #expect(try optionalTerminalFontSize(panelID: panelID, in: initialSnapshot) == nil) + + let restoredWorkspace = Workspace() + let restoredPanelIDs = restoredWorkspace.restoreSessionSnapshot(initialSnapshot) + let restoredPanelID = restoredPanelIDs[panelID] ?? panelID + let recapturedSnapshot = restoredWorkspace.sessionSnapshot(includeScrollback: false) + + #expect( + try optionalTerminalFontSize(panelID: restoredPanelID, in: recapturedSnapshot) == nil + ) + } + + @Test("oversized persisted zoom follows config instead of restoring") + func oversizedPersistedZoomIsRejected() throws { + let workspace = Workspace() + let panelID = try #require(workspace.focusedPanelId) + let oversizedSnapshot = try snapshotBySettingTerminalFontSize( + 511, + panelID: panelID, + in: workspace.sessionSnapshot(includeScrollback: false) + ) + + let restoredWorkspace = Workspace() + let restoredPanelIDs = restoredWorkspace.restoreSessionSnapshot(oversizedSnapshot) + let restoredPanelID = restoredPanelIDs[panelID] ?? panelID + let restoredPanel = try #require( + restoredWorkspace.panels[restoredPanelID] as? TerminalPanel + ) + + #expect(restoredPanel.surface.fontSizeLineageSnapshot() == nil) + #expect( + try optionalTerminalFontSize( + panelID: restoredPanelID, + in: restoredWorkspace.sessionSnapshot(includeScrollback: false) + ) == nil + ) + } + + @Test("remembered source publishes zoom and reset lineage for new workspaces") + func rememberedSourceLineageChangesRefreshNewWorkspaceCache() throws { + let workspace = Workspace() + let panelID = try #require(workspace.focusedPanelId) + let sourcePanel = try #require(workspace.panels[panelID] as? TerminalPanel) + + sourcePanel.surface.recordCurrentFontSizeLineage( + TerminalFontSizeLineage(basePoints: 7, isExplicitOverride: true) + ) + + #expect( + TabManager().inheritedTerminalConfigForNewWorkspace(workspace: workspace)? + .fontSizeLineage == TerminalFontSizeLineage( + basePoints: 7, + isExplicitOverride: true + ) + ) + + sourcePanel.surface.recordCurrentFontSizeLineage( + TerminalFontSizeLineage(basePoints: 12, isExplicitOverride: false) + ) + + #expect( + TabManager().inheritedTerminalConfigForNewWorkspace(workspace: workspace)? + .fontSizeLineage == TerminalFontSizeLineage( + basePoints: 12, + isExplicitOverride: false + ) + ) + } + + @Test("temporary mobile fit does not replace remembered durable lineage") + func rememberedSourceMasksTemporaryMobileFitLineage() throws { + let workspace = Workspace() + let panelID = try #require(workspace.focusedPanelId) + let sourcePanel = try #require(workspace.panels[panelID] as? TerminalPanel) + let durableLineage = TerminalFontSizeLineage( + basePoints: 12, + isExplicitOverride: false + ) + sourcePanel.surface.recordCurrentFontSizeLineage(durableLineage) + sourcePanel.surface.mobileViewportFontFitState = MobileViewportFontFitState( + baseRuntimePointSize: 12, + fittedRuntimePointSize: 6 + ) + + _ = sourcePanel.surface.recordObservedFontSizeLineage( + runtimePoints: 6, + isExplicitOverride: true, + globalFontMagnificationPercent: 100 + ) + + #expect( + TabManager().inheritedTerminalConfigForNewWorkspace(workspace: workspace)? + .fontSizeLineage == durableLineage + ) + } + + @Test("removed remembered source cannot publish stale lineage") + func removedRememberedSourceCannotRefreshNewWorkspaceCache() throws { + let workspace = Workspace() + let panelID = try #require(workspace.focusedPanelId) + let sourcePanel = try #require(workspace.panels[panelID] as? TerminalPanel) + let paneID = try #require(workspace.bonsplitController.focusedPaneId) + _ = try #require( + workspace.newBrowserSurface( + inPane: paneID, + url: URL(string: "about:blank"), + focus: false, + creationPolicy: .restoration + ) + ) + sourcePanel.surface.recordCurrentFontSizeLineage( + TerminalFontSizeLineage(basePoints: 7, isExplicitOverride: true) + ) + #expect(workspace.closePanel(panelID, force: true)) + + sourcePanel.surface.recordCurrentFontSizeLineage( + TerminalFontSizeLineage(basePoints: 9, isExplicitOverride: true) + ) + + #expect(TabManager().inheritedTerminalConfigForNewWorkspace(workspace: workspace) == nil) + } + + @Test("cleared zoom follows current config when the runtime is recreated") + func clearedZoomDoesNotSeedRuntimeRecreation() { + var restoredTemplate = CmuxSurfaceConfigTemplate() + restoredTemplate.setFontSize(5.5, isExplicitOverride: true) + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: restoredTemplate, + runtimeSpawnPolicy: .pacedSessionRestore + ) + surface.surface = UnsafeMutableRawPointer(bitPattern: 0x7540) + surface.surface = nil + + let resetLineage = TerminalFontSizeLineage( + basePoints: 12, + isExplicitOverride: false + ) + surface.recordCurrentFontSizeLineage(resetLineage) + + #expect(surface.runtimeSurfaceGeneration == 2) + #expect(surface.fontSizeLineageSnapshot() == resetLineage) + #expect(surface.runtimeCreationConfigTemplate().fontSizeLineage == nil) + } + + @Test("initial non-explicit template preserves its font size for first runtime creation") + func initialNonExplicitTemplateSeedsFirstRuntimeCreation() { + var inheritedTemplate = CmuxSurfaceConfigTemplate() + inheritedTemplate.setFontSize(12, isExplicitOverride: false) + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: inheritedTemplate, + runtimeSpawnPolicy: .pacedSessionRestore + ) + + #expect(surface.runtimeSurfaceGeneration == 0) + #expect(surface.fontSizeLineageSnapshot() == inheritedTemplate.fontSizeLineage) + #expect( + surface.runtimeCreationConfigTemplate().fontSizeLineage + == inheritedTemplate.fontSizeLineage + ) + } + + @Test("mobile viewport fitting does not claim durable zoom ownership") + func mobileViewportFitPreservesDurableOwnership() { + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + runtimeSpawnPolicy: .pacedSessionRestore + ) + let configLineage = TerminalFontSizeLineage( + basePoints: 12, + isExplicitOverride: false + ) + surface.recordCurrentFontSizeLineage(configLineage) + surface.mobileViewportFontFitState = MobileViewportFontFitState( + baseRuntimePointSize: 12, + fittedRuntimePointSize: 6 + ) + + let fittedLineage = surface.recordObservedFontSizeLineage( + runtimePoints: 6, + isExplicitOverride: true, + globalFontMagnificationPercent: 100 + ) + + #expect(fittedLineage == configLineage) + #expect(surface.sessionFontSizeOverrideBasePoints() == nil) + + let resetLineage = surface.recordObservedFontSizeLineage( + runtimePoints: 6, + isExplicitOverride: false, + globalFontMagnificationPercent: 100 + ) + + #expect(resetLineage == TerminalFontSizeLineage(basePoints: 6, isExplicitOverride: false)) + #expect(surface.mobileViewportFontFitState?.baseRuntimePointSize == 6) + + let userLineage = surface.recordObservedFontSizeLineage( + runtimePoints: 7, + isExplicitOverride: true, + globalFontMagnificationPercent: 100 + ) + + #expect(userLineage == TerminalFontSizeLineage(basePoints: 7, isExplicitOverride: true)) + #expect(surface.mobileViewportFontFitState?.baseRuntimePointSize == 7) + #expect(surface.sessionFontSizeOverrideBasePoints() == 7) + } + + @Test("unzoomed session restore clears inherited explicit zoom") + func unzoomedRestoreDoesNotBorrowNeighborZoom() throws { + let workspace = Workspace() + let sourcePanelID = try #require(workspace.focusedPanelId) + let sourcePanel = try #require(workspace.panels[sourcePanelID] as? TerminalPanel) + sourcePanel.surface.recordCurrentFontSizeLineage( + TerminalFontSizeLineage(basePoints: 5.5, isExplicitOverride: true) + ) + let paneID = try #require(workspace.bonsplitController.focusedPaneId) + + let restoredPanel = try #require( + workspace.newTerminalSurface( + inPane: paneID, + focus: false, + runtimeSpawnPolicy: .pacedSessionRestore, + terminalFontSizeCreationPolicy: .sessionRestore(overrideBasePoints: nil) + ) + ) + + #expect(restoredPanel.surface.fontSizeLineageSnapshot() == nil) + #expect(restoredPanel.surface.sessionFontSizeOverrideBasePoints() == nil) + #expect( + workspace.lastRememberedTerminalPanelForConfigInheritance()?.id == restoredPanel.id + ) + #expect(workspace.closePanel(sourcePanelID, force: true)) + #expect( + TabManager().inheritedTerminalConfigForNewWorkspace(workspace: workspace) == nil + ) + } + + @Test("closing the remembered zoom source discards its explicit lineage") + func closingZoomSourceClearsWorkspaceFallback() throws { + let workspace = Workspace() + let panelID = try #require(workspace.focusedPanelId) + let snapshot = try snapshotBySettingTerminalFontSize( + 5.5, + panelID: panelID, + in: workspace.sessionSnapshot(includeScrollback: false) + ) + let restoredWorkspace = Workspace() + let restoredPanelIDs = restoredWorkspace.restoreSessionSnapshot(snapshot) + let restoredPanelID = restoredPanelIDs[panelID] ?? panelID + let paneID = try #require(restoredWorkspace.bonsplitController.focusedPaneId) + _ = try #require( + restoredWorkspace.newBrowserSurface( + inPane: paneID, + url: URL(string: "about:blank"), + focus: false, + creationPolicy: .restoration + ) + ) + + #expect( + TabManager().inheritedTerminalConfigForNewWorkspace( + workspace: restoredWorkspace + )?.fontSizeLineage?.isExplicitOverride == true + ) + #expect(restoredWorkspace.closePanel(restoredPanelID, force: true)) + #expect( + TabManager().inheritedTerminalConfigForNewWorkspace( + workspace: restoredWorkspace + ) == nil + ) + } + + @Test("unmounted terminal cannot replace workspace zoom source") + func unmountedTerminalDoesNotReplaceWorkspaceZoomSource() throws { + let workspace = Workspace() + let panelID = try #require(workspace.focusedPanelId) + let snapshot = try snapshotBySettingTerminalFontSize( + 5.5, + panelID: panelID, + in: workspace.sessionSnapshot(includeScrollback: false) + ) + let restoredWorkspace = Workspace() + let restoredPanelIDs = restoredWorkspace.restoreSessionSnapshot(snapshot) + let restoredPanelID = restoredPanelIDs[panelID] ?? panelID + #expect( + restoredWorkspace.lastRememberedTerminalFontSizeLineageForConfigInheritance()? + .isExplicitOverride == true + ) + + let unmountedPanel = TerminalPanel( + workspaceId: restoredWorkspace.id, + configTemplate: nil, + runtimeSpawnPolicy: .pacedSessionRestore + ) + restoredWorkspace.rememberTerminalConfigInheritanceSource(unmountedPanel) + + #expect( + restoredWorkspace.lastRememberedTerminalPanelForConfigInheritance()?.id == restoredPanelID + ) + #expect( + restoredWorkspace.lastRememberedTerminalFontSizeLineageForConfigInheritance()? + .isExplicitOverride == true + ) + } + + private func snapshotBySettingTerminalFontSize( + _ fontSize: Double, + panelID: UUID, + in snapshot: SessionWorkspaceSnapshot + ) throws -> SessionWorkspaceSnapshot { + var object = try #require( + JSONSerialization.jsonObject(with: JSONEncoder().encode(snapshot)) as? [String: Any] + ) + var panels = try #require(object["panels"] as? [[String: Any]]) + let panelIndex = try #require(panels.firstIndex { $0["id"] as? String == panelID.uuidString }) + var terminal = try #require(panels[panelIndex]["terminal"] as? [String: Any]) + terminal["fontSize"] = fontSize + panels[panelIndex]["terminal"] = terminal + object["panels"] = panels + + let data = try JSONSerialization.data(withJSONObject: object) + return try JSONDecoder().decode(SessionWorkspaceSnapshot.self, from: data) + } + + private func terminalFontSize( + panelID: UUID, + in snapshot: SessionWorkspaceSnapshot + ) throws -> Double { + let fontSize = try optionalTerminalFontSize(panelID: panelID, in: snapshot) + return try #require(fontSize) + } + + private func optionalTerminalFontSize( + panelID: UUID, + in snapshot: SessionWorkspaceSnapshot + ) throws -> Double? { + let object = try #require( + JSONSerialization.jsonObject(with: JSONEncoder().encode(snapshot)) as? [String: Any] + ) + let panels = try #require(object["panels"] as? [[String: Any]]) + let panel = try #require(panels.first { $0["id"] as? String == panelID.uuidString }) + let terminal = try #require(panel["terminal"] as? [String: Any]) + return terminal["fontSize"] as? Double + } +}