diff --git a/Frameworks/TermindPresentation/Terminal/TerminalAppearance.swift b/Frameworks/TermindPresentation/Terminal/TerminalAppearance.swift index 5bb4e246..a1382530 100644 --- a/Frameworks/TermindPresentation/Terminal/TerminalAppearance.swift +++ b/Frameworks/TermindPresentation/Terminal/TerminalAppearance.swift @@ -26,13 +26,20 @@ nonisolated extension TermindTerminalPalette { } nonisolated extension TerminalCustomConfiguration { - func applying(to base: GhosttyTerminal.TerminalConfiguration) + func applying(to base: GhosttyTerminal.TerminalConfiguration, preferredFontFamily: String) -> GhosttyTerminal.TerminalConfiguration { GhosttyTerminal.TerminalConfiguration(startingFrom: base) { builder in + if !preferredFontFamily.isEmpty { builder.withFontFamily(preferredFontFamily) } // Command alerts are owned by the app's shared settings and dispatcher. for entry in entries where entry.key != "notify-on-command-finish" { builder.withCustom(entry.key, entry.value) + if !preferredFontFamily.isEmpty && entry.key == "font-family" + && (entry.value.isEmpty || entry.value == "\"\"") + { + // Keep the theme's reset semantics without dropping the device's first choice. + builder.withFontFamily(preferredFontFamily) + } } } } @@ -76,14 +83,10 @@ public nonisolated enum TermindTerminalThemeCatalog { ) -> GhosttyTerminal.TerminalConfiguration { let selected = resolvedTheme(named: themeName, isDark: isDark) let base = selected.toTerminalConfiguration() - let themed = customEnabled ? TerminalCustomConfiguration(customText).applying(to: base) : base - // Ghostty renders theme commands after session overrides. Typography must - // come last here, and font-family is a list that needs an explicit reset. + let themed = TerminalCustomConfiguration(customEnabled ? customText : "") + .applying(to: base, preferredFontFamily: font.family) + // Ghostty renders theme commands after session overrides, so manual size must come last here. return GhosttyTerminal.TerminalConfiguration(startingFrom: themed) { builder in - if !font.family.isEmpty { - builder.withFontFamily("") - builder.withFontFamily(font.family) - } if !font.sizeFollowsTheme { builder.withFontSize(Float(font.size)) } } } diff --git a/TFI/Platform/TerminalFontCatalog.swift b/TFI/Platform/TerminalFontCatalog.swift index 05a85f53..d6183ff9 100644 --- a/TFI/Platform/TerminalFontCatalog.swift +++ b/TFI/Platform/TerminalFontCatalog.swift @@ -11,7 +11,7 @@ final class TerminalFontCatalog { func refresh() { families = UIFont.familyNames.filter { family in UIFont.fontNames(forFamilyName: family).contains { name in - UIFont(name: name, size: 12)?.fontDescriptor.symbolicTraits.contains(.traitMonoSpace) == true + UIFont(name: name, size: 12) != nil } }.sorted { $0.localizedStandardCompare($1) == .orderedAscending } } diff --git a/TFITests/MobileTerminalFontTests.swift b/TFITests/MobileTerminalFontTests.swift index 8d110c9a..fccc677f 100644 --- a/TFITests/MobileTerminalFontTests.swift +++ b/TFITests/MobileTerminalFontTests.swift @@ -8,7 +8,7 @@ import UIKit @Suite(.serialized) @MainActor struct MobileTerminalFontTests { - @Test func fontCatalogIncludesOnlyAvailableMonospacedFamilies() { + @Test func fontCatalogIncludesEveryLoadableFamilyInLocalizedOrder() { TerminalFontCatalog.shared.refresh() let catalogFamilies = TerminalFontCatalog.shared.families #expect(!catalogFamilies.isEmpty) @@ -18,9 +18,13 @@ struct MobileTerminalFontTests { for family in catalogFamilies { #expect( UIFont.fontNames(forFamilyName: family).contains { - UIFont(name: $0, size: 14)?.fontDescriptor.symbolicTraits.contains(.traitMonoSpace) == true + UIFont(name: $0, size: 14) != nil }) } + let loadableFamilies = UIFont.familyNames.filter { family in + UIFont.fontNames(forFamilyName: family).contains { UIFont(name: $0, size: 14) != nil } + } + #expect(Set(catalogFamilies) == Set(loadableFamilies)) let proportionalFamily = UIFont.familyNames.first { family in let fonts = UIFont.fontNames(forFamilyName: family).compactMap { UIFont(name: $0, size: 14) } @@ -29,7 +33,7 @@ struct MobileTerminalFontTests { } #expect(proportionalFamily != nil) if let proportionalFamily { - #expect(!catalogFamilies.contains(proportionalFamily)) + #expect(catalogFamilies.contains(proportionalFamily)) } } @@ -38,7 +42,7 @@ struct MobileTerminalFontTests { arguments: ["en", "zh-Hans", "zh-Hant"]) func fontNamesPreviewTheirOwnFamilies(_ language: String) async throws { let view = TerminalFontOptions( - family: .constant("Menlo"), families: ["Courier", "Menlo", "Courier New"], didSelect: {} + family: .constant("Helvetica"), families: ["Courier", "Courier New", "Helvetica", "Menlo"], didSelect: {} ) .environment(\.locale, Locale(identifier: language)) .frame(width: 280, height: 320) @@ -50,7 +54,7 @@ struct MobileTerminalFontTests { @Test( .requiresKeyWindow, - arguments: ["en", "zh-Hans", "zh-Hant"], ["", "Menlo"]) + arguments: ["en", "zh-Hans", "zh-Hant"], ["", "Menlo", "Helvetica"]) func terminalFontSettingsRenderInSupportedLanguages(_ language: String, family: String) async throws { let domain = "MobileTerminalFontRendering.\(UUID())" let defaults = try #require(UserDefaults(suiteName: domain)) diff --git a/TFM/Platform/TerminalFontCatalog.swift b/TFM/Platform/TerminalFontCatalog.swift index c7726182..bd31e2de 100644 --- a/TFM/Platform/TerminalFontCatalog.swift +++ b/TFM/Platform/TerminalFontCatalog.swift @@ -12,7 +12,7 @@ final class TerminalFontCatalog { families = NSFontManager.shared.availableFontFamilies.filter { family in NSFontManager.shared.availableMembers(ofFontFamily: family)?.contains { member in guard let fontName = member.first as? String else { return false } - return NSFont(name: fontName, size: 12)?.isFixedPitch == true + return NSFont(name: fontName, size: 12) != nil } == true }.sorted { $0.localizedStandardCompare($1) == .orderedAscending } } diff --git a/TFMTests/Screenshots/TerminalSettingsRenderingTests.swift b/TFMTests/Screenshots/TerminalSettingsRenderingTests.swift index 4408a465..d64c160d 100644 --- a/TFMTests/Screenshots/TerminalSettingsRenderingTests.swift +++ b/TFMTests/Screenshots/TerminalSettingsRenderingTests.swift @@ -8,7 +8,7 @@ import Testing @Suite(.requiresVisibleWindow, .serialized) @MainActor struct TerminalSettingsRenderingTests { - @Test(arguments: ["en", "zh-Hans", "zh-Hant"], ["", "Menlo"]) + @Test(arguments: ["en", "zh-Hans", "zh-Hant"], ["", "Menlo", "Helvetica"]) func terminalSettingsRenderInSupportedLanguages(_ language: String, family: String) async throws { let domain = "TerminalFontRendering.\(UUID())" let defaults = try #require(UserDefaults(suiteName: domain)) @@ -38,7 +38,7 @@ struct TerminalSettingsRenderingTests { @Test(arguments: ["en", "zh-Hans", "zh-Hant"]) func fontNamesPreviewTheirOwnFamilies(_ language: String) async throws { let view = TerminalFontOptions( - family: .constant("Menlo"), families: ["Courier", "Menlo", "Monaco"], didSelect: {} + family: .constant("Helvetica"), families: ["Courier", "Helvetica", "Menlo", "Monaco"], didSelect: {} ) .environment(\.locale, Locale(identifier: language)) .frame(width: 280, height: 320) diff --git a/TFMTests/TerminalFontRenderingTests.swift b/TFMTests/TerminalFontRenderingTests.swift index 5b9faa71..7adf019d 100644 --- a/TFMTests/TerminalFontRenderingTests.swift +++ b/TFMTests/TerminalFontRenderingTests.swift @@ -9,12 +9,91 @@ import Testing @Suite @MainActor struct TerminalFontRenderingTests { @Test(arguments: [false, true]) - func explicitTypographyOverridesCustomConfigurationForBothAppearances(_ isDark: Bool) { + func preferredFontPrecedesThemeFallbacksAndManualSizeWins(_ isDark: Bool) { let configuration = TermindTerminalThemeCatalog.terminalConfiguration( themeName: isDark ? "Termind Dark" : "Termind Light", - customEnabled: true, customText: "font-family = Courier\nfont-size = 30", isDark: isDark, + customEnabled: true, + customText: "font-family = Courier\nfont-family = Helvetica\nfont-size = 30", isDark: isDark, font: .init(family: "Menlo", size: 18, sizeFollowsTheme: false)) - #expect(configuration.rendered.hasSuffix("font-family = \nfont-family = Menlo\nfont-size = 18")) + #expect( + configuration.rendered.hasSuffix( + "font-family = Menlo\nfont-family = Courier\nfont-family = Helvetica\nfont-size = 30\nfont-size = 18")) + } + + @Test(arguments: [false, true], ["", "\"\""]) + func themeResetsRetainPreferredFontAndDoNotRestoreClearedFallbacks(_ isDark: Bool, reset: String) { + let text = """ + font-family = Courier + font-family = \(reset) + font-family = Helvetica + font-family = \(reset) + font-family = "Courier New" + """ + let preferences = TerminalPreferences( + darkCustomEnabled: true, lightCustomEnabled: true, + darkCustomConfiguration: text, lightCustomConfiguration: text) + let state = TerminalViewState() + state.applyPreferences(preferences, font: .init(family: "Menlo")) + let configuration = isDark ? state.theme.dark : state.theme.light + #expect( + configuration.rendered.hasSuffix( + """ + font-family = Menlo + font-family = Courier + font-family = \(reset) + font-family = Menlo + font-family = Helvetica + font-family = \(reset) + font-family = Menlo + font-family = "Courier New" + """)) + #expect(state.controller.lastConfigurationIssue == nil) + } + + @Test(arguments: [false, true], ["", "Missing Font"]) + func followingThemeOrUnavailableFontPreservesThemeEntries(_ isDark: Bool, family: String) { + let text = "font-family = Courier\nfont-family = \"\"\nfont-family = Helvetica" + let configuration = TermindTerminalThemeCatalog.terminalConfiguration( + themeName: isDark ? "Termind Dark" : "Termind Light", + customEnabled: true, customText: text, isDark: isDark, + font: TerminalFontPreferences(family: family).resolvingFamily(in: ["Menlo"])) + #expect(configuration.rendered.hasSuffix(text)) + #expect(fontFamilies(in: configuration) == ["Courier", "\"\"", "Helvetica"]) + } + + @Test(arguments: [false, true]) + func disabledCustomConfigurationDoesNotContributeFallbacks(_ isDark: Bool) { + let configuration = TermindTerminalThemeCatalog.terminalConfiguration( + themeName: isDark ? "Termind Dark" : "Termind Light", + customEnabled: false, customText: "font-family = Courier", isDark: isDark, + font: .init(family: "Helvetica")) + #expect(fontFamilies(in: configuration) == ["Helvetica"]) + } + + @Test(arguments: [false, true]) + func themeWithoutFontFamiliesStillUsesPreferredFamily(_ isDark: Bool) { + let configuration = TermindTerminalThemeCatalog.terminalConfiguration( + themeName: isDark ? "Termind Dark" : "Termind Light", + customEnabled: true, customText: "background = 123456\nfont-size = 30", isDark: isDark, + font: .init(family: "Helvetica")) + #expect(configuration.rendered.hasSuffix("font-family = Helvetica\nbackground = 123456\nfont-size = 30")) + } + + @Test func themeStylesOtherOptionsAndDuplicateFamiliesRemainUnchanged() { + let text = """ + font-family = "Courier New" + font-family-bold = Courier + font-family-italic = Helvetica + font-family-bold-italic = "Courier New" + font-family = Menlo + font-family = "Courier New" + background = 123456 + """ + let configuration = TermindTerminalThemeCatalog.terminalConfiguration( + themeName: "Termind Dark", customEnabled: true, customText: text, isDark: true, + font: .init(family: "Menlo")) + #expect(configuration.rendered.hasSuffix("font-family = Menlo\n" + text)) + #expect(fontFamilies(in: configuration) == ["Menlo", "\"Courier New\"", "Menlo", "\"Courier New\""]) } @Test func familyAndSizeFollowThemeIndependently() { @@ -25,7 +104,9 @@ struct TerminalFontRenderingTests { ).rendered } #expect(configuration(.init()).hasSuffix("font-family = Courier\nfont-size = 30")) - #expect(configuration(.init(family: "Menlo")).hasSuffix("font-family = \nfont-family = Menlo")) + #expect( + configuration(.init(family: "Menlo")) + .hasSuffix("font-family = Menlo\nfont-family = Courier\nfont-size = 30")) let manualSize = configuration(.init(size: 18, sizeFollowsTheme: false)) #expect(manualSize.hasSuffix("font-size = 18")) #expect(!manualSize.contains("font-family = \n")) @@ -38,27 +119,27 @@ struct TerminalFontRenderingTests { state.applyPreferences(preferences, font: .init(family: "Menlo", size: 18, sizeFollowsTheme: false)) #expect(state.controller === controller) #expect(state.controller.lastConfigurationIssue == nil) - #expect(state.theme.dark.rendered.hasSuffix("font-family = \nfont-family = Menlo\nfont-size = 18")) + #expect(state.theme.dark.rendered.hasSuffix("font-family = Menlo\nfont-family = Courier\nfont-size = 18")) state.applyPreferences(preferences) #expect(state.controller === controller) #expect(state.controller.lastConfigurationIssue == nil) #expect(state.theme.dark.rendered.hasSuffix("font-family = Courier")) } - @Test func fontCatalogIncludesEveryLoadableMonospacedFamilyInLocalizedOrder() { + @Test func fontCatalogIncludesEveryLoadableFamilyInLocalizedOrder() { TerminalFontCatalog.shared.refresh() let catalogFamilies = TerminalFontCatalog.shared.families let manager = NSFontManager.shared - let fixedPitchFamilies = manager.availableFontFamilies.filter { family in + let loadableFamilies = manager.availableFontFamilies.filter { family in NSFontDescriptor(fontAttributes: [.family: family]) .matchingFontDescriptors(withMandatoryKeys: [.family]) - .contains { NSFont(descriptor: $0, size: 14)?.isFixedPitch == true } + .contains { NSFont(descriptor: $0, size: 14) != nil } } .sorted { $0.localizedStandardCompare($1) == .orderedAscending } #expect(!catalogFamilies.isEmpty) - #expect(catalogFamilies == fixedPitchFamilies) + #expect(catalogFamilies == loadableFamilies) let proportionalFamily = manager.availableFontFamilies.first { family in let fonts = NSFontDescriptor(fontAttributes: [.family: family]) @@ -68,8 +149,13 @@ struct TerminalFontRenderingTests { } #expect(proportionalFamily != nil) if let proportionalFamily { - #expect(!catalogFamilies.contains(proportionalFamily)) + #expect(catalogFamilies.contains(proportionalFamily)) } } + + private func fontFamilies(in configuration: GhosttyTerminal.TerminalConfiguration) -> [String] { + TerminalCustomConfiguration(configuration.rendered).entries + .filter { $0.key == "font-family" }.map(\.value) + } } diff --git a/TFMTests/TerminalThemeBrowserTests.swift b/TFMTests/TerminalThemeBrowserTests.swift index 2cc9312b..9412dbba 100644 --- a/TFMTests/TerminalThemeBrowserTests.swift +++ b/TFMTests/TerminalThemeBrowserTests.swift @@ -165,13 +165,20 @@ struct TerminalThemeBrowserTests { #expect(model.issue == nil) #expect(model.terminal.theme == lastValid) #expect(model.palette == lastPalette) - #expect( - model.update( - themeName: name, text: "font-size = 30\nbackground = ABCDEF", isDark: isDark, - font: .init(family: "Menlo", size: 18, sizeFollowsTheme: false))) + let fontText = "font-family = Courier\nfont-family = Helvetica\nfont-size = 30\nbackground = ABCDEF" + let font = TerminalFontPreferences(family: "Menlo", size: 18, sizeFollowsTheme: false) + #expect(model.update(themeName: name, text: fontText, isDark: isDark, font: font)) #expect(model.issue == nil) #expect(model.terminal.controller === controller) - #expect(model.terminal.theme.dark.rendered.hasSuffix("font-family = \nfont-family = Menlo\nfont-size = 18")) + #expect(model.terminal.theme.dark.rendered.hasSuffix("font-family = Menlo\n" + fontText + "\nfont-size = 18")) + let state = TerminalViewState() + state.applyPreferences( + .init( + darkTheme: name, lightTheme: name, + darkCustomEnabled: true, lightCustomEnabled: true, + darkCustomConfiguration: fontText, lightCustomConfiguration: fontText), + font: font) + #expect(model.terminal.theme.dark == (isDark ? state.theme.dark : state.theme.light)) #expect(!model.terminal.isFocused) } }