Unify terminal appearance preference controls

This commit is contained in:
Kaede Akino committed 2026-09-23 18:07:40 +08:00
1 parent 17d0a2d855
commit a4189629e6
10 files changed
+310 -71

No files matched your search

+35 -1
View File
@@ -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"
}
}
@@ -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)
@@ -0,0 +1,51 @@
import SwiftUI
import TermindDomain
public struct TerminalFontFields: View {
public static let sizeRange: ClosedRange<Double> = 8...36
@Binding private var preferences: TerminalFontPreferences
private let families: [String]
public init(preferences: Binding<TerminalFontPreferences>, 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()
}
}
@@ -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<String>, 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)
}
}
}
@@ -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 =
+16 -3
View File
@@ -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"])
+3 -39
View File
@@ -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<Value>(_ keyPath: WritableKeyPath<TerminalPreferences, Value>) -> Binding<Value> {
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() }
+4 -1
View File
@@ -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 }
}
}
+26 -3
View File
@@ -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))
}
}
}
+113
View File
@@ -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
}