fix(terminal): preserve theme fallbacks for preferred fonts

This commit is contained in:
Kaede Akino committed 2026-10-17 02:37:08 +08:00
1 parent 632d913bb3
commit 82daa906a1
7 files changed
+132 -32

No files matched your search

@@ -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)) }
}
}
+1 -1
View File
@@ -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 }
}
+9 -5
View File
@@ -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))
+1 -1
View File
@@ -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 }
}
@@ -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)
+96 -10
View File
@@ -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)
}
}
+12 -5
View File
@@ -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)
}
}