From 8fe58f5b516a766bea405273dc6f54dc60a7d900 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 28 Sep 2026 03:35:30 -0400 Subject: [PATCH 01/11] Settings: Font card with live preview, font gallery, line height Settings > Terminal now opens with a Font card: a preview of shell and code in the chosen font, size and line height, a searchable font list that draws each installed monospaced font in its own face (hover to preview, click to apply), and rows for font size, line height (adjust-cell-height) and thicker strokes (font-thicken). The Font card and the Ghostty options card share one options model so an edit in either survives the other's refresh. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../GhosttyCellHeightAdjustment.swift | 40 ++ .../GhosttyTerminalOptionChange.swift | 8 + .../GhosttyTerminalOptionKey.swift | 2 + .../GhosttyTerminalOptions.swift | 8 + .../GhosttyTerminalOptionsTests.swift | 22 + .../Models/NSFont+TerminalFamily.swift | 24 + .../Models/TerminalGhosttyOptionsModel.swift | 79 +++ ...dSettingEntry+TerminalGhosttyOptions.swift | 16 +- .../Sections/TerminalFontCard.swift | 156 +++++ .../Sections/TerminalFontFamilyPicker.swift | 100 ++++ .../Sections/TerminalFontPreview.swift | 80 +++ .../Sections/TerminalGhosttyOptionRow.swift | 42 ++ .../Sections/TerminalGhosttyOptionsCard.swift | 176 +----- .../Sections/TerminalSection.swift | 7 +- Resources/Localizable.xcstrings | 531 ++++++++++++++++-- .../ui-lab/harnesses/terminal-font-card.swift | 45 ++ scripts/ui-lab/shims/CmuxFont.swift | 9 + 17 files changed, 1136 insertions(+), 209 deletions(-) create mode 100644 Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/GhosttyTerminalOptions/GhosttyCellHeightAdjustment.swift create mode 100644 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Models/NSFont+TerminalFamily.swift create mode 100644 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Models/TerminalGhosttyOptionsModel.swift create mode 100644 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalFontCard.swift create mode 100644 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalFontFamilyPicker.swift create mode 100644 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalFontPreview.swift create mode 100644 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalGhosttyOptionRow.swift create mode 100644 scripts/ui-lab/harnesses/terminal-font-card.swift create mode 100644 scripts/ui-lab/shims/CmuxFont.swift diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/GhosttyTerminalOptions/GhosttyCellHeightAdjustment.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/GhosttyTerminalOptions/GhosttyCellHeightAdjustment.swift new file mode 100644 index 000000000000..33d446790254 --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/GhosttyTerminalOptions/GhosttyCellHeightAdjustment.swift @@ -0,0 +1,40 @@ +import Foundation + +/// An `adjust-cell-height` value: extra line height added to the font's own, +/// as a percentage (`8%`) or a number of pixels (`2`). Negative values tighten +/// the lines. +public enum GhosttyCellHeightAdjustment: Equatable, Sendable { + case percent(Int) + case pixels(Int) + + /// No adjustment, Ghostty's default. + public static let unadjusted = GhosttyCellHeightAdjustment.percent(0) + + /// Parses Ghostty's `20%`, `-15%`, or `2` spelling. + public init?(configValue: String) { + let value = configValue.trimmingCharacters(in: .whitespaces) + if value.hasSuffix("%") { + guard let percent = Int(value.dropLast().trimmingCharacters(in: .whitespaces)) else { return nil } + self = .percent(percent) + } else if let pixels = Int(value) { + self = .pixels(pixels) + } else { + return nil + } + } + + /// The percentage the line height row steps from. A pixel value has no + /// percentage equivalent without the font's metrics, so it steps from 0%. + public var percentValue: Int { + guard case .percent(let percent) = self else { return 0 } + return percent + } + + /// Ghostty's spelling: `8%` or `2`. + public var configValue: String { + switch self { + case .percent(let percent): return "\(percent)%" + case .pixels(let pixels): return String(pixels) + } + } +} diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/GhosttyTerminalOptions/GhosttyTerminalOptionChange.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/GhosttyTerminalOptions/GhosttyTerminalOptionChange.swift index 92b0000f2fcf..35738aaa72d3 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/GhosttyTerminalOptions/GhosttyTerminalOptionChange.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/GhosttyTerminalOptions/GhosttyTerminalOptionChange.swift @@ -8,6 +8,8 @@ public enum GhosttyTerminalOptionChange: Equatable, Sendable { /// font. Build it with ``GhosttyTerminalOptions/fontFamiliesChoosing(_:)``. case fontFamilies([String]) case fontSize(Double) + case cellHeight(GhosttyCellHeightAdjustment) + case fontThicken(Bool) case cursorStyle(GhosttyCursorStyle) case cursorBlinks(Bool) case windowPaddingX(GhosttyWindowPadding) @@ -22,6 +24,8 @@ public enum GhosttyTerminalOptionChange: Equatable, Sendable { switch self { case .fontFamilies: return .fontFamily case .fontSize: return .fontSize + case .cellHeight: return .adjustCellHeight + case .fontThicken: return .fontThicken case .cursorStyle: return .cursorStyle case .cursorBlinks: return .cursorStyleBlink case .windowPaddingX: return .windowPaddingX @@ -45,6 +49,10 @@ public enum GhosttyTerminalOptionChange: Equatable, Sendable { return ["\"\""] + families.map { "\"\($0)\"" } case .fontSize(let points): return [numberFormatter.formattedFontSize(points)] + case .cellHeight(let adjustment): + return [adjustment.configValue] + case .fontThicken(let thicken): + return [thicken ? "true" : "false"] case .cursorStyle(let style): return [style.rawValue] case .cursorBlinks(let blinks): diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/GhosttyTerminalOptions/GhosttyTerminalOptionKey.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/GhosttyTerminalOptions/GhosttyTerminalOptionKey.swift index 555383581d71..d2a6b9a2234f 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/GhosttyTerminalOptions/GhosttyTerminalOptionKey.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/GhosttyTerminalOptions/GhosttyTerminalOptionKey.swift @@ -5,6 +5,8 @@ public enum GhosttyTerminalOptionKey: String, CaseIterable, Sendable { case fontFamily = "font-family" case fontSize = "font-size" + case adjustCellHeight = "adjust-cell-height" + case fontThicken = "font-thicken" case cursorStyle = "cursor-style" case cursorStyleBlink = "cursor-style-blink" case windowPaddingX = "window-padding-x" diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/GhosttyTerminalOptions/GhosttyTerminalOptions.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/GhosttyTerminalOptions/GhosttyTerminalOptions.swift index d479d1b932ab..67e04e3ac172 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/GhosttyTerminalOptions/GhosttyTerminalOptions.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/GhosttyTerminalOptions/GhosttyTerminalOptions.swift @@ -25,6 +25,10 @@ public struct GhosttyTerminalOptions: Equatable, Sendable { public var fontFamily: String? { fontFamilies.first } /// The terminal font size, in points. public var fontSize: Double + /// Extra line height on top of the font's own. + public var cellHeight: GhosttyCellHeightAdjustment + /// Whether glyphs are drawn with a thicker stroke (macOS only). + public var fontThicken: Bool /// The default cursor shape. public var cursorStyle: GhosttyCursorStyle /// Whether the cursor blinks by default. Ghostty blinks when unset. @@ -70,6 +74,8 @@ public struct GhosttyTerminalOptions: Equatable, Sendable { fontSize = Self.fold(values(.fontSize)) { value in Double(value).flatMap { $0.isFinite && $0 > 0 ? $0 : nil } } ?? Self.defaultFontSize + cellHeight = Self.fold(values(.adjustCellHeight), parse: GhosttyCellHeightAdjustment.init(configValue:)) ?? .unadjusted + fontThicken = Self.fold(values(.fontThicken), parse: Self.parseBool) ?? false cursorStyle = Self.fold(values(.cursorStyle), parse: GhosttyCursorStyle.init(rawValue:)) ?? .block cursorBlinks = Self.fold(values(.cursorStyleBlink), parse: Self.parseBool) ?? true let defaultPadding = GhosttyWindowPadding(leading: Self.defaultWindowPadding) @@ -93,6 +99,8 @@ public struct GhosttyTerminalOptions: Equatable, Sendable { switch change { case .fontFamilies(let families): updated.fontFamilies = families case .fontSize(let points): updated.fontSize = Self.hundredths(points) + case .cellHeight(let adjustment): updated.cellHeight = adjustment + case .fontThicken(let thicken): updated.fontThicken = thicken case .cursorStyle(let style): updated.cursorStyle = style case .cursorBlinks(let blinks): updated.cursorBlinks = blinks case .windowPaddingX(let points): updated.windowPaddingX = points diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/GhosttyTerminalOptionsTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/GhosttyTerminalOptionsTests.swift index b98e61b120d4..c81916423652 100644 --- a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/GhosttyTerminalOptionsTests.swift +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/GhosttyTerminalOptionsTests.swift @@ -14,6 +14,8 @@ struct GhosttyTerminalOptionsTests { let options = GhosttyTerminalOptions.defaults #expect(options.fontFamily == nil) #expect(options.fontSize == 13) + #expect(options.cellHeight == .unadjusted) + #expect(!options.fontThicken) #expect(options.cursorStyle == .block) #expect(options.cursorBlinks) #expect(options.windowPaddingX == GhosttyWindowPadding(leading: 2)) @@ -94,6 +96,24 @@ struct GhosttyTerminalOptionsTests { #expect(GhosttyTerminalOptionChange.scrollbackLimitBytes(25_000_000).configValues == ["25000000"]) } + @Test("adjust-cell-height reads percentages and pixels, and writes them back") + func cellHeightSpellings() { + func cellHeight(_ values: [String]) -> GhosttyCellHeightAdjustment { + GhosttyTerminalOptions(directives: ["adjust-cell-height": values]).cellHeight + } + #expect(cellHeight(["8%"]) == .percent(8)) + #expect(cellHeight(["-15 %"]) == .percent(-15)) + #expect(cellHeight(["2"]) == .pixels(2)) + #expect(cellHeight(["10%", "tall"]) == .percent(10)) + #expect(cellHeight(["10%", ""]) == .unadjusted) + #expect(GhosttyCellHeightAdjustment.pixels(2).percentValue == 0) + #expect(GhosttyTerminalOptionChange.cellHeight(.percent(12)).key.rawValue == "adjust-cell-height") + #expect(GhosttyTerminalOptionChange.cellHeight(.percent(12)).configValues == ["12%"]) + #expect(GhosttyTerminalOptionChange.fontThicken(true).key.rawValue == "font-thicken") + #expect(GhosttyTerminalOptionChange.fontThicken(true).configValues == ["true"]) + #expect(GhosttyTerminalOptions(directives: ["font-thicken": ["true"]]).fontThicken) + } + @Test("A font change clears inherited families before setting its own") func fontFamilyChangeResetsList() { #expect(GhosttyTerminalOptionChange.fontFamilies(["JetBrains Mono"]).configValues == ["\"\"", "\"JetBrains Mono\""]) @@ -151,6 +171,8 @@ struct GhosttyTerminalOptionsTests { let changes: [GhosttyTerminalOptionChange] = [ .fontFamilies(["SF Mono"]), .fontSize(16), + .cellHeight(.percent(8)), + .fontThicken(true), .cursorStyle(.underline), .cursorBlinks(false), .windowPaddingX(GhosttyWindowPadding(leading: 10)), diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Models/NSFont+TerminalFamily.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Models/NSFont+TerminalFamily.swift new file mode 100644 index 000000000000..b0af01e053c0 --- /dev/null +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Models/NSFont+TerminalFamily.swift @@ -0,0 +1,24 @@ +import AppKit + +extension NSFont { + /// Ghostty's built-in font, used when no `font-family` is set. + static let ghosttyBuiltInFamily = "JetBrains Mono" + + /// The regular face of `family` at `size`, for previewing a terminal font + /// choice. `nil` means Ghostty's built-in font, which is shown with the + /// installed JetBrains Mono when present and the system monospaced font + /// otherwise. A family with no regular face falls back to its first member. + static func terminalPreview(family: String?, size: CGFloat) -> NSFont { + let manager = NSFontManager.shared + let resolvedFamily = family ?? ghosttyBuiltInFamily + if let font = manager.font(withFamily: resolvedFamily, traits: [], weight: 5, size: size) { + return font + } + if let member = manager.availableMembers(ofFontFamily: resolvedFamily)?.first, + let name = member.first as? String, + let font = NSFont(name: name, size: size) { + return font + } + return .monospacedSystemFont(ofSize: size, weight: .regular) + } +} diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Models/TerminalGhosttyOptionsModel.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Models/TerminalGhosttyOptionsModel.swift new file mode 100644 index 000000000000..d3618863f9be --- /dev/null +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Models/TerminalGhosttyOptionsModel.swift @@ -0,0 +1,79 @@ +import CmuxFoundation +import Foundation +import Observation + +/// The Ghostty option values Settings > Terminal shows, shared by the Font card +/// and the options card so an edit in one isn't undone by the other's refresh. +/// +/// Each edit shows right away, is written to cmux's Ghostty config after a +/// short pause, and is then checked against a re-read of the effective values. +@MainActor +@Observable +final class TerminalGhosttyOptionsModel { + /// Coalesces stepper autorepeat and quick clicks into one write and reload. + private static let writeDelay: Duration = .milliseconds(250) + + @ObservationIgnored private let hostActions: SettingsHostActions + @ObservationIgnored private let tasks = MainActorTaskStore() + /// Changes shown optimistically whose write hasn't finished yet, reapplied + /// over each re-read so one row's refresh doesn't undo another row's edit. + @ObservationIgnored private var pendingChanges: [GhosttyTerminalOptionKey: GhosttyTerminalOptionChange] = [:] + @ObservationIgnored private var isLoading = false + + private(set) var options = GhosttyTerminalOptions.defaults + private(set) var hasLoaded = false + /// Installed fixed-pitch families, sorted; empty until loaded. + private(set) var monospacedFamilies: [String] = [] + private(set) var saveFailed = false + /// Keys whose written value a later-loading config file overrides, with + /// that file's display path. + private(set) var overriddenKeys: [GhosttyTerminalOptionKey: String] = [:] + + init(hostActions: SettingsHostActions) { + self.hostActions = hostActions + } + + /// Reads the effective values and the installed monospaced families once. + func load() async { + guard !hasLoaded, !isLoading else { return } + isLoading = true + let families = Task.detached(priority: .utility) { MonospacedFontFamilies().load() } + options = await hostActions.terminalGhosttyOptions().options + hasLoaded = true + monospacedFamilies = await families.value + isLoading = false + } + + /// Installed monospaced families, plus the configured family when it isn't + /// flagged fixed-pitch, so a picker always shows the current choice. + var fontFamilyChoices: [String] { + guard let current = options.fontFamily, !monospacedFamilies.contains(current) else { + return monospacedFamilies + } + return ([current] + monospacedFamilies).sorted { $0.localizedStandardCompare($1) == .orderedAscending } + } + + /// Shows `change` right away, then writes it after a short pause (a newer + /// change to the same key replaces this task, so stepper autorepeat writes + /// once), and re-reads the effective values. When a later-loading config + /// file still overrides the key, the row names that file instead of + /// silently snapping back. + func apply(_ change: GhosttyTerminalOptionChange) { + let key = change.key + options = options.applying(change) + pendingChanges[key] = change + tasks.replaceOnMainActor(key) { [self] in + try? await Task.sleep(for: Self.writeDelay) + guard !Task.isCancelled else { return } + let saved = await hostActions.applyTerminalGhosttyOption(change) + guard !Task.isCancelled else { return } + pendingChanges[key] = nil + saveFailed = !saved + let snapshot = await hostActions.terminalGhosttyOptions() + overriddenKeys[key] = saved && !snapshot.options.reflects(change) + ? snapshot.sourcePaths[key] + : nil + options = pendingChanges.values.reduce(snapshot.options) { $0.applying($1) } + } + } +} diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+TerminalGhosttyOptions.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+TerminalGhosttyOptions.swift index 3a3d4dc93ddb..b45eeaddb376 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+TerminalGhosttyOptions.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+TerminalGhosttyOptions.swift @@ -2,7 +2,7 @@ import Foundation extension Array where Element == CuratedSettingEntry { /// Search entries for the native Ghostty option rows in Settings > Terminal - /// (`TerminalGhosttyOptionsCard`). Each row anchors itself with the matching + /// (`TerminalFontCard` and `TerminalGhosttyOptionsCard`). Each row anchors itself with the matching /// `setting:terminal:` id, and the synonyms carry its Ghostty key. static var terminalGhosttyOptionEntries: [CuratedSettingEntry] { [ @@ -10,7 +10,7 @@ extension Array where Element == CuratedSettingEntry { section: .terminal, id: "font-family", title: String(localized: "settings.terminal.ghostty.fontFamily", defaultValue: "Font"), - synonyms: "font-family font family typeface monospaced monospace terminal font ghostty nerd font" + synonyms: "font-family font family typeface monospaced monospace terminal font change font switch font preview ghostty nerd font" ), .init( section: .terminal, @@ -18,6 +18,18 @@ extension Array where Element == CuratedSettingEntry { title: String(localized: "settings.terminal.ghostty.fontSize", defaultValue: "Font Size"), synonyms: "font-size terminal font size text size points pt bigger smaller zoom ghostty" ), + .init( + section: .terminal, + id: "adjust-cell-height", + title: String(localized: "settings.terminal.ghostty.lineHeight", defaultValue: "Line Height"), + synonyms: "adjust-cell-height line height line spacing leading cell height taller rows ghostty" + ), + .init( + section: .terminal, + id: "font-thicken", + title: String(localized: "settings.terminal.ghostty.fontThicken", defaultValue: "Thicker Strokes"), + synonyms: "font-thicken thicken bold heavier weight stroke crisp font smoothing ghostty" + ), .init( section: .terminal, id: "cursor-style", diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalFontCard.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalFontCard.swift new file mode 100644 index 000000000000..3171d98fd419 --- /dev/null +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalFontCard.swift @@ -0,0 +1,156 @@ +import AppKit +import CmuxFoundation +import SwiftUI + +/// The first card in Settings > Terminal: a live preview of the terminal font +/// above the font, size, line height, and stroke rows. Each row writes one +/// Ghostty key to cmux's config and applies to open terminals right away. +struct TerminalFontCard: View { + let hostActions: SettingsHostActions + let model: TerminalGhosttyOptionsModel + + @State private var isPickingFamily = false + /// The font the pointer is over in the picker: `.some(nil)` for the + /// built-in font, `nil` when not hovering. + @State private var hoveredFamily: String?? + + var body: some View { + SettingsCard { + TerminalFontPreview( + family: hoveredFamily ?? model.options.fontFamily, + size: model.options.fontSize, + cellHeight: model.options.cellHeight + ) + .padding(12) + .animation(.easeOut(duration: 0.12), value: model.options.cellHeight) + SettingsCardDivider() + familyRow + SettingsCardDivider() + sizeRow + SettingsCardDivider() + lineHeightRow + SettingsCardDivider() + thickenRow + } + .disabled(!model.hasLoaded) + } + + private var familyRow: some View { + TerminalGhosttyOptionRow( + id: "font-family", + title: String(localized: "settings.terminal.ghostty.fontFamily", defaultValue: "Font"), + key: .fontFamily, + controlWidth: 220, + overriddenBy: model.overriddenKeys[.fontFamily] + ) { + Button { + isPickingFamily.toggle() + } label: { + HStack(spacing: 6) { + Text(verbatim: model.options.fontFamily ?? String.localizedStringWithFormat( + String(localized: "settings.terminal.font.builtIn", defaultValue: "Default (%@)"), + NSFont.ghosttyBuiltInFamily + )) + .font(Font(NSFont.terminalPreview(family: model.options.fontFamily, size: 12))) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 4) + Image(systemName: "chevron.up.chevron.down") + .imageScale(.small) + .foregroundStyle(.secondary) + } + .frame(width: 190) + } + .buttonStyle(.bordered) + .controlSize(.small) + .popover(isPresented: $isPickingFamily, arrowEdge: .trailing) { + TerminalFontFamilyPicker( + families: model.fontFamilyChoices, + selection: model.options.fontFamily, + hoveredFamily: $hoveredFamily + ) { family in + model.apply(.fontFamilies(model.options.fontFamiliesChoosing(family))) + hoveredFamily = nil + isPickingFamily = false + } + } + .accessibilityIdentifier("SettingsTerminalGhosttyFontFamilyPicker") + } + } + + private var sizeRow: some View { + TerminalGhosttyOptionRow( + id: "font-size", + title: String(localized: "settings.terminal.ghostty.fontSize", defaultValue: "Font Size"), + key: .fontSize, + controlWidth: 140, + overriddenBy: model.overriddenKeys[.fontSize] + ) { + Stepper( + value: Binding(get: { model.options.fontSize }, set: { model.apply(.fontSize($0)) }), + in: 4...96, + step: 0.5 + ) { + Text(String.localizedStringWithFormat( + String(localized: "settings.fontSize.valuePoints", defaultValue: "%@ pt"), + hostActions.formattedFontSize(model.options.fontSize) + )) + .monospacedDigit() + } + .accessibilityIdentifier("SettingsTerminalGhosttyFontSizeStepper") + } + } + + private var lineHeightRow: some View { + TerminalGhosttyOptionRow( + id: "adjust-cell-height", + title: String(localized: "settings.terminal.ghostty.lineHeight", defaultValue: "Line Height"), + key: .adjustCellHeight, + controlWidth: 140, + overriddenBy: model.overriddenKeys[.adjustCellHeight] + ) { + Stepper( + value: Binding( + get: { model.options.cellHeight.percentValue }, + set: { model.apply(.cellHeight(.percent($0))) } + ), + in: -20...100, + step: 2 + ) { + Text(verbatim: lineHeightLabel) + .monospacedDigit() + } + .accessibilityIdentifier("SettingsTerminalGhosttyLineHeightStepper") + } + } + + private var thickenRow: some View { + TerminalGhosttyOptionRow( + id: "font-thicken", + title: String(localized: "settings.terminal.ghostty.fontThicken", defaultValue: "Thicker Strokes"), + key: .fontThicken, + overriddenBy: model.overriddenKeys[.fontThicken] + ) { + Toggle("", isOn: Binding(get: { model.options.fontThicken }, set: { model.apply(.fontThicken($0)) })) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("SettingsTerminalGhosttyFontThickenToggle") + } + } + + /// `+8%`, `-4%`, or `+2 px` for a pixel value set in a config file. + private var lineHeightLabel: String { + switch model.options.cellHeight { + case .percent(let percent): + return String.localizedStringWithFormat( + String(localized: "settings.terminal.ghostty.lineHeight.percent", defaultValue: "%@%%"), + percent > 0 ? "+\(percent)" : String(percent) + ) + case .pixels(let pixels): + return String.localizedStringWithFormat( + String(localized: "settings.terminal.ghostty.lineHeight.pixels", defaultValue: "%@ px"), + pixels > 0 ? "+\(pixels)" : String(pixels) + ) + } + } +} diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalFontFamilyPicker.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalFontFamilyPicker.swift new file mode 100644 index 000000000000..f02ab56774ec --- /dev/null +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalFontFamilyPicker.swift @@ -0,0 +1,100 @@ +import AppKit +import SwiftUI + +/// A searchable list of installed monospaced fonts, each drawn in its own +/// face, for the Font card's font row. Hovering a font previews it in the +/// card; clicking one applies it. `nil` stands for Ghostty's built-in font, so +/// ``hoveredFamily`` is `.some(nil)` over that row and `nil` over none. +struct TerminalFontFamilyPicker: View { + let families: [String] + let selection: String? + @Binding var hoveredFamily: String?? + let onSelect: (String?) -> Void + + @State private var query = "" + + private var filteredFamilies: [String] { + let query = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { return families } + return families.filter { $0.localizedCaseInsensitiveContains(query) } + } + + var body: some View { + VStack(spacing: 0) { + TextField( + String(localized: "settings.terminal.font.search", defaultValue: "Search fonts"), + text: $query + ) + .textFieldStyle(.roundedBorder) + .padding(10) + .accessibilityIdentifier("SettingsTerminalFontSearchField") + Divider() + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + if query.isEmpty { + row(family: nil) + } + ForEach(filteredFamilies, id: \.self) { family in + row(family: family) + } + if filteredFamilies.isEmpty { + Text(String(localized: "settings.terminal.font.noMatches", defaultValue: "No installed monospaced fonts match.")) + .cmuxFont(.caption) + .foregroundStyle(.secondary) + .padding(14) + } + } + .padding(.vertical, 4) + } + } + .frame(width: 340, height: 400) + .onDisappear { hoveredFamily = nil } + } + + private func row(family: String?) -> some View { + let isSelected = family == selection + let title = family ?? String.localizedStringWithFormat( + String(localized: "settings.terminal.font.builtIn", defaultValue: "Default (%@)"), + NSFont.ghosttyBuiltInFamily + ) + return Button { + onSelect(family) + } label: { + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + Text(verbatim: title) + .font(Font(NSFont.terminalPreview(family: family, size: 14))) + .lineLimit(1) + Text(verbatim: "0O 1lI {} => != ->") + .font(Font(NSFont.terminalPreview(family: family, size: 11))) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer(minLength: 8) + if isSelected { + Image(systemName: "checkmark") + .foregroundStyle(Color.accentColor) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(hoveredFamily == .some(family) ? Color.accentColor.opacity(0.12) : .clear) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .padding(.horizontal, 4) + .onHover { inside in + if inside { + hoveredFamily = .some(family) + } else if hoveredFamily == .some(family) { + hoveredFamily = nil + } + } + .accessibilityLabel(title) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } +} diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalFontPreview.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalFontPreview.swift new file mode 100644 index 000000000000..cbe66a2e5e6c --- /dev/null +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalFontPreview.swift @@ -0,0 +1,80 @@ +import AppKit +import CmuxFoundation +import SwiftUI + +/// A few lines of shell and code set in the terminal font, size, and line +/// height being chosen, with the glyphs people compare fonts by (0O, 1lI, +/// operators, brackets). +struct TerminalFontPreview: View { + let family: String? + let size: Double + let cellHeight: GhosttyCellHeightAdjustment + + private struct Token { + let text: String + let style: Style + } + + private enum Style { + case plain, prompt, keyword, string, comment + } + + private static let lines: [[Token]] = [ + [Token(text: "~/src/app ", style: .comment), Token(text: "❯ ", style: .prompt), Token(text: "cargo run --release", style: .plain)], + [Token(text: "fn ", style: .keyword), Token(text: "render(glyphs: &[Glyph]) -> Result<()> {", style: .plain)], + [Token(text: " // 0O 1lI |! => != >= <= -> {}[]()", style: .comment)], + [Token(text: " let ", style: .keyword), Token(text: "title = ", style: .plain), Token(text: "\"Hello, cmux\"", style: .string), Token(text: ";", style: .plain)], + [Token(text: "}", style: .plain)], + ] + + var body: some View { + let font = NSFont.terminalPreview(family: family, size: CGFloat(size)) + VStack(alignment: .leading, spacing: lineSpacing(for: font)) { + ForEach(Self.lines.indices, id: \.self) { index in + Self.lines[index].reduce(Text(verbatim: "")) { line, token in + line + Text(verbatim: token.text).foregroundColor(color(token.style)) + } + .lineLimit(1) + } + } + .font(Font(font)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 14) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 9, style: .continuous) + .fill(Color(nsColor: .textBackgroundColor)) + ) + .overlay( + RoundedRectangle(cornerRadius: 9, style: .continuous) + .stroke(Color(nsColor: .separatorColor).opacity(0.6), lineWidth: 1) + ) + .clipped() + .accessibilityElement(children: .ignore) + .accessibilityLabel(String( + localized: "settings.terminal.font.preview.accessibility", + defaultValue: "Terminal font preview" + )) + .accessibilityIdentifier("SettingsTerminalFontPreview") + } + + /// Extra space between lines matching Ghostty's `adjust-cell-height`: a + /// percentage of the font's line height, or a number of pixels. + private func lineSpacing(for font: NSFont) -> CGFloat { + let lineHeight = font.ascender - font.descender + font.leading + switch cellHeight { + case .percent(let percent): return lineHeight * CGFloat(percent) / 100 + case .pixels(let pixels): return CGFloat(pixels) + } + } + + private func color(_ style: Style) -> Color { + switch style { + case .plain: return .primary + case .prompt: return .green + case .keyword: return .purple + case .string: return .orange + case .comment: return .secondary + } + } +} diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalGhosttyOptionRow.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalGhosttyOptionRow.swift new file mode 100644 index 000000000000..b61db59a501c --- /dev/null +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalGhosttyOptionRow.swift @@ -0,0 +1,42 @@ +import CmuxFoundation +import SwiftUI + +/// A Settings > Terminal row for one Ghostty option, captioned with its config +/// key (and `detail`, when given), with an override note beneath it when a +/// later-loading config file beats the value cmux wrote. +struct TerminalGhosttyOptionRow: View { + let id: String + let title: String + let key: GhosttyTerminalOptionKey + var detail: String? + var controlWidth: CGFloat? + let overriddenBy: String? + @ViewBuilder let control: () -> Control + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + SettingsCardRow( + configurationReview: .settingsOnly, + searchAnchorID: "setting:terminal:\(id)", + title, + subtitle: [key.rawValue, detail].compactMap { $0 }.joined(separator: " · "), + controlWidth: controlWidth, + trailing: control + ) + if let overriddenBy { + Text(String.localizedStringWithFormat( + String( + localized: "settings.terminal.ghostty.overridden", + defaultValue: "Overridden by your config (%@)" + ), + overriddenBy + )) + .cmuxFont(.caption) + .foregroundStyle(.orange) + .padding(.horizontal, 14) + .padding(.bottom, 8) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } +} diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalGhosttyOptionsCard.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalGhosttyOptionsCard.swift index 8c8b45227aec..d9b3c4e873e3 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalGhosttyOptionsCard.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalGhosttyOptionsCard.swift @@ -1,8 +1,9 @@ import CmuxFoundation import SwiftUI -/// Native rows for the Ghostty options people change most: font, cursor, -/// padding, background, Option as Alt, and scrollback. +/// Native rows for the Ghostty options people change most after the font +/// (which has its own card, ``TerminalFontCard``): cursor, padding, +/// background, Option as Alt, and scrollback. /// /// Each row shows the value in effect, folded from the user's own Ghostty /// config and cmux's config, and writes a single key to cmux's config, which @@ -10,24 +11,11 @@ import SwiftUI /// be found in a config file. @MainActor struct TerminalGhosttyOptionsCard: View { - let hostActions: SettingsHostActions + let model: TerminalGhosttyOptionsModel - @State private var options = GhosttyTerminalOptions.defaults - @State private var hasLoaded = false - @State private var monospacedFamilies: [String] = [] @State private var activeOpacityDragValue: Double? - @State private var saveFailed = false - /// Changes shown optimistically whose write hasn't finished yet, reapplied - /// over each re-read so one row's refresh doesn't undo another row's edit. - @State private var pendingChanges: [GhosttyTerminalOptionKey: GhosttyTerminalOptionChange] = [:] - /// Keys whose written value a later-loading config file overrides, with - /// that file's display path. - @State private var overriddenKeys: [GhosttyTerminalOptionKey: String] = [:] - @State private var tasks = MainActorTaskStore() private static let bytesPerMegabyte = 1_000_000 - /// Coalesces stepper autorepeat and quick clicks into one write and reload. - private static let writeDelay: Duration = .milliseconds(250) var body: some View { SettingsCard { @@ -35,7 +23,7 @@ struct TerminalGhosttyOptionsCard: View { localized: "settings.terminal.ghostty.note", defaultValue: "These rows show the value in effect and save to cmux's Ghostty config, which loads after your own Ghostty config. The caption under each row is its config key." )) - if saveFailed { + if model.saveFailed { Text(String( localized: "settings.terminal.ghostty.saveFailed", defaultValue: "Couldn't save the Ghostty config. Please try again." @@ -47,64 +35,17 @@ struct TerminalGhosttyOptionsCard: View { .frame(maxWidth: .infinity, alignment: .leading) } SettingsCardDivider() - fontRows - SettingsCardDivider() cursorRows SettingsCardDivider() windowRows SettingsCardDivider() inputRows } - .disabled(!hasLoaded) - .task { await load() } + .disabled(!model.hasLoaded) } // MARK: Rows - @ViewBuilder - private var fontRows: some View { - optionRow( - "font-family", - String(localized: "settings.terminal.ghostty.fontFamily", defaultValue: "Font"), - key: .fontFamily, - controlWidth: 220 - ) { - Picker("", selection: Binding( - get: { options.fontFamily ?? "" }, - set: { apply(.fontFamilies(options.fontFamiliesChoosing($0))) } - )) { - Text(String(localized: "settings.terminal.ghostty.fontFamily.default", defaultValue: "Default")).tag("") - Divider() - ForEach(fontFamilyChoices, id: \.self) { family in - Text(verbatim: family).tag(family) - } - } - .labelsHidden() - .pickerStyle(.menu) - .accessibilityIdentifier("SettingsTerminalGhosttyFontFamilyPicker") - } - SettingsCardDivider() - optionRow( - "font-size", - String(localized: "settings.terminal.ghostty.fontSize", defaultValue: "Font Size"), - key: .fontSize, - controlWidth: 140 - ) { - Stepper( - value: Binding(get: { options.fontSize }, set: { apply(.fontSize($0)) }), - in: 4...96, - step: 0.5 - ) { - Text(String.localizedStringWithFormat( - String(localized: "settings.fontSize.valuePoints", defaultValue: "%@ pt"), - hostActions.formattedFontSize(options.fontSize) - )) - .monospacedDigit() - } - .accessibilityIdentifier("SettingsTerminalGhosttyFontSizeStepper") - } - } - @ViewBuilder private var cursorRows: some View { optionRow( @@ -113,7 +54,7 @@ struct TerminalGhosttyOptionsCard: View { key: .cursorStyle, controlWidth: 280 ) { - Picker("", selection: Binding(get: { options.cursorStyle }, set: { apply(.cursorStyle($0)) })) { + Picker("", selection: Binding(get: { model.options.cursorStyle }, set: { model.apply(.cursorStyle($0)) })) { ForEach(GhosttyCursorStyle.allCases, id: \.self) { style in Text(cursorStyleTitle(style)).tag(style) } @@ -128,7 +69,7 @@ struct TerminalGhosttyOptionsCard: View { String(localized: "settings.terminal.ghostty.cursorBlink", defaultValue: "Blinking Cursor"), key: .cursorStyleBlink ) { - Toggle("", isOn: Binding(get: { options.cursorBlinks }, set: { apply(.cursorBlinks($0)) })) + Toggle("", isOn: Binding(get: { model.options.cursorBlinks }, set: { model.apply(.cursorBlinks($0)) })) .labelsHidden() .controlSize(.small) .accessibilityIdentifier("SettingsTerminalGhosttyCursorBlinkToggle") @@ -143,8 +84,8 @@ struct TerminalGhosttyOptionsCard: View { key: .windowPaddingX, controlWidth: 140 ) { - paddingStepper(options.windowPaddingX.leading, identifier: "SettingsTerminalGhosttyPaddingXStepper") { - apply(.windowPaddingX(options.windowPaddingX.withLeading($0))) + paddingStepper(model.options.windowPaddingX.leading, identifier: "SettingsTerminalGhosttyPaddingXStepper") { + model.apply(.windowPaddingX(model.options.windowPaddingX.withLeading($0))) } } SettingsCardDivider() @@ -154,8 +95,8 @@ struct TerminalGhosttyOptionsCard: View { key: .windowPaddingY, controlWidth: 140 ) { - paddingStepper(options.windowPaddingY.leading, identifier: "SettingsTerminalGhosttyPaddingYStepper") { - apply(.windowPaddingY(options.windowPaddingY.withLeading($0))) + paddingStepper(model.options.windowPaddingY.leading, identifier: "SettingsTerminalGhosttyPaddingYStepper") { + model.apply(.windowPaddingY(model.options.windowPaddingY.withLeading($0))) } } SettingsCardDivider() @@ -168,7 +109,7 @@ struct TerminalGhosttyOptionsCard: View { HStack(spacing: 8) { Slider( value: Binding( - get: { activeOpacityDragValue ?? options.backgroundOpacity }, + get: { activeOpacityDragValue ?? model.options.backgroundOpacity }, set: { activeOpacityDragValue = $0 } ), in: 0...1, @@ -176,12 +117,12 @@ struct TerminalGhosttyOptionsCard: View { ) { editing in guard !editing, let value = activeOpacityDragValue else { return } activeOpacityDragValue = nil - apply(.backgroundOpacity(value)) + model.apply(.backgroundOpacity(value)) } .frame(width: 140) .accessibilityIdentifier("SettingsTerminalGhosttyBackgroundOpacitySlider") - Text(activeOpacityDragValue ?? options.backgroundOpacity, format: .percent.precision(.fractionLength(0))) + Text(activeOpacityDragValue ?? model.options.backgroundOpacity, format: .percent.precision(.fractionLength(0))) .cmuxFont(size: 12, weight: .medium, design: .rounded) .monospacedDigit() .frame(width: 44, alignment: .trailing) @@ -193,7 +134,7 @@ struct TerminalGhosttyOptionsCard: View { String(localized: "settings.terminal.ghostty.backgroundBlur", defaultValue: "Background Blur"), key: .backgroundBlur ) { - Toggle("", isOn: Binding(get: { options.backgroundBlurEnabled }, set: { apply(.backgroundBlurEnabled($0)) })) + Toggle("", isOn: Binding(get: { model.options.backgroundBlurEnabled }, set: { model.apply(.backgroundBlurEnabled($0)) })) .labelsHidden() .controlSize(.small) .accessibilityIdentifier("SettingsTerminalGhosttyBackgroundBlurToggle") @@ -208,7 +149,7 @@ struct TerminalGhosttyOptionsCard: View { key: .macosOptionAsAlt, controlWidth: 160 ) { - Picker("", selection: Binding(get: { options.optionAsAlt }, set: { apply(.optionAsAlt($0)) })) { + Picker("", selection: Binding(get: { model.options.optionAsAlt }, set: { model.apply(.optionAsAlt($0)) })) { ForEach(GhosttyOptionAsAlt.allCases, id: \.self) { option in Text(optionAsAltTitle(option)).tag(option) } @@ -230,8 +171,8 @@ struct TerminalGhosttyOptionsCard: View { ) { HStack(spacing: 6) { TextField("", value: Binding( - get: { Int((Double(options.scrollbackLimitBytes) / Double(Self.bytesPerMegabyte)).rounded()) }, - set: { apply(.scrollbackLimitBytes(min(max($0, 0), 100_000) * Self.bytesPerMegabyte)) } + get: { Int((Double(model.options.scrollbackLimitBytes) / Double(Self.bytesPerMegabyte)).rounded()) }, + set: { model.apply(.scrollbackLimitBytes(min(max($0, 0), 100_000) * Self.bytesPerMegabyte)) } ), format: .number) .textFieldStyle(.roundedBorder) .multilineTextAlignment(.trailing) @@ -247,40 +188,23 @@ struct TerminalGhosttyOptionsCard: View { // MARK: Helpers - /// A row captioned with its Ghostty key (and `detail`, when given), with an - /// override note beneath it when a later-loading file beats the written value. private func optionRow( _ id: String, _ title: String, key: GhosttyTerminalOptionKey, detail: String? = nil, controlWidth: CGFloat? = nil, - @ViewBuilder control: () -> Control + @ViewBuilder control: @escaping () -> Control ) -> some View { - VStack(alignment: .leading, spacing: 0) { - SettingsCardRow( - configurationReview: .settingsOnly, - searchAnchorID: "setting:terminal:\(id)", - title, - subtitle: [key.rawValue, detail].compactMap { $0 }.joined(separator: " · "), - controlWidth: controlWidth, - trailing: control - ) - if let path = overriddenKeys[key] { - Text(String.localizedStringWithFormat( - String( - localized: "settings.terminal.ghostty.overridden", - defaultValue: "Overridden by your config (%@)" - ), - path - )) - .cmuxFont(.caption) - .foregroundStyle(.orange) - .padding(.horizontal, 14) - .padding(.bottom, 8) - .frame(maxWidth: .infinity, alignment: .leading) - } - } + TerminalGhosttyOptionRow( + id: id, + title: title, + key: key, + detail: detail, + controlWidth: controlWidth, + overriddenBy: model.overriddenKeys[key], + control: control + ) } private func paddingStepper( @@ -298,15 +222,6 @@ struct TerminalGhosttyOptionsCard: View { .accessibilityIdentifier(identifier) } - /// Installed monospaced families, plus the configured family when it isn't - /// flagged fixed-pitch, so the picker always shows the current choice. - private var fontFamilyChoices: [String] { - guard let current = options.fontFamily, !monospacedFamilies.contains(current) else { - return monospacedFamilies - } - return ([current] + monospacedFamilies).sorted { $0.localizedStandardCompare($1) == .orderedAscending } - } - private func cursorStyleTitle(_ style: GhosttyCursorStyle) -> String { switch style { case .block: @@ -334,37 +249,4 @@ struct TerminalGhosttyOptionsCard: View { return String(localized: "settings.terminal.ghostty.optionAsAlt.both", defaultValue: "Both Option Keys") } } - - // MARK: Loading and saving - - private func load() async { - let families = Task.detached(priority: .utility) { MonospacedFontFamilies().load() } - options = await hostActions.terminalGhosttyOptions().options - hasLoaded = true - monospacedFamilies = await families.value - } - - /// Shows `change` right away, then writes it after a short pause (a newer - /// change to the same key replaces this task, so stepper autorepeat writes - /// once), and re-reads the effective values. When a later-loading config - /// file still overrides the key, the row names that file instead of - /// silently snapping back. - private func apply(_ change: GhosttyTerminalOptionChange) { - let key = change.key - options = options.applying(change) - pendingChanges[key] = change - tasks.replaceOnMainActor(key) { - try? await Task.sleep(for: Self.writeDelay) - guard !Task.isCancelled else { return } - let saved = await hostActions.applyTerminalGhosttyOption(change) - guard !Task.isCancelled else { return } - pendingChanges[key] = nil - saveFailed = !saved - let snapshot = await hostActions.terminalGhosttyOptions() - overriddenKeys[key] = saved && !snapshot.options.reflects(change) - ? snapshot.sourcePaths[key] - : nil - options = pendingChanges.values.reduce(snapshot.options) { $0.applying($1) } - } - } } diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalSection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalSection.swift index a0d1f9db2cb1..551b9543d585 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalSection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalSection.swift @@ -15,6 +15,8 @@ public struct TerminalSection: View { @State private var surfaceTabBarFont: SettingsFontSize @State private var fontSaveFailed = false + /// Ghostty option values shared by the Font card and the options card. + @State private var ghosttyOptions: TerminalGhosttyOptionsModel @State private var tasks = MainActorTaskStore() @State private var scrollSpeed: DefaultsValueModel @State private var activeScrollSpeedDragValue: Double? @@ -49,6 +51,7 @@ public struct TerminalSection: View { self.catalog = catalog self.hostActions = hostActions _surfaceTabBarFont = State(initialValue: hostActions.surfaceTabBarFontSize()) + _ghosttyOptions = State(initialValue: TerminalGhosttyOptionsModel(hostActions: hostActions)) _scrollSpeed = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.scrollSpeed)) _sessionContentMaxWidth = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.sessionContentMaxWidth)) _rememberedSessionContentMaxWidth = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.rememberedSessionContentMaxWidth)) @@ -80,12 +83,14 @@ public struct TerminalSection: View { public var body: some View { Group { SettingsSectionHeader(String(localized: "settings.section.terminal", defaultValue: "Terminal"), section: .terminal) + TerminalFontCard(hostActions: hostActions, model: ghosttyOptions) mainCard - TerminalGhosttyOptionsCard(hostActions: hostActions) + TerminalGhosttyOptionsCard(model: ghosttyOptions) LocalTmuxSettingsCard(hostActions: hostActions) resumeCommandsCard } .task { startObservingSettings() } + .task { await ghosttyOptions.load() } } private func startObservingSettings() { diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 79f82380c4bc..22f7ad1bb36f 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -561083,6 +561083,478 @@ } } }, + "settings.terminal.font.builtIn": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Default (%@)" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الافتراضي (%@)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Standard (%@)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Predeterminada (%@)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Par défaut (%@)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デフォルト (%@)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기본값 (%@)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "默认 (%@)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "預設 (%@)" + } + } + } + }, + "settings.terminal.font.noMatches": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No installed monospaced fonts match." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا توجد خطوط أحادية المسافة مثبتة مطابقة." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine installierte Festbreitenschrift passt." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ninguna fuente monoespaciada instalada coincide." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucune police à chasse fixe installée ne correspond." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "一致する等幅フォントはインストールされていません。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "일치하는 설치된 고정폭 글꼴이 없습니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "没有匹配的已安装等宽字体。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "沒有符合的已安裝等寬字型。" + } + } + } + }, + "settings.terminal.font.preview.accessibility": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Terminal font preview" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "معاينة خط الطرفية" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vorschau der Terminalschrift" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Vista previa de la fuente del terminal" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aperçu de la police du terminal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルフォントのプレビュー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "터미널 글꼴 미리보기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "终端字体预览" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "終端機字型預覽" + } + } + } + }, + "settings.terminal.font.search": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search fonts" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "البحث في الخطوط" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Schriften suchen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Buscar fuentes" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Rechercher des polices" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォントを検索" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "글꼴 검색" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "搜索字体" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "搜尋字型" + } + } + } + }, + "settings.terminal.ghostty.fontThicken": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Thicker Strokes" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "خطوط أسمك" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dickere Striche" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Trazos más gruesos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Traits plus épais" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "太いストローク" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "더 굵은 획" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "加粗笔画" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "加粗筆畫" + } + } + } + }, + "settings.terminal.ghostty.lineHeight": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Line Height" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "ارتفاع السطر" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zeilenhöhe" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Altura de línea" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Hauteur de ligne" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "行の高さ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "줄 높이" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "行高" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "行高" + } + } + } + }, + "settings.terminal.ghostty.lineHeight.percent": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@%%" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@%%" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@%%" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@%%" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@%%" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@%%" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@%%" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@%%" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@%%" + } + } + } + }, + "settings.terminal.ghostty.lineHeight.pixels": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ px" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@ px" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ px" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ px" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ px" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ px" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ px" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ px" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ px" + } + } + } + }, "settings.terminal.ghostty.fontSize": { "extractionState": "manual", "localizations": { @@ -561614,65 +562086,6 @@ } } }, - "settings.terminal.ghostty.fontFamily.default": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Default" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "افتراضي" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Standard" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Predeterminada" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Par défaut" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "デフォルト" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "기본값" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "默认" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "預設" - } - } - } - }, "settings.terminal.ghostty.note": { "extractionState": "manual", "localizations": { diff --git a/scripts/ui-lab/harnesses/terminal-font-card.swift b/scripts/ui-lab/harnesses/terminal-font-card.swift new file mode 100644 index 000000000000..b21985862cc3 --- /dev/null +++ b/scripts/ui-lab/harnesses/terminal-font-card.swift @@ -0,0 +1,45 @@ +// ui-lab: source Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/GhosttyTerminalOptions/GhosttyCellHeightAdjustment.swift +// ui-lab: source Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Models/NSFont+TerminalFamily.swift +// ui-lab: source Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalFontPreview.swift +// ui-lab: source Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalFontFamilyPicker.swift +// ui-lab: shim CmuxFont + +import AppKit +import SwiftUI + +// Settings > Terminal Font card pieces: the preview at a few font, size and +// line-height choices, and the font picker popover (340 x 400 in the app). +UILab.main { + let previewBounds = NSRect(x: 0, y: 0, width: 560, height: 150) + let previews: [(String, String?, Double, GhosttyCellHeightAdjustment)] = [ + ("default", nil, 13, .unadjusted), + ("menlo-14-plus10", "Menlo", 14, .percent(10)), + ("courier-new-16", "Courier New", 16, .unadjusted), + ] + for (name, family, size, cellHeight) in previews { + UILab.render(name: "preview-\(name)") { _ in + let canvas = UILab.Canvas(frame: previewBounds) + canvas.fill = .windowBackgroundColor + let host = NSHostingView(rootView: TerminalFontPreview(family: family, size: size, cellHeight: cellHeight).padding(12)) + host.frame = previewBounds + canvas.addSubview(host) + return canvas + } + } + + let pickerBounds = NSRect(x: 0, y: 0, width: 340, height: 400) + UILab.render(name: "picker") { _ in + let canvas = UILab.Canvas(frame: pickerBounds) + canvas.fill = .windowBackgroundColor + let families = ["Andale Mono", "Courier New", "Menlo", "Monaco", "PT Mono", "SF Mono"] + let picker = TerminalFontFamilyPicker( + families: families, + selection: "Menlo", + hoveredFamily: .constant(.some("Monaco")) + ) { _ in } + let host = NSHostingView(rootView: picker) + host.frame = pickerBounds + canvas.addSubview(host) + return canvas + } +} diff --git a/scripts/ui-lab/shims/CmuxFont.swift b/scripts/ui-lab/shims/CmuxFont.swift new file mode 100644 index 000000000000..5d3469337dec --- /dev/null +++ b/scripts/ui-lab/shims/CmuxFont.swift @@ -0,0 +1,9 @@ +import SwiftUI + +// Stand-in for CmuxFoundation's View.cmuxFont: the base text-style size +// without the global font magnification. +extension View { + func cmuxFont(_ style: Font.TextStyle) -> some View { + font(.system(style)) + } +} From 2aedd3bc7a1a964629402ea267c7e18de60fe919 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 28 Sep 2026 03:50:33 -0400 Subject: [PATCH 02/11] Settings: build the line height label without a localized format Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Sections/TerminalFontCard.swift | 10 +- Resources/Localizable.xcstrings | 118 ------------------ 2 files changed, 2 insertions(+), 126 deletions(-) diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalFontCard.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalFontCard.swift index 3171d98fd419..684e6d2c253d 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalFontCard.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalFontCard.swift @@ -142,15 +142,9 @@ struct TerminalFontCard: View { private var lineHeightLabel: String { switch model.options.cellHeight { case .percent(let percent): - return String.localizedStringWithFormat( - String(localized: "settings.terminal.ghostty.lineHeight.percent", defaultValue: "%@%%"), - percent > 0 ? "+\(percent)" : String(percent) - ) + return (percent > 0 ? "+" : "") + "\(percent)%" case .pixels(let pixels): - return String.localizedStringWithFormat( - String(localized: "settings.terminal.ghostty.lineHeight.pixels", defaultValue: "%@ px"), - pixels > 0 ? "+\(pixels)" : String(pixels) - ) + return (pixels > 0 ? "+" : "") + "\(pixels) px" } } } diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 22f7ad1bb36f..cd49e893581b 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -561437,124 +561437,6 @@ } } }, - "settings.terminal.ghostty.lineHeight.percent": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "%@%%" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "%@%%" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "%@%%" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "%@%%" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "%@%%" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%@%%" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "%@%%" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "%@%%" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "%@%%" - } - } - } - }, - "settings.terminal.ghostty.lineHeight.pixels": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "%@ px" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "%@ px" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "%@ px" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "%@ px" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "%@ px" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%@ px" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "%@ px" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "%@ px" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "%@ px" - } - } - } - }, "settings.terminal.ghostty.fontSize": { "extractionState": "manual", "localizations": { From 134a719aa468f0e060e8ca237ffd02894042ee87 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 28 Sep 2026 03:51:48 -0400 Subject: [PATCH 03/11] Settings: UI test for the Font card preview and font gallery Co-Authored-By: Claude Opus 5.5 (1M context) --- .../SettingsTerminalBehaviorUITests.swift | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/cmuxUITests/SettingsTerminalBehaviorUITests.swift b/cmuxUITests/SettingsTerminalBehaviorUITests.swift index 0cb78f1bb8e2..b9a6c55c12a7 100644 --- a/cmuxUITests/SettingsTerminalBehaviorUITests.swift +++ b/cmuxUITests/SettingsTerminalBehaviorUITests.swift @@ -349,4 +349,37 @@ final class SettingsTerminalBehaviorUITests: SettingsUITestCase { closeSettings(app, window) } + + // MARK: - Font card + + /// The Terminal section opens with the Font card: a font preview above + /// the font button, which opens a searchable font list. Read-only: it + /// doesn't pick a font, so the runner's Ghostty config is left alone. + func testFontCardShowsPreviewAndFontGallery() { + let app = makeLaunchedApp() + let window = openTerminalSettings(app) + + let preview = window.descendants(matching: .any)["SettingsTerminalFontPreview"] + XCTAssertTrue(poll(timeout: 6.0) { preview.exists }, "The Font card should show the font preview") + attachScreenshot(of: window, name: "Settings Terminal Font card") + + let fontButton = window.buttons["SettingsTerminalGhosttyFontFamilyPicker"] + XCTAssertTrue(poll(timeout: 6.0) { fontButton.exists && fontButton.isEnabled }, "The font button should be enabled once options load") + fontButton.click() + + let search = app.textFields["SettingsTerminalFontSearchField"] + XCTAssertTrue(poll(timeout: 4.0) { search.exists }, "The font button should open the searchable font list") + attachScreenshot(of: window, name: "Settings Terminal font gallery") + + app.typeKey(.escape, modifierFlags: []) + XCTAssertTrue(poll(timeout: 4.0) { !search.exists }, "Escape should close the font list") + closeSettings(app, window) + } + + private func attachScreenshot(of window: XCUIElement, name: String) { + let screenshot = XCTAttachment(screenshot: window.screenshot()) + screenshot.name = name + screenshot.lifetime = .keepAlways + add(screenshot) + } } From 86f5e72d208ead493c2127b2bb56cc376318a250 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 28 Sep 2026 04:02:53 -0400 Subject: [PATCH 04/11] Settings: match Ghostty's adjust-cell-height parsing, keep pixel units, fix search anchors Co-Authored-By: Claude Opus 5.5 (1M context) --- .../GhosttyCellHeightAdjustment.swift | 31 +++++++++++-------- .../GhosttyTerminalOptionsTests.swift | 9 ++++-- .../Sections/TerminalFontCard.swift | 15 ++++----- .../Sections/TerminalFontFamilyPicker.swift | 8 +++-- .../Sections/TerminalFontPreview.swift | 6 ++-- .../SettingsRowAnchorResolutionTests.swift | 2 ++ .../SettingsSearchIndexTests.swift | 3 ++ 7 files changed, 46 insertions(+), 28 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/GhosttyTerminalOptions/GhosttyCellHeightAdjustment.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/GhosttyTerminalOptions/GhosttyCellHeightAdjustment.swift index 33d446790254..4e8584a82fb1 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/GhosttyTerminalOptions/GhosttyCellHeightAdjustment.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/GhosttyTerminalOptions/GhosttyCellHeightAdjustment.swift @@ -1,20 +1,21 @@ import Foundation /// An `adjust-cell-height` value: extra line height added to the font's own, -/// as a percentage (`8%`) or a number of pixels (`2`). Negative values tighten -/// the lines. +/// as a percentage (`8%`, `12.5%`) or a number of device pixels (`2`). +/// Negative values tighten the lines. public enum GhosttyCellHeightAdjustment: Equatable, Sendable { - case percent(Int) + case percent(Double) case pixels(Int) /// No adjustment, Ghostty's default. public static let unadjusted = GhosttyCellHeightAdjustment.percent(0) - /// Parses Ghostty's `20%`, `-15%`, or `2` spelling. + /// Parses Ghostty's `20%`, `-12.5%`, or `2` spelling. Like Ghostty, only + /// the ends of the value are trimmed, so `15 %` is invalid. public init?(configValue: String) { let value = configValue.trimmingCharacters(in: .whitespaces) if value.hasSuffix("%") { - guard let percent = Int(value.dropLast().trimmingCharacters(in: .whitespaces)) else { return nil } + guard let percent = Double(value.dropLast()), percent.isFinite else { return nil } self = .percent(percent) } else if let pixels = Int(value) { self = .pixels(pixels) @@ -23,18 +24,22 @@ public enum GhosttyCellHeightAdjustment: Equatable, Sendable { } } - /// The percentage the line height row steps from. A pixel value has no - /// percentage equivalent without the font's metrics, so it steps from 0%. - public var percentValue: Int { - guard case .percent(let percent) = self else { return 0 } - return percent + /// The adjustment one stepper step away: 2% for a percentage, 1 pixel + /// for a pixel value, so a value set in a config file keeps its unit. + public func stepped(by steps: Int) -> GhosttyCellHeightAdjustment { + switch self { + case .percent(let percent): return .percent((percent / 2).rounded() * 2 + Double(steps * 2)) + case .pixels(let pixels): return .pixels(pixels + steps) + } } - /// Ghostty's spelling: `8%` or `2`. + /// Ghostty's spelling: `8%`, `12.5%`, or `2`. public var configValue: String { switch self { - case .percent(let percent): return "\(percent)%" - case .pixels(let pixels): return String(pixels) + case .percent(let percent): + return percent == percent.rounded() ? "\(Int(percent))%" : "\(percent)%" + case .pixels(let pixels): + return String(pixels) } } } diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/GhosttyTerminalOptionsTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/GhosttyTerminalOptionsTests.swift index c81916423652..17e02a8d7a9d 100644 --- a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/GhosttyTerminalOptionsTests.swift +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/GhosttyTerminalOptionsTests.swift @@ -102,11 +102,16 @@ struct GhosttyTerminalOptionsTests { GhosttyTerminalOptions(directives: ["adjust-cell-height": values]).cellHeight } #expect(cellHeight(["8%"]) == .percent(8)) - #expect(cellHeight(["-15 %"]) == .percent(-15)) + #expect(cellHeight(["12.5%"]) == .percent(12.5)) + #expect(cellHeight(["-15%"]) == .percent(-15)) + // Ghostty rejects a space before the percent sign. + #expect(cellHeight(["-15 %"]) == .unadjusted) #expect(cellHeight(["2"]) == .pixels(2)) #expect(cellHeight(["10%", "tall"]) == .percent(10)) #expect(cellHeight(["10%", ""]) == .unadjusted) - #expect(GhosttyCellHeightAdjustment.pixels(2).percentValue == 0) + #expect(GhosttyCellHeightAdjustment.pixels(2).stepped(by: 1) == .pixels(3)) + #expect(GhosttyCellHeightAdjustment.percent(8).stepped(by: -1) == .percent(6)) + #expect(GhosttyCellHeightAdjustment.percent(12.5).configValue == "12.5%") #expect(GhosttyTerminalOptionChange.cellHeight(.percent(12)).key.rawValue == "adjust-cell-height") #expect(GhosttyTerminalOptionChange.cellHeight(.percent(12)).configValues == ["12%"]) #expect(GhosttyTerminalOptionChange.fontThicken(true).key.rawValue == "font-thicken") diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalFontCard.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalFontCard.swift index 684e6d2c253d..cccaafab46e5 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalFontCard.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalFontCard.swift @@ -109,16 +109,13 @@ struct TerminalFontCard: View { controlWidth: 140, overriddenBy: model.overriddenKeys[.adjustCellHeight] ) { - Stepper( - value: Binding( - get: { model.options.cellHeight.percentValue }, - set: { model.apply(.cellHeight(.percent($0))) } - ), - in: -20...100, - step: 2 - ) { + Stepper { Text(verbatim: lineHeightLabel) .monospacedDigit() + } onIncrement: { + model.apply(.cellHeight(model.options.cellHeight.stepped(by: 1))) + } onDecrement: { + model.apply(.cellHeight(model.options.cellHeight.stepped(by: -1))) } .accessibilityIdentifier("SettingsTerminalGhosttyLineHeightStepper") } @@ -142,7 +139,7 @@ struct TerminalFontCard: View { private var lineHeightLabel: String { switch model.options.cellHeight { case .percent(let percent): - return (percent > 0 ? "+" : "") + "\(percent)%" + return (percent > 0 ? "+" : "") + GhosttyCellHeightAdjustment.percent(percent).configValue case .pixels(let pixels): return (pixels > 0 ? "+" : "") + "\(pixels) px" } diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalFontFamilyPicker.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalFontFamilyPicker.swift index f02ab56774ec..bc78f58cda35 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalFontFamilyPicker.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalFontFamilyPicker.swift @@ -13,8 +13,12 @@ struct TerminalFontFamilyPicker: View { @State private var query = "" + private var trimmedQuery: String { + query.trimmingCharacters(in: .whitespacesAndNewlines) + } + private var filteredFamilies: [String] { - let query = query.trimmingCharacters(in: .whitespacesAndNewlines) + let query = trimmedQuery guard !query.isEmpty else { return families } return families.filter { $0.localizedCaseInsensitiveContains(query) } } @@ -31,7 +35,7 @@ struct TerminalFontFamilyPicker: View { Divider() ScrollView { LazyVStack(alignment: .leading, spacing: 0) { - if query.isEmpty { + if trimmedQuery.isEmpty { row(family: nil) } ForEach(filteredFamilies, id: \.self) { family in diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalFontPreview.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalFontPreview.swift index cbe66a2e5e6c..eba52898325f 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalFontPreview.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalFontPreview.swift @@ -10,6 +10,8 @@ struct TerminalFontPreview: View { let size: Double let cellHeight: GhosttyCellHeightAdjustment + @Environment(\.displayScale) private var displayScale + private struct Token { let text: String let style: Style @@ -59,12 +61,12 @@ struct TerminalFontPreview: View { } /// Extra space between lines matching Ghostty's `adjust-cell-height`: a - /// percentage of the font's line height, or a number of pixels. + /// percentage of the font's line height, or a number of device pixels. private func lineSpacing(for font: NSFont) -> CGFloat { let lineHeight = font.ascender - font.descender + font.leading switch cellHeight { case .percent(let percent): return lineHeight * CGFloat(percent) / 100 - case .pixels(let pixels): return CGFloat(pixels) + case .pixels(let pixels): return CGFloat(pixels) / max(displayScale, 1) } } diff --git a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift index a4c1d538e0fc..37e39508eddc 100644 --- a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift +++ b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift @@ -210,6 +210,8 @@ struct SettingsRowAnchorResolutionTests { "setting:terminal:memory-guardrail-threshold", "setting:terminal:font-family", "setting:terminal:font-size", + "setting:terminal:adjust-cell-height", + "setting:terminal:font-thicken", "setting:terminal:cursor-style", "setting:terminal:cursor-blink", "setting:terminal:window-padding-x", diff --git a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsSearchIndexTests.swift b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsSearchIndexTests.swift index ccbfd1084820..598880177509 100644 --- a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsSearchIndexTests.swift +++ b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsSearchIndexTests.swift @@ -225,6 +225,9 @@ struct SettingsSearchIndexTests { @Test(arguments: [ ("font-family", "setting:terminal:font-family"), ("terminal font size", "setting:terminal:font-size"), + ("adjust-cell-height", "setting:terminal:adjust-cell-height"), + ("line height", "setting:terminal:adjust-cell-height"), + ("font-thicken", "setting:terminal:font-thicken"), ("cursor-style", "setting:terminal:cursor-style"), ("cursor blink", "setting:terminal:cursor-blink"), ("window-padding-x", "setting:terminal:window-padding-x"), From 9a3a5d8d63bee773767bf6c5b81b8d906a201fa7 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 28 Sep 2026 04:32:12 -0400 Subject: [PATCH 05/11] Settings: widen the font button and drop the font list below it Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Sources/CmuxSettingsUI/Sections/TerminalFontCard.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalFontCard.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalFontCard.swift index cccaafab46e5..70802bc4559b 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalFontCard.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalFontCard.swift @@ -40,7 +40,7 @@ struct TerminalFontCard: View { id: "font-family", title: String(localized: "settings.terminal.ghostty.fontFamily", defaultValue: "Font"), key: .fontFamily, - controlWidth: 220, + controlWidth: 250, overriddenBy: model.overriddenKeys[.fontFamily] ) { Button { @@ -59,11 +59,11 @@ struct TerminalFontCard: View { .imageScale(.small) .foregroundStyle(.secondary) } - .frame(width: 190) + .frame(width: 220) } .buttonStyle(.bordered) .controlSize(.small) - .popover(isPresented: $isPickingFamily, arrowEdge: .trailing) { + .popover(isPresented: $isPickingFamily, arrowEdge: .bottom) { TerminalFontFamilyPicker( families: model.fontFamilyChoices, selection: model.options.fontFamily, From 6b4d75dda3f7d05db132afe786c5f88879b1599f Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 28 Sep 2026 05:13:59 -0400 Subject: [PATCH 06/11] Settings: font strings for the remaining catalog locales; saturate pixel line height steps Co-Authored-By: Claude Opus 5.5 (1M context) --- .../GhosttyCellHeightAdjustment.swift | 4 +- Resources/Localizable.xcstrings | 396 ++++++++++++++++++ 2 files changed, 399 insertions(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/GhosttyTerminalOptions/GhosttyCellHeightAdjustment.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/GhosttyTerminalOptions/GhosttyCellHeightAdjustment.swift index 4e8584a82fb1..6a528e4c1ad3 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/GhosttyTerminalOptions/GhosttyCellHeightAdjustment.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/GhosttyTerminalOptions/GhosttyCellHeightAdjustment.swift @@ -29,7 +29,9 @@ public enum GhosttyCellHeightAdjustment: Equatable, Sendable { public func stepped(by steps: Int) -> GhosttyCellHeightAdjustment { switch self { case .percent(let percent): return .percent((percent / 2).rounded() * 2 + Double(steps * 2)) - case .pixels(let pixels): return .pixels(pixels + steps) + case .pixels(let pixels): + let (sum, overflow) = pixels.addingReportingOverflow(steps) + return .pixels(overflow ? (steps > 0 ? .max : .min) : sum) } } diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index cd49e893581b..e817ea31aec6 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -561139,6 +561139,72 @@ "state": "translated", "value": "預設 (%@)" } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Zadano (%@)" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Standard (%@)" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Predefinito (%@)" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "លំនាំដើម (%@)" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Standard (%@)" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Domyślna (%@)" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Padrão (%@)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "По умолчанию (%@)" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ค่าเริ่มต้น (%@)" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Varsayılan (%@)" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Типовий (%@)" + } } } }, @@ -561198,6 +561264,72 @@ "state": "translated", "value": "沒有符合的已安裝等寬字型。" } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nijedan instalirani font fiksne širine se ne podudara." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ingen installerede fastbreddeskrifttyper matcher." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nessun font a spaziatura fissa installato corrisponde." + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "មិនមានពុម្ពអក្សរដែលមានទទឹងថេរដែលបានដំឡើងត្រូវគ្នាទេ។" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ingen installerte skrifter med fast bredde samsvarer." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Żadna zainstalowana czcionka o stałej szerokości nie pasuje." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Nenhuma fonte monoespaçada instalada corresponde." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нет подходящих установленных моноширинных шрифтов." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่มีฟอนต์ความกว้างคงที่ที่ติดตั้งไว้ตรงกัน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Eşleşen yüklü eş aralıklı yazı tipi yok." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Немає відповідних встановлених моноширинних шрифтів." + } } } }, @@ -561257,6 +561389,72 @@ "state": "translated", "value": "終端機字型預覽" } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pregled fonta terminala" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Forhåndsvisning af terminalskrifttype" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Anteprima del font del terminale" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "មើលពុម្ពអក្សរស្ថានីយជាមុន" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Forhåndsvisning av terminalskrift" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Podgląd czcionki terminala" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Pré-visualização da fonte do terminal" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Предпросмотр шрифта терминала" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ตัวอย่างฟอนต์เทอร์มินัล" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Terminal yazı tipi önizlemesi" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Попередній перегляд шрифту термінала" + } } } }, @@ -561316,6 +561514,72 @@ "state": "translated", "value": "搜尋字型" } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pretraži fontove" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Søg i skrifttyper" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Cerca font" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ស្វែងរកពុម្ពអក្សរ" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Søk i skrifter" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Szukaj czcionek" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Buscar fontes" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Поиск шрифтов" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ค้นหาฟอนต์" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yazı tiplerinde ara" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Пошук шрифтів" + } } } }, @@ -561375,6 +561639,72 @@ "state": "translated", "value": "加粗筆畫" } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Deblji potezi" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Tykkere streger" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Tratti più spessi" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ខ្សែក្រាស់ជាង" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Tykkere streker" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Grubsze linie" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Traços mais grossos" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Более толстые штрихи" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เส้นหนาขึ้น" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Daha kalın çizgiler" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Товстіші штрихи" + } } } }, @@ -561434,6 +561764,72 @@ "state": "translated", "value": "行高" } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Visina reda" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Linjehøjde" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Altezza riga" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "កម្ពស់បន្ទាត់" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Linjehøyde" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wysokość wiersza" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Altura da linha" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Высота строки" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ความสูงบรรทัด" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Satır yüksekliği" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Висота рядка" + } } } }, From 7da97d8db0039e9ba57fc2c0fb27f229b688263c Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 28 Sep 2026 05:16:49 -0400 Subject: [PATCH 07/11] Settings: walk the Font card in its UI test (hover, search, pick, line height, restore) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../SettingsTerminalBehaviorUITests.swift | 51 +++++++++++++++---- 1 file changed, 41 insertions(+), 10 deletions(-) diff --git a/cmuxUITests/SettingsTerminalBehaviorUITests.swift b/cmuxUITests/SettingsTerminalBehaviorUITests.swift index b9a6c55c12a7..bf493b2d2d72 100644 --- a/cmuxUITests/SettingsTerminalBehaviorUITests.swift +++ b/cmuxUITests/SettingsTerminalBehaviorUITests.swift @@ -352,16 +352,17 @@ final class SettingsTerminalBehaviorUITests: SettingsUITestCase { // MARK: - Font card - /// The Terminal section opens with the Font card: a font preview above - /// the font button, which opens a searchable font list. Read-only: it - /// doesn't pick a font, so the runner's Ghostty config is left alone. + /// The Terminal section opens with the Font card. Walks the card the way a + /// person would, capturing a frame at each step for the PR: hover fonts in + /// the gallery (the preview follows), pick Menlo, raise the line height, + /// then put both back so the runner's Ghostty config ends at the defaults. func testFontCardShowsPreviewAndFontGallery() { let app = makeLaunchedApp() let window = openTerminalSettings(app) let preview = window.descendants(matching: .any)["SettingsTerminalFontPreview"] XCTAssertTrue(poll(timeout: 6.0) { preview.exists }, "The Font card should show the font preview") - attachScreenshot(of: window, name: "Settings Terminal Font card") + attachScreenshot(name: "01 Font card") let fontButton = window.buttons["SettingsTerminalGhosttyFontFamilyPicker"] XCTAssertTrue(poll(timeout: 6.0) { fontButton.exists && fontButton.isEnabled }, "The font button should be enabled once options load") @@ -369,15 +370,45 @@ final class SettingsTerminalBehaviorUITests: SettingsUITestCase { let search = app.textFields["SettingsTerminalFontSearchField"] XCTAssertTrue(poll(timeout: 4.0) { search.exists }, "The font button should open the searchable font list") - attachScreenshot(of: window, name: "Settings Terminal font gallery") - - app.typeKey(.escape, modifierFlags: []) - XCTAssertTrue(poll(timeout: 4.0) { !search.exists }, "Escape should close the font list") + attachScreenshot(name: "02 Font gallery") + + for family in ["Courier New", "Menlo"] { + let row = app.buttons[family] + guard poll(timeout: 2.0, { row.exists }) else { continue } + row.hover() + attachScreenshot(name: "03 Hover \(family)") + } + + search.click() + search.typeText("men") + attachScreenshot(name: "04 Search men") + + let menlo = app.buttons["Menlo"] + XCTAssertTrue(poll(timeout: 4.0) { menlo.exists }, "Searching should find Menlo") + menlo.click() + XCTAssertTrue(poll(timeout: 4.0) { !search.exists }, "Picking a font should close the list") + XCTAssertTrue(poll(timeout: 4.0) { fontButton.label.contains("Menlo") }, "The font button should name the picked font") + + let lineHeight = window.steppers["SettingsTerminalGhosttyLineHeightStepper"] + XCTAssertTrue(poll(timeout: 4.0) { lineHeight.exists }, "The Line Height stepper should exist") + for _ in 0..<5 { lineHeight.incrementArrows.firstMatch.click() } + XCTAssertTrue(waitForStaticText(window, "+10%"), "Five steps should read +10%") + attachScreenshot(name: "05 Menlo with +10% line height") + + for _ in 0..<5 { lineHeight.decrementArrows.firstMatch.click() } + XCTAssertTrue(waitForStaticText(window, "0%"), "Stepping back should read 0%") + fontButton.click() + let defaultRow = app.buttons.matching(NSPredicate(format: "label BEGINSWITH %@", "Default (")).firstMatch + XCTAssertTrue(poll(timeout: 4.0) { defaultRow.exists }, "The font list should offer the built-in font") + defaultRow.click() + XCTAssertTrue(poll(timeout: 4.0) { fontButton.label.hasPrefix("Default") }, "Picking Default should restore the built-in font") + attachScreenshot(name: "06 Back to defaults") closeSettings(app, window) } - private func attachScreenshot(of window: XCUIElement, name: String) { - let screenshot = XCTAttachment(screenshot: window.screenshot()) + /// The whole screen, so the font list popover (its own window) is in frame. + private func attachScreenshot(name: String) { + let screenshot = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) screenshot.name = name screenshot.lifetime = .keepAlways add(screenshot) From 9d545a53a0a9ad6f92e74e8c7d4e23e0754afa4b Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 28 Sep 2026 06:03:33 -0400 Subject: [PATCH 08/11] Settings: read the line height stepper's own label in the Font card UI test Co-Authored-By: Claude Opus 5.5 (1M context) --- cmuxUITests/SettingsTerminalBehaviorUITests.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/cmuxUITests/SettingsTerminalBehaviorUITests.swift b/cmuxUITests/SettingsTerminalBehaviorUITests.swift index bf493b2d2d72..00914a564e44 100644 --- a/cmuxUITests/SettingsTerminalBehaviorUITests.swift +++ b/cmuxUITests/SettingsTerminalBehaviorUITests.swift @@ -392,11 +392,11 @@ final class SettingsTerminalBehaviorUITests: SettingsUITestCase { let lineHeight = window.steppers["SettingsTerminalGhosttyLineHeightStepper"] XCTAssertTrue(poll(timeout: 4.0) { lineHeight.exists }, "The Line Height stepper should exist") for _ in 0..<5 { lineHeight.incrementArrows.firstMatch.click() } - XCTAssertTrue(waitForStaticText(window, "+10%"), "Five steps should read +10%") + XCTAssertTrue(poll(timeout: 4.0) { stepperText(lineHeight).contains("+10%") }, "Five steps should read +10%") attachScreenshot(name: "05 Menlo with +10% line height") for _ in 0..<5 { lineHeight.decrementArrows.firstMatch.click() } - XCTAssertTrue(waitForStaticText(window, "0%"), "Stepping back should read 0%") + XCTAssertTrue(poll(timeout: 4.0) { !stepperText(lineHeight).contains("+") }, "Stepping back should read 0%") fontButton.click() let defaultRow = app.buttons.matching(NSPredicate(format: "label BEGINSWITH %@", "Default (")).firstMatch XCTAssertTrue(poll(timeout: 4.0) { defaultRow.exists }, "The font list should offer the built-in font") @@ -406,6 +406,12 @@ final class SettingsTerminalBehaviorUITests: SettingsUITestCase { closeSettings(app, window) } + /// A SwiftUI stepper's label text, which XCUITest exposes on the stepper + /// itself (as its label or value) rather than as a child static text. + private func stepperText(_ stepper: XCUIElement) -> String { + [stepper.label, stepper.value as? String ?? ""].joined(separator: " ") + } + /// The whole screen, so the font list popover (its own window) is in frame. private func attachScreenshot(name: String) { let screenshot = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) From e8296c0e112e403077e81cba7091dfc3b4baf991 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 28 Sep 2026 09:09:34 -0400 Subject: [PATCH 09/11] Settings: identify font list rows so the UI test doesn't also match the font button Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Sections/TerminalFontFamilyPicker.swift | 1 + cmuxUITests/SettingsTerminalBehaviorUITests.swift | 12 +++++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalFontFamilyPicker.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalFontFamilyPicker.swift index bc78f58cda35..6e591cea525f 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalFontFamilyPicker.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalFontFamilyPicker.swift @@ -99,6 +99,7 @@ struct TerminalFontFamilyPicker: View { } } .accessibilityLabel(title) + .accessibilityIdentifier("SettingsTerminalFontRow-\(family ?? "default")") .accessibilityAddTraits(isSelected ? .isSelected : []) } } diff --git a/cmuxUITests/SettingsTerminalBehaviorUITests.swift b/cmuxUITests/SettingsTerminalBehaviorUITests.swift index 00914a564e44..1808a54b34ae 100644 --- a/cmuxUITests/SettingsTerminalBehaviorUITests.swift +++ b/cmuxUITests/SettingsTerminalBehaviorUITests.swift @@ -373,7 +373,7 @@ final class SettingsTerminalBehaviorUITests: SettingsUITestCase { attachScreenshot(name: "02 Font gallery") for family in ["Courier New", "Menlo"] { - let row = app.buttons[family] + let row = fontRow(app, family) guard poll(timeout: 2.0, { row.exists }) else { continue } row.hover() attachScreenshot(name: "03 Hover \(family)") @@ -383,7 +383,7 @@ final class SettingsTerminalBehaviorUITests: SettingsUITestCase { search.typeText("men") attachScreenshot(name: "04 Search men") - let menlo = app.buttons["Menlo"] + let menlo = fontRow(app, "Menlo") XCTAssertTrue(poll(timeout: 4.0) { menlo.exists }, "Searching should find Menlo") menlo.click() XCTAssertTrue(poll(timeout: 4.0) { !search.exists }, "Picking a font should close the list") @@ -398,7 +398,7 @@ final class SettingsTerminalBehaviorUITests: SettingsUITestCase { for _ in 0..<5 { lineHeight.decrementArrows.firstMatch.click() } XCTAssertTrue(poll(timeout: 4.0) { !stepperText(lineHeight).contains("+") }, "Stepping back should read 0%") fontButton.click() - let defaultRow = app.buttons.matching(NSPredicate(format: "label BEGINSWITH %@", "Default (")).firstMatch + let defaultRow = fontRow(app, "default") XCTAssertTrue(poll(timeout: 4.0) { defaultRow.exists }, "The font list should offer the built-in font") defaultRow.click() XCTAssertTrue(poll(timeout: 4.0) { fontButton.label.hasPrefix("Default") }, "Picking Default should restore the built-in font") @@ -406,6 +406,12 @@ final class SettingsTerminalBehaviorUITests: SettingsUITestCase { closeSettings(app, window) } + /// A row in the font list. The font button behind the popover is also + /// labeled with the current font, so match the list's row identifier. + private func fontRow(_ app: XCUIApplication, _ family: String) -> XCUIElement { + app.buttons["SettingsTerminalFontRow-\(family)"] + } + /// A SwiftUI stepper's label text, which XCUITest exposes on the stepper /// itself (as its label or value) rather than as a child static text. private func stepperText(_ stepper: XCUIElement) -> String { From b0051020914d4b2c1e9ffcefd52963527c14cd36 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 28 Sep 2026 10:28:58 -0400 Subject: [PATCH 10/11] Settings: expose the line height as the stepper's accessibility value Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Sources/CmuxSettingsUI/Sections/TerminalFontCard.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalFontCard.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalFontCard.swift index 70802bc4559b..29c7bd98391e 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalFontCard.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalFontCard.swift @@ -117,6 +117,7 @@ struct TerminalFontCard: View { } onDecrement: { model.apply(.cellHeight(model.options.cellHeight.stepped(by: -1))) } + .accessibilityValue(lineHeightLabel) .accessibilityIdentifier("SettingsTerminalGhosttyLineHeightStepper") } } From d1a764220c72fdad31099c201e9402911feead81 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 30 Sep 2026 11:43:23 -0700 Subject: [PATCH 11/11] fix: safely serialize large line height percentages --- .../GhosttyTerminalOptions/GhosttyCellHeightAdjustment.swift | 5 ++++- .../CmuxFoundationTests/GhosttyTerminalOptionsTests.swift | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/GhosttyTerminalOptions/GhosttyCellHeightAdjustment.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/GhosttyTerminalOptions/GhosttyCellHeightAdjustment.swift index 6a528e4c1ad3..6cc1a5204333 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/GhosttyTerminalOptions/GhosttyCellHeightAdjustment.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/GhosttyTerminalOptions/GhosttyCellHeightAdjustment.swift @@ -39,7 +39,10 @@ public enum GhosttyCellHeightAdjustment: Equatable, Sendable { public var configValue: String { switch self { case .percent(let percent): - return percent == percent.rounded() ? "\(Int(percent))%" : "\(percent)%" + if percent == percent.rounded(), let integer = Int(exactly: percent) { + return "\(integer)%" + } + return "\(percent)%" case .pixels(let pixels): return String(pixels) } diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/GhosttyTerminalOptionsTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/GhosttyTerminalOptionsTests.swift index 17e02a8d7a9d..cdf0b4d758c6 100644 --- a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/GhosttyTerminalOptionsTests.swift +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/GhosttyTerminalOptionsTests.swift @@ -112,6 +112,8 @@ struct GhosttyTerminalOptionsTests { #expect(GhosttyCellHeightAdjustment.pixels(2).stepped(by: 1) == .pixels(3)) #expect(GhosttyCellHeightAdjustment.percent(8).stepped(by: -1) == .percent(6)) #expect(GhosttyCellHeightAdjustment.percent(12.5).configValue == "12.5%") + let hugePercent = GhosttyCellHeightAdjustment.percent(1e20) + #expect(GhosttyCellHeightAdjustment(configValue: hugePercent.configValue) == hugePercent) #expect(GhosttyTerminalOptionChange.cellHeight(.percent(12)).key.rawValue == "adjust-cell-height") #expect(GhosttyTerminalOptionChange.cellHeight(.percent(12)).configValues == ["12%"]) #expect(GhosttyTerminalOptionChange.fontThicken(true).key.rawValue == "font-thicken")