From 6ab559f5fc0d3b4e3098def8412eb401a8120947 Mon Sep 17 00:00:00 2001 From: Gigi Sayfan Date: Fri, 24 Apr 2026 15:49:21 -0700 Subject: [PATCH 1/5] [cmux] add global font magnification across terminals and chrome --- Resources/Localizable.xcstrings | 68 +++ Sources/AppDelegate.swift | 20 + Sources/ContentView.swift | 104 ++--- Sources/Find/BrowserSearchOverlay.swift | 4 +- Sources/Find/SurfaceSearchOverlay.swift | 4 +- Sources/GhosttyConfig.swift | 72 +++ Sources/GhosttyTerminalView.swift | 18 + Sources/KeyboardShortcutSettings.swift | 10 +- .../KeyboardShortcutSettingsFileStore.swift | 16 + Sources/NotificationsPage.swift | 18 +- Sources/Panels/BrowserPanelView.swift | 48 +- Sources/Panels/MarkdownPanelView.swift | 12 +- Sources/RightSidebarPanelView.swift | 4 +- Sources/SessionIndexView.swift | 56 +-- Sources/Settings/ConfigSettingsView.swift | 6 +- Sources/ShortcutHintPill.swift | 2 +- Sources/Update/UpdatePopoverView.swift | 54 +-- Sources/Update/UpdateTitlebarAccessory.swift | 20 +- Sources/WorkspaceContentView.swift | 6 +- Sources/cmuxApp.swift | 411 ++++++++++++++---- web/data/cmux-settings.schema.json | 7 + 21 files changed, 690 insertions(+), 270 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 3eb89b320a06..1e64a817e31a 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -49469,6 +49469,74 @@ } } }, + "settings.app.globalFontMagnification" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Global Font Magnification" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "グローバルフォント倍率" + } + } + } + }, + "settings.app.globalFontMagnification.subtitleOff" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scale every font in cmux by the same percentage. 100% = design size." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "cmux のすべてのフォントを同じ割合でスケーリングします。100% = 設計サイズ。" + } + } + } + }, + "settings.app.globalFontMagnification.subtitleOn" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Terminals, tabs, and chrome all render at this magnification. Per-pane zoom (⌘= / ⌘-) still overrides for the focused pane." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ターミナル、タブ、UI をこの倍率で描画します。ペインごとのズーム (⌘= / ⌘-) はフォーカス中のペインに対して引き続き優先されます。" + } + } + } + }, + "settings.app.globalFontMagnification.reset" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reset" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "リセット" + } + } + } + }, "settings.app.newWorkspacePlacement": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 803f2434a9f4..23830f8d3c57 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -638,6 +638,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var activeConfiguredShortcutChordPrefixForCurrentEvent: ShortcutStroke? private var configuredShortcutChordActions: [KeyboardShortcutSettings.Action] = [] private var ghosttyConfigObserver: NSObjectProtocol? + private var globalFontSizeObserver: NSObjectProtocol? private var ghosttyGotoSplitLeftShortcut: StoredShortcut? private var ghosttyGotoSplitRightShortcut: StoredShortcut? private var ghosttyGotoSplitUpShortcut: StoredShortcut? @@ -1010,6 +1011,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent installMainWindowKeyObserver() refreshGhosttyGotoSplitShortcuts() installGhosttyConfigObserver() + installGlobalFontSizeObserver() installWindowResponderSwizzles() installBrowserAddressBarFocusObservers() installShortcutMonitor() @@ -9039,6 +9041,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } + private func installGlobalFontSizeObserver() { + guard globalFontSizeObserver == nil else { return } + globalFontSizeObserver = NotificationCenter.default.addObserver( + forName: GlobalFontMagnification.didChangeNotification, + object: nil, + queue: .main + ) { _ in + // Push the new magnification into the live Ghostty config so existing + // terminals reflow. Called for UI-driven and cmux.json-driven + // changes — the reload is idempotent so double-reloads are fine. + GhosttyConfig.invalidateLoadCache() + GhosttyApp.shared.reloadConfiguration( + source: "globalFontMagnificationDidChange", + reloadSettingsFromFile: false + ) + } + } + private func refreshGhosttyGotoSplitShortcuts() { guard let config = GhosttyApp.shared.config else { ghosttyGotoSplitLeftShortcut = nil diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index bcfe0395eb5d..714f5b0c784a 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2631,7 +2631,7 @@ struct ContentView: View { } Text(titlebarText) - .font(.system(size: 13, weight: .bold)) + .cmuxFont(size: 13, weight: .bold) .foregroundColor(fakeTitlebarTextColor) .lineLimit(1) .allowsHitTesting(false) @@ -3983,7 +3983,7 @@ struct ContentView: View { if visibleResults.isEmpty { if commandPaletteShouldShowEmptyState { Text(commandPaletteEmptyStateText) - .font(.system(size: 13, weight: .regular)) + .cmuxFont(size: 13, weight: .regular) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 12) @@ -4142,7 +4142,7 @@ struct ContentView: View { case .singleLine(let accessibilityIdentifier, let focus, let onDeleteBackward): TextField(placeholder, text: text) .textFieldStyle(.plain) - .font(.system(size: 13, weight: .regular)) + .cmuxFont(size: 13, weight: .regular) .tint(Color(nsColor: sidebarActiveForegroundNSColor(opacity: 1.0))) .focused(focus) .accessibilityIdentifier(accessibilityIdentifier) @@ -4191,7 +4191,7 @@ struct ContentView: View { Divider() Text(renameInputHintText(target: target)) - .font(.system(size: 11)) + .cmuxFont(size: 11) .foregroundStyle(.secondary) .lineLimit(1) .frame(maxWidth: .infinity, alignment: .leading) @@ -4223,7 +4223,7 @@ struct ContentView: View { return VStack(spacing: 0) { Text(nextName) - .font(.system(size: 13, weight: .regular)) + .cmuxFont(size: 13, weight: .regular) .lineLimit(1) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 9) @@ -4232,7 +4232,7 @@ struct ContentView: View { Divider() Text(renameConfirmHintText(target: target)) - .font(.system(size: 11)) + .cmuxFont(size: 11) .foregroundStyle(.secondary) .lineLimit(1) .frame(maxWidth: .infinity, alignment: .leading) @@ -4281,7 +4281,7 @@ struct ContentView: View { Divider() Text(target.inputHint) - .font(.system(size: 11)) + .cmuxFont(size: 11) .foregroundStyle(.secondary) .lineLimit(1) .frame(maxWidth: .infinity, alignment: .leading) @@ -5750,7 +5750,7 @@ struct ContentView: View { switch trailingLabel.style { case .shortcut: Text(trailingLabel.text) - .font(.system(size: 11, weight: .medium)) + .cmuxFont(size: 11, weight: .medium) .foregroundStyle(.secondary) .padding(.horizontal, 4) .padding(.vertical, 1) @@ -5760,7 +5760,7 @@ struct ContentView: View { ) case .kind: Text(trailingLabel.text) - .font(.system(size: 11, weight: .regular)) + .cmuxFont(size: 11, weight: .regular) .foregroundStyle(.secondary) .lineLimit(1) } @@ -5777,7 +5777,7 @@ struct ContentView: View { title, matchedIndices: matchedIndices ) - .font(.system(size: 13, weight: .regular)) + .cmuxFont(size: 13, weight: .regular) .lineLimit(1) Spacer() commandPaletteTrailingLabelView(trailingLabel) @@ -10531,7 +10531,7 @@ private struct SidebarFeedbackComposerSheet: View { var body: some View { VStack(alignment: .leading, spacing: 16) { Text(String(localized: "sidebar.help.feedback.title", defaultValue: "Send Feedback")) - .font(.title3.weight(.semibold)) + .cmuxFont(.title3, weight: .semibold) if didSend { successView @@ -10547,14 +10547,14 @@ private struct SidebarFeedbackComposerSheet: View { private var successView: some View { VStack(alignment: .leading, spacing: 12) { Text(String(localized: "sidebar.help.feedback.successTitle", defaultValue: "Thanks for the feedback.")) - .font(.headline) + .cmuxFont(.headline) Text( String( localized: "sidebar.help.feedback.successBody", defaultValue: "You can also reach us at founders@manaflow.com." ) ) - .font(.system(size: 12)) + .cmuxFont(size: 12) .foregroundStyle(.secondary) .textSelection(.enabled) @@ -10576,12 +10576,12 @@ private struct SidebarFeedbackComposerSheet: View { defaultValue: "A human will read this! You can also reach us at founders@manaflow.com." ) ) - .font(.system(size: 12)) + .cmuxFont(size: 12) .foregroundStyle(.secondary) VStack(alignment: .leading, spacing: 6) { Text(String(localized: "sidebar.help.feedback.email", defaultValue: "Your Email")) - .font(.system(size: 12, weight: .medium)) + .cmuxFont(size: 12, weight: .medium) TextField( String(localized: "sidebar.help.feedback.emailPlaceholder", defaultValue: "you@example.com"), text: $email @@ -10594,10 +10594,10 @@ private struct SidebarFeedbackComposerSheet: View { VStack(alignment: .leading, spacing: 6) { HStack(alignment: .firstTextBaseline) { Text(String(localized: "sidebar.help.feedback.message", defaultValue: "Message")) - .font(.system(size: 12, weight: .medium)) + .cmuxFont(size: 12, weight: .medium) Spacer(minLength: 0) Text("\(message.count)/\(FeedbackComposerSettings.maxMessageLength)") - .font(.system(size: 11)) + .cmuxFont(size: 11) .foregroundStyle( message.count > FeedbackComposerSettings.maxMessageLength ? Color.red @@ -10635,7 +10635,7 @@ private struct SidebarFeedbackComposerSheet: View { defaultValue: "Up to 10 images." ) ) - .font(.system(size: 11)) + .cmuxFont(size: 11) .foregroundStyle(.secondary) } @@ -10646,12 +10646,12 @@ private struct SidebarFeedbackComposerSheet: View { Image(systemName: "photo") .foregroundStyle(.secondary) Text(attachment.fileName) - .font(.system(size: 12)) + .cmuxFont(size: 12) .lineLimit(1) .truncationMode(.middle) Spacer(minLength: 0) Text(attachment.displaySize) - .font(.system(size: 11)) + .cmuxFont(size: 11) .foregroundStyle(.secondary) Button( String(localized: "sidebar.help.feedback.removeAttachment", defaultValue: "Remove") @@ -10672,7 +10672,7 @@ private struct SidebarFeedbackComposerSheet: View { if let submissionErrorMessage, submissionErrorMessage.isEmpty == false { Text(submissionErrorMessage) - .font(.system(size: 12)) + .cmuxFont(size: 12) .foregroundStyle(.red) } @@ -11023,7 +11023,7 @@ private struct SidebarHelpMenuButton: View { } label: { Image(systemName: "questionmark.circle") .symbolRenderingMode(.monochrome) - .font(.system(size: iconSize, weight: .medium)) + .cmuxFont(size: iconSize, weight: .medium) .foregroundStyle(Color(nsColor: .secondaryLabelColor)) .frame(width: buttonSize, height: buttonSize, alignment: .center) } @@ -11135,7 +11135,7 @@ private struct SidebarHelpMenuButton: View { } label: { HStack(spacing: 8) { Text(title) - .font(.system(size: 12)) + .cmuxFont(size: 12) Spacer(minLength: 0) if let shortcutHint { helpOptionShortcutHint(text: shortcutHint) @@ -11159,7 +11159,7 @@ private struct SidebarHelpMenuButton: View { Text(text) .lineLimit(1) .fixedSize(horizontal: true, vertical: false) - .font(.system(size: 10, weight: .regular, design: .rounded)) + .cmuxFont(size: 10, weight: .regular, design: .rounded) .monospacedDigit() .foregroundStyle(Color(nsColor: .secondaryLabelColor)) } @@ -11420,7 +11420,7 @@ private struct SidebarDevFooter: View { SidebarFooterButtons(updateViewModel: updateViewModel, fileExplorerState: fileExplorerState, onSendFeedback: onSendFeedback) if showSidebarDevBuildBanner { Text(String(localized: "debug.devBuildBanner.title", defaultValue: "THIS IS A DEV BUILD")) - .font(.system(size: 11, weight: .semibold)) + .cmuxFont(size: 11, weight: .semibold) .foregroundColor(.red) } } @@ -11947,7 +11947,7 @@ private struct TabItemView: View, Equatable { VStack(alignment: .leading, spacing: 2) { HStack(spacing: 6) { Text(remoteWorkspaceSidebarText) - .font(.system(size: 10, design: .monospaced)) + .cmuxFont(size: 10, design: .monospaced) .foregroundColor(activeSecondaryColor(0.8)) .lineLimit(1) .truncationMode(.middle) @@ -11955,7 +11955,7 @@ private struct TabItemView: View, Equatable { Spacer(minLength: 0) Text(workspaceSnapshot.remoteConnectionStatusText) - .font(.system(size: 9, weight: .medium)) + .cmuxFont(size: 9, weight: .medium) .foregroundColor(activeSecondaryColor(0.58)) .lineLimit(1) } @@ -11999,7 +11999,7 @@ private struct TabItemView: View, Equatable { Circle() .fill(activeUnreadBadgeFillColor) Text("\(unreadCount)") - .font(.system(size: 9, weight: .semibold)) + .cmuxFont(size: 9, weight: .semibold) .foregroundColor(.white) } .frame(width: 16, height: 16) @@ -12007,13 +12007,13 @@ private struct TabItemView: View, Equatable { if workspaceSnapshot.isPinned { Image(systemName: "pin.fill") - .font(.system(size: 9, weight: .semibold)) + .cmuxFont(size: 9, weight: .semibold) .foregroundColor(activeSecondaryColor(0.8)) .safeHelp(protectedWorkspaceTooltip) } Text(workspaceSnapshot.title) - .font(.system(size: 12.5, weight: titleFontWeight)) + .cmuxFont(size: 12.5, weight: titleFontWeight) .foregroundColor(activePrimaryTextColor) .lineLimit(1) .truncationMode(.tail) @@ -12029,7 +12029,7 @@ private struct TabItemView: View, Equatable { tabManager.closeWorkspaceWithConfirmation(tab) }) { Image(systemName: "xmark") - .font(.system(size: 9, weight: .medium)) + .cmuxFont(size: 9, weight: .medium) .foregroundColor(activeSecondaryColor(0.7)) } .buttonStyle(.plain) @@ -12061,7 +12061,7 @@ private struct TabItemView: View, Equatable { if let subtitle = effectiveSubtitle { Text(subtitle) - .font(.system(size: 10)) + .cmuxFont(size: 10) .foregroundColor(activeSecondaryColor(0.8)) .lineLimit(2) .truncationMode(.tail) @@ -12095,10 +12095,10 @@ private struct TabItemView: View, Equatable { if detailVisibility.showsLog, let latestLog = workspaceSnapshot.latestLog { HStack(spacing: 4) { Image(systemName: logLevelIcon(latestLog.level)) - .font(.system(size: 8)) + .cmuxFont(size: 8) .foregroundColor(logLevelColor(latestLog.level, isActive: usesInvertedActiveForeground)) Text(latestLog.message) - .font(.system(size: 10)) + .cmuxFont(size: 10) .foregroundColor(activeSecondaryColor(0.8)) .lineLimit(1) .truncationMode(.tail) @@ -12122,7 +12122,7 @@ private struct TabItemView: View, Equatable { if let label = progress.label { Text(label) - .font(.system(size: 9)) + .cmuxFont(size: 9) .foregroundColor(activeSecondaryColor(0.6)) .lineLimit(1) } @@ -12137,7 +12137,7 @@ private struct TabItemView: View, Equatable { HStack(alignment: .top, spacing: 3) { if sidebarShowGitBranchIcon, workspaceSnapshot.branchLinesContainBranch { Image(systemName: "arrow.triangle.branch") - .font(.system(size: 9)) + .cmuxFont(size: 9) .foregroundColor(activeSecondaryColor(0.6)) } VStack(alignment: .leading, spacing: 1) { @@ -12145,20 +12145,20 @@ private struct TabItemView: View, Equatable { HStack(spacing: 3) { if let branch = line.branch { Text(branch) - .font(.system(size: 10, design: .monospaced)) + .cmuxFont(size: 10, 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)) + .cmuxFont(size: 3) .foregroundColor(activeSecondaryColor(0.6)) .padding(.horizontal, 1) } if let directory = line.directory { Text(directory) - .font(.system(size: 10, design: .monospaced)) + .cmuxFont(size: 10, design: .monospaced) .foregroundColor(activeSecondaryColor(0.75)) .lineLimit(1) .truncationMode(.tail) @@ -12172,11 +12172,11 @@ private struct TabItemView: View, Equatable { HStack(spacing: 3) { if sidebarShowGitBranchIcon, workspaceSnapshot.compactGitBranchSummaryText != nil { Image(systemName: "arrow.triangle.branch") - .font(.system(size: 9)) + .cmuxFont(size: 9) .foregroundColor(activeSecondaryColor(0.6)) } Text(dirRow) - .font(.system(size: 10, design: .monospaced)) + .cmuxFont(size: 10, design: .monospaced) .foregroundColor(activeSecondaryColor(0.75)) .lineLimit(1) .truncationMode(.tail) @@ -12204,7 +12204,7 @@ private struct TabItemView: View, Equatable { .lineLimit(1) Spacer(minLength: 0) } - .font(.system(size: 10, weight: .semibold)) + .cmuxFont(size: 10, weight: .semibold) .foregroundColor(pullRequestForegroundColor) .opacity(pullRequest.isStale ? 0.5 : 1) } @@ -12229,7 +12229,7 @@ private struct TabItemView: View, Equatable { } Spacer(minLength: 0) } - .font(.system(size: 10, design: .monospaced)) + .cmuxFont(size: 10, design: .monospaced) .foregroundColor(activeSecondaryColor(0.75)) .lineLimit(1) } @@ -13203,7 +13203,7 @@ private struct TabItemView: View, Equatable { PullRequestMergedIcon(color: color) case .closed: Image(systemName: "xmark.circle") - .font(.system(size: 7, weight: .regular)) + .cmuxFont(size: 7, weight: .regular) .foregroundColor(color) .frame(width: Self.frameSize, height: Self.frameSize) } @@ -13387,7 +13387,7 @@ private struct SidebarWorkspaceDescriptionText: View { Text(markdown) } } - .font(.system(size: 10.5)) + .cmuxFont(size: 10.5) .foregroundColor(foregroundColor) .multilineTextAlignment(.leading) .fixedSize(horizontal: false, vertical: true) @@ -13465,7 +13465,7 @@ private struct SidebarMetadataRows: View { } } .buttonStyle(.plain) - .font(.system(size: 10, weight: .semibold)) + .cmuxFont(size: 10, weight: .semibold) .foregroundColor(isActive ? activeSecondaryTextColor : .secondary.opacity(0.9)) .frame(maxWidth: .infinity, alignment: .leading) } @@ -13531,7 +13531,7 @@ private struct SidebarMetadataEntryRow: View { .truncationMode(.tail) Spacer(minLength: 0) } - .font(.system(size: 10)) + .cmuxFont(size: 10) .frame(maxWidth: .infinity, alignment: .leading) } @@ -13555,12 +13555,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).cmuxFont(size: 9)) } 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).cmuxFont(size: 8, weight: .semibold)) } let symbolName: String if iconRaw.hasPrefix("sf:") { @@ -13569,7 +13569,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).cmuxFont(size: 8, weight: .medium)) } @ViewBuilder @@ -13618,7 +13618,7 @@ private struct SidebarMetadataMarkdownBlocks: View { } } .buttonStyle(.plain) - .font(.system(size: 10, weight: .semibold)) + .cmuxFont(size: 10, weight: .semibold) .foregroundColor(isActive ? .white.opacity(0.65) : .secondary.opacity(0.9)) .frame(maxWidth: .infinity, alignment: .leading) } @@ -13652,7 +13652,7 @@ private struct SidebarMetadataMarkdownBlockRow: View { .foregroundColor(foregroundColor) } } - .font(.system(size: 10)) + .cmuxFont(size: 10) .multilineTextAlignment(.leading) .fixedSize(horizontal: false, vertical: true) .contentShape(Rectangle()) diff --git a/Sources/Find/BrowserSearchOverlay.swift b/Sources/Find/BrowserSearchOverlay.swift index 6a25ba987c48..dc1b8bf3a3f6 100644 --- a/Sources/Find/BrowserSearchOverlay.swift +++ b/Sources/Find/BrowserSearchOverlay.swift @@ -47,13 +47,13 @@ struct BrowserSearchOverlay: View { if let selected = searchState.selected { let totalText = searchState.total.map { String($0) } ?? "?" Text("\(selected + 1)/\(totalText)") - .font(.caption) + .cmuxFont(.caption) .foregroundColor(.secondary) .monospacedDigit() .padding(.trailing, 8) } else if let total = searchState.total { Text(total == 0 ? "0/0" : "-/\(total)") - .font(.caption) + .cmuxFont(.caption) .foregroundColor(.secondary) .monospacedDigit() .padding(.trailing, 8) diff --git a/Sources/Find/SurfaceSearchOverlay.swift b/Sources/Find/SurfaceSearchOverlay.swift index 2e602ef79c63..dd2e47aaaab7 100644 --- a/Sources/Find/SurfaceSearchOverlay.swift +++ b/Sources/Find/SurfaceSearchOverlay.swift @@ -68,13 +68,13 @@ struct SurfaceSearchOverlay: View { if let selected = searchState.selected { let totalText = searchState.total.map { String($0) } ?? "?" Text("\(selected + 1)/\(totalText)") - .font(.caption) + .cmuxFont(.caption) .foregroundColor(.secondary) .monospacedDigit() .padding(.trailing, 8) } else if let total = searchState.total { Text("-/\(total)") - .font(.caption) + .cmuxFont(.caption) .foregroundColor(.secondary) .monospacedDigit() .padding(.trailing, 8) diff --git a/Sources/GhosttyConfig.swift b/Sources/GhosttyConfig.swift index 40986343fbea..9410b1b9faff 100644 --- a/Sources/GhosttyConfig.swift +++ b/Sources/GhosttyConfig.swift @@ -257,10 +257,20 @@ struct GhosttyConfig { config.resolveSidebarBackground(preferredColorScheme: preferredColorScheme) config.applySidebarAppearanceToUserDefaults() + config.applyGlobalMagnificationIfNeeded() return config } + /// Multiply the parsed terminal and tab-bar font sizes by the global + /// magnification so both scale in lockstep with SwiftUI chrome. + mutating func applyGlobalMagnificationIfNeeded() { + guard !GlobalFontMagnification.isDefault else { return } + let scale = GlobalFontMagnification.scale + fontSize = max(1, fontSize * scale) + surfaceTabBarFontSize = max(1, surfaceTabBarFontSize * scale) + } + mutating func applyCmuxDefaultAppearance( environment: [String: String], bundleResourceURL: URL?, @@ -759,3 +769,65 @@ extension NSColor { ) } } + +/// App-wide magnification for cmux's chrome and terminals. +/// +/// Stored as an integer percent (100 = default, 150 = 1.5x, 200 = 2x, ...). +/// Every SwiftUI `.font(...)` call site is expected to go through the +/// `cmuxFont(size:)` / `cmuxFont(_:)` view modifiers, which multiply the +/// design-time size by `scale` and read via `@AppStorage` so they re-render +/// when the user changes the value in Settings. +/// +/// Ghostty's terminal font size is scaled by the same factor (`fontSize` +/// in the parsed `GhosttyConfig` is multiplied on load, and the resulting +/// value is injected into Ghostty's live config). Per-pane runtime zoom +/// (cmd+=/-/0 while a terminal pane is focused, or the browser's own zoom) +/// still acts on top of whatever magnification resolved to. +enum GlobalFontMagnification { + static let percentKey = "globalFontMagnificationPercent" + + static let defaultPercent: Int = 100 + static let minimumPercent: Int = 50 + static let maximumPercent: Int = 400 + static let stepPercent: Int = 10 + + static let didChangeNotification = Notification.Name("cmux.globalFontMagnification.didChange") + + /// Raw percent stored in UserDefaults. If the key is unset, treat as 100%. + static var storedPercent: Int { + let value = UserDefaults.standard.object(forKey: percentKey) as? Int + let resolved = value ?? defaultPercent + return clamp(resolved) + } + + /// Multiplier (1.0 for 100%, 1.5 for 150%, ...). Always returns a finite + /// positive number so multiplying hardcoded sizes is always safe. + static var scale: CGFloat { + CGFloat(storedPercent) / CGFloat(defaultPercent) + } + + /// `true` when the user has left magnification at the default 100%. + static var isDefault: Bool { storedPercent == defaultPercent } + + /// Scale a design-time point size by the current magnification. Clamped + /// to at least 1pt to avoid zero-size fonts. + static func scaled(_ base: CGFloat) -> CGFloat { + max(1, base * scale) + } + + static func clamp(_ percent: Int) -> Int { + Swift.max(minimumPercent, Swift.min(maximumPercent, percent)) + } + + static func setPercent(_ percent: Int) { + UserDefaults.standard.set(clamp(percent), forKey: percentKey) + GhosttyConfig.invalidateLoadCache() + NotificationCenter.default.post(name: didChangeNotification, object: nil) + } + + static func resetToDefault() { + UserDefaults.standard.removeObject(forKey: percentKey) + GhosttyConfig.invalidateLoadCache() + NotificationCenter.default.post(name: didChangeNotification, object: nil) + } +} diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index a9f9c5aaa3eb..cca7bebf7879 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -2113,6 +2113,24 @@ class GhosttyApp { logLabel: "shell integration override" ) + // Apply the Settings-driven magnification last so it wins over any + // `font-size` declared in user configs. cmux's own Swift parser already + // applied the same scale to its parallel `GhosttyConfig.fontSize`, so + // reading it back here yields a size that's consistent with what the + // tab bar and chrome will render at. Per-surface zoom (cmd+=/-/0) + // keeps operating on top of whatever value Ghostty ends up with. + if !GlobalFontMagnification.isDefault { + let scaled = GhosttyConfig.load().fontSize + if scaled > 0 { + loadInlineGhosttyConfig( + "font-size = \(scaled)", + into: config, + prefix: "cmux-global-font-magnification", + logLabel: "global font magnification" + ) + } + } + ghostty_config_finalize(config) } diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 129a55192b30..bc1be865be86 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -2263,7 +2263,7 @@ struct KeyboardShortcutRecorder: View { Text(label) if let subtitle { Text(subtitle) - .font(.caption) + .cmuxFont(.caption) .foregroundStyle(.secondary) } } @@ -2286,24 +2286,24 @@ struct KeyboardShortcutRecorder: View { if let validationMessage { HStack(alignment: .firstTextBaseline, spacing: 8) { Image(systemName: "exclamationmark.triangle.fill") - .font(.caption) + .cmuxFont(.caption) .foregroundStyle(.red) Text(validationMessage) - .font(.caption) + .cmuxFont(.caption) .foregroundStyle(.red) .fixedSize(horizontal: false, vertical: true) if let validationButtonTitle, let onValidationButtonPressed { Button(validationButtonTitle, action: onValidationButtonPressed) .buttonStyle(.link) - .font(.caption) + .cmuxFont(.caption) } if let undoButtonTitle, let onUndoButtonPressed { Button(undoButtonTitle, action: onUndoButtonPressed) .buttonStyle(.link) - .font(.caption) + .cmuxFont(.caption) } } .padding(.horizontal, 8) diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift index 0e8b63eefd7f..98ba733cb33a 100644 --- a/Sources/KeyboardShortcutSettingsFileStore.swift +++ b/Sources/KeyboardShortcutSettingsFileStore.swift @@ -39,6 +39,7 @@ final class CmuxSettingsFileStore { "app.warnBeforeQuit", "app.renameSelectsExistingName", "app.commandPaletteSearchesAllSurfaces", + "app.globalFontMagnification", "terminal.showScrollBar", "notifications.dockBadge", "notifications.showInMenuBar", @@ -433,6 +434,16 @@ final class CmuxSettingsFileStore { if let value = jsonBool(section["commandPaletteSearchesAllSurfaces"]) { snapshot.managedUserDefaults[CommandPaletteSwitcherSearchSettings.searchAllSurfacesKey] = .bool(value) } + if let raw = section["globalFontMagnification"] { + if raw is NSNull { + snapshot.managedUserDefaults[GlobalFontMagnification.percentKey] = .int(GlobalFontMagnification.defaultPercent) + } else if let value = jsonDouble(raw) { + let clamped = GlobalFontMagnification.clamp(Int(value.rounded())) + snapshot.managedUserDefaults[GlobalFontMagnification.percentKey] = .int(clamped) + } else { + logInvalid("app.globalFontMagnification", sourcePath: sourcePath) + } + } } private func parseNotificationsSection( @@ -1042,6 +1053,11 @@ final class CmuxSettingsFileStore { TerminalScrollBarSettings.notifyDidChange(notificationCenter: notificationCenter) } + if defaultsKey == GlobalFontMagnification.percentKey, didMutateStoredValue { + GhosttyConfig.invalidateLoadCache() + notificationCenter.post(name: GlobalFontMagnification.didChangeNotification, object: nil) + } + switch defaultsKey { case LanguageSettings.languageKey: let language = AppLanguage(rawValue: UserDefaults.standard.string(forKey: defaultsKey) ?? "") ?? .system diff --git a/Sources/NotificationsPage.swift b/Sources/NotificationsPage.swift index 4ec4cc8843bd..182898b26d5d 100644 --- a/Sources/NotificationsPage.swift +++ b/Sources/NotificationsPage.swift @@ -69,7 +69,7 @@ struct NotificationsPage: View { private var header: some View { HStack { Text(String(localized: "notifications.title", defaultValue: "Notifications")) - .font(.title2) + .cmuxFont(.title2) .fontWeight(.semibold) Spacer() @@ -90,12 +90,12 @@ struct NotificationsPage: View { private var emptyState: some View { VStack(spacing: 8) { Image(systemName: "bell.slash") - .font(.system(size: 32)) + .cmuxFont(size: 32) .foregroundColor(.secondary) Text(String(localized: "notifications.empty.title", defaultValue: "No notifications yet")) - .font(.headline) + .cmuxFont(.headline) Text(String(localized: "notifications.empty.description", defaultValue: "Desktop notifications will appear here for quick review.")) - .font(.subheadline) + .cmuxFont(.subheadline) .foregroundColor(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -160,7 +160,7 @@ struct ShortcutAnnotation: View { private var badge: some View { Text(text) - .font(.system(size: 10, weight: .semibold, design: .rounded)) + .cmuxFont(size: 10, weight: .semibold, design: .rounded) .foregroundStyle(.primary) .padding(.horizontal, 6) .padding(.vertical, 2) @@ -194,24 +194,24 @@ private struct NotificationRow: View { VStack(alignment: .leading, spacing: 6) { HStack { Text(notification.title) - .font(.headline) + .cmuxFont(.headline) .foregroundColor(.primary) Spacer() Text(notification.createdAt.formatted(date: .omitted, time: .shortened)) - .font(.caption) + .cmuxFont(.caption) .foregroundColor(.secondary) } if !notification.body.isEmpty { Text(notification.body) - .font(.subheadline) + .cmuxFont(.subheadline) .foregroundColor(.secondary) .lineLimit(3) } if let tabTitle { Text(tabTitle) - .font(.caption) + .cmuxFont(.caption) .foregroundColor(.secondary) } } diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 2382048cc9f8..5d262fa864fe 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -880,7 +880,7 @@ struct BrowserPanelView: View { panel.goBack() }) { Image(systemName: "chevron.left") - .font(.system(size: 12, weight: .medium)) + .cmuxFont(size: 12, weight: .medium) .frame(width: addressBarButtonHitSize, height: addressBarButtonHitSize, alignment: .center) .contentShape(Rectangle()) } @@ -896,7 +896,7 @@ struct BrowserPanelView: View { panel.goForward() }) { Image(systemName: "chevron.right") - .font(.system(size: 12, weight: .medium)) + .cmuxFont(size: 12, weight: .medium) .frame(width: addressBarButtonHitSize, height: addressBarButtonHitSize, alignment: .center) .contentShape(Rectangle()) } @@ -919,7 +919,7 @@ struct BrowserPanelView: View { } }) { Image(systemName: panel.isLoading ? "xmark" : "arrow.clockwise") - .font(.system(size: 12, weight: .medium)) + .cmuxFont(size: 12, weight: .medium) .frame(width: addressBarButtonHitSize, height: addressBarButtonHitSize, alignment: .center) .contentShape(Rectangle()) } @@ -931,7 +931,7 @@ struct BrowserPanelView: View { ProgressView() .controlSize(.small) Text(String(localized: "browser.downloading", defaultValue: "Downloading...")) - .font(.system(size: 11)) + .cmuxFont(size: 11) .foregroundStyle(.secondary) } .padding(.leading, 6) @@ -948,7 +948,7 @@ struct BrowserPanelView: View { Image(systemName: "cursorarrow.click.2") .symbolRenderingMode(.monochrome) .cmuxFlatSymbolColorRendering() - .font(.system(size: devToolsButtonIconSize, weight: .medium)) + .cmuxFont(size: devToolsButtonIconSize, weight: .medium) .foregroundStyle(panel.isReactGrabActive ? Color.accentColor : Color.secondary) .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) } @@ -965,7 +965,7 @@ struct BrowserPanelView: View { Image(systemName: devToolsIconOption.rawValue) .symbolRenderingMode(.monochrome) .cmuxFlatSymbolColorRendering() - .font(.system(size: devToolsButtonIconSize, weight: .medium)) + .cmuxFont(size: devToolsButtonIconSize, weight: .medium) .foregroundStyle(devToolsColorOption.color) .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) } @@ -982,7 +982,7 @@ struct BrowserPanelView: View { Image(systemName: "person.crop.circle") .symbolRenderingMode(.monochrome) .cmuxFlatSymbolColorRendering() - .font(.system(size: devToolsButtonIconSize, weight: .medium)) + .cmuxFont(size: devToolsButtonIconSize, weight: .medium) .foregroundStyle(devToolsColorOption.color) .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) } @@ -1010,7 +1010,7 @@ struct BrowserPanelView: View { Image(systemName: browserThemeMode.iconName) .symbolRenderingMode(.monochrome) .cmuxFlatSymbolColorRendering() - .font(.system(size: devToolsButtonIconSize, weight: .medium)) + .cmuxFont(size: devToolsButtonIconSize, weight: .medium) .foregroundStyle(browserThemeModeIconColor) .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) } @@ -1037,9 +1037,9 @@ struct BrowserPanelView: View { }) { HStack(spacing: 4) { Image(systemName: "square.and.arrow.down.on.square") - .font(.system(size: 10, weight: .medium)) + .cmuxFont(size: 10, weight: .medium) Text(String(localized: "browser.import.hint.toolbar", defaultValue: "Import")) - .font(.system(size: 11, weight: .medium)) + .cmuxFont(size: 11, weight: .medium) .lineLimit(1) } .foregroundStyle(devToolsColorOption.color) @@ -1057,7 +1057,7 @@ struct BrowserPanelView: View { private var browserProfilePopover: some View { VStack(alignment: .leading, spacing: 8) { Text(String(localized: "browser.profile.menu.title", defaultValue: "Profiles")) - .font(.system(size: 12, weight: .semibold)) + .cmuxFont(size: 12, weight: .semibold) .foregroundStyle(.secondary) VStack(alignment: .leading, spacing: 2) { @@ -1067,11 +1067,11 @@ struct BrowserPanelView: View { } label: { HStack(spacing: 8) { Image(systemName: profile.id == panel.profileID ? "checkmark" : "circle") - .font(.system(size: 10, weight: .semibold)) + .cmuxFont(size: 10, weight: .semibold) .opacity(profile.id == panel.profileID ? 1.0 : 0.0) .frame(width: 12, alignment: .center) Text(profile.displayName) - .font(.system(size: 12)) + .cmuxFont(size: 12) Spacer(minLength: 0) } .padding(.horizontal, 8) @@ -1093,7 +1093,7 @@ struct BrowserPanelView: View { presentCreateBrowserProfilePrompt() } label: { Text(String(localized: "browser.profile.new", defaultValue: "New Profile...")) - .font(.system(size: 12)) + .cmuxFont(size: 12) } .buttonStyle(.plain) @@ -1101,7 +1101,7 @@ struct BrowserPanelView: View { presentImportDialogFromProfileMenu() } label: { Text(String(localized: "menu.view.importFromBrowser", defaultValue: "Import Browser Data…")) - .font(.system(size: 12)) + .cmuxFont(size: 12) } .buttonStyle(.plain) @@ -1111,7 +1111,7 @@ struct BrowserPanelView: View { presentRenameBrowserProfilePrompt() } label: { Text(String(localized: "browser.profile.rename", defaultValue: "Rename Current Profile...")) - .font(.system(size: 12)) + .cmuxFont(size: 12) } .buttonStyle(.plain) } @@ -1130,11 +1130,11 @@ struct BrowserPanelView: View { } label: { HStack(spacing: 8) { Image(systemName: mode == browserThemeMode ? "checkmark" : "circle") - .font(.system(size: 10, weight: .semibold)) + .cmuxFont(size: 10, weight: .semibold) .opacity(mode == browserThemeMode ? 1.0 : 0.0) .frame(width: 12, alignment: .center) Text(mode.displayName) - .font(.system(size: 12)) + .cmuxFont(size: 12) Spacer(minLength: 0) } .padding(.horizontal, 8) @@ -1163,7 +1163,7 @@ struct BrowserPanelView: View { return HStack(spacing: 4) { if showSecureBadge { Image(systemName: "lock.fill") - .font(.system(size: 10)) + .cmuxFont(size: 10) .foregroundColor(.secondary) } @@ -1632,15 +1632,15 @@ struct BrowserPanelView: View { private var browserImportHintBody: some View { VStack(alignment: .leading, spacing: 8) { Text(String(localized: "browser.import.hint.title", defaultValue: "Import browser data")) - .font(.system(size: 12.5, weight: .semibold)) + .cmuxFont(size: 12.5, weight: .semibold) Text(browserImportHintSummary) - .font(.system(size: 11.5)) + .cmuxFont(size: 11.5) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) Text(String(localized: "browser.import.hint.settingsFootnote", defaultValue: "You can always find this in Settings > Browser.")) - .font(.system(size: 10.5)) + .cmuxFont(size: 10.5) .foregroundStyle(.tertiary) .fixedSize(horizontal: false, vertical: true) @@ -4291,13 +4291,13 @@ private struct OmnibarSuggestionsView: View { } label: { HStack(spacing: 6) { Text(item.listText) - .font(.system(size: 11)) + .cmuxFont(size: 11) .foregroundStyle(listTextColor) .lineLimit(1) .truncationMode(.tail) if let badge = item.trailingBadgeText { Text(badge) - .font(.system(size: 9.5, weight: .medium)) + .cmuxFont(size: 9.5, weight: .medium) .foregroundStyle(badgeTextColor) .padding(.horizontal, 6) .padding(.vertical, 2) diff --git a/Sources/Panels/MarkdownPanelView.swift b/Sources/Panels/MarkdownPanelView.swift index 75367ead1b72..d5d03daa8f2d 100644 --- a/Sources/Panels/MarkdownPanelView.swift +++ b/Sources/Panels/MarkdownPanelView.swift @@ -71,9 +71,9 @@ struct MarkdownPanelView: View { HStack(spacing: 6) { Image(systemName: "doc.richtext") .foregroundColor(.secondary) - .font(.system(size: 12)) + .cmuxFont(size: 12) Text(panel.filePath) - .font(.system(size: 11, design: .monospaced)) + .cmuxFont(size: 11, design: .monospaced) .foregroundColor(.secondary) .lineLimit(1) .truncationMode(.middle) @@ -84,20 +84,20 @@ struct MarkdownPanelView: View { private var fileUnavailableView: some View { VStack(spacing: 12) { Image(systemName: "doc.questionmark") - .font(.system(size: 40)) + .cmuxFont(size: 40) .foregroundColor(.secondary) Text(String(localized: "markdown.fileUnavailable.title", defaultValue: "File unavailable")) - .font(.headline) + .cmuxFont(.headline) .foregroundColor(.primary) Text(panel.filePath) - .font(.system(size: 12, design: .monospaced)) + .cmuxFont(size: 12, design: .monospaced) .foregroundColor(.secondary) .multilineTextAlignment(.center) .textSelection(.enabled) .fixedSize(horizontal: false, vertical: true) .padding(.horizontal, 24) Text(String(localized: "markdown.fileUnavailable.message", defaultValue: "The file may have been moved or deleted.")) - .font(.caption) + .cmuxFont(.caption) .foregroundColor(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/Sources/RightSidebarPanelView.swift b/Sources/RightSidebarPanelView.swift index dd65781a7f37..0f522b3dd71e 100644 --- a/Sources/RightSidebarPanelView.swift +++ b/Sources/RightSidebarPanelView.swift @@ -94,9 +94,9 @@ private struct ModeBarButton: View { Button(action: action) { HStack(spacing: 4) { Image(systemName: mode.symbolName) - .font(.system(size: 11, weight: .medium)) + .cmuxFont(size: 11, weight: .medium) Text(mode.label) - .font(.system(size: 11, weight: .medium)) + .cmuxFont(size: 11, weight: .medium) } .foregroundColor(isSelected ? .primary : .secondary) .padding(.horizontal, 8) diff --git a/Sources/SessionIndexView.swift b/Sources/SessionIndexView.swift index 54c8650a46b2..350984c1c030 100644 --- a/Sources/SessionIndexView.swift +++ b/Sources/SessionIndexView.swift @@ -70,7 +70,7 @@ struct SessionIndexView: View { Toggle(isOn: $store.scopeToCurrentDirectory) { Text(String(localized: "sessionIndex.scope.thisFolder", defaultValue: "This folder only")) - .font(.system(size: 11)) + .cmuxFont(size: 11) .foregroundColor(.secondary) } .toggleStyle(.checkbox) @@ -81,7 +81,7 @@ struct SessionIndexView: View { store.reload() } label: { Image(systemName: "arrow.clockwise") - .font(.system(size: 10, weight: .medium)) + .cmuxFont(size: 10, weight: .medium) } .buttonStyle(.borderless) .help(String(localized: "sessionIndex.reload.tooltip", defaultValue: "Reload sessions")) @@ -96,7 +96,7 @@ struct SessionIndexView: View { VStack(spacing: 6) { ProgressView().controlSize(.small) Text(String(localized: "sessionIndex.loading", defaultValue: "Scanning sessions…")) - .font(.system(size: 11)) + .cmuxFont(size: 11) .foregroundColor(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -105,11 +105,11 @@ struct SessionIndexView: View { private var emptyView: some View { VStack(spacing: 4) { Text(String(localized: "sessionIndex.empty.title", defaultValue: "No sessions found")) - .font(.system(size: 12)) + .cmuxFont(size: 12) .foregroundColor(.secondary) Text(String(localized: "sessionIndex.empty.subtitle", defaultValue: "Sessions from Claude Code, Codex, and OpenCode will appear here.")) - .font(.system(size: 11)) + .cmuxFont(size: 11) .foregroundColor(.secondary.opacity(0.7)) .multilineTextAlignment(.center) .padding(.horizontal, 16) @@ -207,9 +207,9 @@ private struct GroupingButton: View { Button(action: action) { HStack(spacing: 3) { Image(systemName: mode.symbolName) - .font(.system(size: 10, weight: .medium)) + .cmuxFont(size: 10, weight: .medium) Text(mode.label) - .font(.system(size: 11, weight: .medium)) + .cmuxFont(size: 11, weight: .medium) } .foregroundColor(isSelected ? .primary : .secondary) .padding(.horizontal, 6) @@ -294,7 +294,7 @@ private struct IndexSectionView: View, Equatable { if !isCollapsed { if section.entries.isEmpty { Text(String(localized: "sessionIndex.section.noChats", defaultValue: "No chats")) - .font(.system(size: 12)) + .cmuxFont(size: 12) .foregroundColor(.secondary.opacity(0.6)) .padding(.leading, 32) .padding(.vertical, 4) @@ -318,7 +318,7 @@ private struct IndexSectionView: View, Equatable { isPopoverOpen = true } label: { Text(String(localized: "sessionIndex.section.showMore", defaultValue: "Show more")) - .font(.system(size: 12, weight: .medium)) + .cmuxFont(size: 12, weight: .medium) .foregroundColor(.secondary.opacity(0.7)) .padding(.leading, 32) .padding(.vertical, 4) @@ -343,12 +343,12 @@ private struct IndexSectionView: View, Equatable { HStack(spacing: 8) { sectionIconView Text(section.title) - .font(.system(size: 13, weight: .regular)) + .cmuxFont(size: 13, weight: .regular) .foregroundColor(.secondary) .lineLimit(1) .truncationMode(.middle) Image(systemName: "chevron.down") - .font(.system(size: 9, weight: .semibold)) + .cmuxFont(size: 9, weight: .semibold) .foregroundColor(.secondary.opacity(0.6)) .rotationEffect(.degrees(isCollapsed ? -90 : 0)) Spacer(minLength: 0) @@ -366,7 +366,7 @@ private struct IndexSectionView: View, Equatable { HStack(spacing: 8) { sectionIconView Text(section.title) - .font(.system(size: 13)) + .cmuxFont(size: 13) } .padding(.horizontal, 10) .padding(.vertical, 5) @@ -385,7 +385,7 @@ private struct IndexSectionView: View, Equatable { .frame(width: 14, height: 14) case .folder: Image(systemName: "folder") - .font(.system(size: 12, weight: .regular)) + .cmuxFont(size: 12, weight: .regular) .foregroundColor(.secondary) .frame(width: 14, height: 14) } @@ -483,13 +483,13 @@ private struct SessionRow: View, Equatable { .aspectRatio(contentMode: .fit) .frame(width: 12, height: 12) Text(entry.displayTitle) - .font(.system(size: 13)) + .cmuxFont(size: 13) .foregroundColor(.primary.opacity(0.92)) .lineLimit(1) .truncationMode(.tail) Spacer(minLength: 8) Text(relativeTime(entry.modified)) - .font(.system(size: 12).monospacedDigit()) + .cmuxFont(size: 12, monospacedDigit: true) .foregroundColor(.secondary.opacity(0.65)) .fixedSize() } @@ -512,7 +512,7 @@ private struct SessionRow: View, Equatable { .aspectRatio(contentMode: .fit) .frame(width: 12, height: 12) Text(entry.displayTitle) - .font(.system(size: 12, weight: .medium)) + .cmuxFont(size: 12, weight: .medium) .lineLimit(1) .truncationMode(.tail) } @@ -646,7 +646,7 @@ private struct SectionPopoverView: View { HStack(spacing: 8) { sectionIconView Text(section.title) - .font(.system(size: 13, weight: .semibold)) + .cmuxFont(size: 13, weight: .semibold) .foregroundColor(.primary) .lineLimit(1) .truncationMode(.middle) @@ -658,7 +658,7 @@ private struct SectionPopoverView: View { HStack(spacing: 6) { Image(systemName: "magnifyingglass") - .font(.system(size: 11, weight: .medium)) + .cmuxFont(size: 11, weight: .medium) .foregroundColor(.secondary) TextField( String(localized: "sessionIndex.popover.searchPlaceholder", @@ -666,14 +666,14 @@ private struct SectionPopoverView: View { text: $query ) .textFieldStyle(.plain) - .font(.system(size: 12)) + .cmuxFont(size: 12) .focused($searchFocused) if !query.isEmpty { Button { query = "" } label: { Image(systemName: "xmark.circle.fill") - .font(.system(size: 11)) + .cmuxFont(size: 11) .foregroundColor(.secondary) } .buttonStyle(.plain) @@ -695,10 +695,10 @@ private struct SectionPopoverView: View { ForEach(errorMessages, id: \.self) { msg in HStack(alignment: .top, spacing: 6) { Image(systemName: "exclamationmark.triangle.fill") - .font(.system(size: 10)) + .cmuxFont(size: 10) .foregroundColor(.orange) Text(msg) - .font(.system(size: 11)) + .cmuxFont(size: 11) .foregroundColor(.primary.opacity(0.85)) } } @@ -715,7 +715,7 @@ private struct SectionPopoverView: View { } else if loaded.isEmpty { Text(String(localized: "sessionIndex.popover.noMatches", defaultValue: "No matches")) - .font(.system(size: 12)) + .cmuxFont(size: 12) .foregroundColor(.secondary) .padding(.horizontal, 12) .padding(.vertical, 10) @@ -738,7 +738,7 @@ private struct SectionPopoverView: View { } else { Text(String(localized: "sessionIndex.popover.endOfList", defaultValue: "You've reached the end")) - .font(.system(size: 11)) + .cmuxFont(size: 11) .foregroundColor(.secondary.opacity(0.5)) .frame(maxWidth: .infinity, alignment: .center) .padding(.vertical, 8) @@ -860,7 +860,7 @@ private struct SectionPopoverView: View { HStack(spacing: 6) { ProgressView().controlSize(.small) Text(String(localized: "sessionIndex.popover.loading", defaultValue: "Loading…")) - .font(.system(size: 11)) + .cmuxFont(size: 11) .foregroundColor(.secondary) Spacer(minLength: 0) } @@ -950,7 +950,7 @@ private struct SectionPopoverView: View { .frame(width: 14, height: 14) case .folder: Image(systemName: "folder") - .font(.system(size: 12, weight: .regular)) + .cmuxFont(size: 12, weight: .regular) .foregroundColor(.secondary) .frame(width: 14, height: 14) } @@ -988,7 +988,7 @@ private struct PopoverRow: View, Equatable { TimelineView(RelativeTimestampSchedule(modified: entry.modified)) { context in Text(SessionIndexView.relativeFormatter.localizedString(for: entry.modified, relativeTo: context.date)) } - .font(.system(size: 11).monospacedDigit()) + .cmuxFont(size: 11, monospacedDigit: true) .foregroundColor(.secondary.opacity(0.7)) .fixedSize() } @@ -1005,7 +1005,7 @@ private struct PopoverRow: View, Equatable { // always constrain a Text that has hard line breaks in the // source string. Text(Self.flatten(entry.displayTitle)) - .font(.system(size: 12)) + .cmuxFont(size: 12) .foregroundColor(.primary.opacity(0.92)) .lineLimit(1) .truncationMode(.tail) diff --git a/Sources/Settings/ConfigSettingsView.swift b/Sources/Settings/ConfigSettingsView.swift index ab75e6748088..e1db25eebfe5 100644 --- a/Sources/Settings/ConfigSettingsView.swift +++ b/Sources/Settings/ConfigSettingsView.swift @@ -74,7 +74,7 @@ struct ConfigSettingsView: View { VStack(alignment: .leading, spacing: 4) { ForEach(currentSnapshot.displayPaths, id: \.self) { path in Text(verbatim: path) - .font(.system(size: 12, weight: .regular, design: .monospaced)) + .cmuxFont(size: 12, weight: .regular, design: .monospaced) .foregroundStyle(.secondary) .textSelection(.enabled) } @@ -108,7 +108,7 @@ struct ConfigSettingsView: View { HStack(spacing: 8) { if !statusMessage.isEmpty { Text(statusMessage) - .font(.caption) + .cmuxFont(.caption) .foregroundColor(statusIsError ? .red : .secondary) } @@ -280,7 +280,7 @@ private struct ConfigSettingsBanner: View { Image(systemName: "info.circle") .foregroundStyle(.secondary) Text(text) - .font(.footnote) + .cmuxFont(.footnote) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } diff --git a/Sources/ShortcutHintPill.swift b/Sources/ShortcutHintPill.swift index 306670240a94..8a951cd65820 100644 --- a/Sources/ShortcutHintPill.swift +++ b/Sources/ShortcutHintPill.swift @@ -34,7 +34,7 @@ struct ShortcutHintPill: View { var body: some View { Text(text) - .font(.system(size: fontSize, weight: .semibold, design: .rounded)) + .cmuxFont(size: fontSize, weight: .semibold, design: .rounded) .monospacedDigit() .lineLimit(1) .fixedSize(horizontal: true, vertical: false) diff --git a/Sources/Update/UpdatePopoverView.swift b/Sources/Update/UpdatePopoverView.swift index 5642bf913cc7..fff22d00a19f 100644 --- a/Sources/Update/UpdatePopoverView.swift +++ b/Sources/Update/UpdatePopoverView.swift @@ -67,7 +67,7 @@ fileprivate struct UpdateMetadataView: View { .frame(width: labelWidth, alignment: .trailing) Text(item.displayVersionString) } - .font(.system(size: 11)) + .cmuxFont(size: 11) if item.contentLength > 0 { HStack(spacing: 6) { @@ -76,7 +76,7 @@ fileprivate struct UpdateMetadataView: View { .frame(width: labelWidth, alignment: .trailing) Text(ByteCountFormatter.string(fromByteCount: Int64(item.contentLength), countStyle: .file)) } - .font(.system(size: 11)) + .cmuxFont(size: 11) } if let date = item.date { @@ -86,7 +86,7 @@ fileprivate struct UpdateMetadataView: View { .frame(width: labelWidth, alignment: .trailing) Text(date.formatted(date: .abbreviated, time: .omitted)) } - .font(.system(size: 11)) + .cmuxFont(size: 11) } } .textSelection(.enabled) @@ -100,12 +100,12 @@ fileprivate struct UpdateReleaseNotesLink: View { Link(destination: notes.url) { HStack { Image(systemName: "doc.text") - .font(.system(size: 11)) + .cmuxFont(size: 11) Text(notes.label) - .font(.system(size: 11, weight: .medium)) + .cmuxFont(size: 11, weight: .medium) Spacer() Image(systemName: "arrow.up.right") - .font(.system(size: 10)) + .cmuxFont(size: 10) } .foregroundColor(.primary) .padding(12) @@ -128,7 +128,7 @@ fileprivate struct DetectedBackgroundUpdateView: View { VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 8) { Text(String(localized: "update.popover.updateAvailable", defaultValue: "Update Available")) - .font(.system(size: 13, weight: .semibold)) + .cmuxFont(size: 13, weight: .semibold) UpdateMetadataView(item: item, labelWidth: labelWidth) } @@ -168,7 +168,7 @@ fileprivate struct DetectedBackgroundUpdatePendingView: View { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 8) { Text(String(localized: "update.popover.updateAvailable", defaultValue: "Update Available")) - .font(.system(size: 13, weight: .semibold)) + .cmuxFont(size: 13, weight: .semibold) HStack(spacing: 6) { Text(String(localized: "update.popover.version", defaultValue: "Version:")) @@ -176,14 +176,14 @@ fileprivate struct DetectedBackgroundUpdatePendingView: View { .frame(width: 60, alignment: .trailing) Text(version) } - .font(.system(size: 11)) + .cmuxFont(size: 11) } HStack(spacing: 10) { ProgressView() .controlSize(.small) Text(String(localized: "update.popover.checking", defaultValue: "Checking for updates…")) - .font(.system(size: 13)) + .cmuxFont(size: 13) } } .padding(16) @@ -198,10 +198,10 @@ fileprivate struct PermissionRequestView: View { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 8) { Text(String(localized: "update.popover.enableAutoUpdates", defaultValue: "Enable automatic updates?")) - .font(.system(size: 13, weight: .semibold)) + .cmuxFont(size: 13, weight: .semibold) Text(String(localized: "update.popover.autoUpdatesDescription", defaultValue: "cmux can automatically check for updates in the background.")) - .font(.system(size: 11)) + .cmuxFont(size: 11) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) } @@ -243,7 +243,7 @@ fileprivate struct CheckingView: View { ProgressView() .controlSize(.small) Text(String(localized: "update.popover.checking", defaultValue: "Checking for updates…")) - .font(.system(size: 13)) + .cmuxFont(size: 13) } HStack { @@ -271,7 +271,7 @@ fileprivate struct UpdateAvailableView: View { VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 8) { Text(String(localized: "update.popover.updateAvailable", defaultValue: "Update Available")) - .font(.system(size: 13, weight: .semibold)) + .cmuxFont(size: 13, weight: .semibold) UpdateMetadataView(item: update.appcastItem, labelWidth: labelWidth) } @@ -319,14 +319,14 @@ fileprivate struct DownloadingView: View { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 8) { Text(String(localized: "update.popover.downloadingUpdate", defaultValue: "Downloading Update")) - .font(.system(size: 13, weight: .semibold)) + .cmuxFont(size: 13, weight: .semibold) if let expectedLength = download.expectedLength, expectedLength > 0 { let progress = min(1, max(0, Double(download.progress) / Double(expectedLength))) VStack(alignment: .leading, spacing: 6) { ProgressView(value: progress) Text(String(format: "%.0f%%", progress * 100)) - .font(.system(size: 11)) + .cmuxFont(size: 11) .foregroundColor(.secondary) } } else { @@ -355,12 +355,12 @@ fileprivate struct ExtractingView: View { var body: some View { VStack(alignment: .leading, spacing: 8) { Text(String(localized: "update.popover.preparingUpdate", defaultValue: "Preparing Update")) - .font(.system(size: 13, weight: .semibold)) + .cmuxFont(size: 13, weight: .semibold) VStack(alignment: .leading, spacing: 6) { ProgressView(value: min(1, max(0, extracting.progress)), total: 1.0) Text(String(format: "%.0f%%", min(1, max(0, extracting.progress)) * 100)) - .font(.system(size: 11)) + .cmuxFont(size: 11) .foregroundColor(.secondary) } } @@ -376,10 +376,10 @@ fileprivate struct InstallingView: View { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 8) { Text(String(localized: "update.popover.restartRequired", defaultValue: "Restart Required")) - .font(.system(size: 13, weight: .semibold)) + .cmuxFont(size: 13, weight: .semibold) Text(String(localized: "update.popover.restartRequired.message", defaultValue: "The update is ready. Please restart the application to complete the installation.")) - .font(.system(size: 11)) + .cmuxFont(size: 11) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) } @@ -415,10 +415,10 @@ fileprivate struct NotFoundView: View { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 8) { Text(String(localized: "update.popover.noUpdatesFound", defaultValue: "No Updates Found")) - .font(.system(size: 13, weight: .semibold)) + .cmuxFont(size: 13, weight: .semibold) Text(String(localized: "update.popover.noUpdatesFound.message", defaultValue: "You're already running the latest version.")) - .font(.system(size: 11)) + .cmuxFont(size: 11) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) } @@ -455,23 +455,23 @@ fileprivate struct UpdateErrorView: View { HStack(spacing: 8) { Image(systemName: "exclamationmark.triangle.fill") .foregroundColor(.orange) - .font(.system(size: 13)) + .cmuxFont(size: 13) Text(title) - .font(.system(size: 13, weight: .semibold)) + .cmuxFont(size: 13, weight: .semibold) } Text(message) - .font(.system(size: 11)) + .cmuxFont(size: 11) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) } VStack(alignment: .leading, spacing: 6) { Text(String(localized: "update.popover.details", defaultValue: "Details")) - .font(.system(size: 11, weight: .semibold)) + .cmuxFont(size: 11, weight: .semibold) ScrollView(.vertical) { Text(details) - .font(.system(size: 10, design: .monospaced)) + .cmuxFont(size: 10, design: .monospaced) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index fac8dbc887ad..2b4d319dca9b 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -380,7 +380,7 @@ struct TitlebarControlsView: View { if notificationStore.unreadCount > 0 { Text("\(min(notificationStore.unreadCount, 99))") - .font(.system(size: max(8, config.badgeSize - 5), weight: .semibold)) + .cmuxFont(size: max(8, config.badgeSize - 5), weight: .semibold) .foregroundColor(.white) .frame(width: config.badgeSize, height: config.badgeSize) .background( @@ -525,7 +525,7 @@ struct TitlebarControlsView: View { @ViewBuilder private func iconLabel(systemName: String, config: TitlebarControlsStyleConfig) -> some View { let icon = Image(systemName: systemName) - .font(.system(size: config.iconSize, weight: .semibold)) + .cmuxFont(size: config.iconSize, weight: .semibold) .frame(width: config.buttonSize, height: config.buttonSize) if config.buttonBackground { @@ -1034,7 +1034,7 @@ private struct NotificationsPopoverView: View { VStack(spacing: 0) { HStack { Text(String(localized: "notifications.title", defaultValue: "Notifications")) - .font(.headline) + .cmuxFont(.headline) Spacer() Button(action: jumpToLatestUnread) { HStack(spacing: 6) { @@ -1067,12 +1067,12 @@ private struct NotificationsPopoverView: View { if notificationStore.notifications.isEmpty { VStack(spacing: 8) { Image(systemName: "bell.slash") - .font(.system(size: 28)) + .cmuxFont(size: 28) .foregroundColor(.secondary) Text(String(localized: "notifications.empty.title", defaultValue: "No notifications yet")) - .font(.headline) + .cmuxFont(.headline) Text(String(localized: "notifications.empty.subtitle", defaultValue: "Desktop notifications will appear here.")) - .font(.subheadline) + .cmuxFont(.subheadline) .foregroundColor(.secondary) } .frame(minWidth: 420, idealWidth: 520, maxWidth: 640, minHeight: 180) @@ -1152,24 +1152,24 @@ private struct NotificationPopoverRow: View { VStack(alignment: .leading, spacing: 4) { HStack { Text(notification.title) - .font(.headline) + .cmuxFont(.headline) .foregroundColor(.primary) Spacer() Text(notification.createdAt.formatted(date: .omitted, time: .shortened)) - .font(.caption) + .cmuxFont(.caption) .foregroundColor(.secondary) } if !notification.body.isEmpty { Text(notification.body) - .font(.subheadline) + .cmuxFont(.subheadline) .foregroundColor(.secondary) .lineLimit(3) } if let tabTitle { Text(tabTitle) - .font(.caption) + .cmuxFont(.caption) .foregroundColor(.secondary) } } diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index 1b58b8356cbf..1fb6c098eb0c 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -722,7 +722,7 @@ struct EmptyPanelView: View { var body: some View { Text(text) - .font(.system(size: 11, weight: .semibold, design: .rounded)) + .cmuxFont(size: 11, weight: .semibold, design: .rounded) .foregroundStyle(.white.opacity(0.9)) .padding(.horizontal, 8) .padding(.vertical, 3) @@ -790,11 +790,11 @@ struct EmptyPanelView: View { var body: some View { VStack(spacing: 16) { Image(systemName: "terminal.fill") - .font(.system(size: 48)) + .cmuxFont(size: 48) .foregroundStyle(.tertiary) Text("Empty Panel") - .font(.headline) + .cmuxFont(.headline) .foregroundStyle(.secondary) HStack(spacing: 12) { diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index c5a78b61256d..7af609285101 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -1391,7 +1391,7 @@ private struct SettingsAboutTitlebarDebugView: View { ScrollView { VStack(alignment: .leading, spacing: 14) { Text("Settings/About Titlebar Debug") - .font(.headline) + .cmuxFont(.headline) editor(for: .settings) editor(for: .about) @@ -1429,7 +1429,7 @@ private struct SettingsAboutTitlebarDebugView: View { Toggle("Enable Debug Overrides", isOn: overridesEnabled) Text("When disabled, cmux uses normal default titlebar behavior for this window.") - .font(.caption) + .cmuxFont(.caption) .foregroundColor(.secondary) Divider() @@ -1460,7 +1460,7 @@ private struct SettingsAboutTitlebarDebugView: View { Divider() Text("Style Mask") - .font(.caption) + .cmuxFont(.caption) .foregroundColor(.secondary) Toggle("Titled", isOn: binding(for: kind, keyPath: \.titled)) @@ -1649,7 +1649,7 @@ private struct DebugWindowControlsView: View { ScrollView { VStack(alignment: .leading, spacing: 14) { Text("Debug Window Controls") - .font(.headline) + .cmuxFont(.headline) GroupBox("Open") { VStack(alignment: .leading, spacing: 8) { @@ -1754,7 +1754,7 @@ private struct DebugWindowControlsView: View { Text("Leading extra") Slider(value: $titlebarLeadingExtra, in: 0...40) Text(String(format: "%.0f", titlebarLeadingExtra)) - .font(.caption) + .cmuxFont(.caption) .monospacedDigit() .frame(width: 30, alignment: .trailing) } @@ -1795,7 +1795,7 @@ private struct DebugWindowControlsView: View { Text("Preview") Spacer() Image(systemName: selectedDevToolsIconOption.rawValue) - .font(.system(size: 12, weight: .medium)) + .cmuxFont(size: 12, weight: .medium) .foregroundStyle(selectedDevToolsColorOption.color) } @@ -1817,7 +1817,7 @@ private struct DebugWindowControlsView: View { DebugWindowConfigSnapshot.copyCombinedToPasteboard() } Text("Copies sidebar, background, menu bar, and browser devtools settings as one payload.") - .font(.caption) + .cmuxFont(.caption) .foregroundColor(.secondary) } .frame(maxWidth: .infinity, alignment: .leading) @@ -1835,7 +1835,7 @@ private struct DebugWindowControlsView: View { private func hintOffsetSection(_ title: String, x: Binding, y: Binding) -> some View { VStack(alignment: .leading, spacing: 6) { Text(title) - .font(.caption) + .cmuxFont(.caption) .foregroundColor(.secondary) sliderRow("X", value: x) sliderRow("Y", value: y) @@ -1847,7 +1847,7 @@ private struct DebugWindowControlsView: View { Text(label) Slider(value: value, in: ShortcutHintDebugSettings.offsetRange) Text(String(format: "%.1f", ShortcutHintDebugSettings.clamped(value.wrappedValue))) - .font(.caption) + .cmuxFont(.caption) .monospacedDigit() .frame(width: 44, alignment: .trailing) } @@ -1991,7 +1991,7 @@ private struct BrowserProfilePopoverDebugView: View { defaultValue: "Browser Profile Popover" ) ) - .font(.headline) + .cmuxFont(.headline) Text( String( @@ -1999,7 +1999,7 @@ private struct BrowserProfilePopoverDebugView: View { defaultValue: "Tune the profile popover padding live while comparing it against the browser toolbar menu." ) ) - .font(.caption) + .cmuxFont(.caption) .foregroundStyle(.secondary) GroupBox( @@ -2057,7 +2057,7 @@ private struct BrowserProfilePopoverDebugView: View { defaultValue: "Changes apply live to the browser profile popover." ) ) - .font(.caption) + .cmuxFont(.caption) .foregroundStyle(.secondary) Spacer(minLength: 0) @@ -2071,16 +2071,16 @@ private struct BrowserProfilePopoverDebugView: View { private var profilePopoverPreview: some View { VStack(alignment: .leading, spacing: 8) { Text(String(localized: "browser.profile.menu.title", defaultValue: "Profiles")) - .font(.system(size: 12, weight: .semibold)) + .cmuxFont(size: 12, weight: .semibold) .foregroundStyle(.secondary) VStack(alignment: .leading, spacing: 2) { HStack(spacing: 8) { Image(systemName: "checkmark") - .font(.system(size: 10, weight: .semibold)) + .cmuxFont(size: 10, weight: .semibold) .frame(width: 12, alignment: .center) Text(String(localized: "browser.profile.default", defaultValue: "Default")) - .font(.system(size: 12)) + .cmuxFont(size: 12) Spacer(minLength: 0) } .padding(.horizontal, 8) @@ -2094,10 +2094,10 @@ private struct BrowserProfilePopoverDebugView: View { Divider() Text(String(localized: "browser.profile.new", defaultValue: "New Profile...")) - .font(.system(size: 12)) + .cmuxFont(size: 12) Text(String(localized: "menu.view.importFromBrowser", defaultValue: "Import Browser Data…")) - .font(.system(size: 12)) + .cmuxFont(size: 12) } .padding(.horizontal, BrowserProfilePopoverDebugSettings.resolvedHorizontalPadding(horizontalPaddingRaw)) .padding(.vertical, BrowserProfilePopoverDebugSettings.resolvedVerticalPadding(verticalPaddingRaw)) @@ -2117,7 +2117,7 @@ private struct BrowserProfilePopoverDebugView: View { Text(label) Slider(value: value, in: range, step: 1) Text(String(format: "%.0f", value.wrappedValue)) - .font(.caption) + .cmuxFont(.caption) .monospacedDigit() .frame(width: 32, alignment: .trailing) } @@ -2167,10 +2167,10 @@ private struct BrowserImportHintDebugView: View { ScrollView { VStack(alignment: .leading, spacing: 14) { Text("Browser Import Hint") - .font(.headline) + .cmuxFont(.headline) Text("Try lighter blank-tab import surfaces and dismissal states without touching the permanent Browser settings home.") - .font(.caption) + .cmuxFont(.caption) .foregroundStyle(.secondary) GroupBox("Variant") { @@ -2183,7 +2183,7 @@ private struct BrowserImportHintDebugView: View { .pickerStyle(.menu) Text(description(for: selectedVariant)) - .font(.caption) + .cmuxFont(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } @@ -2196,10 +2196,10 @@ private struct BrowserImportHintDebugView: View { Toggle("Pretend the user dismissed it", isOn: $isDismissed) Text("Current blank-tab placement: \(placementTitle(presentation.blankTabPlacement))") - .font(.caption) + .cmuxFont(.caption) .foregroundStyle(.secondary) Text("Settings status: \(settingsStatusTitle(presentation.settingsStatus))") - .font(.caption) + .cmuxFont(.caption) .foregroundStyle(.secondary) } .padding(.top, 2) @@ -2232,7 +2232,7 @@ private struct BrowserImportHintDebugView: View { Text("Toolbar chip: most subtle, best when the hint should stay out of the content area.") Text("Settings only: no in-browser nudge, Browser settings becomes the only permanent home.") } - .font(.caption) + .cmuxFont(.caption) .foregroundStyle(.secondary) .padding(.top, 2) } @@ -2371,7 +2371,7 @@ private struct AcknowledgmentsView: View { var body: some View { ScrollView { Text(content) - .font(.system(.body, design: .monospaced)) + .cmuxFont(.body, design: .monospaced) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) .padding() @@ -2550,7 +2550,7 @@ private struct FileExplorerStyleDebugView: View { var body: some View { VStack(alignment: .leading, spacing: 12) { Text("File Explorer Style") - .font(.headline) + .cmuxFont(.headline) ForEach(FileExplorerStyle.allCases, id: \.rawValue) { style in HStack(spacing: 8) { @@ -2565,9 +2565,9 @@ private struct FileExplorerStyleDebugView: View { .frame(width: 16) VStack(alignment: .leading, spacing: 2) { Text(style.label) - .font(.system(size: 13, weight: .medium)) + .cmuxFont(size: 13, weight: .medium) Text(styleDescription(style)) - .font(.system(size: 11)) + .cmuxFont(size: 11) .foregroundColor(.secondary) } } @@ -2589,9 +2589,9 @@ private struct FileExplorerStyleDebugView: View { VStack(alignment: .leading, spacing: 4) { Text("Current: \(currentStyle.label)") - .font(.system(size: 11, weight: .medium)) + .cmuxFont(size: 11, weight: .medium) Text("Row: \(Int(currentStyle.rowHeight))pt, Indent: \(Int(currentStyle.indentation))pt, Icon: \(Int(currentStyle.iconSize))pt") - .font(.system(size: 11, design: .monospaced)) + .cmuxFont(size: 11, design: .monospaced) .foregroundColor(.secondary) } } @@ -2712,11 +2712,11 @@ private struct AboutPanelView: View { VStack(alignment: .center, spacing: 8) { Text(String(localized: "about.appName", defaultValue: "cmux")) .bold() - .font(.title) + .cmuxFont(.title) Text(String(localized: "about.description", defaultValue: "A Ghostty-based terminal with vertical tabs\nand a notification panel for macOS.")) .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) - .font(.caption) + .cmuxFont(.caption) .tint(.secondary) .opacity(0.8) } @@ -2755,7 +2755,7 @@ private struct AboutPanelView: View { if let copy = copyright, !copy.isEmpty { Text(copy) - .font(.caption) + .cmuxFont(.caption) .textSelection(.enabled) .tint(.secondary) .opacity(0.8) @@ -2828,7 +2828,7 @@ private struct SidebarDebugView: View { ScrollView { VStack(alignment: .leading, spacing: 14) { Text("Sidebar Appearance") - .font(.headline) + .cmuxFont(.headline) Toggle(String(localized: "settings.sidebarAppearance.matchTerminalBackground", defaultValue: "Match Terminal Background"), isOn: $matchTerminalBackground) @@ -2868,7 +2868,7 @@ private struct SidebarDebugView: View { Text("Strength") Slider(value: $sidebarBlurOpacity, in: 0...1) Text(String(format: "%.0f%%", sidebarBlurOpacity * 100)) - .font(.caption) + .cmuxFont(.caption) .frame(width: 44, alignment: .trailing) } } @@ -2883,7 +2883,7 @@ private struct SidebarDebugView: View { Text("Opacity") Slider(value: $sidebarTintOpacity, in: 0...0.7) Text(String(format: "%.0f%%", sidebarTintOpacity * 100)) - .font(.caption) + .cmuxFont(.caption) .frame(width: 44, alignment: .trailing) } } @@ -2895,7 +2895,7 @@ private struct SidebarDebugView: View { Text("Corner Radius") Slider(value: $sidebarCornerRadius, in: 0...20) Text(String(format: "%.0f", sidebarCornerRadius)) - .font(.caption) + .cmuxFont(.caption) .frame(width: 32, alignment: .trailing) } .padding(.top, 2) @@ -2940,7 +2940,7 @@ private struct SidebarDebugView: View { Button(String(localized: "sidebar.debug.resetSelectionColor", defaultValue: "Reset to Default")) { sidebarSelectionColorHex = nil } - .font(.caption) + .cmuxFont(.caption) } } .padding(.top, 2) @@ -2950,7 +2950,7 @@ private struct SidebarDebugView: View { VStack(alignment: .leading, spacing: 8) { Toggle("Render branch list vertically", isOn: $sidebarBranchVerticalLayout) Text("When enabled, each branch appears on its own line in the sidebar.") - .font(.caption) + .cmuxFont(.caption) .foregroundColor(.secondary) } .padding(.top, 2) @@ -3007,7 +3007,7 @@ private struct SidebarDebugView: View { private func hintOffsetSection(_ title: String, x: Binding, y: Binding) -> some View { VStack(alignment: .leading, spacing: 6) { Text(title) - .font(.caption) + .cmuxFont(.caption) .foregroundColor(.secondary) sliderRow("X", value: x) sliderRow("Y", value: y) @@ -3019,7 +3019,7 @@ private struct SidebarDebugView: View { Text(label) Slider(value: value, in: ShortcutHintDebugSettings.offsetRange) Text(String(format: "%.1f", ShortcutHintDebugSettings.clamped(value.wrappedValue))) - .font(.caption) + .cmuxFont(.caption) .monospacedDigit() .frame(width: 44, alignment: .trailing) } @@ -3132,7 +3132,7 @@ private struct MenuBarExtraDebugView: View { ScrollView { VStack(alignment: .leading, spacing: 14) { Text("Menu Bar Extra Icon") - .font(.headline) + .cmuxFont(.headline) GroupBox("Preview Count") { VStack(alignment: .leading, spacing: 8) { @@ -3143,7 +3143,7 @@ private struct MenuBarExtraDebugView: View { Text("Unread Count") Spacer() Text("\(previewCount)") - .font(.caption) + .cmuxFont(.caption) .monospacedDigit() } } @@ -3202,7 +3202,7 @@ private struct MenuBarExtraDebugView: View { } Text("Tip: enable override count, then tune until the menu bar icon looks right.") - .font(.caption) + .cmuxFont(.caption) .foregroundColor(.secondary) Spacer(minLength: 0) @@ -3236,7 +3236,7 @@ private struct MenuBarExtraDebugView: View { Text(label) Slider(value: value, in: range) Text(String(format: format, value.wrappedValue)) - .font(.caption) + .cmuxFont(.caption) .monospacedDigit() .frame(width: 58, alignment: .trailing) } @@ -3296,7 +3296,7 @@ private struct SplitButtonLayoutDebugView: View { var body: some View { VStack(alignment: .leading, spacing: 10) { Text("Button Backdrop Color") - .font(.headline) + .cmuxFont(.headline) ForEach(options, id: \.0) { id, label in HStack { @@ -3309,7 +3309,7 @@ private struct SplitButtonLayoutDebugView: View { } Text("Changes apply live.") - .font(.caption) + .cmuxFont(.caption) .foregroundColor(.secondary) } .padding(16) @@ -3363,7 +3363,7 @@ private struct BackgroundDebugView: View { ScrollView { VStack(alignment: .leading, spacing: 14) { Text("Window Background Glass") - .font(.headline) + .cmuxFont(.headline) GroupBox("Glass Effect") { VStack(alignment: .leading, spacing: 8) { @@ -3391,7 +3391,7 @@ private struct BackgroundDebugView: View { Slider(value: $bgGlassTintOpacity, in: 0...0.8) .disabled(!bgGlassEnabled) Text(String(format: "%.0f%%", bgGlassTintOpacity * 100)) - .font(.caption) + .cmuxFont(.caption) .frame(width: 44, alignment: .trailing) } } @@ -3413,7 +3413,7 @@ private struct BackgroundDebugView: View { } Text("Tint changes apply live. Enable/disable requires reload.") - .font(.caption) + .cmuxFont(.caption) .foregroundColor(.secondary) Spacer(minLength: 0) @@ -3547,7 +3547,7 @@ private struct StartupAppearanceDebugView: View { defaultValue: "Startup Appearance Debug" ) ) - .font(.headline) + .cmuxFont(.headline) GroupBox( String( @@ -3570,7 +3570,7 @@ private struct StartupAppearanceDebugView: View { .pickerStyle(.menu) Text(selectedProfile.detail) - .font(.caption) + .cmuxFont(.caption) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -3620,7 +3620,7 @@ private struct StartupAppearanceDebugView: View { VStack(alignment: .leading, spacing: 8) { ScrollView { Text(selectedConfigText) - .font(.system(.caption, design: .monospaced)) + .cmuxFont(.caption, design: .monospaced) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .topLeading) .padding(8) @@ -3673,7 +3673,7 @@ private struct StartupAppearanceDebugView: View { defaultValue: "Reloads the running app through Ghostty config update, matching startup theme resolution without editing config files." ) ) - .font(.caption) + .cmuxFont(.caption) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) } @@ -3785,7 +3785,7 @@ private struct AboutPropertyRow: View { textView } } - .font(.callout) + .cmuxFont(.callout) .textSelection(.enabled) .frame(maxWidth: .infinity) } @@ -3870,6 +3870,142 @@ enum AppearanceSettings { } } +/// Observable wrapper around `GlobalFontMagnification` for AppKit-side chrome +/// that can't use `@AppStorage` directly. Listens only to +/// `GlobalFontMagnification.didChangeNotification` to avoid thrashing on +/// unrelated UserDefaults mutations. +/// +/// SwiftUI chrome should prefer the `cmuxFont` view modifiers below, which +/// read via `@AppStorage` and are safe inside `LazyVStack`/`ForEach` rows. +final class GlobalFontSizeObserver: ObservableObject { + static let shared = GlobalFontSizeObserver() + + @Published private(set) var revision: UInt64 = 0 + + private var notificationObserver: NSObjectProtocol? + + private init(notificationCenter: NotificationCenter = .default) { + notificationObserver = notificationCenter.addObserver( + forName: GlobalFontMagnification.didChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.revision &+= 1 + } + } + + deinit { + if let notificationObserver { + NotificationCenter.default.removeObserver(notificationObserver) + } + } + + var scale: CGFloat { GlobalFontMagnification.scale } + var percent: Int { GlobalFontMagnification.storedPercent } +} + +/// Design-time point sizes used by cmux when a call site passes a `Font.TextStyle` +/// instead of a numeric size. These mirror Apple's macOS defaults closely enough +/// that replacing `.font(.caption)` with `.cmuxFont(.caption)` doesn't visibly +/// change anything at 100% magnification. +enum CmuxTextStyleMetrics { + static func baseSize(for style: Font.TextStyle) -> CGFloat { + switch style { + case .largeTitle: return 26 + case .title: return 22 + case .title2: return 17 + case .title3: return 15 + case .headline: return 13 + case .subheadline: return 11 + case .body: return 13 + case .callout: return 12 + case .footnote: return 10 + case .caption: return 10 + case .caption2: return 9 + @unknown default: return 13 + } + } + + static func baseWeight(for style: Font.TextStyle) -> Font.Weight { + switch style { + case .headline: return .semibold + default: return .regular + } + } +} + +/// Multiplies a hardcoded design-time font size by the global magnification. +/// +/// Uses `@AppStorage` (not the shared `GlobalFontSizeObserver`) so it is safe +/// to apply inside `LazyVStack`/`ForEach` rows without tripping the +/// "orthogonal @Published invalidates every row" spin-loop class of bugs +/// (see CLAUDE.md snapshot-boundary rule). +struct CmuxFontModifier: ViewModifier { + @AppStorage(GlobalFontMagnification.percentKey) private var percent: Int = GlobalFontMagnification.defaultPercent + let baseSize: CGFloat + let weight: Font.Weight + let design: Font.Design + var monospacedDigit: Bool = false + + func body(content: Content) -> some View { + content.font(resolvedFont) + } + + private var resolvedFont: Font { + var font = Font.system(size: scaledSize, weight: weight, design: design) + if monospacedDigit { + font = font.monospacedDigit() + } + return font + } + + private var scaledSize: CGFloat { + let clamped = GlobalFontMagnification.clamp(percent) + return max(1, baseSize * CGFloat(clamped) / CGFloat(GlobalFontMagnification.defaultPercent)) + } +} + +extension View { + /// Apply a system font at `size` points, scaled by the global magnification. + /// Replacement for hardcoded `.font(.system(size: size, weight:, design:))`. + func cmuxFont( + size: CGFloat, + weight: Font.Weight = .regular, + design: Font.Design = .default, + monospacedDigit: Bool = false + ) -> some View { + modifier( + CmuxFontModifier( + baseSize: size, + weight: weight, + design: design, + monospacedDigit: monospacedDigit + ) + ) + } + + /// Apply a Dynamic-Type-style system font, scaled by the global magnification. + /// Replacement for hardcoded `.font(.caption)`, `.font(.headline)`, etc. + /// Loses macOS accessibility dynamic-type scaling in favor of cmux's + /// global magnification; pass an explicit `weight:` to override the + /// built-in weight for the style (e.g. `.cmuxFont(.title3, weight: .semibold)` + /// replaces `.font(.title3.weight(.semibold))`) or an explicit `design:` + /// for `.font(.system(.body, design: .monospaced))`-style call sites. + func cmuxFont( + _ style: Font.TextStyle, + weight: Font.Weight? = nil, + design: Font.Design = .default + ) -> some View { + modifier( + CmuxFontModifier( + baseSize: CmuxTextStyleMetrics.baseSize(for: style), + weight: weight ?? CmuxTextStyleMetrics.baseWeight(for: style), + design: design + ) + ) + } +} + enum AppLanguage: String, CaseIterable, Identifiable { case system case en @@ -4598,6 +4734,7 @@ struct SettingsView: View { private var paneFirstClickFocusEnabled = PaneFirstClickFocusSettings.defaultEnabled @AppStorage(TerminalScrollBarSettings.showScrollBarKey) private var showTerminalScrollBar = TerminalScrollBarSettings.defaultShowScrollBar + @AppStorage(GlobalFontMagnification.percentKey) private var globalFontMagnificationPercent: Int = GlobalFontMagnification.defaultPercent @AppStorage(WorkspaceAutoReorderSettings.key) private var workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue @AppStorage(SidebarWorkspaceDetailSettings.hideAllDetailsKey) private var sidebarHideAllDetails = SidebarWorkspaceDetailSettings.defaultHideAllDetails @@ -4675,6 +4812,27 @@ struct SettingsView: View { ) } + private var globalFontMagnificationSubtitle: String { + if globalFontMagnificationPercent != GlobalFontMagnification.defaultPercent { + return String( + localized: "settings.app.globalFontMagnification.subtitleOn", + defaultValue: "Terminals, tabs, and chrome all render at this magnification. Per-pane zoom (⌘= / ⌘-) still overrides for the focused pane." + ) + } + return String( + localized: "settings.app.globalFontMagnification.subtitleOff", + defaultValue: "Scale every font in cmux by the same percentage. 100% = design size." + ) + } + + private func applyGlobalFontMagnificationChange() { + guard !isResettingSettings else { return } + // AppDelegate's `installGlobalFontSizeObserver` listens for this and + // triggers the Ghostty config reload; don't reload here too. + GhosttyConfig.invalidateLoadCache() + NotificationCenter.default.post(name: GlobalFontMagnification.didChangeNotification, object: nil) + } + private var keepWorkspaceOpenOnLastSurfaceShortcut: Bool { !closeWorkspaceOnLastSurfaceShortcut } @@ -5282,6 +5440,19 @@ struct SettingsView: View { SettingsCardDivider() + SettingsCardRow( + configurationReview: .json("app.globalFontMagnification"), + String(localized: "settings.app.globalFontMagnification", defaultValue: "Global Font Magnification"), + subtitle: globalFontMagnificationSubtitle + ) { + GlobalFontMagnificationControl( + percent: $globalFontMagnificationPercent, + onApply: applyGlobalFontMagnificationChange + ) + } + + SettingsCardDivider() + SettingsCardRow( configurationReview: .json("app.openMarkdownInCmuxViewer"), String(localized: "settings.app.openMarkdownInCmuxViewer", defaultValue: "Open Markdown in cmux Viewer"), @@ -5373,7 +5544,7 @@ struct SettingsView: View { ) { HStack(spacing: 6) { Text(notificationPermissionStatusText) - .font(.system(size: 11, weight: .semibold)) + .cmuxFont(size: 11, weight: .semibold) .foregroundStyle(notificationPermissionStatusColor) .frame(width: 98, alignment: .trailing) @@ -5409,7 +5580,7 @@ struct SettingsView: View { previewNotificationSound() } label: { Image(systemName: "play.fill") - .font(.system(size: 9)) + .cmuxFont(size: 9) } .buttonStyle(.bordered) .controlSize(.small) @@ -5419,7 +5590,7 @@ struct SettingsView: View { if notificationSound == NotificationSoundSettings.customFileValue { HStack(spacing: 6) { Text(notificationSoundCustomFileDisplayName) - .font(.system(size: 11)) + .cmuxFont(size: 11) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.middle) @@ -5447,7 +5618,7 @@ struct SettingsView: View { } if let notificationCustomSoundStatusMessage { Text(notificationCustomSoundStatusMessage) - .font(.system(size: 11)) + .cmuxFont(size: 11) .foregroundStyle(notificationCustomSoundStatusIsError ? Color.red : Color.secondary) .lineLimit(2) .multilineTextAlignment(.trailing) @@ -5751,7 +5922,7 @@ struct SettingsView: View { .frame(width: 38) Text(sidebarSelectionColorHex ?? String(localized: "settings.sidebarAppearance.defaultLabel", defaultValue: "Default")) - .font(.system(size: 12, weight: .medium, design: .monospaced)) + .cmuxFont(size: 12, weight: .medium, design: .monospaced) .foregroundStyle(.secondary) .frame(width: 76, alignment: .trailing) } @@ -5782,7 +5953,7 @@ struct SettingsView: View { .frame(width: 38) Text(sidebarNotificationBadgeColorHex ?? String(localized: "settings.sidebarAppearance.defaultLabel", defaultValue: "Default")) - .font(.system(size: 12, weight: .medium, design: .monospaced)) + .cmuxFont(size: 12, weight: .medium, design: .monospaced) .foregroundStyle(.secondary) .frame(width: 76, alignment: .trailing) } @@ -5829,7 +6000,7 @@ struct SettingsView: View { .frame(width: 38) Text(entry.hex) - .font(.system(size: 12, weight: .medium, design: .monospaced)) + .cmuxFont(size: 12, weight: .medium, design: .monospaced) .foregroundStyle(.secondary) .frame(width: 76, alignment: .trailing) @@ -5893,7 +6064,7 @@ struct SettingsView: View { .frame(width: 38) Text(sidebarTintHexLight ?? String(localized: "settings.sidebarAppearance.defaultLabel", defaultValue: "Default")) - .font(.system(size: 12, weight: .medium, design: .monospaced)) + .cmuxFont(size: 12, weight: .medium, design: .monospaced) .foregroundStyle(.secondary) .frame(width: 76, alignment: .trailing) } @@ -5916,7 +6087,7 @@ struct SettingsView: View { .frame(width: 38) Text(sidebarTintHexDark ?? String(localized: "settings.sidebarAppearance.defaultLabel", defaultValue: "Default")) - .font(.system(size: 12, weight: .medium, design: .monospaced)) + .cmuxFont(size: 12, weight: .medium, design: .monospaced) .foregroundStyle(.secondary) .frame(width: 76, alignment: .trailing) } @@ -5933,7 +6104,7 @@ struct SettingsView: View { Slider(value: $sidebarTintOpacity, in: 0...1) .frame(width: 140) Text(String(format: "%.0f%%", sidebarTintOpacity * 100)) - .font(.system(size: 12, weight: .medium, design: .monospaced)) + .cmuxFont(size: 12, weight: .medium, design: .monospaced) .foregroundStyle(.secondary) .frame(width: 36, alignment: .trailing) } @@ -6005,7 +6176,7 @@ struct SettingsView: View { } if let message = socketPasswordStatusMessage { Text(message) - .font(.caption) + .cmuxFont(.caption) .foregroundStyle(socketPasswordStatusIsError ? Color.red : Color.secondary) .padding(.horizontal, 14) .padding(.bottom, 8) @@ -6014,7 +6185,7 @@ struct SettingsView: View { if selectedSocketControlMode == .allowAll { SettingsCardDivider() Text(String(localized: "settings.automation.openAccessWarning", defaultValue: "Warning: Full open access makes the control socket world-readable/writable on this Mac and disables auth checks. Use only for local debugging.")) - .font(.caption) + .cmuxFont(.caption) .foregroundStyle(.red) .padding(.horizontal, 14) .padding(.vertical, 8) @@ -6191,7 +6362,7 @@ struct SettingsView: View { } TextEditor(text: $browserHostWhitelist) - .font(.system(.body, design: .monospaced)) + .cmuxFont(.body, design: .monospaced) .frame(minHeight: 60, maxHeight: 120) .scrollContentBackground(.hidden) .padding(6) @@ -6217,7 +6388,7 @@ struct SettingsView: View { } TextEditor(text: $browserExternalOpenPatterns) - .font(.system(.body, design: .monospaced)) + .cmuxFont(.body, design: .monospaced) .frame(minHeight: 60, maxHeight: 120) .scrollContentBackground(.hidden) .padding(6) @@ -6236,14 +6407,14 @@ struct SettingsView: View { VStack(alignment: .leading, spacing: 8) { Text(String(localized: "settings.browser.httpAllowlist", defaultValue: "HTTP Hosts Allowed in Embedded Browser")) - .font(.system(size: 13, weight: .semibold)) + .cmuxFont(size: 13, weight: .semibold) Text(String(localized: "settings.browser.httpAllowlist.description", defaultValue: "Controls which HTTP (non-HTTPS) hosts can open in cmux without a warning prompt. Defaults include localhost, 127.0.0.1, ::1, 0.0.0.0, and *.localtest.me.")) - .font(.caption) + .cmuxFont(.caption) .foregroundStyle(.secondary) TextEditor(text: $browserInsecureHTTPAllowlistDraft) - .font(.system(size: 12, weight: .regular, design: .monospaced)) + .cmuxFont(size: 12, weight: .regular, design: .monospaced) .frame(minHeight: 86) .padding(6) .background( @@ -6259,7 +6430,7 @@ struct SettingsView: View { ViewThatFits(in: .horizontal) { HStack(alignment: .center, spacing: 10) { Text(String(localized: "settings.browser.httpAllowlist.hint", defaultValue: "One host or wildcard per line (for example: localhost, 127.0.0.1, ::1, 0.0.0.0, *.localtest.me).")) - .font(.caption) + .cmuxFont(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -6276,7 +6447,7 @@ struct SettingsView: View { VStack(alignment: .leading, spacing: 8) { Text(String(localized: "settings.browser.httpAllowlist.hint", defaultValue: "One host or wildcard per line (for example: localhost, 127.0.0.1, ::1, 0.0.0.0, *.localtest.me).")) - .font(.caption) + .cmuxFont(.caption) .foregroundStyle(.secondary) HStack { @@ -6300,19 +6471,19 @@ struct SettingsView: View { VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 8) { Text(String(localized: "settings.browser.import", defaultValue: "Import Browser Data")) - .font(.system(size: 13, weight: .semibold)) + .cmuxFont(size: 13, weight: .semibold) VStack(alignment: .leading, spacing: 6) { Text(String(localized: "browser.import.hint.title", defaultValue: "Import browser data")) - .font(.system(size: 12.5, weight: .semibold)) + .cmuxFont(size: 12.5, weight: .semibold) Text(browserImportSubtitle) - .font(.caption) + .cmuxFont(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) Text(String(localized: "browser.import.hint.settingsFootnote", defaultValue: "You can always find this in Settings > Browser.")) - .font(.system(size: 10.5)) + .cmuxFont(size: 10.5) .foregroundStyle(.tertiary) .fixedSize(horizontal: false, vertical: true) } @@ -6356,7 +6527,7 @@ struct SettingsView: View { .accessibilityIdentifier("SettingsBrowserImportHintToggle") Text(browserImportHintSettingsNote) - .font(.caption) + .cmuxFont(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } @@ -6375,7 +6546,7 @@ struct SettingsView: View { TextField("", text: $reactGrabVersion) .textFieldStyle(.roundedBorder) .frame(width: 100) - .font(.system(.body, design: .monospaced)) + .cmuxFont(.body, design: .monospaced) .accessibilityIdentifier("SettingsReactGrabVersionField") } @@ -6404,7 +6575,7 @@ struct SettingsView: View { ) { HStack(spacing: 8) { Link(String(localized: "settings.shortcuts.chords.docsButton", defaultValue: "Chord docs"), destination: shortcutChordsDocsURL) - .font(.caption) + .cmuxFont(.caption) .accessibilityIdentifier("SettingsKeyboardShortcutsChordDocsLink") Button(String(localized: "settings.app.settingsFile.openButton", defaultValue: "Open settings.json")) { @@ -6456,7 +6627,7 @@ struct SettingsView: View { .id(shortcutResetToken) Text(String(localized: "settings.shortcuts.recordHint", defaultValue: "Click a shortcut value to record a new shortcut.")) - .font(.caption) + .cmuxFont(.caption) .foregroundColor(.secondary) .padding(.leading, 2) .accessibilityIdentifier("ShortcutRecordingHint") @@ -6532,7 +6703,7 @@ struct SettingsView: View { HStack { Text(String(localized: "settings.title", defaultValue: "Settings")) - .font(.system(size: 16, weight: .semibold)) + .cmuxFont(size: 16, weight: .semibold) .foregroundColor(.primary.opacity(0.92)) Spacer(minLength: 0) HStack(spacing: 6) { @@ -6843,7 +7014,7 @@ private struct SettingsSectionHeader: View { var body: some View { Text(title) - .font(.system(size: 13, weight: .semibold)) + .cmuxFont(size: 13, weight: .semibold) .foregroundColor(.secondary) .padding(.leading, 2) .padding(.bottom, -2) @@ -6857,10 +7028,10 @@ private struct AuthSettingsRow: View { HStack(alignment: .center, spacing: 12) { VStack(alignment: .leading, spacing: 2) { Text(titleText) - .font(.system(size: 13, weight: .medium)) + .cmuxFont(size: 13, weight: .medium) if let subtitle = subtitleText { Text(subtitle) - .font(.system(size: 11)) + .cmuxFont(size: 11) .foregroundColor(.secondary) } } @@ -6959,7 +7130,7 @@ private struct SettingsHeaderActionButton: View { var body: some View { Button(action: action) { Text(title) - .font(.system(size: 11.5, weight: .medium)) + .cmuxFont(size: 11.5, weight: .medium) .foregroundStyle(.secondary) .padding(.horizontal, 9) .padding(.vertical, 4) @@ -6979,6 +7150,54 @@ private struct SettingsHeaderActionButton: View { } } +private struct GlobalFontMagnificationControl: View { + @Binding var percent: Int + let onApply: () -> Void + + private let minimum = GlobalFontMagnification.minimumPercent + private let maximum = GlobalFontMagnification.maximumPercent + private let step = GlobalFontMagnification.stepPercent + + private var isAtDefault: Bool { percent == GlobalFontMagnification.defaultPercent } + + var body: some View { + HStack(spacing: 10) { + Text(String(format: "%d%%", GlobalFontMagnification.clamp(percent))) + .monospacedDigit() + .foregroundStyle(.primary) + .frame(minWidth: 48, alignment: .trailing) + + Stepper( + "", + value: Binding( + get: { percent }, + set: { newValue in + let clamped = GlobalFontMagnification.clamp(newValue) + if clamped != percent { + percent = clamped + onApply() + } + } + ), + in: minimum...maximum, + step: step + ) + .labelsHidden() + .accessibilityIdentifier("SettingsGlobalFontMagnificationStepper") + + Button(String(localized: "settings.app.globalFontMagnification.reset", defaultValue: "Reset")) { + if !isAtDefault { + percent = GlobalFontMagnification.defaultPercent + onApply() + } + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(isAtDefault) + } + } +} + private struct SettingsCardRow: View { let configurationReview: SettingsConfigurationReview let title: String @@ -7005,10 +7224,10 @@ private struct SettingsCardRow: View { HStack(alignment: .center, spacing: 12) { VStack(alignment: .leading, spacing: subtitle == nil ? 0 : 3) { Text(title) - .font(.system(size: 13, weight: .medium)) + .cmuxFont(size: 13, weight: .medium) if let subtitle { Text(subtitle) - .font(.caption) + .cmuxFont(.caption) .foregroundColor(.secondary) .lineLimit(2) } @@ -7143,7 +7362,7 @@ private struct SettingsCardNote: View { var body: some View { Text(text) - .font(.caption) + .cmuxFont(.caption) .foregroundColor(.secondary) .padding(.horizontal, 14) .padding(.vertical, 8) @@ -7193,7 +7412,7 @@ private struct ThemeWindowThumbnail: View { VStack(spacing: 0) { HStack { Image(systemName: "applelogo") - .font(.system(size: max(height * 0.08, 6))) + .cmuxFont(size: max(height * 0.08, 6)) .foregroundColor(isDark ? .white : .black) .opacity(0.8) Spacer() @@ -7276,7 +7495,7 @@ private struct ThemePickerRow: View { var body: some View { HStack(alignment: .top, spacing: 12) { Text(String(localized: "settings.app.theme", defaultValue: "Theme")) - .font(.system(size: 13, weight: .medium)) + .cmuxFont(size: 13, weight: .medium) .frame(maxWidth: .infinity, alignment: .leading) HStack(spacing: 8) { @@ -7319,7 +7538,7 @@ private struct ThemePickerRow: View { .frame(width: thumbWidth, height: thumbHeight) Text(mode.displayName) - .font(.system(size: 10)) + .cmuxFont(size: 10) .fontWeight(isSelected ? .semibold : .regular) .foregroundColor(isSelected ? .primary : .secondary) } @@ -7373,9 +7592,9 @@ private struct AppIconPickerRow: View { HStack(alignment: .top, spacing: 12) { VStack(alignment: .leading, spacing: 3) { Text(String(localized: "settings.app.appIcon", defaultValue: "App Icon")) - .font(.system(size: 13, weight: .medium)) + .cmuxFont(size: 13, weight: .medium) Text(String(localized: "settings.app.appIcon.subtitle", defaultValue: "Dock and app switcher")) - .font(.caption) + .cmuxFont(.caption) .foregroundColor(.secondary) } .frame(maxWidth: .infinity, alignment: .leading) @@ -7414,7 +7633,7 @@ private struct AppIconPickerRow: View { } Text(mode.displayName) - .font(.system(size: 10)) + .cmuxFont(size: 10) .foregroundColor(isSelected ? .primary : .secondary) } .padding(.vertical, 8) diff --git a/web/data/cmux-settings.schema.json b/web/data/cmux-settings.schema.json index a15374cc7f2e..d7f16b759d4f 100644 --- a/web/data/cmux-settings.schema.json +++ b/web/data/cmux-settings.schema.json @@ -117,6 +117,13 @@ "type": "boolean", "default": false, "description": "Search every surface in the command palette switcher instead of only the active workspace." + }, + "globalFontMagnification": { + "type": "integer", + "minimum": 50, + "maximum": 400, + "default": 100, + "description": "Global font magnification percent applied to every cmux font — terminals, tab bar, sidebar, workspace titles, all SwiftUI chrome. 100 = design size (no change); 150 = 1.5x; 200 = 2x. Per-pane zoom (⌘= / ⌘- / ⌘0) still overrides this value for the focused pane." } } }, From 87f78929a26248bf7fa300ed53eea9d29e7201f6 Mon Sep 17 00:00:00 2001 From: Gigi Sayfan Date: Fri, 24 Apr 2026 19:43:50 -0700 Subject: [PATCH 2/5] [cmux] address PR review for global font magnification --- Sources/GhosttyConfig.swift | 11 +++-- Sources/GhosttyTerminalView.swift | 43 +++++++++++-------- .../KeyboardShortcutSettingsFileStore.swift | 7 ++- Sources/cmuxApp.swift | 11 +++-- 4 files changed, 48 insertions(+), 24 deletions(-) diff --git a/Sources/GhosttyConfig.swift b/Sources/GhosttyConfig.swift index 9410b1b9faff..756c264eb65c 100644 --- a/Sources/GhosttyConfig.swift +++ b/Sources/GhosttyConfig.swift @@ -821,13 +821,18 @@ enum GlobalFontMagnification { static func setPercent(_ percent: Int) { UserDefaults.standard.set(clamp(percent), forKey: percentKey) - GhosttyConfig.invalidateLoadCache() + // AppDelegate's `installGlobalFontSizeObserver` owns cache + reload. NotificationCenter.default.post(name: didChangeNotification, object: nil) } + /// Restores the default magnification. Writes `defaultPercent` explicitly + /// (rather than `removeObject`) so storage stays symmetric with the UI + /// reset path — both leave the key present at 100. Callers in the file + /// store rely on stored-value equality to decide whether to fire change + /// notifications, and the asymmetry would silently skip reloads after a + /// UI-driven reset. static func resetToDefault() { - UserDefaults.standard.removeObject(forKey: percentKey) - GhosttyConfig.invalidateLoadCache() + UserDefaults.standard.set(defaultPercent, forKey: percentKey) NotificationCenter.default.post(name: didChangeNotification, object: nil) } } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index cca7bebf7879..3724284b5ea3 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1927,6 +1927,7 @@ class GhosttyApp { logLabel: "shell integration override (fallback)" ) usesHostLayerBackground = true + applyGlobalFontMagnificationOverride(to: fallbackConfig) ghostty_config_finalize(fallbackConfig) updateDefaultBackground(from: fallbackConfig, source: "initialize.fallbackConfig") @@ -2113,27 +2114,35 @@ class GhosttyApp { logLabel: "shell integration override" ) - // Apply the Settings-driven magnification last so it wins over any - // `font-size` declared in user configs. cmux's own Swift parser already - // applied the same scale to its parallel `GhosttyConfig.fontSize`, so - // reading it back here yields a size that's consistent with what the - // tab bar and chrome will render at. Per-surface zoom (cmd+=/-/0) - // keeps operating on top of whatever value Ghostty ends up with. - if !GlobalFontMagnification.isDefault { - let scaled = GhosttyConfig.load().fontSize - if scaled > 0 { - loadInlineGhosttyConfig( - "font-size = \(scaled)", - into: config, - prefix: "cmux-global-font-magnification", - logLabel: "global font magnification" - ) - } - } + applyGlobalFontMagnificationOverride(to: config) ghostty_config_finalize(config) } + /// Multiply Ghostty's currently-parsed `font-size` by the global + /// magnification and re-inject the result. Reads the base size from the + /// `ghostty_config_t` being finalized (not from cmux's parallel Swift + /// parser) so the override stays consistent with whatever this config + /// pass actually loaded — including DEBUG startup-preview profiles where + /// the live config is synthetic. Per-surface zoom (cmd+=/-/0) keeps + /// operating on top of whatever value Ghostty ends up with. + private func applyGlobalFontMagnificationOverride(to config: ghostty_config_t) { + guard !GlobalFontMagnification.isDefault else { return } + var baseFontSize: Float = 0 + let key = "font-size" + let success = key.withCString { cKey in + ghostty_config_get(config, &baseFontSize, cKey, UInt(key.lengthOfBytes(using: .utf8))) + } + guard success, baseFontSize > 0 else { return } + let scaled = max(1.0, baseFontSize * Float(GlobalFontMagnification.scale)) + loadInlineGhosttyConfig( + "font-size = \(scaled)", + into: config, + prefix: "cmux-global-font-magnification", + logLabel: "global font magnification" + ) + } + /// When the user has not configured `font-codepoint-map` for CJK ranges /// and has not already provided an explicit multi-entry `font-family` /// fallback chain, Ghostty's `CTFontCollection` scoring may pick an diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift index 98ba733cb33a..68be393414ec 100644 --- a/Sources/KeyboardShortcutSettingsFileStore.swift +++ b/Sources/KeyboardShortcutSettingsFileStore.swift @@ -1054,7 +1054,7 @@ final class CmuxSettingsFileStore { } if defaultsKey == GlobalFontMagnification.percentKey, didMutateStoredValue { - GhosttyConfig.invalidateLoadCache() + // AppDelegate's `installGlobalFontSizeObserver` owns cache + reload. notificationCenter.post(name: GlobalFontMagnification.didChangeNotification, object: nil) } @@ -1104,6 +1104,11 @@ final class CmuxSettingsFileStore { TerminalScrollBarSettings.notifyDidChange(notificationCenter: notificationCenter) } + if defaultsKey == GlobalFontMagnification.percentKey { + // AppDelegate's `installGlobalFontSizeObserver` owns cache + reload. + notificationCenter.post(name: GlobalFontMagnification.didChangeNotification, object: nil) + } + switch defaultsKey { case LanguageSettings.languageKey: let language = AppLanguage(rawValue: UserDefaults.standard.string(forKey: defaultsKey) ?? "") ?? .system diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 7af609285101..df3c46ed3bda 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -4827,9 +4827,8 @@ struct SettingsView: View { private func applyGlobalFontMagnificationChange() { guard !isResettingSettings else { return } - // AppDelegate's `installGlobalFontSizeObserver` listens for this and - // triggers the Ghostty config reload; don't reload here too. - GhosttyConfig.invalidateLoadCache() + // AppDelegate's `installGlobalFontSizeObserver` owns cache invalidation + // and the Ghostty config reload — Settings just signals intent. NotificationCenter.default.post(name: GlobalFontMagnification.didChangeNotification, object: nil) } @@ -6898,6 +6897,12 @@ struct SettingsView: View { if previousShowTerminalScrollBar != showTerminalScrollBar { TerminalScrollBarSettings.notifyDidChange() } + let previousGlobalFontMagnificationPercent = globalFontMagnificationPercent + globalFontMagnificationPercent = GlobalFontMagnification.defaultPercent + if previousGlobalFontMagnificationPercent != globalFontMagnificationPercent { + // AppDelegate's `installGlobalFontSizeObserver` owns cache + reload. + NotificationCenter.default.post(name: GlobalFontMagnification.didChangeNotification, object: nil) + } workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue sidebarHideAllDetails = SidebarWorkspaceDetailSettings.defaultHideAllDetails sidebarShowNotificationMessage = SidebarWorkspaceDetailSettings.defaultShowNotificationMessage From da5fe05b7058b6c62c63eb295203f7288251887d Mon Sep 17 00:00:00 2001 From: Gigi Sayfan Date: Fri, 24 Apr 2026 19:57:24 -0700 Subject: [PATCH 3/5] [cmux] cap global font magnification at 200% to fit chrome budgets --- Sources/GhosttyConfig.swift | 6 +++++- web/data/cmux-settings.schema.json | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Sources/GhosttyConfig.swift b/Sources/GhosttyConfig.swift index 756c264eb65c..f312e482105c 100644 --- a/Sources/GhosttyConfig.swift +++ b/Sources/GhosttyConfig.swift @@ -788,7 +788,11 @@ enum GlobalFontMagnification { static let defaultPercent: Int = 100 static let minimumPercent: Int = 50 - static let maximumPercent: Int = 400 + /// Capped at 200% so cmux's fixed-size chrome (titlebar height, command + /// palette rows, badge frames, browser panel button hit areas) doesn't + /// clip or overflow. Users who need larger magnification should reach for + /// macOS Accessibility → Zoom for system-wide scaling instead. + static let maximumPercent: Int = 200 static let stepPercent: Int = 10 static let didChangeNotification = Notification.Name("cmux.globalFontMagnification.didChange") diff --git a/web/data/cmux-settings.schema.json b/web/data/cmux-settings.schema.json index d7f16b759d4f..a7f1ea328c4f 100644 --- a/web/data/cmux-settings.schema.json +++ b/web/data/cmux-settings.schema.json @@ -121,9 +121,9 @@ "globalFontMagnification": { "type": "integer", "minimum": 50, - "maximum": 400, + "maximum": 200, "default": 100, - "description": "Global font magnification percent applied to every cmux font — terminals, tab bar, sidebar, workspace titles, all SwiftUI chrome. 100 = design size (no change); 150 = 1.5x; 200 = 2x. Per-pane zoom (⌘= / ⌘- / ⌘0) still overrides this value for the focused pane." + "description": "Global font magnification percent applied to every cmux font — terminals, tab bar, sidebar, workspace titles, all SwiftUI chrome. 100 = design size (no change); 150 = 1.5x; 200 = 2x. Capped at 200 so cmux's fixed-size chrome doesn't clip; for larger zoom use macOS Accessibility → Zoom. Per-pane zoom (⌘= / ⌘- / ⌘0) still overrides this value for the focused pane." } } }, From a9401f4c7c24be243e4027020e50ffd8731f4709 Mon Sep 17 00:00:00 2001 From: Gigi Sayfan Date: Fri, 24 Apr 2026 20:11:28 -0700 Subject: [PATCH 4/5] [cmux] keep About title bold, label magnification stepper, robust percent parse --- Sources/GhosttyConfig.swift | 14 ++++++++++++-- Sources/cmuxApp.swift | 10 ++++++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/Sources/GhosttyConfig.swift b/Sources/GhosttyConfig.swift index f312e482105c..d4415414386d 100644 --- a/Sources/GhosttyConfig.swift +++ b/Sources/GhosttyConfig.swift @@ -798,9 +798,19 @@ enum GlobalFontMagnification { static let didChangeNotification = Notification.Name("cmux.globalFontMagnification.didChange") /// Raw percent stored in UserDefaults. If the key is unset, treat as 100%. + /// Accepts any numeric storage (Int / Double NSNumber) and string-encoded + /// integers so values written by `defaults write -float`/`-string` from a + /// terminal still resolve cleanly instead of falling back to default. static var storedPercent: Int { - let value = UserDefaults.standard.object(forKey: percentKey) as? Int - let resolved = value ?? defaultPercent + let raw = UserDefaults.standard.object(forKey: percentKey) + let resolved: Int + if let number = raw as? NSNumber { + resolved = number.intValue + } else if let string = raw as? String, let parsed = Int(string) { + resolved = parsed + } else { + resolved = defaultPercent + } return clamp(resolved) } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index df3c46ed3bda..a1aeda1650e1 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -2711,8 +2711,7 @@ private struct AboutPanelView: View { VStack(alignment: .center, spacing: 32) { VStack(alignment: .center, spacing: 8) { Text(String(localized: "about.appName", defaultValue: "cmux")) - .bold() - .cmuxFont(.title) + .cmuxFont(.title, weight: .bold) Text(String(localized: "about.description", defaultValue: "A Ghostty-based terminal with vertical tabs\nand a notification panel for macOS.")) .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) @@ -7188,6 +7187,13 @@ private struct GlobalFontMagnificationControl: View { step: step ) .labelsHidden() + .accessibilityLabel( + String( + localized: "settings.app.globalFontMagnification", + defaultValue: "Global Font Magnification" + ) + ) + .accessibilityValue(Text("\(GlobalFontMagnification.clamp(percent))%")) .accessibilityIdentifier("SettingsGlobalFontMagnificationStepper") Button(String(localized: "settings.app.globalFontMagnification.reset", defaultValue: "Reset")) { From ceb392ca6b35dc4dd4bd3995f868cf5805b77909 Mon Sep 17 00:00:00 2001 From: Gigi Sayfan Date: Sun, 31 May 2026 16:42:45 -0700 Subject: [PATCH 5/5] [cmux] scale magnification control text and unclip settings subtitles --- Sources/cmuxApp.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index a1aeda1650e1..0a350f7465cb 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -7206,6 +7206,7 @@ private struct GlobalFontMagnificationControl: View { .controlSize(.small) .disabled(isAtDefault) } + .cmuxFont(.body) } } @@ -7240,7 +7241,7 @@ private struct SettingsCardRow: View { Text(subtitle) .cmuxFont(.caption) .foregroundColor(.secondary) - .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) } } .frame(maxWidth: .infinity, alignment: .leading)