From 7db9a5c5aa0cfdca64298d957adb05c7c08f8876 Mon Sep 17 00:00:00 2001 From: Doug Hairfield Date: Sat, 18 Apr 2026 17:50:28 -0400 Subject: [PATCH 1/7] feat(sidebar): add sidebar-font-size Ghostty config option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a configurable sidebar font size (10-20pt, default 12.5) via the Ghostty config key `sidebar-font-size`. All other sidebar text (close buttons, metadata rows, progress labels, PR/remote/branch info, log entries, markdown blocks) scales proportionally from this primary size, preserving the existing visual hierarchy. Scope: TabItemView and its sub-views (SidebarWorkspaceDescriptionText, SidebarMetadataRows, SidebarMetadataEntryRow, SidebarMetadataMarkdownBlocks, SidebarMetadataMarkdownBlockRow, PullRequestStatusIcon). Other sidebar chrome (feedback composer, help menu, dev footer) out of scope for this change. The scale is exposed on SidebarTabItemSettingsSnapshot (already Equatable and observed by TabItemView) so the typing-latency contract around TabItemView equatable snapshotting is preserved — no new @ObservedObject or body-time lookups in the hot path. Refs #2643 Co-Authored-By: Claude Opus 4.7 --- Sources/ContentView.swift | 109 +++++++++++++++++++++++++----------- Sources/GhosttyConfig.swift | 15 +++++ 2 files changed, 91 insertions(+), 33 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index fc2ca2a35c6..b27fd9a655d 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -10084,6 +10084,9 @@ private struct SidebarTabItemSettingsSnapshot: Equatable { let selectionColorHex: String? let notificationBadgeColorHex: String? let visibleAuxiliaryDetails: SidebarWorkspaceAuxiliaryDetailVisibility + // Proportional scale applied to every hardcoded sidebar font size. + // Derived from `sidebar-font-size` in the Ghostty config. + let sidebarFontScale: CGFloat init(defaults: UserDefaults = .standard) { sidebarShortcutHintXOffset = Self.double( @@ -10140,6 +10143,13 @@ private struct SidebarTabItemSettingsSnapshot: Equatable { activeTabIndicatorStyle = SidebarActiveTabIndicatorSettings.current(defaults: defaults) selectionColorHex = defaults.string(forKey: "sidebarSelectionColorHex") notificationBadgeColorHex = defaults.string(forKey: "sidebarNotificationBadgeColorHex") + + let primarySidebarFontSize = GhosttyConfig.load().sidebarFontSize + let clampedPrimary = min( + GhosttyConfig.maxSidebarFontSize, + max(GhosttyConfig.minSidebarFontSize, primarySidebarFontSize) + ) + sidebarFontScale = clampedPrimary / GhosttyConfig.defaultSidebarFontSize } private static func bool( @@ -10167,6 +10177,7 @@ private final class SidebarTabItemSettingsStore: ObservableObject { private let defaults: UserDefaults private var defaultsObserver: NSObjectProtocol? + private var ghosttyConfigObserver: NSObjectProtocol? init(defaults: UserDefaults = .standard) { self.defaults = defaults @@ -10180,12 +10191,26 @@ private final class SidebarTabItemSettingsStore: ObservableObject { self?.refreshSnapshot() } } + // Also refresh when the Ghostty config reloads — `sidebar-font-size` + // lives in the Ghostty config, not UserDefaults. + ghosttyConfigObserver = NotificationCenter.default.addObserver( + forName: .ghosttyConfigDidReload, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.refreshSnapshot() + } + } } deinit { if let defaultsObserver { NotificationCenter.default.removeObserver(defaultsObserver) } + if let ghosttyConfigObserver { + NotificationCenter.default.removeObserver(ghosttyConfigObserver) + } } private func refreshSnapshot() { @@ -12853,6 +12878,13 @@ private struct TabItemView: View, Equatable { .semibold } + // Applied as a multiplier to every hardcoded sidebar font size in this + // view (and plumbed into its sub-views). Driven by `sidebar-font-size` in + // the Ghostty config; see GhosttyConfig.sidebarFontSize. + private var fontScale: CGFloat { + settings.sidebarFontScale + } + private var showsLeadingRail: Bool { explicitRailColor != nil } @@ -12985,7 +13017,7 @@ private struct TabItemView: View, Equatable { VStack(alignment: .leading, spacing: 2) { HStack(spacing: 6) { Text(remoteWorkspaceSidebarText) - .font(.system(size: 10, design: .monospaced)) + .font(.system(size: 10 * fontScale, design: .monospaced)) .foregroundColor(activeSecondaryColor(0.8)) .lineLimit(1) .truncationMode(.middle) @@ -12993,7 +13025,7 @@ private struct TabItemView: View, Equatable { Spacer(minLength: 0) Text(remoteConnectionStatusText) - .font(.system(size: 9, weight: .medium)) + .font(.system(size: 9 * fontScale, weight: .medium)) .foregroundColor(activeSecondaryColor(0.58)) .lineLimit(1) } @@ -13074,7 +13106,7 @@ private struct TabItemView: View, Equatable { Circle() .fill(activeUnreadBadgeFillColor) Text("\(unreadCount)") - .font(.system(size: 9, weight: .semibold)) + .font(.system(size: 9 * fontScale, weight: .semibold)) .foregroundColor(.white) } .frame(width: 16, height: 16) @@ -13082,13 +13114,13 @@ private struct TabItemView: View, Equatable { if tab.isPinned { Image(systemName: "pin.fill") - .font(.system(size: 9, weight: .semibold)) + .font(.system(size: 9 * fontScale, weight: .semibold)) .foregroundColor(activeSecondaryColor(0.8)) .safeHelp(protectedWorkspaceTooltip) } Text(tab.title) - .font(.system(size: 12.5, weight: titleFontWeight)) + .font(.system(size: 12.5 * fontScale, weight: titleFontWeight)) .foregroundColor(activePrimaryTextColor) .lineLimit(1) .truncationMode(.tail) @@ -13104,7 +13136,7 @@ private struct TabItemView: View, Equatable { tabManager.closeWorkspaceWithConfirmation(tab) }) { Image(systemName: "xmark") - .font(.system(size: 9, weight: .medium)) + .font(.system(size: 9 * fontScale, weight: .medium)) .foregroundColor(activeSecondaryColor(0.7)) } .buttonStyle(.plain) @@ -13129,14 +13161,15 @@ private struct TabItemView: View, Equatable { if let description = tab.customDescription { SidebarWorkspaceDescriptionText( markdown: description, - isActive: usesInvertedActiveForeground + isActive: usesInvertedActiveForeground, + fontScale: fontScale ) .id(description) } if let subtitle = effectiveSubtitle { Text(subtitle) - .font(.system(size: 10)) + .font(.system(size: 10 * fontScale)) .foregroundColor(activeSecondaryColor(0.8)) .lineLimit(2) .truncationMode(.tail) @@ -13152,7 +13185,8 @@ private struct TabItemView: View, Equatable { SidebarMetadataRows( entries: metadataEntries, isActive: usesInvertedActiveForeground, - onFocus: { updateSelection() } + onFocus: { updateSelection() }, + fontScale: fontScale ) .transition(.opacity.combined(with: .move(edge: .top))) } @@ -13160,7 +13194,8 @@ private struct TabItemView: View, Equatable { SidebarMetadataMarkdownBlocks( blocks: metadataBlocks, isActive: usesInvertedActiveForeground, - onFocus: { updateSelection() } + onFocus: { updateSelection() }, + fontScale: fontScale ) .transition(.opacity.combined(with: .move(edge: .top))) } @@ -13170,10 +13205,10 @@ private struct TabItemView: View, Equatable { if detailVisibility.showsLog, let latestLog = tab.logEntries.last { HStack(spacing: 4) { Image(systemName: logLevelIcon(latestLog.level)) - .font(.system(size: 8)) + .font(.system(size: 8 * fontScale)) .foregroundColor(logLevelColor(latestLog.level, isActive: usesInvertedActiveForeground)) Text(latestLog.message) - .font(.system(size: 10)) + .font(.system(size: 10 * fontScale)) .foregroundColor(activeSecondaryColor(0.8)) .lineLimit(1) .truncationMode(.tail) @@ -13197,7 +13232,7 @@ private struct TabItemView: View, Equatable { if let label = progress.label { Text(label) - .font(.system(size: 9)) + .font(.system(size: 9 * fontScale)) .foregroundColor(activeSecondaryColor(0.6)) .lineLimit(1) } @@ -13212,7 +13247,7 @@ private struct TabItemView: View, Equatable { HStack(alignment: .top, spacing: 3) { if sidebarShowGitBranchIcon, branchLinesContainBranch { Image(systemName: "arrow.triangle.branch") - .font(.system(size: 9)) + .font(.system(size: 9 * fontScale)) .foregroundColor(activeSecondaryColor(0.6)) } VStack(alignment: .leading, spacing: 1) { @@ -13220,20 +13255,20 @@ private struct TabItemView: View, Equatable { HStack(spacing: 3) { if let branch = line.branch { Text(branch) - .font(.system(size: 10, design: .monospaced)) + .font(.system(size: 10 * fontScale, design: .monospaced)) .foregroundColor(activeSecondaryColor(0.75)) .lineLimit(1) .truncationMode(.tail) } if line.branch != nil, line.directory != nil { Image(systemName: "circle.fill") - .font(.system(size: 3)) + .font(.system(size: 3 * fontScale)) .foregroundColor(activeSecondaryColor(0.6)) .padding(.horizontal, 1) } if let directory = line.directory { Text(directory) - .font(.system(size: 10, design: .monospaced)) + .font(.system(size: 10 * fontScale, design: .monospaced)) .foregroundColor(activeSecondaryColor(0.75)) .lineLimit(1) .truncationMode(.tail) @@ -13247,11 +13282,11 @@ private struct TabItemView: View, Equatable { HStack(spacing: 3) { if sidebarShowGitBranchIcon, compactGitBranchSummaryText != nil { Image(systemName: "arrow.triangle.branch") - .font(.system(size: 9)) + .font(.system(size: 9 * fontScale)) .foregroundColor(activeSecondaryColor(0.6)) } Text(dirRow) - .font(.system(size: 10, design: .monospaced)) + .font(.system(size: 10 * fontScale, design: .monospaced)) .foregroundColor(activeSecondaryColor(0.75)) .lineLimit(1) .truncationMode(.tail) @@ -13269,7 +13304,8 @@ private struct TabItemView: View, Equatable { HStack(spacing: 4) { PullRequestStatusIcon( status: pullRequest.status, - color: pullRequestForegroundColor + color: pullRequestForegroundColor, + fontScale: fontScale ) Text("\(pullRequest.label) #\(pullRequest.number)") .underline() @@ -13279,7 +13315,7 @@ private struct TabItemView: View, Equatable { .lineLimit(1) Spacer(minLength: 0) } - .font(.system(size: 10, weight: .semibold)) + .font(.system(size: 10 * fontScale, weight: .semibold)) .foregroundColor(pullRequestForegroundColor) .opacity(pullRequest.isStale ? 0.5 : 1) } @@ -13304,7 +13340,7 @@ private struct TabItemView: View, Equatable { } Spacer(minLength: 0) } - .font(.system(size: 10, design: .monospaced)) + .font(.system(size: 10 * fontScale, design: .monospaced)) .foregroundColor(activeSecondaryColor(0.75)) .lineLimit(1) } @@ -14208,6 +14244,7 @@ private struct TabItemView: View, Equatable { private struct PullRequestStatusIcon: View { let status: SidebarPullRequestStatus let color: Color + var fontScale: CGFloat = 1 private static let frameSize: CGFloat = 12 var body: some View { @@ -14218,7 +14255,7 @@ private struct TabItemView: View, Equatable { PullRequestMergedIcon(color: color) case .closed: Image(systemName: "xmark.circle") - .font(.system(size: 7, weight: .regular)) + .font(.system(size: 7 * fontScale, weight: .regular)) .foregroundColor(color) .frame(width: Self.frameSize, height: Self.frameSize) } @@ -14392,6 +14429,7 @@ private struct TabItemView: View, Equatable { private struct SidebarWorkspaceDescriptionText: View { let markdown: String let isActive: Bool + var fontScale: CGFloat = 1 var body: some View { let renderedMarkdown = SidebarMarkdownRenderer.renderWorkspaceDescription(markdown) @@ -14402,7 +14440,7 @@ private struct SidebarWorkspaceDescriptionText: View { Text(markdown) } } - .font(.system(size: 10.5)) + .font(.system(size: 10.5 * fontScale)) .foregroundColor(foregroundColor) .multilineTextAlignment(.leading) .fixedSize(horizontal: false, vertical: true) @@ -14462,6 +14500,7 @@ private struct SidebarMetadataRows: View { let entries: [SidebarStatusEntry] let isActive: Bool let onFocus: () -> Void + var fontScale: CGFloat = 1 @State private var isExpanded: Bool = false private let collapsedEntryLimit = 3 @@ -14469,7 +14508,7 @@ private struct SidebarMetadataRows: View { var body: some View { VStack(alignment: .leading, spacing: 2) { ForEach(visibleEntries, id: \.key) { entry in - SidebarMetadataEntryRow(entry: entry, isActive: isActive, onFocus: onFocus) + SidebarMetadataEntryRow(entry: entry, isActive: isActive, onFocus: onFocus, fontScale: fontScale) } if shouldShowToggle { @@ -14480,7 +14519,7 @@ private struct SidebarMetadataRows: View { } } .buttonStyle(.plain) - .font(.system(size: 10, weight: .semibold)) + .font(.system(size: 10 * fontScale, weight: .semibold)) .foregroundColor(isActive ? activeSecondaryTextColor : .secondary.opacity(0.9)) .frame(maxWidth: .infinity, alignment: .leading) } @@ -14514,6 +14553,7 @@ private struct SidebarMetadataEntryRow: View { let entry: SidebarStatusEntry let isActive: Bool let onFocus: () -> Void + var fontScale: CGFloat = 1 var body: some View { Group { @@ -14546,7 +14586,7 @@ private struct SidebarMetadataEntryRow: View { .truncationMode(.tail) Spacer(minLength: 0) } - .font(.system(size: 10)) + .font(.system(size: 10 * fontScale)) .frame(maxWidth: .infinity, alignment: .leading) } @@ -14570,12 +14610,12 @@ private struct SidebarMetadataEntryRow: View { if iconRaw.hasPrefix("emoji:") { let value = String(iconRaw.dropFirst("emoji:".count)) guard !value.isEmpty else { return nil } - return AnyView(Text(value).font(.system(size: 9))) + return AnyView(Text(value).font(.system(size: 9 * fontScale))) } if iconRaw.hasPrefix("text:") { let value = String(iconRaw.dropFirst("text:".count)) guard !value.isEmpty else { return nil } - return AnyView(Text(value).font(.system(size: 8, weight: .semibold))) + return AnyView(Text(value).font(.system(size: 8 * fontScale, weight: .semibold))) } let symbolName: String if iconRaw.hasPrefix("sf:") { @@ -14584,7 +14624,7 @@ private struct SidebarMetadataEntryRow: View { symbolName = iconRaw } guard !symbolName.isEmpty else { return nil } - return AnyView(Image(systemName: symbolName).font(.system(size: 8, weight: .medium))) + return AnyView(Image(systemName: symbolName).font(.system(size: 8 * fontScale, weight: .medium))) } @ViewBuilder @@ -14611,6 +14651,7 @@ private struct SidebarMetadataMarkdownBlocks: View { let blocks: [SidebarMetadataBlock] let isActive: Bool let onFocus: () -> Void + var fontScale: CGFloat = 1 @State private var isExpanded: Bool = false private let collapsedBlockLimit = 1 @@ -14621,7 +14662,8 @@ private struct SidebarMetadataMarkdownBlocks: View { SidebarMetadataMarkdownBlockRow( block: block, isActive: isActive, - onFocus: onFocus + onFocus: onFocus, + fontScale: fontScale ) } @@ -14633,7 +14675,7 @@ private struct SidebarMetadataMarkdownBlocks: View { } } .buttonStyle(.plain) - .font(.system(size: 10, weight: .semibold)) + .font(.system(size: 10 * fontScale, weight: .semibold)) .foregroundColor(isActive ? .white.opacity(0.65) : .secondary.opacity(0.9)) .frame(maxWidth: .infinity, alignment: .leading) } @@ -14654,6 +14696,7 @@ private struct SidebarMetadataMarkdownBlockRow: View { let block: SidebarMetadataBlock let isActive: Bool let onFocus: () -> Void + var fontScale: CGFloat = 1 @State private var renderedMarkdown: AttributedString? @@ -14667,7 +14710,7 @@ private struct SidebarMetadataMarkdownBlockRow: View { .foregroundColor(foregroundColor) } } - .font(.system(size: 10)) + .font(.system(size: 10 * fontScale)) .multilineTextAlignment(.leading) .fixedSize(horizontal: false, vertical: true) .contentShape(Rectangle()) diff --git a/Sources/GhosttyConfig.swift b/Sources/GhosttyConfig.swift index 58ce3f7cd1e..f74280ca367 100644 --- a/Sources/GhosttyConfig.swift +++ b/Sources/GhosttyConfig.swift @@ -14,6 +14,13 @@ struct GhosttyConfig { var fontFamily: String = "Menlo" var fontSize: CGFloat = 12 var surfaceTabBarFontSize: CGFloat = 11 + // Primary sidebar workspace-label font size. Secondary sidebar text scales + // proportionally from this value (see SidebarFontScale). Default matches + // the hardcoded size used prior to this setting. + static let defaultSidebarFontSize: CGFloat = 12.5 + static let minSidebarFontSize: CGFloat = 10 + static let maxSidebarFontSize: CGFloat = 20 + var sidebarFontSize: CGFloat = GhosttyConfig.defaultSidebarFontSize var theme: String? var workingDirectory: String? // Ghostty measures scrollback-limit in bytes, not lines. @@ -248,6 +255,14 @@ struct GhosttyConfig { if let size = Double(value) { surfaceTabBarFontSize = CGFloat(size) } + case "sidebar-font-size": + if let size = Double(value) { + let clamped = min( + Double(GhosttyConfig.maxSidebarFontSize), + max(Double(GhosttyConfig.minSidebarFontSize), size) + ) + sidebarFontSize = CGFloat(clamped) + } case "theme": theme = value case "working-directory": From a3270f56a94b780343291190ece618098d43eb6e Mon Sep 17 00:00:00 2001 From: Doug Hairfield Date: Sat, 18 Apr 2026 17:51:00 -0400 Subject: [PATCH 2/7] test(sidebar): cover sidebar-font-size parse + clamp + default Adds SidebarFontSizeConfigTests matching the SidebarBackgroundConfigTests pattern: verify default, valid parse, fractional parse, min/max clamp, and non-numeric fallback to default. Co-Authored-By: Claude Opus 4.7 --- cmuxTests/GhosttyConfigTests.swift | 44 ++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 04b6ad233b8..c276c8874bb 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -2648,6 +2648,50 @@ final class GhosttyMouseFocusTests: XCTestCase { } } +final class SidebarFontSizeConfigTests: XCTestCase { + + func testDefaultSidebarFontSizeMatchesConstant() { + let config = GhosttyConfig() + XCTAssertEqual(config.sidebarFontSize, GhosttyConfig.defaultSidebarFontSize, accuracy: 0.0001) + } + + func testParseSidebarFontSizeValid() { + var config = GhosttyConfig() + config.parse("sidebar-font-size = 14") + XCTAssertEqual(config.sidebarFontSize, 14, accuracy: 0.0001) + } + + func testParseSidebarFontSizeFractional() { + var config = GhosttyConfig() + config.parse("sidebar-font-size = 13.5") + XCTAssertEqual(config.sidebarFontSize, 13.5, accuracy: 0.0001) + } + + func testParseSidebarFontSizeClampedAboveMax() { + var config = GhosttyConfig() + config.parse("sidebar-font-size = 9999") + XCTAssertEqual(config.sidebarFontSize, + GhosttyConfig.maxSidebarFontSize, + accuracy: 0.0001) + } + + func testParseSidebarFontSizeClampedBelowMin() { + var config = GhosttyConfig() + config.parse("sidebar-font-size = 1") + XCTAssertEqual(config.sidebarFontSize, + GhosttyConfig.minSidebarFontSize, + accuracy: 0.0001) + } + + func testParseSidebarFontSizeIgnoresNonNumeric() { + var config = GhosttyConfig() + config.parse("sidebar-font-size = huge") + XCTAssertEqual(config.sidebarFontSize, + GhosttyConfig.defaultSidebarFontSize, + accuracy: 0.0001) + } +} + final class SidebarBackgroundConfigTests: XCTestCase { func testParseSidebarBackgroundSingleHex() { From bc27aedd601b29c246a80eaf9afc23312edfe51d Mon Sep 17 00:00:00 2001 From: Doug Hairfield Date: Sat, 18 Apr 2026 18:17:55 -0400 Subject: [PATCH 3/7] test(sidebar): expand sidebar-font-size coverage + make snapshot injectable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds: - GhosttyConfigTests: trailing-zero parse (14.0 == 14), injected-loader round-trip through GhosttyConfig.load(loadFromDisk:), and later-path- wins semantics (the contract that ~/Library/App Support overrides ~/.config/ghostty when both are present). - SidebarTabItemSettingsSnapshotFontScaleTests: unit scale at default, proportional scaling at 18pt (→ 1.44), clamp-up below min, clamp-down above max, Equatable-flips-on-scale-diff (protects the TabItemView.equatable() re-render contract), Equatable-holds-on-match. Refactor to support the snapshot tests: - SidebarTabItemSettingsSnapshot is promoted from private to internal so `@testable import cmux_DEV` can see it. The store and view both live in the same file, so no external API is newly exposed — only tests. - init now takes an injectable `sidebarFontSizeProvider: () -> CGFloat` defaulting to `{ GhosttyConfig.load().sidebarFontSize }`. Production behavior is unchanged; tests pass a constant closure to avoid touching the real config file. All 15 new tests pass locally via `xcodebuild test-without-building -scheme cmux-unit \ -only-testing:cmuxTests/SidebarFontSizeConfigTests \ -only-testing:cmuxTests/SidebarTabItemSettingsSnapshotFontScaleTests`. Co-Authored-By: Claude Opus 4.7 --- Sources/ContentView.swift | 9 ++- cmuxTests/GhosttyConfigTests.swift | 30 ++++++++++ cmuxTests/SidebarOrderingTests.swift | 84 ++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 3 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index b27fd9a655d..87328defdce 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -10069,7 +10069,7 @@ private struct SidebarResizerAccessibilityModifier: ViewModifier { } } -private struct SidebarTabItemSettingsSnapshot: Equatable { +struct SidebarTabItemSettingsSnapshot: Equatable { let sidebarShortcutHintXOffset: Double let sidebarShortcutHintYOffset: Double let alwaysShowShortcutHints: Bool @@ -10088,7 +10088,10 @@ private struct SidebarTabItemSettingsSnapshot: Equatable { // Derived from `sidebar-font-size` in the Ghostty config. let sidebarFontScale: CGFloat - init(defaults: UserDefaults = .standard) { + init( + defaults: UserDefaults = .standard, + sidebarFontSizeProvider: () -> CGFloat = { GhosttyConfig.load().sidebarFontSize } + ) { sidebarShortcutHintXOffset = Self.double( defaults: defaults, key: ShortcutHintDebugSettings.sidebarHintXKey, @@ -10144,7 +10147,7 @@ private struct SidebarTabItemSettingsSnapshot: Equatable { selectionColorHex = defaults.string(forKey: "sidebarSelectionColorHex") notificationBadgeColorHex = defaults.string(forKey: "sidebarNotificationBadgeColorHex") - let primarySidebarFontSize = GhosttyConfig.load().sidebarFontSize + let primarySidebarFontSize = sidebarFontSizeProvider() let clampedPrimary = min( GhosttyConfig.maxSidebarFontSize, max(GhosttyConfig.minSidebarFontSize, primarySidebarFontSize) diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index c276c8874bb..9a33e31980c 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -2690,6 +2690,36 @@ final class SidebarFontSizeConfigTests: XCTestCase { GhosttyConfig.defaultSidebarFontSize, accuracy: 0.0001) } + + func testParseSidebarFontSizeTrailingZero() { + var config = GhosttyConfig() + config.parse("sidebar-font-size = 14.0") + XCTAssertEqual(config.sidebarFontSize, 14, accuracy: 0.0001) + } + + func testLoadReadsSidebarFontSizeViaInjectedLoader() { + let loaded = GhosttyConfig.load( + preferredColorScheme: .dark, + useCache: false, + loadFromDisk: { _ in + var config = GhosttyConfig() + config.parse("sidebar-font-size = 14\n") + return config + } + ) + XCTAssertEqual(loaded.sidebarFontSize, 14, accuracy: 0.0001) + } + + func testParseLaterSidebarFontSizeWins() { + // When multiple config files are loaded in order, later paths + // should override earlier values for the same key — this is the + // contract that makes ~/Library/Application Support override + // ~/.config/ghostty on macOS. + var config = GhosttyConfig() + config.parse("sidebar-font-size = 11") + config.parse("sidebar-font-size = 18") + XCTAssertEqual(config.sidebarFontSize, 18, accuracy: 0.0001) + } } final class SidebarBackgroundConfigTests: XCTestCase { diff --git a/cmuxTests/SidebarOrderingTests.swift b/cmuxTests/SidebarOrderingTests.swift index da58fd0761d..39db67173e7 100644 --- a/cmuxTests/SidebarOrderingTests.swift +++ b/cmuxTests/SidebarOrderingTests.swift @@ -999,3 +999,87 @@ final class TerminalControllerSidebarDedupeTests: XCTestCase { ) } } + +final class SidebarTabItemSettingsSnapshotFontScaleTests: XCTestCase { + + /// A UserDefaults suite isolated to this test class so we don't mutate + /// the real user defaults used by other tests / the host app. + private var defaults: UserDefaults! + private var suiteName: String! + + override func setUp() { + super.setUp() + suiteName = "cmux-tests-\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + defaults = nil + suiteName = nil + super.tearDown() + } + + func testDefaultConfigYieldsUnitScale() { + let snapshot = SidebarTabItemSettingsSnapshot( + defaults: defaults, + sidebarFontSizeProvider: { GhosttyConfig.defaultSidebarFontSize } + ) + XCTAssertEqual(snapshot.sidebarFontScale, 1.0, accuracy: 0.0001) + } + + func testPrimaryEighteenScalesProportionally() { + let snapshot = SidebarTabItemSettingsSnapshot( + defaults: defaults, + sidebarFontSizeProvider: { 18 } + ) + let expected = 18 / GhosttyConfig.defaultSidebarFontSize + XCTAssertEqual(snapshot.sidebarFontScale, expected, accuracy: 0.0001) + } + + func testBelowMinClampsUp() { + let snapshot = SidebarTabItemSettingsSnapshot( + defaults: defaults, + sidebarFontSizeProvider: { 4 } + ) + let expected = GhosttyConfig.minSidebarFontSize / GhosttyConfig.defaultSidebarFontSize + XCTAssertEqual(snapshot.sidebarFontScale, expected, accuracy: 0.0001) + } + + func testAboveMaxClampsDown() { + let snapshot = SidebarTabItemSettingsSnapshot( + defaults: defaults, + sidebarFontSizeProvider: { 9999 } + ) + let expected = GhosttyConfig.maxSidebarFontSize / GhosttyConfig.defaultSidebarFontSize + XCTAssertEqual(snapshot.sidebarFontScale, expected, accuracy: 0.0001) + } + + /// If this test ever passes incorrectly (i.e. two snapshots with + /// different scales compare equal), TabItemView's + /// `.equatable()` will skip re-rendering on font-size changes and the + /// live-reload path will silently break. + func testSnapshotEqualityReflectsScaleDifference() { + let base = SidebarTabItemSettingsSnapshot( + defaults: defaults, + sidebarFontSizeProvider: { GhosttyConfig.defaultSidebarFontSize } + ) + let bigger = SidebarTabItemSettingsSnapshot( + defaults: defaults, + sidebarFontSizeProvider: { 18 } + ) + XCTAssertNotEqual(base, bigger) + } + + func testSnapshotEqualityHoldsWhenScaleMatches() { + let a = SidebarTabItemSettingsSnapshot( + defaults: defaults, + sidebarFontSizeProvider: { 14 } + ) + let b = SidebarTabItemSettingsSnapshot( + defaults: defaults, + sidebarFontSizeProvider: { 14 } + ) + XCTAssertEqual(a, b) + } +} From 8f743e06f546162cf3d2177125cac96a5bb7de16 Mon Sep 17 00:00:00 2001 From: Doug Hairfield Date: Sat, 18 Apr 2026 18:50:30 -0400 Subject: [PATCH 4/7] fix(sidebar): address CodeRabbit review on #2990 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from CodeRabbit's review on PR #2990: 1. NaN/infinity poisoning the clamp. `Double("nan")` and `Double("inf")` are valid per IEEE 754 / Apple docs; `min/max` on NaN yields NaN, which would have made `sidebarFontScale` NaN → every `N * scale` NaN → SwiftUI renders nothing → invisible sidebar. Added `size.isFinite` guard and three regression tests: `nan`, `inf`, `-infinity`. 2. PR row icons didn't scale. `PullRequestOpenIcon` and `PullRequestMergedIcon` are hand-drawn with hardcoded `Path` geometry, stroke widths, and node positions. Rather than thread `fontScale` through every coordinate (CodeRabbit's proposed diff touches ~20 literals, fragile for pixel-grid snapping), apply a single `.scaleEffect(fontScale)` with a proportionally-sized frame. Behaviorally identical at scale=1, and SwiftUI handles the stroke/path/frame transform together. Also scaled the closed-state `xmark.circle` frame so all three branches grow together. 3. Workspace shortcut hint pill. The `ShortcutHintPill` call in the sidebar trailing accessory hardcoded `fontSize: 10`; now uses `10 * fontScale`. All 18 tests still pass locally. Debug build (`reload.sh --tag sidebar-font-scale`) compiles clean. Co-Authored-By: Claude Opus 4.7 --- Sources/ContentView.swift | 43 ++++++++++++++++++++++-------- Sources/GhosttyConfig.swift | 6 ++++- cmuxTests/GhosttyConfigTests.swift | 26 ++++++++++++++++++ 3 files changed, 63 insertions(+), 12 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 87328defdce..1b53d4d0e7f 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -13149,7 +13149,7 @@ private struct TabItemView: View, Equatable { .allowsHitTesting(showCloseButton && !showsWorkspaceShortcutHint) if showsWorkspaceShortcutHint, let workspaceShortcutLabel { - ShortcutHintPill(text: workspaceShortcutLabel, fontSize: 10, emphasis: shortcutHintEmphasis) + ShortcutHintPill(text: workspaceShortcutLabel, fontSize: 10 * fontScale, emphasis: shortcutHintEmphasis) .offset( x: ShortcutHintDebugSettings.clamped(sidebarShortcutHintXOffset), y: ShortcutHintDebugSettings.clamped(sidebarShortcutHintYOffset) @@ -14251,16 +14251,35 @@ private struct TabItemView: View, Equatable { private static let frameSize: CGFloat = 12 var body: some View { - switch status { - case .open: - PullRequestOpenIcon(color: color) - case .merged: - PullRequestMergedIcon(color: color) - case .closed: - Image(systemName: "xmark.circle") - .font(.system(size: 7 * fontScale, weight: .regular)) - .foregroundColor(color) - .frame(width: Self.frameSize, height: Self.frameSize) + Group { + switch status { + case .open: + // The open/merged icons are hand-drawn with hardcoded Path + // geometry; use .scaleEffect rather than threading fontScale + // through every coordinate to keep the diff small and the + // visuals pixel-equivalent at scale=1. + PullRequestOpenIcon(color: color) + .scaleEffect(fontScale) + .frame( + width: PullRequestOpenIcon.intrinsicFrameSize * fontScale, + height: PullRequestOpenIcon.intrinsicFrameSize * fontScale + ) + case .merged: + PullRequestMergedIcon(color: color) + .scaleEffect(fontScale) + .frame( + width: PullRequestMergedIcon.intrinsicFrameSize * fontScale, + height: PullRequestMergedIcon.intrinsicFrameSize * fontScale + ) + case .closed: + Image(systemName: "xmark.circle") + .font(.system(size: 7 * fontScale, weight: .regular)) + .foregroundColor(color) + .frame( + width: Self.frameSize * fontScale, + height: Self.frameSize * fontScale + ) + } } } } @@ -14270,6 +14289,7 @@ private struct TabItemView: View, Equatable { private static let stroke = StrokeStyle(lineWidth: 1.2, lineCap: .round, lineJoin: .round) private static let nodeDiameter: CGFloat = 3.0 private static let frameSize: CGFloat = 13 + static let intrinsicFrameSize: CGFloat = frameSize var body: some View { ZStack { @@ -14308,6 +14328,7 @@ private struct TabItemView: View, Equatable { private static let stroke = StrokeStyle(lineWidth: 1.2, lineCap: .round, lineJoin: .round) private static let nodeDiameter: CGFloat = 3.0 private static let frameSize: CGFloat = 13 + static let intrinsicFrameSize: CGFloat = frameSize var body: some View { ZStack { diff --git a/Sources/GhosttyConfig.swift b/Sources/GhosttyConfig.swift index f74280ca367..3456ca504a9 100644 --- a/Sources/GhosttyConfig.swift +++ b/Sources/GhosttyConfig.swift @@ -256,7 +256,11 @@ struct GhosttyConfig { surfaceTabBarFontSize = CGFloat(size) } case "sidebar-font-size": - if let size = Double(value) { + // `Double(_:)` accepts "nan" / "inf" per IEEE 754 / Apple + // docs. Reject non-finite values so they don't poison the + // clamp (min/max of NaN → NaN → NaN-valued fontScale → + // invisible sidebar text). + if let size = Double(value), size.isFinite { let clamped = min( Double(GhosttyConfig.maxSidebarFontSize), max(Double(GhosttyConfig.minSidebarFontSize), size) diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 9a33e31980c..7a6ae9ec9fa 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -2710,6 +2710,32 @@ final class SidebarFontSizeConfigTests: XCTestCase { XCTAssertEqual(loaded.sidebarFontSize, 14, accuracy: 0.0001) } + func testParseSidebarFontSizeIgnoresNaN() { + // `Double("nan")` returns `.nan`. Without explicit rejection the + // min/max clamp would produce NaN, yielding an invisible sidebar. + var config = GhosttyConfig() + config.parse("sidebar-font-size = nan") + XCTAssertEqual(config.sidebarFontSize, + GhosttyConfig.defaultSidebarFontSize, + accuracy: 0.0001) + } + + func testParseSidebarFontSizeIgnoresInfinity() { + var config = GhosttyConfig() + config.parse("sidebar-font-size = inf") + XCTAssertEqual(config.sidebarFontSize, + GhosttyConfig.defaultSidebarFontSize, + accuracy: 0.0001) + } + + func testParseSidebarFontSizeIgnoresNegativeInfinity() { + var config = GhosttyConfig() + config.parse("sidebar-font-size = -infinity") + XCTAssertEqual(config.sidebarFontSize, + GhosttyConfig.defaultSidebarFontSize, + accuracy: 0.0001) + } + func testParseLaterSidebarFontSizeWins() { // When multiple config files are loaded in order, later paths // should override earlier values for the same key — this is the From 0468628f21741d8ba115fafdb2109e61ecf34b00 Mon Sep 17 00:00:00 2001 From: Doug Hairfield Date: Sun, 19 Apr 2026 10:50:42 -0400 Subject: [PATCH 5/7] fix(sidebar): scale accessory frames with sidebar-font-size Addresses CodeRabbit finding on #2990: the unread badge, close button, and trailing accessory container kept hardcoded 16pt frames while their inner glyphs were already scaling with fontScale, so at sidebar-font-size above ~12.5pt the glyphs risked clipping or overlapping. Introduce `scaledAccessorySize = max(16, 16 * fontScale)` so the floor at scale <= 1 preserves the existing hit-target size (no-op for default and smaller fonts), and only grows when the user bumps the sidebar font. Apply it to the badge circle, close-button frame, and outer trailing accessory frame. Width of the trailing container uses `max(trailingAccessoryWidth, scaledAccessorySize)` so the shortcut-hint pill path keeps its prior layout when fontScale is 1. Co-Authored-By: Claude Opus 4.7 --- Sources/ContentView.swift | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 1b53d4d0e7f..fcd25ed3d1b 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -13101,6 +13101,12 @@ private struct TabItemView: View, Equatable { guard detailVisibility.showsPullRequests, let orderedPanelIds else { return [] } return pullRequestDisplays(orderedPanelIds: orderedPanelIds) }() + // Grow the fixed 16pt accessory containers proportionally when the + // sidebar font scales above 1, so the glyphs inside (unread count, + // close X, shortcut pill) don't clip or overlap. At scale <= 1 this + // is a no-op — the 16pt floor preserves the original hit-target size. + let scaledAccessorySize = max(16, 16 * fontScale) + let scaledTrailingAccessoryWidth = max(trailingAccessoryWidth, scaledAccessorySize) VStack(alignment: .leading, spacing: 4) { HStack(spacing: 8) { @@ -13112,7 +13118,7 @@ private struct TabItemView: View, Equatable { .font(.system(size: 9 * fontScale, weight: .semibold)) .foregroundColor(.white) } - .frame(width: 16, height: 16) + .frame(width: scaledAccessorySize, height: scaledAccessorySize) } if tab.isPinned { @@ -13144,7 +13150,7 @@ private struct TabItemView: View, Equatable { } .buttonStyle(.plain) .safeHelp(closeButtonTooltip) - .frame(width: SidebarTrailingAccessoryWidthPolicy.closeButtonWidth, height: 16, alignment: .center) + .frame(width: scaledAccessorySize, height: scaledAccessorySize, alignment: .center) .opacity(showCloseButton && !showsWorkspaceShortcutHint ? 1 : 0) .allowsHitTesting(showCloseButton && !showsWorkspaceShortcutHint) @@ -13158,7 +13164,7 @@ private struct TabItemView: View, Equatable { } } .animation(.easeOut(duration: 0.12), value: showsModifierShortcutHints || alwaysShowShortcutHints) - .frame(width: trailingAccessoryWidth, height: 16, alignment: .trailing) + .frame(width: scaledTrailingAccessoryWidth, height: scaledAccessorySize, alignment: .trailing) } if let description = tab.customDescription { From ef2588b0ac2abf23972706cc193d9b172cea1732 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 24 Apr 2026 18:35:49 -0400 Subject: [PATCH 6/7] fix(sidebar): don't reserve trailing gutter when no accessory shown CodeRabbit review on #2990: the merge resolution applied max(16, 16 * fontScale) unconditionally to scaledTrailingAccessoryWidth, which forces a 16pt gutter on rows where trailingAccessoryWidth was 0 (no close button AND no shortcut pill). That steals title space from single- workspace rows that previously had a zero-width trailing gutter. Keep the 16pt floor only when a trailing accessory is actually shown. Co-Authored-By: Claude Opus 4.7 --- Sources/ContentView.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index cce791573ea..c7f8bb8af08 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -12031,7 +12031,12 @@ private struct TabItemView: View, Equatable { // close X, shortcut pill) don't clip or overlap. At scale <= 1 this // is a no-op — the 16pt floor preserves the original hit-target size. let scaledAccessorySize = max(16, 16 * fontScale) - let scaledTrailingAccessoryWidth = max(trailingAccessoryWidth, scaledAccessorySize) + // Preserve the zero-width gutter when no trailing accessory is shown + // (e.g. workspaces with no close button and no shortcut pill), so the + // 16pt floor doesn't steal title space from rows that had none. + let scaledTrailingAccessoryWidth = trailingAccessoryWidth > 0 + ? max(trailingAccessoryWidth, scaledAccessorySize) + : 0 VStack(alignment: .leading, spacing: 4) { HStack(spacing: 8) { From 5a28a35e1808c24e50c90b9a1b911f69436cbc48 Mon Sep 17 00:00:00 2001 From: Doug Hairfield Date: Mon, 25 May 2026 13:48:27 -0400 Subject: [PATCH 7/7] fix(sidebar): expose SidebarTabItemSettingsSnapshot to test target Restore internal visibility so cmuxTests can `@testable import` the struct. Commit bc27aedd6 ("test(sidebar): expand sidebar-font-size coverage + make snapshot injectable") originally relaxed this from `private` to internal, but the access modifier was reverted somewhere in the merge history. That broke compilation of the existing 6 SidebarTabItemSettingsSnapshotFontScaleTests tests in cmuxTests/SidebarOrderingTests.swift with `cannot find 'SidebarTabItemSettingsSnapshot' in scope`. The init's `sidebarFontSizeProvider` parameter is intact; only the access modifier needed restoring. With this fix all 18 feature tests pass (12 SidebarFontSizeConfigTests + 6 snapshot scale tests). Co-Authored-By: Claude Opus 4.7 --- Sources/ContentView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 9aed1bfa8ba..616aaeffa89 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -9361,7 +9361,7 @@ private struct SidebarResizerAccessibilityModifier: ViewModifier { } } -private struct SidebarTabItemSettingsSnapshot: Equatable { +struct SidebarTabItemSettingsSnapshot: Equatable { let hidesAllDetails: Bool let showsWorkspaceDescription: Bool let sidebarShortcutHintXOffset: Double