diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index 7417213a..000965e5 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -17143,6 +17143,23 @@ } } }, + "No Theme Selected" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未选择主题" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "未選擇主題" + } + } + } + }, "No Transfers" : { "extractionState" : "manual", "localizations" : { @@ -28762,6 +28779,23 @@ } } }, + "This theme is unavailable for this appearance. Using “%@” instead." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此主题不适用于当前外观,将改用“%@”。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "此主題不適用於目前外觀,將改用「%@」。" + } + } + } + }, "This Vault record requires sync format %lld." : { "extractionState" : "stale", "localizations" : { @@ -31898,4 +31932,4 @@ } }, "version" : "1.2" -} \ No newline at end of file +} diff --git a/Frameworks/TermindPresentation/Terminal/TerminalAppearance.swift b/Frameworks/TermindPresentation/Terminal/TerminalAppearance.swift index 053ea920..552e325f 100644 --- a/Frameworks/TermindPresentation/Terminal/TerminalAppearance.swift +++ b/Frameworks/TermindPresentation/Terminal/TerminalAppearance.swift @@ -59,6 +59,20 @@ public nonisolated enum TermindTerminalThemeCatalog { public static let defaultDark = TermindTerminalPalette.termindDark.ghosttyDefinition public static let defaultLight = TermindTerminalPalette.termindLight.ghosttyDefinition + public static let darkThemes = allThemes.filter(\.isDark).sorted { $0.name < $1.name } + public static let lightThemes = allThemes.filter { !$0.isDark }.sorted { $0.name < $1.name } + + public static func themes(isDark: Bool) -> [GhosttyThemeDefinition] { + isDark ? darkThemes : lightThemes + } + + public static func fallbackTheme(isDark: Bool) -> GhosttyThemeDefinition { + isDark ? defaultDark : defaultLight + } + + public static func resolvedTheme(named name: String, isDark: Bool) -> GhosttyThemeDefinition { + theme(named: name).flatMap { $0.isDark == isDark ? $0 : nil } ?? fallbackTheme(isDark: isDark) + } public static func terminalConfiguration( themeName: String, @@ -67,8 +81,7 @@ public nonisolated enum TermindTerminalThemeCatalog { isDark: Bool, font: TerminalFontPreferences = .init() ) -> GhosttyTerminal.TerminalConfiguration { - let fallback = isDark ? defaultDark : defaultLight - let selected = theme(named: themeName).flatMap { $0.isDark == isDark ? $0 : nil } ?? fallback + 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 @@ -88,8 +101,7 @@ public nonisolated enum TermindTerminalThemeCatalog { customText: String, isDark: Bool ) -> TermindTerminalPalette.RGB { - let fallback = isDark ? defaultDark : defaultLight - let selected = theme(named: themeName).flatMap { $0.isDark == isDark ? $0 : nil } ?? fallback + let selected = resolvedTheme(named: themeName, isDark: isDark) if customEnabled, let value = TerminalCustomConfiguration(customText).lastValue(for: "background"), let customBackground = TermindTerminalPalette.RGB(hex: value) diff --git a/Frameworks/TermindPresentation/Terminal/TerminalFontFields.swift b/Frameworks/TermindPresentation/Terminal/TerminalFontFields.swift new file mode 100644 index 00000000..8d72261d --- /dev/null +++ b/Frameworks/TermindPresentation/Terminal/TerminalFontFields.swift @@ -0,0 +1,51 @@ +import SwiftUI +import TermindDomain + +public struct TerminalFontFields: View { + public static let sizeRange: ClosedRange = 8...36 + + @Binding private var preferences: TerminalFontPreferences + private let families: [String] + + public init(preferences: Binding, families: [String]) { + _preferences = preferences + self.families = families + } + + public var body: some View { + TerminalFontPicker(family: $preferences.family, families: families) + VStack(alignment: .leading, spacing: 8) { + Picker("Font Size", selection: $preferences.sizeFollowsTheme) { + Text("Follow Theme").tag(true) + Text("Custom").tag(false) + } + .pickerStyle(.menu) + if !preferences.sizeFollowsTheme { + sizeEditor + } + } + } + + @ViewBuilder private var sizeEditor: some View { + #if os(macOS) + HStack { + Slider(value: $preferences.size, in: Self.sizeRange, step: 1) + .labelsHidden() + .accessibilityLabel("Font Size") + .accessibilityValue(preferences.size.formatted()) + sizeLabel.frame(width: 24, alignment: .trailing) + } + #else + Stepper(value: $preferences.size, in: Self.sizeRange, step: 1) { + sizeLabel + } + .accessibilityLabel("Font Size") + .accessibilityValue(preferences.size.formatted()) + #endif + } + + private var sizeLabel: some View { + Text(preferences.size, format: .number.precision(.fractionLength(0))) + .monospacedDigit() + } +} diff --git a/Frameworks/TermindPresentation/Terminal/TerminalThemePicker.swift b/Frameworks/TermindPresentation/Terminal/TerminalThemePicker.swift new file mode 100644 index 00000000..85567091 --- /dev/null +++ b/Frameworks/TermindPresentation/Terminal/TerminalThemePicker.swift @@ -0,0 +1,44 @@ +import GhosttyTheme +import SwiftUI + +public struct TerminalThemePicker: View { + @Binding private var selection: String + private let isDark: Bool + + public init(selection: Binding, isDark: Bool) { + _selection = selection + self.isDark = isDark + } + + private var themes: [GhosttyThemeDefinition] { + TermindTerminalThemeCatalog.themes(isDark: isDark) + } + + private var selectedThemeIsAvailable: Bool { + TermindTerminalThemeCatalog.theme(named: selection)?.isDark == isDark + } + + private var fallbackName: String { + TermindTerminalThemeCatalog.fallbackTheme(isDark: isDark).name + } + + public var body: some View { + Picker("Theme", selection: $selection) { + if !selectedThemeIsAvailable { + if selection.isEmpty { + Text("No Theme Selected").tag(selection) + } else { + Text(verbatim: selection).tag(selection) + } + } + ForEach(themes, id: \.name) { theme in + Text(verbatim: theme.name).tag(theme.name) + } + } + if !selectedThemeIsAvailable { + Text("This theme is unavailable for this appearance. Using “\(Text(verbatim: fallbackName))” instead.") + .font(.caption) + .foregroundStyle(.secondary) + } + } +} diff --git a/TFI/Features/Settings/MobileTerminalSettingsView.swift b/TFI/Features/Settings/MobileTerminalSettingsView.swift index b5dc8527..34b368c4 100644 --- a/TFI/Features/Settings/MobileTerminalSettingsView.swift +++ b/TFI/Features/Settings/MobileTerminalSettingsView.swift @@ -15,21 +15,7 @@ struct MobileTerminalSettingsView: View { themeSection(dark: false) themeSection(dark: true) Section("Font") { - TerminalFontPicker(family: $font.family, families: TerminalFontCatalog.shared.families) - VStack(alignment: .leading, spacing: 8) { - Picker("Font Size", selection: $font.sizeFollowsTheme) { - Text("Follow Theme").tag(true) - Text("Custom").tag(false) - } - .pickerStyle(.menu) - if !font.sizeFollowsTheme { - Stepper(value: $font.size, in: 8...36, step: 1) { - Text(font.size, format: .number.precision(.fractionLength(0))).monospacedDigit() - } - .accessibilityLabel("Font Size") - .accessibilityValue(font.size.formatted()) - } - } + TerminalFontFields(preferences: $font, families: TerminalFontCatalog.shared.families) } Section("Integration") { Toggle("Shell Integration", isOn: binding(\.shellIntegration)) } if let error = model.storageError { Text(error).foregroundStyle(.red) } @@ -64,11 +50,7 @@ struct MobileTerminalSettingsView: View { private func themeSection(dark: Bool) -> some View { Section { - Picker("Theme", selection: binding(dark ? \.darkTheme : \.lightTheme)) { - ForEach(TermindTerminalThemeCatalog.allThemes.filter { $0.isDark == dark }, id: \.name) { - Text(verbatim: $0.name).tag($0.name) - } - } + TerminalThemePicker(selection: binding(dark ? \.darkTheme : \.lightTheme), isDark: dark) Toggle("Custom Configuration", isOn: binding(dark ? \.darkCustomEnabled : \.lightCustomEnabled)) Button("Edit Configuration", systemImage: "pencil") { customText = diff --git a/TFITests/MobileTerminalFontTests.swift b/TFITests/MobileTerminalFontTests.swift index bc5641f0..c5faf9f1 100644 --- a/TFITests/MobileTerminalFontTests.swift +++ b/TFITests/MobileTerminalFontTests.swift @@ -11,14 +11,27 @@ import UIKit struct MobileTerminalFontTests { @Test func fontCatalogIncludesOnlyAvailableMonospacedFamilies() { TerminalFontCatalog.shared.refresh() - #expect(!TerminalFontCatalog.shared.families.isEmpty) - #expect(!TerminalFontCatalog.shared.families.contains("Helvetica")) - for family in TerminalFontCatalog.shared.families { + let catalogFamilies = TerminalFontCatalog.shared.families + #expect(!catalogFamilies.isEmpty) + #expect( + catalogFamilies + == catalogFamilies.sorted { $0.localizedStandardCompare($1) == .orderedAscending }) + for family in catalogFamilies { #expect( UIFont.fontNames(forFamilyName: family).contains { UIFont(name: $0, size: 14)?.fontDescriptor.symbolicTraits.contains(.traitMonoSpace) == true }) } + + let proportionalFamily = UIFont.familyNames.first { family in + let fonts = UIFont.fontNames(forFamilyName: family).compactMap { UIFont(name: $0, size: 14) } + return !fonts.isEmpty + && fonts.allSatisfy { !$0.fontDescriptor.symbolicTraits.contains(.traitMonoSpace) } + } + #expect(proportionalFamily != nil) + if let proportionalFamily { + #expect(!catalogFamilies.contains(proportionalFamily)) + } } @Test(arguments: ["en", "zh-Hans", "zh-Hant"]) diff --git a/TFM/Features/Settings/SettingsPageView.swift b/TFM/Features/Settings/SettingsPageView.swift index dbfac41a..96120a9c 100644 --- a/TFM/Features/Settings/SettingsPageView.swift +++ b/TFM/Features/Settings/SettingsPageView.swift @@ -682,10 +682,6 @@ private struct TerminalSettings: View { @Environment(AppModel.self) private var app @TerminalFontSettings private var font @Environment(\.scenePhase) private var scenePhase - private var darkTheme: String { app.terminalSettings.preferences.darkTheme } - private var lightTheme: String { app.terminalSettings.preferences.lightTheme } - private var darkCustomEnabled: Bool { app.terminalSettings.preferences.darkCustomEnabled } - private var lightCustomEnabled: Bool { app.terminalSettings.preferences.lightCustomEnabled } private func binding(_ keyPath: WritableKeyPath) -> Binding { Binding( @@ -693,31 +689,20 @@ private struct TerminalSettings: View { set: { app.terminalSettings.set($0, for: keyPath) }) } - private var darkThemes: [GhosttyThemeDefinition] { - TermindTerminalThemeCatalog.allThemes.filter(\.isDark).sorted { $0.name < $1.name } - } - private var lightThemes: [GhosttyThemeDefinition] { - TermindTerminalThemeCatalog.allThemes.filter { !$0.isDark }.sorted { $0.name < $1.name } - } - var body: some View { Group { if let error = app.terminalSettings.storageError { Text(error).foregroundStyle(.orange) } Section("Light") { - Picker("Theme", selection: binding(\.lightTheme)) { - ForEach(lightThemes) { Text($0.name).tag($0.name) } - } - .disabled(lightCustomEnabled) + TerminalThemePicker(selection: binding(\.lightTheme), isDark: false) Toggle("Custom Configuration", isOn: binding(\.lightCustomEnabled)) FormNavigationAction(title: "Edit Custom Configuration") { editConfiguration(.light) } } Section { - Picker("Theme", selection: binding(\.darkTheme)) { ForEach(darkThemes) { Text($0.name).tag($0.name) } } - .disabled(darkCustomEnabled) + TerminalThemePicker(selection: binding(\.darkTheme), isDark: true) Toggle("Custom Configuration", isOn: binding(\.darkCustomEnabled)) FormNavigationAction(title: "Edit Custom Configuration") { editConfiguration(.dark) @@ -730,22 +715,7 @@ private struct TerminalSettings: View { ) } Section("Font") { - TerminalFontPicker(family: $font.family, families: TerminalFontCatalog.shared.families) - VStack(alignment: .leading, spacing: Metrics.Space.m) { - Picker("Font Size", selection: $font.sizeFollowsTheme) { - Text("Follow Theme").tag(true) - Text("Custom").tag(false) - } - .pickerStyle(.menu) - if !font.sizeFollowsTheme { - HStack { - Slider(value: $font.size, in: 9...24, step: 1) - .labelsHidden().accessibilityLabel("Font Size") - Text(font.size, format: .number.precision(.fractionLength(0))) - .monospacedDigit().frame(width: 24, alignment: .trailing) - } - } - } + TerminalFontFields(preferences: $font, families: TerminalFontCatalog.shared.families) } Section("Mouse") { Toggle("Copy on Selection", isOn: binding(\.copyOnSelect)) @@ -777,12 +747,6 @@ private struct TerminalSettings: View { } .onAppear { TerminalFontCatalog.shared.refresh() - if TermindTerminalThemeCatalog.darkTheme(named: darkTheme) == nil { - app.terminalSettings.set(SettingsKey.defaultTerminalThemeDark, for: \.darkTheme) - } - if TermindTerminalThemeCatalog.lightTheme(named: lightTheme) == nil { - app.terminalSettings.set(SettingsKey.defaultTerminalThemeLight, for: \.lightTheme) - } } .onChange(of: scenePhase) { _, phase in if phase == .active { TerminalFontCatalog.shared.refresh() } diff --git a/TFM/Platform/TerminalFontCatalog.swift b/TFM/Platform/TerminalFontCatalog.swift index 46e02a21..c7726182 100644 --- a/TFM/Platform/TerminalFontCatalog.swift +++ b/TFM/Platform/TerminalFontCatalog.swift @@ -10,7 +10,10 @@ final class TerminalFontCatalog { func refresh() { families = NSFontManager.shared.availableFontFamilies.filter { family in - NSFontManager.shared.font(withFamily: family, traits: [], weight: 5, size: 12)?.isFixedPitch == true + 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 + } == true }.sorted { $0.localizedStandardCompare($1) == .orderedAscending } } } diff --git a/TFMTests/TerminalFontRenderingTests.swift b/TFMTests/TerminalFontRenderingTests.swift index 12876975..5b9faa71 100644 --- a/TFMTests/TerminalFontRenderingTests.swift +++ b/TFMTests/TerminalFontRenderingTests.swift @@ -1,3 +1,4 @@ +import AppKit import GhosttyTerminal import TermindDomain import TermindPresentation @@ -44,9 +45,31 @@ struct TerminalFontRenderingTests { #expect(state.theme.dark.rendered.hasSuffix("font-family = Courier")) } - @Test func installedMonospacedFamiliesIncludeMenlo() { + @Test func fontCatalogIncludesEveryLoadableMonospacedFamilyInLocalizedOrder() { TerminalFontCatalog.shared.refresh() - #expect(TerminalFontCatalog.shared.families.contains("Menlo")) - #expect(!TerminalFontCatalog.shared.families.contains("Helvetica")) + let catalogFamilies = TerminalFontCatalog.shared.families + let manager = NSFontManager.shared + + let fixedPitchFamilies = manager.availableFontFamilies.filter { family in + NSFontDescriptor(fontAttributes: [.family: family]) + .matchingFontDescriptors(withMandatoryKeys: [.family]) + .contains { NSFont(descriptor: $0, size: 14)?.isFixedPitch == true } + } + .sorted { $0.localizedStandardCompare($1) == .orderedAscending } + + #expect(!catalogFamilies.isEmpty) + #expect(catalogFamilies == fixedPitchFamilies) + + let proportionalFamily = manager.availableFontFamilies.first { family in + let fonts = NSFontDescriptor(fontAttributes: [.family: family]) + .matchingFontDescriptors(withMandatoryKeys: [.family]) + .compactMap { NSFont(descriptor: $0, size: 14) } + return !fonts.isEmpty && fonts.allSatisfy { !$0.isFixedPitch } + } + #expect(proportionalFamily != nil) + if let proportionalFamily { + #expect(!catalogFamilies.contains(proportionalFamily)) + } + } } diff --git a/TFMTests/TerminalThemeTests.swift b/TFMTests/TerminalThemeTests.swift index 27261603..16b8ef27 100644 --- a/TFMTests/TerminalThemeTests.swift +++ b/TFMTests/TerminalThemeTests.swift @@ -1,3 +1,5 @@ +import AppKit +import SwiftUI import TermindApplication import TermindDomain import TermindInfrastructure @@ -9,6 +11,31 @@ import Testing @testable import Termind struct TerminalThemeTests { + @Test + func appearanceOptionsAreFilteredAndSortedByName() { + let lightNames = TermindTerminalThemeCatalog.lightThemes.map(\.name) + let darkNames = TermindTerminalThemeCatalog.darkThemes.map(\.name) + + #expect(lightNames == lightNames.sorted()) + #expect(darkNames == darkNames.sorted()) + #expect(TermindTerminalThemeCatalog.lightThemes.allSatisfy { !$0.isDark }) + #expect(TermindTerminalThemeCatalog.darkThemes.allSatisfy { $0.isDark }) + } + + @Test + func resolverFallsBackForMissingOrWrongAppearancePreferences() { + let preferences = TerminalPreferences( + darkTheme: SettingsKey.defaultTerminalThemeLight, + lightTheme: "Missing Light Theme" + ) + + let light = TermindTerminalThemeCatalog.resolvedTheme(named: preferences.lightTheme, isDark: false) + let dark = TermindTerminalThemeCatalog.resolvedTheme(named: preferences.darkTheme, isDark: true) + + #expect(light.name == SettingsKey.defaultTerminalThemeLight) + #expect(dark.name == SettingsKey.defaultTerminalThemeDark) + } + @Test func unavailableThemesFallBackForBothAppearances() { let light = TermindTerminalThemeCatalog.background( @@ -89,4 +116,90 @@ struct TerminalThemeTests { #expect(background == TermindTerminalPalette.termindDark.background) } + + @Test + func changingBaseThemeWithCustomOverlayPreservesOverrideAndChangesUncoveredColors() throws { + let first = TermindTerminalThemeCatalog.defaultDark + let second = try #require( + TermindTerminalThemeCatalog.darkThemes.first { + $0.name != first.name && $0.foreground != first.foreground + }) + + let firstConfiguration = TermindTerminalThemeCatalog.terminalConfiguration( + themeName: first.name, + customEnabled: true, + customText: "background = 010203", + isDark: true + ) + let secondConfiguration = TermindTerminalThemeCatalog.terminalConfiguration( + themeName: second.name, + customEnabled: true, + customText: "background = 010203", + isDark: true + ) + + #expect(firstConfiguration.rendered.hasSuffix("background = 010203")) + #expect(secondConfiguration.rendered.hasSuffix("background = 010203")) + #expect(firstConfiguration.rendered != secondConfiguration.rendered) + } + + @Test @MainActor + func presentingTerminalSettingsDoesNotRewriteUnavailableThemes() async throws { + let initial = TerminalPreferences( + darkTheme: SettingsKey.defaultTerminalThemeLight, + lightTheme: "Missing Light Theme" + ) + let persistence = RecordingTerminalPreferencesPersistence(initial: initial) + let terminalSettings = TerminalSettingsModel(persistence: persistence) + let app = AppModel(hosts: HostLibraryModel(), terminalSettings: terminalSettings) + let windowModel = WindowModel(workspaces: []) + windowModel.openSettings(route: .category(.terminal)) + let appearance = AppearanceProbe() + defer { app.shutdownForTermination() } + + let view = SettingsPageView() + .environment(app) + .environment(windowModel) + .frame(width: 640, height: 1100) + .onAppear { appearance.count += 1 } + let controller = NSHostingController(rootView: view) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 1100), + styleMask: [.borderless], backing: .buffered, defer: false + ) + window.isReleasedWhenClosed = false + window.contentViewController = controller + defer { + window.contentViewController = nil + window.close() + } + + controller.view.layoutSubtreeIfNeeded() + for _ in 0..<10 where appearance.count == 0 { + try await Task.sleep(for: .milliseconds(10)) + controller.view.layoutSubtreeIfNeeded() + } + + #expect(appearance.count > 0) + #expect(windowModel.settingsRoute == .category(.terminal)) + #expect(terminalSettings.preferences == initial) + #expect(persistence.saveCount == 0) + } +} + +@MainActor private final class RecordingTerminalPreferencesPersistence: TerminalPreferencesPersisting { + let initial: TerminalPreferences + var saveCount = 0 + + init(initial: TerminalPreferences) { + self.initial = initial + } + + func loadTerminal() throws -> TerminalPreferences { initial } + + func saveTerminal(_ value: TerminalPreferences) throws { saveCount += 1 } +} + +@MainActor private final class AppearanceProbe { + var count = 0 }