diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 6ddb1531408..e5172e4a683 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -9757,9 +9757,15 @@ 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 let iMessageModeEnabled: Bool - init(defaults: UserDefaults = .standard) { + init( + defaults: UserDefaults = .standard, + sidebarFontSizeProvider: () -> CGFloat = { GhosttyConfig.load().sidebarFontSize } + ) { sidebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultSidebarHintX sidebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultSidebarHintY alwaysShowShortcutHints = ShortcutHintDebugSettings.alwaysShowHints() @@ -9814,6 +9820,13 @@ struct SidebarTabItemSettingsSnapshot: Equatable { selectionColorHex = defaults.string(forKey: "sidebarSelectionColorHex") notificationBadgeColorHex = defaults.string(forKey: "sidebarNotificationBadgeColorHex") iMessageModeEnabled = IMessageModeSettings.isEnabled(defaults: defaults) + + let primarySidebarFontSize = sidebarFontSizeProvider() + let clampedPrimary = min( + GhosttyConfig.maxSidebarFontSize, + max(GhosttyConfig.minSidebarFontSize, primarySidebarFontSize) + ) + sidebarFontScale = clampedPrimary / GhosttyConfig.defaultSidebarFontSize } private static func bool( @@ -9825,14 +9838,6 @@ struct SidebarTabItemSettingsSnapshot: Equatable { return defaults.bool(forKey: key) } - private static func double( - defaults: UserDefaults, - key: String, - defaultValue: Double - ) -> Double { - guard let value = defaults.object(forKey: key) as? NSNumber else { return defaultValue } - return value.doubleValue - } } private extension String { @@ -9920,6 +9925,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 @@ -9933,12 +9939,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() { @@ -14204,6 +14224,13 @@ 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 } @@ -14352,7 +14379,7 @@ 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) @@ -14360,7 +14387,7 @@ struct TabItemView: View, Equatable { Spacer(minLength: 0) Text(workspaceSnapshot.remoteConnectionStatusText) - .font(.system(size: 9, weight: .medium)) + .font(.system(size: 9 * fontScale, weight: .medium)) .foregroundColor(activeSecondaryColor(0.58)) .lineLimit(1) } @@ -14415,6 +14442,11 @@ struct TabItemView: View, Equatable { : nil let effectiveSubtitle = latestNotificationSubtitle ?? conversationMessageSubtitle let detailVisibility = visibleAuxiliaryDetails + // 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) VStack(alignment: .leading, spacing: 4) { HStack(alignment: .top, spacing: 8) { @@ -14423,21 +14455,21 @@ struct TabItemView: View, Equatable { Circle() .fill(activeUnreadBadgeFillColor) Text("\(unreadCount)") - .font(.system(size: 9, weight: .semibold)) + .font(.system(size: 9 * fontScale, weight: .semibold)) .foregroundColor(activeUnreadBadgeTextColor) } - .frame(width: 16, height: 16) + .frame(width: scaledAccessorySize, height: scaledAccessorySize) } if workspaceSnapshot.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(workspaceSnapshot.title) - .font(.system(size: 12.5, weight: titleFontWeight)) + .font(.system(size: 12.5 * fontScale, weight: titleFontWeight)) .foregroundColor(activePrimaryTextColor) .lineLimit(settings.wrapsWorkspaceTitles ? nil : 1) .truncationMode(.tail) @@ -14450,14 +14482,15 @@ struct TabItemView: View, Equatable { SidebarWorkspaceDescriptionText( markdown: description, isActive: usesInvertedActiveForeground, - activeForegroundColor: activeSecondaryColor(0.84) + activeForegroundColor: activeSecondaryColor(0.84), + 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) @@ -14475,7 +14508,8 @@ struct TabItemView: View, Equatable { isActive: usesInvertedActiveForeground, activeForegroundColor: activeSecondaryColor(0.95), activeSecondaryForegroundColor: activeSecondaryColor(0.65), - onFocus: { updateSelection() } + onFocus: { updateSelection() }, + fontScale: fontScale ) .transition(.opacity.combined(with: .move(edge: .top))) } @@ -14485,7 +14519,8 @@ struct TabItemView: View, Equatable { isActive: usesInvertedActiveForeground, activeForegroundColor: activeSecondaryColor(0.8), activeSecondaryForegroundColor: activeSecondaryColor(0.65), - onFocus: { updateSelection() } + onFocus: { updateSelection() }, + fontScale: fontScale ) .transition(.opacity.combined(with: .move(edge: .top))) } @@ -14494,10 +14529,10 @@ struct TabItemView: View, Equatable { if detailVisibility.showsLog, let latestLog = workspaceSnapshot.latestLog { 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) @@ -14520,7 +14555,7 @@ 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) } @@ -14535,7 +14570,7 @@ struct TabItemView: View, Equatable { HStack(alignment: .top, spacing: 3) { if sidebarShowGitBranchIcon, workspaceSnapshot.branchLinesContainBranch { Image(systemName: "arrow.triangle.branch") - .font(.system(size: 9)) + .font(.system(size: 9 * fontScale)) .foregroundColor(activeSecondaryColor(0.6)) } VStack(alignment: .leading, spacing: 1) { @@ -14543,7 +14578,7 @@ struct TabItemView: View, Equatable { if sidebarStacksBranchAndDirectory { 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) @@ -14551,28 +14586,30 @@ struct TabItemView: View, Equatable { if !line.directoryCandidates.isEmpty { SidebarDirectoryText( candidates: line.directoryCandidates, - color: activeSecondaryColor(0.75) + color: activeSecondaryColor(0.75), + fontScale: fontScale ) } } else { 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.directoryCandidates.isEmpty { Image(systemName: "circle.fill") - .font(.system(size: 3)) + .font(.system(size: 3 * fontScale)) .foregroundColor(activeSecondaryColor(0.6)) .padding(.horizontal, 1) } if !line.directoryCandidates.isEmpty { SidebarDirectoryText( candidates: line.directoryCandidates, - color: activeSecondaryColor(0.75) + color: activeSecondaryColor(0.75), + fontScale: fontScale ) } } @@ -14587,13 +14624,13 @@ struct TabItemView: View, Equatable { HStack(alignment: .top, spacing: 3) { if sidebarShowGitBranchIcon, workspaceSnapshot.compactGitBranchSummaryText != nil { Image(systemName: "arrow.triangle.branch") - .font(.system(size: 9)) + .font(.system(size: 9 * fontScale)) .foregroundColor(activeSecondaryColor(0.6)) } VStack(alignment: .leading, spacing: 1) { if let branchRow = workspaceSnapshot.compactGitBranchSummaryText { Text(branchRow) - .font(.system(size: 10, design: .monospaced)) + .font(.system(size: 10 * fontScale, design: .monospaced)) .foregroundColor(activeSecondaryColor(0.75)) .lineLimit(1) .truncationMode(.tail) @@ -14601,7 +14638,8 @@ struct TabItemView: View, Equatable { if !workspaceSnapshot.compactDirectoryCandidates.isEmpty { SidebarDirectoryText( candidates: workspaceSnapshot.compactDirectoryCandidates, - color: activeSecondaryColor(0.75) + color: activeSecondaryColor(0.75), + fontScale: fontScale ) } } @@ -14610,12 +14648,13 @@ struct TabItemView: View, Equatable { HStack(spacing: 3) { if sidebarShowGitBranchIcon, workspaceSnapshot.compactGitBranchSummaryText != nil { Image(systemName: "arrow.triangle.branch") - .font(.system(size: 9)) + .font(.system(size: 9 * fontScale)) .foregroundColor(activeSecondaryColor(0.6)) } SidebarDirectoryText( candidates: workspaceSnapshot.compactBranchDirectoryCandidates, - color: activeSecondaryColor(0.75) + color: activeSecondaryColor(0.75), + fontScale: fontScale ) } } @@ -14628,12 +14667,16 @@ struct TabItemView: View, Equatable { let pullRequestNumber = String(pullRequest.number) let pullRequestTitle = "\(pullRequest.label) #\(pullRequestNumber)" let rowContent = HStack(spacing: 4) { - PullRequestStatusIcon(status: pullRequest.status, color: pullRequestForegroundColor) + PullRequestStatusIcon( + status: pullRequest.status, + color: pullRequestForegroundColor, + fontScale: fontScale + ) Text(pullRequestTitle).underline(settings.makesPullRequestsClickable).lineLimit(1).truncationMode(.tail) Text(pullRequestStatusLabel(pullRequest.status)).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) if settings.makesPullRequestsClickable { @@ -14666,7 +14709,7 @@ 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) } @@ -14698,7 +14741,8 @@ struct TabItemView: View, Equatable { text: showsWorkspaceShortcutHint ? workspaceShortcutLabel : nil, emphasis: shortcutHintEmphasis, offsetX: sidebarShortcutHintXOffset, - offsetY: sidebarShortcutHintYOffset + offsetY: sidebarShortcutHintYOffset, + fontScale: fontScale ) .overlay(alignment: .topTrailing) { if showsWorkspaceShortcutHint { @@ -14711,12 +14755,12 @@ 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) .safeHelp(closeButtonTooltip) - .frame(width: SidebarTrailingAccessoryWidthPolicy.closeButtonWidth, height: 16, alignment: .center) + .frame(width: SidebarTrailingAccessoryWidthPolicy.closeButtonWidth, height: max(16, 16 * fontScale), alignment: .center) .padding(.top, 8) .padding(.trailing, 10) } @@ -15721,19 +15765,39 @@ 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 { - switch status { - case .open: - PullRequestOpenIcon(color: color) - case .merged: - PullRequestMergedIcon(color: color) - case .closed: - Image(systemName: "xmark.circle") - .font(.system(size: 7, 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 + ) + } } } } @@ -15743,6 +15807,7 @@ 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 { @@ -15781,6 +15846,7 @@ 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 { @@ -15911,6 +15977,7 @@ private struct SidebarWorkspaceDescriptionText: View { let markdown: String let isActive: Bool let activeForegroundColor: Color + var fontScale: CGFloat = 1 var body: some View { let renderedMarkdown = SidebarMarkdownRenderer.renderWorkspaceDescription(markdown) @@ -15921,7 +15988,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) @@ -15983,6 +16050,7 @@ private struct SidebarMetadataRows: View { let activeForegroundColor: Color let activeSecondaryForegroundColor: Color let onFocus: () -> Void + var fontScale: CGFloat = 1 @State private var isExpanded: Bool = false private let collapsedEntryLimit = 3 @@ -15994,7 +16062,8 @@ private struct SidebarMetadataRows: View { entry: entry, isActive: isActive, activeForegroundColor: activeForegroundColor, - onFocus: onFocus + onFocus: onFocus, + fontScale: fontScale ) } @@ -16006,7 +16075,7 @@ private struct SidebarMetadataRows: View { } } .buttonStyle(.plain) - .font(.system(size: 10, weight: .semibold)) + .font(.system(size: 10 * fontScale, weight: .semibold)) .foregroundColor(isActive ? activeSecondaryForegroundColor : .secondary.opacity(0.9)) .frame(maxWidth: .infinity, alignment: .leading) } @@ -16037,6 +16106,7 @@ private struct SidebarMetadataEntryRow: View { let isActive: Bool let activeForegroundColor: Color let onFocus: () -> Void + var fontScale: CGFloat = 1 var body: some View { Group { @@ -16069,7 +16139,7 @@ private struct SidebarMetadataEntryRow: View { .truncationMode(.tail) Spacer(minLength: 0) } - .font(.system(size: 10)) + .font(.system(size: 10 * fontScale)) .frame(maxWidth: .infinity, alignment: .leading) } @@ -16093,12 +16163,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:") { @@ -16107,7 +16177,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 @@ -16136,6 +16206,7 @@ private struct SidebarMetadataMarkdownBlocks: View { let activeForegroundColor: Color let activeSecondaryForegroundColor: Color let onFocus: () -> Void + var fontScale: CGFloat = 1 @State private var isExpanded: Bool = false private let collapsedBlockLimit = 1 @@ -16147,7 +16218,8 @@ private struct SidebarMetadataMarkdownBlocks: View { block: block, isActive: isActive, activeForegroundColor: activeForegroundColor, - onFocus: onFocus + onFocus: onFocus, + fontScale: fontScale ) } @@ -16159,7 +16231,7 @@ private struct SidebarMetadataMarkdownBlocks: View { } } .buttonStyle(.plain) - .font(.system(size: 10, weight: .semibold)) + .font(.system(size: 10 * fontScale, weight: .semibold)) .foregroundColor(isActive ? activeSecondaryForegroundColor : .secondary.opacity(0.9)) .frame(maxWidth: .infinity, alignment: .leading) } @@ -16181,6 +16253,7 @@ private struct SidebarMetadataMarkdownBlockRow: View { let isActive: Bool let activeForegroundColor: Color let onFocus: () -> Void + var fontScale: CGFloat = 1 @State private var renderedMarkdown: AttributedString? @@ -16194,7 +16267,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 667ac64fb60..dbcbd7b7cfd 100644 --- a/Sources/GhosttyConfig.swift +++ b/Sources/GhosttyConfig.swift @@ -17,6 +17,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. @@ -394,6 +401,18 @@ struct GhosttyConfig { if let size = Double(value) { surfaceTabBarFontSize = CGFloat(size) } + case "sidebar-font-size": + // `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) + ) + sidebarFontSize = CGFloat(clamped) + } case "theme": theme = value if let preferredColorScheme { diff --git a/Sources/ShortcutHintPill.swift b/Sources/ShortcutHintPill.swift index 0e04c0bb83c..10606683736 100644 --- a/Sources/ShortcutHintPill.swift +++ b/Sources/ShortcutHintPill.swift @@ -70,11 +70,12 @@ extension View { text: String?, emphasis: Double, offsetX: Double, - offsetY: Double + offsetY: Double, + fontScale: CGFloat = 1.0 ) -> some View { overlay(alignment: .topTrailing) { if let text { - ShortcutHintPill(text: text, fontSize: 10, emphasis: emphasis) + ShortcutHintPill(text: text, fontSize: 10 * fontScale, emphasis: emphasis) .offset( x: ShortcutHintDebugSettings.clamped(offsetX), y: ShortcutHintDebugSettings.clamped(offsetY) diff --git a/Sources/Sidebar/SidebarDirectoryText.swift b/Sources/Sidebar/SidebarDirectoryText.swift index 2550720c40d..0449bdcd4b7 100644 --- a/Sources/Sidebar/SidebarDirectoryText.swift +++ b/Sources/Sidebar/SidebarDirectoryText.swift @@ -9,11 +9,12 @@ import SwiftUI struct SidebarDirectoryText: View { let candidates: [String] let color: Color + var fontScale: CGFloat = 1 var body: some View { if candidates.count <= 1 { Text(candidates.first ?? "") - .font(.system(size: 10, design: .monospaced)) + .font(.system(size: 10 * fontScale, design: .monospaced)) .foregroundColor(color) .lineLimit(1) .truncationMode(.tail) @@ -21,13 +22,13 @@ struct SidebarDirectoryText: View { ViewThatFits(in: .horizontal) { ForEach(Array(candidates.dropLast().enumerated()), id: \.offset) { _, candidate in Text(candidate) - .font(.system(size: 10, design: .monospaced)) + .font(.system(size: 10 * fontScale, design: .monospaced)) .foregroundColor(color) .lineLimit(1) .fixedSize(horizontal: true, vertical: false) } Text(candidates.last ?? "") - .font(.system(size: 10, design: .monospaced)) + .font(.system(size: 10 * fontScale, design: .monospaced)) .foregroundColor(color) .lineLimit(1) .truncationMode(.tail) diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index af735db3ced..72c832f20ab 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -4126,6 +4126,106 @@ 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) + } + + 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 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 + // 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 { func testParseSidebarBackgroundSingleHex() { 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) + } +}