feat(themes): add synced custom themes and native editors

This commit is contained in:
Kaede Akino committed 2026-10-16 17:03:57 +08:00
1 parent 7f66b3a444
commit 7d77acd3ed
49 files changed
+3029 -418

No files matched your search

@@ -0,0 +1,317 @@
import CloudKit
import Foundation
import SwiftData
import TermindApplication
import TermindDomain
import TermindInfrastructure
import TermindPersistence
import Testing
@Suite @MainActor
struct TerminalCustomThemeTests {
@Test func choicesAreIndependentAndDeletingAnActiveThemeRestoresBothFallbacks() throws {
let fixture = try Fixture()
defer { fixture.cleanUp() }
let model = TerminalSettingsModel(persistence: fixture.store)
let first = theme("First")
let second = theme("Second", appearance: .dark)
#expect(model.saveCustomTheme(first, isNew: true))
#expect(model.saveCustomTheme(second, isNew: true))
#expect(model.themeLibrary.selections.isEmpty)
#expect(model.selectTheme(named: "GitHub Light", isDark: false))
#expect(model.selectCustomTheme(id: first.id, isDark: false))
#expect(model.themeLibrary.selectedTheme(isDark: true) == nil)
#expect(!model.selectCustomTheme(id: second.id, isDark: false))
#expect(model.selectCustomTheme(id: first.id, isDark: true))
#expect(model.deleteCustomTheme(id: first.id))
#expect(model.themeLibrary.selection(isDark: false)?.builtInThemeName == "GitHub Light")
#expect(model.themeLibrary.selection(isDark: false)?.customThemeID == nil)
#expect(model.themeLibrary.selection(isDark: true)?.customThemeID == nil)
#expect(try fixture.store.loadTerminalThemes().themes == [second])
}
@Test func draftsDoNotApplyUntilSavedAndCannotResurrectDeletedThemes() {
let model = TerminalSettingsModel()
let original = theme("Personal")
#expect(model.saveCustomTheme(original, isNew: true))
let draft = TerminalCustomThemeDraft(original, isNew: false)
draft.value.configuration = "background = 123456"
#expect(model.themeLibrary.themes.first?.configuration == "")
var remote = original
remote.name = "Renamed remotely"
model.applyRemoteThemes(.init(themes: [remote]))
draft.refresh(from: model)
#expect(draft.value.name == original.name)
#expect(draft.save(in: model))
#expect(!draft.isDirty)
#expect(model.themeLibrary.themes.first?.configuration == "background = 123456")
model.applyRemoteThemes(.init())
draft.value.configuration = "background = ABCDEF"
#expect(draft.isDeleted(in: model))
#expect(!draft.save(in: model))
draft.makeCopy(name: "Recovered")
#expect(draft.id != original.id)
#expect(draft.save(in: model))
#expect(model.themeLibrary.themes.first?.name == "Recovered")
}
@Test func changingApplicabilityRepairsOnlyTheUnsupportedSelection() {
let model = TerminalSettingsModel()
var value = theme("Both")
#expect(model.saveCustomTheme(value, isNew: true))
#expect(model.selectCustomTheme(id: value.id, isDark: false))
#expect(model.selectCustomTheme(id: value.id, isDark: true))
value.appearance = .light
#expect(model.saveCustomTheme(value, isNew: false))
#expect(model.themeLibrary.selectedTheme(isDark: false)?.id == value.id)
#expect(model.themeLibrary.selection(isDark: true)?.customThemeID == nil)
}
@Test func failedLibraryWriteLeavesTheModelAndDraftUntouched() {
let store = FailingThemeStore()
let model = TerminalSettingsModel(persistence: store)
let draft = TerminalCustomThemeDraft(theme("New"), isNew: true)
draft.value.configuration = "foreground = 123456"
#expect(!draft.save(in: model))
#expect(draft.isDirty)
#expect(draft.isNew)
#expect(model.themeLibrary.themes.isEmpty)
#expect(model.storageError != nil)
store.fails = false
#expect(draft.save(in: model))
#expect(model.themeLibrary.themes.first?.configuration == "foreground = 123456")
#expect(model.storageError == nil)
}
@Test func legacyCloudImportUsesStableIDsAndDoesNotRepeat() throws {
let first = try Fixture()
let second = try Fixture()
defer {
first.cleanUp()
second.cleanUp()
}
let payload = SettingsRecordPayload.terminal(
.init(
darkCustomEnabled: true, darkCustomConfiguration: "background = 123456",
lightCustomConfiguration: "foreground = ABCDEF"))
let envelope = SettingsEnvelope(
key: payload.key, version: .init(modifiedAt: .now, counter: 0, writerID: UUID()), payload: payload)
for fixture in [first, second] {
let imported = try fixture.store.applyRemote([envelope]).snapshot.terminalThemes
#expect(imported.themes.count == 2)
#expect(imported.selectedTheme(isDark: true)?.configuration == "background = 123456")
#expect(imported.selectedTheme(isDark: false) == nil)
_ = try fixture.store.applyRemote([envelope])
try fixture.store.prepare()
#expect(try fixture.store.loadTerminalThemes().themes.count == 2)
let seeds = try fixture.store.journal.pendingEnvelopes().filter {
$0.key.kind == .terminalCustomTheme || $0.key.kind == .terminalThemeSelection
}
#expect(seeds.allSatisfy { $0.version.modifiedAt == VaultVersion.baselineDate })
}
#expect(try first.store.loadTerminalThemes() == second.store.loadTerminalThemes())
}
@Test func nativeBatchFailureRollsBackThemesAndSelectionTogether() throws {
let fixture = try Fixture()
defer { fixture.cleanUp() }
let custom = theme("Atomic")
let payloads: [SettingsRecordPayload] = [
.terminalCustomTheme(custom),
.terminalThemeSelection(
.init(appearance: .dark, builtInThemeName: "Termind Dark", customThemeID: custom.id)),
]
let envelopes = payloads.map {
SettingsEnvelope(key: $0.key, version: .init(modifiedAt: .now, counter: 0, writerID: UUID()), payload: $0)
}
#expect(throws: (any Error).self) {
try fixture.store.applyRemote(envelopes, beforeSave: { throw CocoaError(.fileWriteNoPermission) })
}
#expect(try fixture.store.loadTerminalThemes() == TerminalThemeLibrary())
for envelope in envelopes { #expect(try fixture.store.journal.envelope(for: envelope.key) == nil) }
}
@Test func nativeRecordsRoundTripWithoutJSONAndValidateTheirFields() throws {
let value = theme("Native", configuration: "background = 112233\n# UTF-8: \u{4e3b}\u{9898}")
let payloads: [SettingsRecordPayload] = [
.terminalCustomTheme(value),
.terminalThemeSelection(
.init(appearance: .light, builtInThemeName: "Termind Light", customThemeID: value.id)),
.terminalThemeSelection(.init(appearance: .dark, builtInThemeName: "Termind Dark")),
]
for payload in payloads {
let envelope = SettingsEnvelope(key: payload.key, version: .baseline(writerID: UUID()), payload: payload)
let record = try nativeRecord(envelope)
#expect(record["payload"] == nil)
#expect(record["payloadAsset"] == nil)
#expect(record["payloadHash"] == nil)
#expect(try SettingsSyncCoordinator.envelope(from: record) == envelope)
record["appearance"] = "unknown" as NSString
#expect(throws: (any Error).self) { try SettingsSyncCoordinator.envelope(from: record) }
record["formatVersion"] = NSNumber(value: 99)
#expect(throws: CloudSyncCompatibilityError.self) { try SettingsSyncCoordinator.envelope(from: record) }
}
let tombstone = SettingsEnvelope(
key: payloads[0].key, version: .baseline(writerID: UUID()), isDeleted: true)
let record = try nativeRecord(tombstone)
#expect(record["name"] == nil)
#expect(record["configuration"] == nil)
#expect(try SettingsSyncCoordinator.envelope(from: record) == tombstone)
}
@Test func largeConfigurationUsesRawUTF8AssetAndRejectsCorruption() throws {
let value = theme("Large", configuration: String(repeating: "# line\n", count: 100_001))
let payload = SettingsRecordPayload.terminalCustomTheme(value)
let envelope = SettingsEnvelope(key: payload.key, version: .baseline(writerID: UUID()), payload: payload)
let record = try nativeRecord(envelope)
let asset = try #require(record["configurationAsset"] as? CKAsset)
let url = try #require(asset.fileURL)
defer { try? FileManager.default.removeItem(at: url) }
#expect(record["configuration"] == nil)
#expect(try String(contentsOf: url, encoding: .utf8) == value.configuration)
#expect(try SettingsSyncCoordinator.envelope(from: record) == envelope)
record["configurationHash"] = "corrupt" as NSString
#expect(throws: (any Error).self) { try SettingsSyncCoordinator.envelope(from: record) }
}
@Test func selectionCanArriveBeforeThemeAndLegacyWritesDoNotEraseTheLibrary() throws {
let source = try Fixture()
let destination = try Fixture()
defer {
source.cleanUp()
destination.cleanUp()
}
let model = TerminalSettingsModel(persistence: source.store)
let value = theme("Synced")
#expect(model.saveCustomTheme(value, isNew: true))
#expect(model.selectCustomTheme(id: value.id, isDark: false))
let entries = try source.store.journal.pendingEnvelopes()
let choices = entries.filter { $0.key.kind == .terminalThemeSelection }
let themes = entries.filter { $0.key.kind == .terminalCustomTheme }
let pending = try destination.store.applyRemote(choices).snapshot.terminalThemes
#expect(pending.selection(isDark: false)?.customThemeID == value.id)
#expect(pending.selectedTheme(isDark: false) == nil)
let resolved = try destination.store.applyRemote(themes).snapshot.terminalThemes
#expect(resolved.selectedTheme(isDark: false)?.id == value.id)
#expect(try !destination.store.journal.pendingEnvelopes().contains { $0.key.kind == .terminalCustomTheme })
let legacy = SettingsRecordPayload.terminal(.init(lightTheme: "Other Legacy Theme"))
let oldWriter = SettingsEnvelope(
key: legacy.key, version: .init(modifiedAt: .distantFuture, counter: 0, writerID: UUID()), payload: legacy)
let afterLegacy = try destination.store.applyRemote([oldWriter]).snapshot.terminalThemes
#expect(afterLegacy == resolved)
#expect(model.deleteCustomTheme(id: value.id))
let deletion = try source.store.journal.pendingEnvelopes().filter {
$0.key.kind == .terminalCustomTheme || $0.key.kind == .terminalThemeSelection
}
let removed = try destination.store.applyRemote(deletion).snapshot.terminalThemes
#expect(removed.themes.isEmpty)
#expect(removed.selection(isDark: false)?.customThemeID == nil)
try destination.store.prepare()
#expect(try destination.store.loadTerminalThemes().themes.isEmpty)
}
@Test func independentDeviceEditsMergePerThemeRatherThanReplacingTheWholeLibrary() throws {
let first = try Fixture()
let second = try Fixture()
defer {
first.cleanUp()
second.cleanUp()
}
let a = theme("A")
let b = theme("B")
try first.store.saveTerminalThemes(.init(themes: [a, b]))
_ = try second.store.applyRemote(first.store.journal.pendingEnvelopes())
var left = try first.store.loadTerminalThemes()
var right = try second.store.loadTerminalThemes()
left.themes[left.themes.firstIndex(where: { $0.id == a.id })!].name = "A edited"
right.themes[right.themes.firstIndex(where: { $0.id == b.id })!].name = "B edited"
try first.store.saveTerminalThemes(left)
try second.store.saveTerminalThemes(right)
let leftChanges = try first.store.journal.pendingEnvelopes()
let rightChanges = try second.store.journal.pendingEnvelopes()
_ = try first.store.applyRemote(rightChanges)
_ = try second.store.applyRemote(leftChanges)
for fixture in [first, second] {
let names = Set(try fixture.store.loadTerminalThemes().themes.map(\.name))
#expect(names == ["A edited", "B edited"])
}
}
@Test func historicalCustomConfigurationsMigrateOnceAndSurviveStoreReopening() throws {
let directory = FileManager.default.temporaryDirectory.appending(path: "ThemeMigration-\(UUID())")
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let url = directory.appending(path: "store.sqlite")
let defaults = UserDefaults(suiteName: "ThemeMigration-\(UUID())")!
let schema = Schema(versionedSchema: TermindSchemaV56.self)
var old: ModelContainer? = try ModelContainer(
for: schema, configurations: ModelConfiguration(schema: schema, url: url, cloudKitDatabase: .none))
old!.mainContext.insert(
try PersistentTerminalPreferences(
.init(
darkCustomEnabled: true, darkCustomConfiguration: "background = 123456",
lightCustomConfiguration: "foreground = ABCDEF")))
try old!.mainContext.save()
old = nil
var retained: TerminalThemeLibrary?
do {
let container = try TermindPersistence.makeContainer(url: url)
let store = SettingsSyncPersistence(context: container.mainContext, defaults: defaults)
try store.prepare()
retained = try store.loadTerminalThemes()
#expect(retained?.themes.count == 2)
#expect(retained?.selectedTheme(isDark: true)?.configuration == "background = 123456")
#expect(retained?.selectedTheme(isDark: false) == nil)
try store.prepare()
#expect(try store.loadTerminalThemes() == retained)
}
let reopened = try TermindPersistence.makeContainer(url: url)
let store = SettingsSyncPersistence(context: reopened.mainContext, defaults: defaults)
try store.prepare()
#expect(try store.loadTerminalThemes() == retained)
let model = TerminalSettingsModel(persistence: store)
for value in model.themeLibrary.themes { #expect(model.deleteCustomTheme(id: value.id)) }
try store.prepare()
#expect(try store.loadTerminalThemes().themes.isEmpty)
}
private func theme(
_ name: String, appearance: TerminalThemeAppearance = .both, configuration: String = ""
) -> TerminalCustomTheme {
.init(name: name, appearance: appearance, baseThemeName: "Termind Dark", configuration: configuration)
}
private func nativeRecord(_ envelope: SettingsEnvelope) throws -> CKRecord {
try SettingsSyncCoordinator.makeRecord(
.init(envelope: envelope),
recordID: .init(
recordName: envelope.key.recordName,
zoneID: .init(zoneName: SettingsSyncCoordinator.zoneName, ownerName: CKCurrentUserDefaultName)))
}
private struct Fixture {
let container: ModelContainer
let store: SettingsSyncPersistence
let domain = "TerminalCustomThemeTests.\(UUID())"
let defaults: UserDefaults
init() throws {
container = try TermindPersistence.makeContainer(inMemory: true)
defaults = UserDefaults(suiteName: domain)!
store = SettingsSyncPersistence(context: container.mainContext, defaults: defaults)
try store.prepare()
}
func cleanUp() { defaults.removePersistentDomain(forName: domain) }
}
}
@MainActor private final class FailingThemeStore: TerminalPreferencesPersisting {
var fails = true
var usesNativeTerminalThemes: Bool { true }
func loadTerminal() throws -> TerminalPreferences { .init() }
func saveTerminal(_ value: TerminalPreferences) throws {}
func saveTerminalThemes(_ value: TerminalThemeLibrary) throws {
if fails { throw CocoaError(.fileWriteNoPermission) }
}
}
+533
View File
@@ -5097,6 +5097,47 @@
}
}
},
"ANSI Palette" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "ANSI-Palette"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Palette ANSI"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "ANSIパレット"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "ANSI 팔레트"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "ANSI色板"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "ANSI色盤"
}
}
}
},
"API Key" : {
"extractionState" : "manual",
"localizations" : {
@@ -8573,6 +8614,47 @@
}
}
},
"Appearances using it will return to their previous built-in theme." : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Die betroffenen Erscheinungsbilder verwenden wieder ihr vorheriges integriertes Design."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Les apparences concernées reprendront leur thème intégré précédent."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "使用中の外観は以前の内蔵テーマに戻ります。"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "이 테마를 사용하는 모드는 이전 기본 제공 테마로 돌아갑니다."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "使用它的外观模式将恢复为之前的内置主题。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "使用它的外觀模式將恢復為先前的內建主題。"
}
}
}
},
"Apple Speech Recognition could not transcribe this recording." : {
"extractionState" : "manual",
"localizations" : {
@@ -9065,6 +9147,47 @@
}
}
},
"Are you sure you want to discard this new custom theme?" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Möchtest du dieses neue eigene Design wirklich verwerfen?"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Voulez-vous vraiment abandonner ce nouveau thème personnalisé ?"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "この新しいカスタムテーマを破棄してもよろしいですか?"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "이 새 사용자 설정 테마를 삭제할까요?"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "确定要放弃这个新建自定义主题吗?"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "確定要放棄這個新建自訂主題嗎?"
}
}
}
},
"Are you sure you want to discard this new environment variable?" : {
"extractionState" : "manual",
"localizations" : {
@@ -11589,6 +11712,47 @@
}
}
},
"Both" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Beide"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Les deux"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "両方"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "둘 다"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "两者"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "兩者"
}
}
}
},
"Broadcast input" : {
"extractionState" : "manual",
"localizations" : {
@@ -11712,6 +11876,47 @@
}
}
},
"Built-in Themes" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Integrierte Designs"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Thèmes intégrés"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "内蔵テーマ"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "기본 제공 테마"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "内置主题"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "內建主題"
}
}
}
},
"Buy Termind Pro" : {
"comment" : "A button that purchases Termind Pro.",
"extractionState" : "manual",
@@ -19861,6 +20066,47 @@
}
}
},
"Create a custom theme to get started." : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Erstelle zunächst ein eigenes Design."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Créez un thème personnalisé pour commencer."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "カスタムテーマを作成して始めましょう。"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "사용자 설정 테마를 만들어 시작하세요."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "创建一个自定义主题以开始使用。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "建立一個自訂主題以開始使用。"
}
}
}
},
"Create a forwarding rule, then start it manually." : {
"extractionState" : "manual",
"localizations" : {
@@ -20107,6 +20353,47 @@
}
}
},
"Cursor" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Cursor"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Curseur"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "カーソル"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "커서"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "光标"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "游標"
}
}
}
},
"Custom" : {
"extractionState" : "manual",
"localizations" : {
@@ -20271,6 +20558,47 @@
}
}
},
"Custom Themes" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Eigene Designs"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Thèmes personnalisés"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "カスタムテーマ"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "사용자 설정 테마"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "自定义主题"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "自訂主題"
}
}
}
},
"Custom headers are stored as plain text. For secrets, use the bearer token field above." : {
"extractionState" : "manual",
"localizations" : {
@@ -22590,6 +22918,47 @@
}
}
},
"Delete this custom theme?" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Dieses eigene Design löschen?"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Supprimer ce thème personnalisé ?"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "このカスタムテーマを削除しますか?"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "이 사용자 설정 테마를 삭제할까요?"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "删除此自定义主题?"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "刪除此自訂主題?"
}
}
}
},
"Delete this logging file?" : {
"extractionState" : "manual",
"localizations" : {
@@ -41605,6 +41974,47 @@
}
}
},
"New Custom Theme" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Neues eigenes Design"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nouveau thème personnalisé"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "新規カスタムテーマ"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "새 사용자 설정 테마"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "新建自定义主题"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "新增自訂主題"
}
}
}
},
"New Environment Variable" : {
"extractionState" : "manual",
"localizations" : {
@@ -60414,6 +60824,47 @@
}
}
},
"Save as New Theme" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Als neues Design speichern"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Enregistrer comme nouveau thème"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "新しいテーマとして保存"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "새 테마로 저장"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "另存为新主题"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "另存為新主題"
}
}
}
},
"Save as Snippet" : {
"extractionState" : "manual",
"localizations" : {
@@ -62074,6 +62525,47 @@
}
}
},
"Selected text" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ausgewählter Text"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Texte sélectionné"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "選択したテキスト"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "선택한 텍스트"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "选中文本"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "選取文字"
}
}
}
},
"Send" : {
"extractionState" : "manual",
"localizations" : {
@@ -77352,6 +77844,47 @@
}
}
},
"This theme was deleted on another device. You can save your changes as a new theme." : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Dieses Design wurde auf einem anderen Gerät gelöscht. Du kannst deine Änderungen als neues Design speichern."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ce thème a été supprimé sur un autre appareil. Vous pouvez enregistrer vos modifications dans un nouveau thème."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "このテーマは別のデバイスで削除されました。変更を新しいテーマとして保存できます。"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "다른 기기에서 이 테마를 삭제했습니다. 변경 사항을 새 테마로 저장할 수 있습니다."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "此主题已在其他设备上删除。你可以将更改另存为新主题。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "此主題已在其他裝置上刪除。你可以將變更另存為新主題。"
}
}
}
},
"This version cannot safely process some iCloud records. Their local changes are kept on this device." : {
"extractionState" : "manual",
"localizations" : {
@@ -0,0 +1,43 @@
import Foundation
import TermindDomain
@Observable
public final class TerminalCustomThemeDraft: Identifiable {
public var value: TerminalCustomTheme
public private(set) var original: TerminalCustomTheme
public private(set) var isNew: Bool
public var id: UUID { value.id }
public var isDirty: Bool { value != original }
public init(_ value: TerminalCustomTheme, isNew: Bool) {
self.value = value
original = value
self.isNew = isNew
}
public func refresh(from model: TerminalSettingsModel) {
guard !isNew, !isDirty, let remote = model.themeLibrary.themes.first(where: { $0.id == id }) else { return }
original = remote
value = remote
}
public func isDeleted(in model: TerminalSettingsModel) -> Bool {
!isNew && !model.themeLibrary.themes.contains { $0.id == id }
}
public func save(in model: TerminalSettingsModel) -> Bool {
var saved = value
saved.name = saved.name.trimmingCharacters(in: .whitespacesAndNewlines)
guard model.saveCustomTheme(saved, isNew: isNew) else { return false }
value = saved
original = saved
isNew = false
return true
}
public func makeCopy(name: String) {
value.id = UUID()
value.name = name
isNew = true
}
}
@@ -5,12 +5,18 @@ import TermindDomain
@Observable
public final class TerminalSettingsModel {
public private(set) var preferences = TerminalPreferences()
public private(set) var themeLibrary = TerminalThemeLibrary()
public private(set) var storageError: String?
private let persistence: (any TerminalPreferencesPersisting)?
public init(persistence: (any TerminalPreferencesPersisting)? = nil) {
self.persistence = persistence
do { if let persistence { preferences = try persistence.loadTerminal() } } catch {
do {
if let persistence {
preferences = try persistence.loadTerminal()
themeLibrary = try persistence.loadTerminalThemes()
}
} catch {
storageError = error.localizedDescription
}
}
@@ -25,6 +31,11 @@ public final class TerminalSettingsModel {
/// A theme choice is one persisted edit, including disabling the custom overlay.
@discardableResult
public func selectTheme(named name: String, isDark: Bool) -> Bool {
if persistence?.usesNativeTerminalThemes == true || !themeLibrary.selections.isEmpty {
var next = themeLibrary
next.select(.init(appearance: isDark ? .dark : .light, builtInThemeName: name))
return saveLibrary(next)
}
var next = preferences
if isDark {
next.darkTheme = name
@@ -57,4 +68,56 @@ public final class TerminalSettingsModel {
preferences = value
storageError = nil
}
public func applyRemoteThemes(_ value: TerminalThemeLibrary) {
themeLibrary = value
storageError = nil
}
@discardableResult
public func saveCustomTheme(_ value: TerminalCustomTheme, isNew: Bool) -> Bool {
guard value.isValid, isNew != themeLibrary.themes.contains(where: { $0.id == value.id }) else { return false }
var next = themeLibrary
next.themes.removeAll { $0.id == value.id }
next.themes.append(value)
next.repairSelections(for: value.id)
return saveLibrary(next)
}
@discardableResult
public func deleteCustomTheme(id: UUID) -> Bool {
var next = themeLibrary
next.themes.removeAll { $0.id == id }
next.repairSelections(for: id)
return saveLibrary(next)
}
@discardableResult
public func selectCustomTheme(id: UUID, isDark: Bool) -> Bool {
guard themeLibrary.themes.contains(where: { $0.id == id && $0.appearance.includes(isDark: isDark) }) else {
return false
}
var next = themeLibrary
let legacyBase = isDark ? preferences.darkTheme : preferences.lightTheme
var selection =
next.selection(isDark: isDark)
?? .init(
appearance: isDark ? .dark : .light,
builtInThemeName: legacyBase.isEmpty ? (isDark ? "Termind Dark" : "Termind Light") : legacyBase)
selection.customThemeID = id
next.select(selection)
return saveLibrary(next)
}
private func saveLibrary(_ value: TerminalThemeLibrary) -> Bool {
guard value != themeLibrary else { return true }
do {
try persistence?.saveTerminalThemes(value)
applyRemoteThemes(value)
return true
} catch {
storageError = error.localizedDescription
return false
}
}
}
@@ -3,6 +3,17 @@ import TermindDomain
@MainActor
public protocol TerminalPreferencesPersisting: AnyObject {
var usesNativeTerminalThemes: Bool { get }
func loadTerminal() throws -> TerminalPreferences
func saveTerminal(_ value: TerminalPreferences) throws
func loadTerminalThemes() throws -> TerminalThemeLibrary
func saveTerminalThemes(_ value: TerminalThemeLibrary) throws
}
extension TerminalPreferencesPersisting {
public var usesNativeTerminalThemes: Bool { false }
public func loadTerminalThemes() throws -> TerminalThemeLibrary { .init() }
public func saveTerminalThemes(_ value: TerminalThemeLibrary) throws {
throw CocoaError(.featureUnsupported)
}
}
@@ -0,0 +1,81 @@
import Foundation
public nonisolated enum TerminalThemeAppearance: String, Codable, CaseIterable, Sendable {
case light, dark, both
public func includes(isDark: Bool) -> Bool {
self == .both || self == (isDark ? .dark : .light)
}
}
public nonisolated struct TerminalCustomTheme: Codable, Hashable, Identifiable, Sendable {
public var id: UUID
public var name: String
public var appearance: TerminalThemeAppearance
public var baseThemeName: String
public var configuration: String
public init(
id: UUID = UUID(), name: String, appearance: TerminalThemeAppearance,
baseThemeName: String, configuration: String = ""
) {
self.id = id
self.name = name
self.appearance = appearance
self.baseThemeName = baseThemeName
self.configuration = configuration
}
public var isValid: Bool {
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !baseThemeName.isEmpty
}
}
/// The built-in name also remembers the fallback while a custom theme is selected.
public nonisolated struct TerminalThemeSelection: Codable, Hashable, Sendable {
public var appearance: TerminalThemeAppearance
public var builtInThemeName: String
public var customThemeID: UUID?
public init(appearance: TerminalThemeAppearance, builtInThemeName: String, customThemeID: UUID? = nil) {
self.appearance = appearance
self.builtInThemeName = builtInThemeName
self.customThemeID = customThemeID
}
public var isValid: Bool { appearance != .both && !builtInThemeName.isEmpty }
}
public nonisolated struct TerminalThemeLibrary: Equatable, Sendable {
public var themes: [TerminalCustomTheme]
public var selections: [TerminalThemeSelection]
public init(themes: [TerminalCustomTheme] = [], selections: [TerminalThemeSelection] = []) {
self.themes = themes
self.selections = selections
}
public func selection(isDark: Bool) -> TerminalThemeSelection? {
selections.first { $0.appearance == (isDark ? .dark : .light) }
}
public func selectedTheme(isDark: Bool) -> TerminalCustomTheme? {
guard let id = selection(isDark: isDark)?.customThemeID else { return nil }
return themes.first { $0.id == id && $0.appearance.includes(isDark: isDark) }
}
public mutating func select(_ selection: TerminalThemeSelection) {
selections.removeAll { $0.appearance == selection.appearance }
selections.append(selection)
selections.sort { $0.appearance.rawValue < $1.appearance.rawValue }
}
public mutating func repairSelections(for id: UUID) {
for index in selections.indices where selections[index].customThemeID == id {
let isDark = selections[index].appearance == .dark
if !themes.contains(where: { $0.id == id && $0.appearance.includes(isDark: isDark) }) {
selections[index].customThemeID = nil
}
}
}
}
@@ -161,6 +161,7 @@ public nonisolated struct AIWebSearchPreferences: Codable, Hashable, Sendable {
public nonisolated enum SettingsRecordKind: String, Codable, CaseIterable, Sendable {
case provider, mcpServer, skill, role, selection, webSearch, terminal, ssh, sftp, workspace, notifications
case terminalHighlighting
case terminalCustomTheme, terminalThemeSelection
/// Retired record identifiers are recognized so their data can be skipped without decoding.
public var participatesInSync: Bool { self != .selection && self != .terminalHighlighting }
@@ -193,10 +194,12 @@ public nonisolated struct SettingsRecordKey: Codable, Hashable, Sendable {
else { return nil }
let id = String(parts[2])
switch kind {
case .provider, .mcpServer, .skill:
case .provider, .mcpServer, .skill, .terminalCustomTheme:
guard let uuid = UUID(uuidString: id), uuid.uuidString.lowercased() == id else { return nil }
case .role:
guard AIRole(rawValue: id) != nil || id == Self.legacySecurityReviewID else { return nil }
case .terminalThemeSelection:
guard id == "light" || id == "dark" else { return nil }
case .selection, .webSearch, .terminal, .ssh, .sftp, .workspace, .notifications, .terminalHighlighting:
guard id == "default" else { return nil }
}
@@ -212,6 +215,8 @@ public nonisolated enum SettingsRecordPayload: Codable, Hashable, Sendable {
case selection(AIModelRef?)
case webSearch(AIWebSearchPreferences)
case terminal(TerminalPreferences)
case terminalCustomTheme(TerminalCustomTheme)
case terminalThemeSelection(TerminalThemeSelection)
case ssh(SSHPreferences)
case sftp(SFTPPreferences)
case workspace(WorkspacePreferences)
@@ -226,6 +231,10 @@ public nonisolated enum SettingsRecordPayload: Codable, Hashable, Sendable {
case .selection: .init(kind: .selection, id: "default")
case .webSearch: .init(kind: .webSearch, id: "default")
case .terminal: .init(kind: .terminal, id: "default")
case .terminalCustomTheme(let value):
.init(kind: .terminalCustomTheme, id: value.id.uuidString.lowercased())
case .terminalThemeSelection(let value):
.init(kind: .terminalThemeSelection, id: value.appearance.rawValue)
case .ssh: .init(kind: .ssh, id: "default")
case .sftp: .init(kind: .sftp, id: "default")
case .workspace: .init(kind: .workspace, id: "default")
@@ -255,6 +264,8 @@ public nonisolated struct SettingsEnvelope: Codable, Hashable, Sendable {
}
public var isValid: Bool {
if case .terminalCustomTheme(let value) = payload, !value.isValid { return false }
if case .terminalThemeSelection(let value) = payload, !value.isValid { return false }
if case .notifications(let value) = payload, !value.isValid { return false }
if case .ssh(let value) = payload, !value.isValid { return false }
if case .sftp(let value) = payload, !value.isValid { return false }
@@ -250,7 +250,12 @@ public final class SettingsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unc
decode: Self.validatedEnvelope,
quarantine: {
try CloudSyncRecordCoding.quarantine(
$0, error: $1, in: safety)
$0, error: $1, in: safety,
nativeFields: $0.recordType == TerminalThemeCloudRecordV1.themeRecordType
|| $0.recordType == TerminalThemeCloudRecordV1.selectionRecordType
|| SettingsRecordKey(recordName: $0.recordID.recordName).map {
TerminalThemeCloudRecordV1.recordType(for: $0.kind) != nil
} == true)
},
logFailure: { lifecycle.logFailure($0, stage: "decode-quarantine") })
let result = try applyRemote(
@@ -325,6 +330,8 @@ public final class SettingsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unc
nonisolated static func acceptsRecordType(_ type: String) -> Bool {
type == recordType || type == sshRecordType || type == sftpRecordType || type == workspaceRecordType
|| type == notificationRecordType
|| type == TerminalThemeCloudRecordV1.themeRecordType
|| type == TerminalThemeCloudRecordV1.selectionRecordType
}
private nonisolated static func recordType(for kind: SettingsRecordKind) -> String {
@@ -334,6 +341,8 @@ public final class SettingsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unc
case .workspace: workspaceRecordType
case .notifications: notificationRecordType
case .terminalHighlighting: terminalHighlightRecordType
case .terminalCustomTheme: TerminalThemeCloudRecordV1.themeRecordType
case .terminalThemeSelection: TerminalThemeCloudRecordV1.selectionRecordType
default: recordType
}
}
@@ -347,6 +356,10 @@ public final class SettingsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unc
guard record.recordType == recordType(for: envelope.key.kind), record.recordID == recordID else {
throw SettingsSyncPersistenceError.invalidEnvelope(recordID.recordName)
}
if TerminalThemeCloudRecordV1.recordType(for: envelope.key.kind) != nil {
try TerminalThemeCloudRecordV1.write(envelope, into: record, assetURL: { try assetURL(for: envelope.key) })
return record
}
CloudSyncRecordCoding.setMetadata(
on: record, formatVersion: envelope.formatVersion, kind: envelope.key.kind.rawValue,
entityID: envelope.key.id, version: envelope.version, isDeleted: envelope.isDeleted)
@@ -360,6 +373,11 @@ public final class SettingsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unc
}
private nonisolated static func validatedEnvelope(from record: CKRecord) throws -> SettingsEnvelope {
if record.recordType == TerminalThemeCloudRecordV1.themeRecordType
|| record.recordType == TerminalThemeCloudRecordV1.selectionRecordType
{
return try TerminalThemeCloudRecordV1.read(record)
}
let data = try CloudSyncRecordCoding.validatedPayloadData(
from: record, invalidEnvelope: SettingsSyncPersistenceError.invalidEnvelope(record.recordID.recordName))
try CloudSyncRecordCoding.validateFormat(in: data, supported: SettingsEnvelope.currentFormatVersion)
@@ -0,0 +1,128 @@
import CloudKit
import CryptoKit
import Foundation
import TermindDomain
import TermindPersistence
/// Frozen native-field contract. Journal JSON is deliberately not the cloud representation.
nonisolated enum TerminalThemeCloudRecordV1 {
static let themeRecordType = "TerminalCustomThemeItem"
static let selectionRecordType = "TerminalThemeSelectionItem"
static func recordType(for kind: SettingsRecordKind) -> String? {
switch kind {
case .terminalCustomTheme: themeRecordType
case .terminalThemeSelection: selectionRecordType
default: nil
}
}
static func write(_ envelope: SettingsEnvelope, into record: CKRecord, assetURL: () throws -> URL) throws {
guard envelope.isValid, envelope.version.counter <= UInt64(Int64.max),
record.recordType == recordType(for: envelope.key.kind),
record.recordID.recordName == envelope.key.recordName
else { throw SettingsSyncPersistenceError.invalidEnvelope(record.recordID.recordName) }
CloudSyncRecordCoding.setMetadata(
on: record, formatVersion: 1, kind: envelope.key.kind.rawValue, entityID: envelope.key.id,
version: envelope.version, isDeleted: envelope.isDeleted)
let fields =
envelope.key.kind == .terminalCustomTheme
? ["name", "appearance", "baseThemeName", "configuration", "configurationAsset", "configurationHash"]
: ["appearance", "selectionKind", "builtInThemeName", "customThemeID"]
for field in fields { record[field] = nil }
switch envelope.payload {
case .terminalCustomTheme(let value):
record["name"] = value.name as NSString
record["appearance"] = value.appearance.rawValue as NSString
record["baseThemeName"] = value.baseThemeName as NSString
let data = Data(value.configuration.utf8)
record["configurationHash"] = hash(data) as NSString
if data.count > CloudSyncRecordCoding.inlinePayloadLimit {
let url = try assetURL()
try data.write(to: url, options: .atomic)
record["configurationAsset"] = CKAsset(fileURL: url)
} else {
record["configuration"] = value.configuration as NSString
}
case .terminalThemeSelection(let value):
record["appearance"] = value.appearance.rawValue as NSString
record["selectionKind"] = (value.customThemeID == nil ? "builtin" : "custom") as NSString
record["builtInThemeName"] = value.builtInThemeName as NSString
record["customThemeID"] = value.customThemeID?.uuidString.lowercased() as NSString?
case nil: break
default: throw SettingsSyncPersistenceError.invalidEnvelope(record.recordID.recordName)
}
}
static func read(_ record: CKRecord) throws -> SettingsEnvelope {
let invalid = SettingsSyncPersistenceError.invalidEnvelope(record.recordID.recordName)
guard let format = record["formatVersion"] as? NSNumber, format == NSNumber(value: format.intValue) else {
throw invalid
}
guard format.intValue == 1 else { throw CloudSyncCompatibilityError.unsupportedFormat(format.intValue) }
guard let key = SettingsRecordKey(recordName: record.recordID.recordName),
record.recordType == recordType(for: key.kind), record["kind"] as? String == key.kind.rawValue,
record["entityID"] as? String == key.id,
let date = record["modifiedAt"] as? Date,
let counter = record["counter"] as? NSNumber, counter.int64Value >= 0,
counter == NSNumber(value: counter.int64Value),
let writer = record["writerID"] as? String, let writerID = UUID(uuidString: writer),
let deleted = record["isDeleted"] as? NSNumber,
deleted == NSNumber(value: 0) || deleted == NSNumber(value: 1)
else { throw invalid }
var payload: SettingsRecordPayload?
if !deleted.boolValue {
guard let rawAppearance = record["appearance"] as? String,
let appearance = TerminalThemeAppearance(rawValue: rawAppearance)
else { throw invalid }
switch key.kind {
case .terminalCustomTheme:
guard let id = UUID(uuidString: key.id), let name = record["name"] as? String,
let base = record["baseThemeName"] as? String
else { throw invalid }
let data: Data
if let text = record["configuration"] as? String {
guard record["configurationAsset"] == nil else { throw invalid }
data = Data(text.utf8)
} else if record["configuration"] == nil,
let asset = record["configurationAsset"] as? CKAsset, let url = asset.fileURL
{
data = try Data(contentsOf: url)
} else {
throw invalid
}
guard record["configurationHash"] as? String == hash(data),
let text = String(data: data, encoding: .utf8)
else { throw invalid }
payload = .terminalCustomTheme(
.init(id: id, name: name, appearance: appearance, baseThemeName: base, configuration: text))
case .terminalThemeSelection:
guard appearance.rawValue == key.id, let base = record["builtInThemeName"] as? String,
let kind = record["selectionKind"] as? String
else { throw invalid }
let id: UUID?
if kind == "custom" {
guard let raw = record["customThemeID"] as? String, let parsed = UUID(uuidString: raw) else {
throw invalid
}
id = parsed
} else {
guard kind == "builtin", record["customThemeID"] == nil else { throw invalid }
id = nil
}
payload = .terminalThemeSelection(
.init(appearance: appearance, builtInThemeName: base, customThemeID: id))
default: throw invalid
}
}
let envelope = SettingsEnvelope(
key: key, version: .init(modifiedAt: date, counter: counter.uint64Value, writerID: writerID),
isDeleted: deleted.boolValue, payload: payload)
guard envelope.isValid else { throw invalid }
return envelope
}
private static func hash(_ data: Data) -> String {
SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
}
}
@@ -992,6 +992,14 @@ public enum TermindSchemaV56: VersionedSchema {
}
}
/// Stores named terminal themes and independent appearance selections as native fields.
public enum TermindSchemaV57: VersionedSchema {
public static let versionIdentifier = Schema.Version(57, 0, 0)
public static var models: [any PersistentModel.Type] {
TermindSchemaV56.models + [PersistentTerminalCustomTheme.self, PersistentTerminalThemeSelection.self]
}
}
public enum TermindMigrationPlan: SchemaMigrationPlan {
public static var schemas: [any VersionedSchema.Type] {
[
@@ -1009,7 +1017,7 @@ public enum TermindMigrationPlan: SchemaMigrationPlan {
TermindSchemaV42.self, TermindSchemaV43.self, TermindSchemaV44.self, TermindSchemaV45.self,
TermindSchemaV46.self, TermindSchemaV47.self, TermindSchemaV48.self, TermindSchemaV49.self,
TermindSchemaV50.self, TermindSchemaV51.self, TermindSchemaV52.self, TermindSchemaV53.self,
TermindSchemaV54.self, TermindSchemaV55.self, TermindSchemaV56.self,
TermindSchemaV54.self, TermindSchemaV55.self, TermindSchemaV56.self, TermindSchemaV57.self,
]
}
@@ -1125,6 +1133,7 @@ public enum TermindMigrationPlan: SchemaMigrationPlan {
MigrationStage.lightweight(fromVersion: TermindSchemaV53.self, toVersion: TermindSchemaV54.self),
MigrationStage.lightweight(fromVersion: TermindSchemaV54.self, toVersion: TermindSchemaV55.self),
MigrationStage.lightweight(fromVersion: TermindSchemaV55.self, toVersion: TermindSchemaV56.self),
MigrationStage.lightweight(fromVersion: TermindSchemaV56.self, toVersion: TermindSchemaV57.self),
]
}
}
@@ -9,6 +9,7 @@ public nonisolated struct SettingsSyncSnapshot: Sendable {
public var skills: [AgentSkill]
public var webSearch: AIWebSearchPreferences
public var terminal = TerminalPreferences()
public var terminalThemes = TerminalThemeLibrary()
public var ssh = SSHPreferences()
public var sftp = SFTPPreferences()
public var workspace = WorkspacePreferences()
@@ -18,12 +19,13 @@ public nonisolated struct SettingsSyncSnapshot: Sendable {
configuration: AIConfigurationSnapshot, skills: [AgentSkill], webSearch: AIWebSearchPreferences,
terminal: TerminalPreferences = .init(), ssh: SSHPreferences = .init(),
sftp: SFTPPreferences = .init(), workspace: WorkspacePreferences = .init(),
notifications: NotificationPreferences = .init()
notifications: NotificationPreferences = .init(), terminalThemes: TerminalThemeLibrary = .init()
) {
self.configuration = configuration
self.skills = skills
self.webSearch = webSearch
self.terminal = terminal
self.terminalThemes = terminalThemes
self.ssh = ssh
self.sftp = sftp
self.workspace = workspace
@@ -47,6 +49,8 @@ public nonisolated struct SettingsSyncSnapshot: Sendable {
for role in AIRole.allCases { payloads.append(.role(role, configuration.roles[role])) }
payloads.append(.webSearch(webSearch))
payloads.append(.terminal(terminal))
payloads += terminalThemes.themes.map(SettingsRecordPayload.terminalCustomTheme)
payloads += terminalThemes.selections.map(SettingsRecordPayload.terminalThemeSelection)
payloads.append(.ssh(ssh))
payloads.append(.sftp(sftp))
payloads.append(.workspace(workspace))
@@ -78,6 +82,7 @@ public final class SettingsSyncPersistence {
try context.commit {
mutations.keys.formUnion(try journal.migrateLegacyGuardianRole().keys)
try journal.discardInactiveEntries()
mutations.keys.formUnion(try migrateTerminalThemes(loadTerminal()))
let previous = try load()
mutations.keys.formUnion(try journal.bootstrapIfNeeded(previous).keys)
// Add preferences introduced after an installed store completed its bootstrap.
@@ -129,7 +134,8 @@ public final class SettingsSyncPersistence {
configuration: try ai.load(preservingUnresolvedReferences: true),
skills: try skills.load(), webSearch: try preferences().value, terminal: try terminalPreferences().load(),
ssh: try sshPreferences().load(), sftp: try sftpPreferences().load(),
workspace: try workspacePreferences().load(), notifications: try notificationPreferences().load())
workspace: try workspacePreferences().load(), notifications: try notificationPreferences().load(),
terminalThemes: try loadTerminalThemes())
}
public func saveConfiguration(_ configuration: AIConfigurationSnapshot) throws {
@@ -145,7 +151,7 @@ public final class SettingsSyncPersistence {
mirror(value)
}
private func saveLocal(_ change: () throws -> Void) throws {
func saveLocal(_ change: () throws -> Void) throws {
var mutations = SettingsMutationSet.empty
try context.commit {
let previous = try load()
@@ -192,6 +198,11 @@ public final class SettingsSyncPersistence {
return value.id
})
snapshot = try materialize()
let migrated = try migrateTerminalThemes(snapshot.terminal)
if !migrated.isEmpty {
merge.needsUpload.formUnion(migrated)
snapshot = try materialize()
}
let remoteSnapshot = snapshot
for index in snapshot.configuration.providers.indices {
let id = snapshot.configuration.providers[index].id
@@ -217,6 +228,7 @@ public final class SettingsSyncPersistence {
try skills.replace(snapshot.skills)
try preferences().apply(snapshot.webSearch)
try terminalPreferences().apply(snapshot.terminal)
try applyTerminalThemes(snapshot.terminalThemes)
try sshPreferences().apply(snapshot.ssh)
try sftpPreferences().apply(snapshot.sftp)
try workspacePreferences().apply(snapshot.workspace)
@@ -242,6 +254,7 @@ public final class SettingsSyncPersistence {
providers: [], roles: [:], lastSelectedModel: localSelection)
var webSearch = try preferences().value
var terminal = try terminalPreferences().load()
var terminalThemes = TerminalThemeLibrary()
var ssh = try sshPreferences().load()
var sftp = try sftpPreferences().load()
var workspace = try workspacePreferences().load()
@@ -250,6 +263,12 @@ public final class SettingsSyncPersistence {
switch envelope.key.kind {
case .webSearch: webSearch = AIWebSearchPreferences()
case .terminal: terminal = TerminalPreferences()
case .terminalThemeSelection:
let isDark = envelope.key.id == "dark"
terminalThemes.select(
.init(
appearance: isDark ? .dark : .light,
builtInThemeName: isDark ? "Termind Dark" : "Termind Light"))
case .ssh: ssh = SSHPreferences()
case .sftp: sftp = SFTPPreferences()
case .workspace: workspace = WorkspacePreferences()
@@ -266,6 +285,8 @@ public final class SettingsSyncPersistence {
case .selection: break
case .webSearch(let value): webSearch = value
case .terminal(let value): terminal = value
case .terminalCustomTheme(let value): terminalThemes.themes.append(value)
case .terminalThemeSelection(let value): terminalThemes.select(value)
case .ssh(let value): ssh = value
case .sftp(let value): sftp = value
case .workspace(let value): workspace = value
@@ -295,7 +316,7 @@ public final class SettingsSyncPersistence {
}
return SettingsSyncSnapshot(
configuration: configuration, skills: skillRows.map(\.0), webSearch: webSearch, terminal: terminal,
ssh: ssh, sftp: sftp, workspace: workspace, notifications: notifications)
ssh: ssh, sftp: sftp, workspace: workspace, notifications: notifications, terminalThemes: terminalThemes)
}
private func terminalPreferences() throws -> PersistentTerminalPreferences {
@@ -0,0 +1,160 @@
import CryptoKit
import Foundation
import SwiftData
import TermindDomain
@Model
public final class PersistentTerminalCustomTheme {
@Attribute(.unique) public var id: UUID
public var name: String
public var appearance: String
public var baseThemeName: String
public var configuration: String
public init(_ value: TerminalCustomTheme) {
id = value.id
name = value.name
appearance = value.appearance.rawValue
baseThemeName = value.baseThemeName
configuration = value.configuration
}
public var value: TerminalCustomTheme {
get throws {
guard let mode = TerminalThemeAppearance(rawValue: appearance) else { throw CocoaError(.coderReadCorrupt) }
return .init(
id: id, name: name, appearance: mode, baseThemeName: baseThemeName, configuration: configuration)
}
}
}
@Model
public final class PersistentTerminalThemeSelection {
@Attribute(.unique) public var appearance: String
public var builtInThemeName: String
public var customThemeID: UUID?
public init(_ value: TerminalThemeSelection) {
appearance = value.appearance.rawValue
builtInThemeName = value.builtInThemeName
customThemeID = value.customThemeID
}
public var value: TerminalThemeSelection {
get throws {
guard let mode = TerminalThemeAppearance(rawValue: appearance), mode != .both else {
throw CocoaError(.coderReadCorrupt)
}
return .init(appearance: mode, builtInThemeName: builtInThemeName, customThemeID: customThemeID)
}
}
}
extension SettingsSyncPersistence {
public var usesNativeTerminalThemes: Bool { true }
public func loadTerminalThemes() throws -> TerminalThemeLibrary {
let themes = try context.fetch(FetchDescriptor<PersistentTerminalCustomTheme>()).map { try $0.value }
let selections = try context.fetch(FetchDescriptor<PersistentTerminalThemeSelection>()).map { try $0.value }
return .init(
themes: themes.sorted { $0.id.uuidString < $1.id.uuidString },
selections: selections.sorted { $0.appearance.rawValue < $1.appearance.rawValue })
}
public func saveTerminalThemes(_ value: TerminalThemeLibrary) throws {
guard value.themes.allSatisfy(\.isValid), value.selections.allSatisfy(\.isValid),
Set(value.themes.map(\.id)).count == value.themes.count,
Set(value.selections.map(\.appearance)).count == value.selections.count
else { throw CocoaError(.coderInvalidValue) }
try saveLocal { try applyTerminalThemes(value) }
}
func applyTerminalThemes(_ value: TerminalThemeLibrary) throws {
let themes = try context.fetch(FetchDescriptor<PersistentTerminalCustomTheme>())
let selections = try context.fetch(FetchDescriptor<PersistentTerminalThemeSelection>())
for row in themes {
guard let updated = value.themes.first(where: { $0.id == row.id }) else {
context.delete(row)
continue
}
row.name = updated.name
row.appearance = updated.appearance.rawValue
row.baseThemeName = updated.baseThemeName
row.configuration = updated.configuration
}
for theme in value.themes where !themes.contains(where: { $0.id == theme.id }) {
context.insert(PersistentTerminalCustomTheme(theme))
}
for row in selections {
guard let updated = value.selections.first(where: { $0.appearance.rawValue == row.appearance }) else {
context.delete(row)
continue
}
row.builtInThemeName = updated.builtInThemeName
row.customThemeID = updated.customThemeID
}
for selection in value.selections
where !selections.contains(where: { $0.appearance == selection.appearance.rawValue }) {
context.insert(PersistentTerminalThemeSelection(selection))
}
}
/// A selection record (including a tombstone) is the durable per-mode migration marker.
/// Seeds use baseline clocks, so they can never beat an explicit choice from another device.
func migrateTerminalThemes(_ preferences: TerminalPreferences) throws -> Set<SettingsRecordKey> {
var changed = Set<SettingsRecordKey>()
var library = try loadTerminalThemes()
for isDark in [false, true] {
let appearance: TerminalThemeAppearance = isDark ? .dark : .light
let selectionKey = SettingsRecordKey(kind: .terminalThemeSelection, id: appearance.rawValue)
guard library.selection(isDark: isDark) == nil, try journal.envelope(for: selectionKey) == nil else {
continue
}
let base = isDark ? preferences.darkTheme : preferences.lightTheme
let text = isDark ? preferences.darkCustomConfiguration : preferences.lightCustomConfiguration
let enabled = isDark ? preferences.darkCustomEnabled : preferences.lightCustomEnabled
let defaultBase = isDark ? "Termind Dark" : "Termind Light"
// Fresh installations wait for legacy cloud settings instead of publishing an empty migration.
guard enabled || !text.isEmpty || base != defaultBase else { continue }
var selection = TerminalThemeSelection(
appearance: appearance, builtInThemeName: base.isEmpty ? defaultBase : base)
var payloads: [SettingsRecordPayload] = []
if enabled || !text.isEmpty {
let id = Self.legacyThemeID(appearance: appearance, base: base, text: text)
let key = SettingsRecordKey(kind: .terminalCustomTheme, id: id.uuidString.lowercased())
if try journal.envelope(for: key) == nil {
let theme = TerminalCustomTheme(
id: id, name: isDark ? "Custom Dark" : "Custom Light", appearance: appearance,
baseThemeName: selection.builtInThemeName, configuration: text)
library.themes.append(theme)
payloads.append(.terminalCustomTheme(theme))
}
if enabled { selection.customThemeID = id }
}
library.select(selection)
payloads.append(.terminalThemeSelection(selection))
for payload in payloads {
let envelope = SettingsEnvelope(
key: payload.key, version: .baseline(writerID: try journal.state().installationID), payload: payload
)
context.insert(try PersistentAIConfigurationSyncEntry(envelope: envelope, needsUpload: true))
changed.insert(payload.key)
}
}
if !changed.isEmpty { try applyTerminalThemes(library) }
return changed
}
private nonisolated static func legacyThemeID(
appearance: TerminalThemeAppearance, base: String, text: String
) -> UUID {
let seed = "termind-custom-theme-v1\u{0}\(appearance.rawValue)\u{0}\(base)\u{0}\(text)"
var bytes = Array(SHA256.hash(data: Data(seed.utf8)).prefix(16))
bytes[6] = (bytes[6] & 0x0f) | 0x80
bytes[8] = (bytes[8] & 0x3f) | 0x80
return UUID(
uuid: (
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
bytes[8], bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15]
))
}
}
@@ -20,7 +20,7 @@ public enum TermindPersistence {
#endif
// Local schema migrations do not change the independently versioned CloudKit envelopes.
public static let schema = Schema(versionedSchema: TermindSchemaV56.self)
public static let schema = Schema(versionedSchema: TermindSchemaV57.self)
public static func makeContainer(inMemory: Bool = false, url: URL? = nil) throws -> ModelContainer {
precondition(!inMemory || url == nil, "An in-memory store cannot also have a file URL")
@@ -0,0 +1,28 @@
import Foundation
extension AppStrings.Settings {
public static var customThemes: LocalizedStringResource { "Custom Themes" }
public static var newCustomTheme: LocalizedStringResource { "New Custom Theme" }
public static var builtInThemes: LocalizedStringResource { "Built-in Themes" }
public static var themeLight: LocalizedStringResource { "Light" }
public static var themeDark: LocalizedStringResource { "Dark" }
public static var themeBoth: LocalizedStringResource { "Both" }
public static var noCustomThemes: LocalizedStringResource { "Create a custom theme to get started." }
public static var themePreviewCursor: LocalizedStringResource { "Cursor" }
public static var themePreviewSelection: LocalizedStringResource { "Selected text" }
public static var themePreviewPalette: LocalizedStringResource { "ANSI Palette" }
public static var deleteCustomThemeConfirmation: LocalizedStringResource {
"Delete this custom theme?"
}
public static var deleteCustomThemeWarning: LocalizedStringResource {
"Appearances using it will return to their previous built-in theme."
}
public static var discardNewCustomThemeQuestion: LocalizedStringResource {
"Are you sure you want to discard this new custom theme?"
}
public static var customThemeDeleted: LocalizedStringResource {
"This theme was deleted on another device. You can save your changes as a new theme."
}
public static var saveThemeCopy: LocalizedStringResource { "Save as New Theme" }
public static func customThemeCopy(_ name: String) -> LocalizedStringResource { "\(name) Copy" }
}
@@ -92,14 +92,21 @@ public nonisolated enum TermindTerminalThemeCatalog {
@MainActor
extension TerminalViewState {
public func applyPreferences(_ value: TerminalPreferences, font: TerminalFontPreferences = .init()) {
public func applyPreferences(
_ value: TerminalPreferences, font: TerminalFontPreferences = .init(),
library: TerminalThemeLibrary = .init()
) {
let lightOptions = TerminalThemeOptions(preferences: value, isDark: false, library: library)
let darkOptions = TerminalThemeOptions(preferences: value, isDark: true, library: library)
let light = TermindTerminalThemeCatalog.terminalConfiguration(
themeName: value.lightTheme,
customEnabled: value.lightCustomEnabled, customText: value.lightCustomConfiguration, isDark: false,
themeName: lightOptions.baseTheme.name,
customEnabled: lightOptions.isCustom, customText: lightOptions.customText,
isDark: lightOptions.baseTheme.isDark,
font: font)
let dark = TermindTerminalThemeCatalog.terminalConfiguration(
themeName: value.darkTheme,
customEnabled: value.darkCustomEnabled, customText: value.darkCustomConfiguration, isDark: true,
themeName: darkOptions.baseTheme.name,
customEnabled: darkOptions.isCustom, customText: darkOptions.customText,
isDark: darkOptions.baseTheme.isDark,
font: font)
setTheme(GhosttyTerminal.TerminalTheme(light: light, dark: dark))
setTerminalConfiguration(
@@ -8,31 +8,35 @@ import TermindDomain
public final class TerminalConfigurationPreviewModel: ObservableObject {
public let terminal: TerminalViewState
@Published public private(set) var issue: String?
@Published public private(set) var palette: TerminalThemeThumbnailPalette
public init(
themeName: String = TermindTerminalThemeCatalog.defaultDark.name,
isDark: Bool = true, font: TerminalFontPreferences = .init()
) {
palette = TerminalThemeThumbnailPalette(
theme: TermindTerminalThemeCatalog.resolvedTheme(named: themeName, isDark: isDark))
let backend = InMemoryTerminalSession(write: { _ in }, resize: { _ in })
terminal = TerminalViewState(configSource: .none)
terminal.configuration = TerminalSurfaceOptions(backend: .inMemory(backend), resizeThrottleMilliseconds: 0)
terminal.setTerminalConfiguration(
.init { builder in
builder.withCustom("notify-on-command-finish", "never")
builder.withWindowPaddingX(8)
builder.withWindowPaddingY(8)
builder.withWindowPaddingX(16)
builder.withWindowPaddingY(16)
})
let normalColors = (0..<8).map { "\u{1B}[\(40 + $0)m " }.joined()
let brightColors = (0..<8).map { "\u{1B}[\(100 + $0)m " }.joined()
let lines = [
"\u{1B}[36muser@server\u{1B}[0m \u{1B}[34m~\u{1B}[0m $ ls",
"\u{1B}[1;34msrc/\u{1B}[0m README.md \u{1B}[32mrun.sh\u{1B}[0m",
"\u{1B}[36muser@server\u{1B}[0m \u{1B}[34m~/termind\u{1B}[0m",
"$ git status --short",
"\u{1B}[33m M\u{1B}[0m theme.conf",
"\u{1B}[32m A\u{1B}[0m themes/ocean",
"",
"\u{1B}[1mTermind\u{1B}[0m",
normalColors + "\u{1B}[0m",
brightColors + "\u{1B}[0m",
"$ git diff",
"\u{1B}[31m- background = 1e1e1e\u{1B}[0m",
"\u{1B}[32m+ background = 152033\u{1B}[0m",
"",
"\u{1B}[36muser@server\u{1B}[0m \u{1B}[34m~\u{1B}[0m $ ",
"\u{1B}[1mAa Bb\u{1B}[0m \u{1B}[3mAa Bb\u{1B}[0m \u{1B}[4mAa Bb\u{1B}[0m",
"$ ",
]
backend.receive(lines.joined(separator: "\r\n"))
update(themeName: themeName, text: "", isDark: isDark, font: font)
@@ -47,6 +51,10 @@ public final class TerminalConfigurationPreviewModel: ObservableObject {
// draft after an error is a successful recovery, even without reconfiguration.
let success = theme == terminal.theme || terminal.setTheme(theme)
issue = success ? nil : terminal.controller.lastConfigurationIssue
if success {
palette = TerminalThemeThumbnailPalette(
theme: TermindTerminalThemeCatalog.resolvedTheme(named: themeName, isDark: isDark), customText: text)
}
return success
}
}
@@ -72,13 +80,23 @@ public struct TerminalConfigurationPreview: View {
public var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text(AppStrings.Settings.themePreview).font(.headline)
TerminalSurfaceView(context: model.terminal)
.environment(\.colorScheme, isDark ? .dark : .light)
.allowsHitTesting(false)
.disabled(true)
.accessibilityHidden(true)
.frame(minHeight: 220)
.clipShape(.rect(cornerRadius: 8))
VStack(spacing: 0) {
TerminalSurfaceView(context: model.terminal)
.environment(\.colorScheme, isDark ? .dark : .light)
.allowsHitTesting(false)
.disabled(true)
.accessibilityHidden(true)
.frame(minHeight: 260)
colorSamples
}
.background(model.palette.background.color)
.clipShape(.rect(cornerRadius: 8))
.overlay {
RoundedRectangle(cornerRadius: 8)
.strokeBorder(.separator, lineWidth: 1)
.allowsHitTesting(false)
}
.accessibilityIdentifier("custom-theme-preview")
if let issue = model.issue {
Text(AppStrings.Settings.themePreviewError(issue))
.font(.caption).foregroundStyle(.red)
@@ -94,6 +112,56 @@ public struct TerminalConfigurationPreview: View {
}
}
private var colorSamples: some View {
VStack(alignment: .leading, spacing: 16) {
ViewThatFits(in: .horizontal) {
HStack(spacing: 20) { textSamples }
VStack(alignment: .leading, spacing: 12) { textSamples }
}
VStack(alignment: .leading, spacing: 8) {
Text(AppStrings.Settings.themePreviewPalette)
.font(.caption.weight(.medium))
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 8), spacing: 8) {
ForEach(0..<16, id: \.self) { index in
VStack(spacing: 4) {
RoundedRectangle(cornerRadius: 3)
.fill(model.palette.ansi[index].color)
.frame(height: 20)
.overlay {
RoundedRectangle(cornerRadius: 3)
.strokeBorder(model.palette.foreground.color.opacity(0.2), lineWidth: 1)
}
Text(verbatim: "\(index)")
.font(.caption2.monospacedDigit())
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(Text(verbatim: "\(index): #\(model.palette.ansi[index].hex)"))
}
}
}
}
.foregroundStyle(model.palette.foreground.color)
.padding(16)
.fixedSize(horizontal: false, vertical: true)
}
@ViewBuilder
private var textSamples: some View {
HStack(spacing: 8) {
Text(AppStrings.Settings.themePreviewCursor).font(.caption)
Text(verbatim: "A")
.font(.body.monospaced())
.foregroundStyle(model.palette.cursorText.color)
.padding(.horizontal, 4).padding(.vertical, 2)
.background(model.palette.cursor.color, in: .rect(cornerRadius: 2))
}
Text(AppStrings.Settings.themePreviewSelection)
.font(.body.monospaced())
.foregroundStyle(model.palette.selectionForeground.color)
.padding(.horizontal, 6).padding(.vertical, 2)
.background(model.palette.selection.color, in: .rect(cornerRadius: 2))
}
private struct Request: Equatable {
let themeName: String
let text: String
@@ -0,0 +1,44 @@
import SwiftUI
import TermindDomain
public struct TerminalCustomThemeSummary: View {
private let theme: TerminalCustomTheme
private let library: TerminalThemeLibrary
public init(theme: TerminalCustomTheme, library: TerminalThemeLibrary) {
self.theme = theme
self.library = library
}
public var body: some View {
HStack(spacing: 12) {
TerminalThemeThumbnail(theme: theme.baseTheme, customText: theme.configuration)
.frame(width: 96)
VStack(alignment: .leading, spacing: 6) {
Text(verbatim: theme.name).font(.headline)
Text(theme.appearance.title).font(.caption).foregroundStyle(.secondary)
ForEach([false, true], id: \.self) { isDark in
if library.selectedTheme(isDark: isDark)?.id == theme.id {
Label(
(isDark ? AppStrings.Settings.darkTheme : AppStrings.Settings.lightTheme).swiftUIKey,
systemImage: "checkmark"
)
.font(.caption).foregroundStyle(.tint)
}
}
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
extension TerminalThemeLibrary {
public func themes(matching query: String) -> [TerminalCustomTheme] {
let query = query.trimmingCharacters(in: .whitespacesAndNewlines)
return themes.filter { query.isEmpty || $0.name.localizedStandardContains(query) }.sorted {
if $0.name == $1.name { return $0.id.uuidString < $1.id.uuidString }
return $0.name.localizedStandardCompare($1.name) == .orderedAscending
}
}
}
@@ -6,22 +6,35 @@ import TermindDomain
public struct TerminalThemeOptions {
public let preferences: TerminalPreferences
public let isDark: Bool
public let library: TerminalThemeLibrary
public init(preferences: TerminalPreferences, isDark: Bool) {
public init(preferences: TerminalPreferences, isDark: Bool, library: TerminalThemeLibrary = .init()) {
self.preferences = preferences
self.isDark = isDark
self.library = library
}
public var name: String { isDark ? preferences.darkTheme : preferences.lightTheme }
public var isCustom: Bool { isDark ? preferences.darkCustomEnabled : preferences.lightCustomEnabled }
public var selectedCustomTheme: TerminalCustomTheme? { library.selectedTheme(isDark: isDark) }
private var selection: TerminalThemeSelection? { library.selection(isDark: isDark) }
public var name: String {
selectedCustomTheme?.baseThemeName ?? selection?.builtInThemeName
?? (isDark ? preferences.darkTheme : preferences.lightTheme)
}
public var isCustom: Bool {
if selection != nil { return selectedCustomTheme != nil }
return isDark ? preferences.darkCustomEnabled : preferences.lightCustomEnabled
}
public var customText: String {
isDark ? preferences.darkCustomConfiguration : preferences.lightCustomConfiguration
if selection != nil { return selectedCustomTheme?.configuration ?? "" }
return isDark ? preferences.darkCustomConfiguration : preferences.lightCustomConfiguration
}
public var title: LocalizedStringResource {
isDark ? AppStrings.Settings.darkTheme : AppStrings.Settings.lightTheme
}
public var summary: Text {
if isCustom {
if let theme = selectedCustomTheme {
Text(verbatim: theme.name)
} else if isCustom {
Text(AppStrings.Settings.customTheme)
} else if name.isEmpty {
Text(AppStrings.Settings.noThemeSelected)
@@ -29,9 +42,15 @@ public struct TerminalThemeOptions {
Text(verbatim: name)
}
}
public var isAvailable: Bool { TermindTerminalThemeCatalog.theme(named: name)?.isDark == isDark }
public var isAvailable: Bool {
if let theme = selectedCustomTheme {
return TermindTerminalThemeCatalog.theme(named: theme.baseThemeName) != nil
}
if selection?.customThemeID != nil { return false }
return TermindTerminalThemeCatalog.theme(named: name)?.isDark == isDark
}
public var baseTheme: GhosttyThemeDefinition {
TermindTerminalThemeCatalog.resolvedTheme(named: name, isDark: isDark)
selectedCustomTheme?.baseTheme ?? TermindTerminalThemeCatalog.resolvedTheme(named: name, isDark: isDark)
}
public func themes(matching query: String) -> [GhosttyThemeDefinition] {
let query = query.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -40,6 +59,33 @@ public struct TerminalThemeOptions {
}
}
public func isSelected(_ name: String) -> Bool { !isCustom && self.name == name }
public func customThemes(matching query: String) -> [TerminalCustomTheme] {
let query = query.trimmingCharacters(in: .whitespacesAndNewlines)
return library.themes.filter {
$0.appearance.includes(isDark: isDark) && (query.isEmpty || $0.name.localizedStandardContains(query))
}.sorted {
$0.name == $1.name
? $0.id.uuidString < $1.id.uuidString : $0.name.localizedStandardCompare($1.name) == .orderedAscending
}
}
}
extension TerminalCustomTheme {
public var baseTheme: GhosttyThemeDefinition {
TermindTerminalThemeCatalog.theme(named: baseThemeName)
?? TermindTerminalThemeCatalog.fallbackTheme(isDark: appearance != .light)
}
}
extension TerminalThemeAppearance {
public var title: LocalizedStringResource {
switch self {
case .light: AppStrings.Settings.themeLight
case .dark: AppStrings.Settings.themeDark
case .both: AppStrings.Settings.themeBoth
}
}
}
public struct TerminalThemeAvailabilityNotice: View {
@@ -9,6 +9,7 @@ public struct TerminalThemeThumbnailPalette: Equatable, Sendable {
public var selection: TermindTerminalPalette.RGB
public var selectionForeground: TermindTerminalPalette.RGB
public var cursor: TermindTerminalPalette.RGB
public var cursorText: TermindTerminalPalette.RGB
public var ansi: [TermindTerminalPalette.RGB]
public init(theme: GhosttyThemeDefinition, customText: String = "") {
@@ -18,6 +19,7 @@ public struct TerminalThemeThumbnailPalette: Equatable, Sendable {
selection = theme.selectionBackground.flatMap(TermindTerminalPalette.RGB.init(hex:)) ?? fallback.selection
selectionForeground = theme.selectionForeground.flatMap(TermindTerminalPalette.RGB.init(hex:)) ?? foreground
cursor = theme.cursorColor.flatMap(TermindTerminalPalette.RGB.init(hex:)) ?? foreground
cursorText = theme.cursorText.flatMap(TermindTerminalPalette.RGB.init(hex:)) ?? background
ansi = (0..<16).map { theme.palette[$0].flatMap(TermindTerminalPalette.RGB.init(hex:)) ?? fallback.ansi[$0] }
for entry in TerminalCustomConfiguration(customText).entries {
if entry.key == "palette" {
@@ -38,6 +40,7 @@ public struct TerminalThemeThumbnailPalette: Equatable, Sendable {
case "selection-background": selection = color
case "selection-foreground": selectionForeground = color
case "cursor-color": cursor = color
case "cursor-text": cursorText = color
default: break
}
}
+1
View File
@@ -433,6 +433,7 @@ final class MobileAppModel {
settingsSync?.didApplyRemote = { [weak self] snapshot, _ in
self?.ai.applyRemoteSnapshot(snapshot.configuration, webSearch: snapshot.webSearch)
self?.terminalSettings.applyRemoteSnapshot(snapshot.terminal)
self?.terminalSettings.applyRemoteThemes(snapshot.terminalThemes)
self?.sshSettings.applyRemoteSnapshot(snapshot.ssh)
self?.sftpSettings.applyRemoteSnapshot(snapshot.sftp)
self?.workspaceSettings.applyRemoteSnapshot(snapshot.workspace)
@@ -0,0 +1,119 @@
import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
import UniformTypeIdentifiers
struct MobileCustomThemeEditorSheet: View {
@Bindable var draft: TerminalCustomThemeDraft
let model: TerminalSettingsModel
@Environment(\.dismiss) private var dismiss
@Environment(\.locale) private var locale
@TerminalFontSettings private var font
@State private var importing = false
@State private var deleting = false
@State private var importError: String?
var body: some View {
MobileEditorSheet(
title: AppLocalization.resolve(
draft.isNew ? AppStrings.Settings.newCustomTheme : AppStrings.Settings.customThemes, locale: locale),
isDirty: draft.isDirty,
canSave: draft.value.isValid && !draft.isDeleted(in: model),
actions: editorActions,
discardQuestion: draft.isNew
? AppStrings.Settings.discardNewCustomThemeQuestion : AppStrings.Common.discardChangesQuestion,
save: {
guard draft.save(in: model) else {
throw MobileEditorError.message(
model.storageError ?? AppLocalization.resolve(AppStrings.Common.unableToSave, locale: locale))
}
}
) {
Section {
TextField(AppStrings.Common.name.swiftUIKey, text: $draft.value.name)
Picker(AppStrings.Settings.appearance.swiftUIKey, selection: $draft.value.appearance) {
ForEach(TerminalThemeAppearance.allCases, id: \.self) { appearance in
Text(appearance.title).tag(appearance)
}
}
.pickerStyle(.segmented)
}
Section {
TerminalConfigurationEditorLayout {
TextEditor(text: $draft.value.configuration)
.font(.system(.body, design: .monospaced))
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.frame(minHeight: 280)
.accessibilityLabel(AppStrings.Settings.customConfiguration.swiftUIKey)
} preview: {
TerminalConfigurationPreview(
themeName: draft.value.baseTheme.name, text: draft.value.configuration,
isDark: draft.value.baseTheme.isDark,
font: font.resolvingFamily(in: TerminalFontCatalog.shared.families))
}
} footer: {
Text(AppStrings.Settings.terminalConfigurationExplanation)
}
if draft.isDeleted(in: model) {
Section {
Button(AppStrings.Settings.saveThemeCopy.swiftUIKey, systemImage: "doc.on.doc") {
draft.makeCopy(
name: AppLocalization.resolve(
AppStrings.Settings.customThemeCopy(draft.value.name), locale: locale))
if draft.save(in: model) { dismiss() }
}
.disabled(!draft.value.isValid)
} footer: {
Text(AppStrings.Settings.customThemeDeleted)
}
}
if let error = importError ?? model.storageError {
Text(error).font(.caption).foregroundStyle(.red).textSelection(.enabled)
}
}
.onChange(of: model.themeLibrary) { _, _ in draft.refresh(from: model) }
.fileImporter(isPresented: $importing, allowedContentTypes: [.plainText, .data]) { result in
do { try importFile(result.get()) } catch { importError = error.localizedDescription }
}
.operationConfirmation(
.init(AppStrings.Settings.deleteCustomThemeConfirmation),
isPresented: $deleting, placement: .centered,
actions: [
.init(AppStrings.Common.delete, role: .destructive) {
if model.deleteCustomTheme(id: draft.id) { dismiss() }
},
.init(AppStrings.Common.cancel, role: .cancel) {},
],
message: .init(AppStrings.Settings.deleteCustomThemeWarning)
)
}
var editorActions: [MobileEditorToolbarAction] {
var actions: [MobileEditorToolbarAction] = [
.init(
id: "import", title: AppStrings.Settings.importFromFile,
systemImage: "square.and.arrow.down", perform: { importing = true })
]
if !draft.isNew, !draft.isDeleted(in: model) {
actions.append(
.init(
id: "delete", title: AppStrings.Common.delete, systemImage: "trash",
role: .destructive, startsGroup: true, perform: { deleting = true }))
}
return actions
}
private func importFile(_ url: URL) throws {
let access = url.startAccessingSecurityScopedResource()
defer { if access { url.stopAccessingSecurityScopedResource() } }
let data = try Data(contentsOf: url)
guard let text = String(data: data, encoding: .utf8) else {
importError = AppLocalization.resolve(AppStrings.Settings.fileIsNotValidUtf8Text, locale: locale)
return
}
draft.value.configuration = text
importError = nil
}
}
@@ -0,0 +1,98 @@
import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
struct MobileCustomThemesView: View {
let model: TerminalSettingsModel
@Environment(\.colorScheme) private var colorScheme
@Environment(\.locale) private var locale
@State private var query = ""
@State private var draft: TerminalCustomThemeDraft?
@State private var deleting: TerminalCustomTheme?
var body: some View {
MobileCardPage {
if let error = model.storageError { Text(error).foregroundStyle(.red) }
if model.themeLibrary.themes.isEmpty {
ContentUnavailableView {
Label(AppStrings.Settings.customThemes.swiftUIKey, systemImage: "paintpalette")
} description: {
Text(AppStrings.Settings.noCustomThemes)
}
} else if model.themeLibrary.themes(matching: query).isEmpty {
ContentUnavailableView.search(text: query)
}
MobileCardGrid {
ForEach(model.themeLibrary.themes(matching: query)) { theme in
MobileActionCard(alignment: .leading) {
draft = .init(theme, isNew: false)
} content: {
TerminalCustomThemeSummary(theme: theme, library: model.themeLibrary)
.padding(.trailing, 32)
} actions: {
actions(theme)
}
.overlay(alignment: .topTrailing) {
Menu {
actions(theme)
} label: {
Label(AppStrings.Common.edit.swiftUIKey, systemImage: "ellipsis")
.labelStyle(.iconOnly)
.frame(width: 44, height: 44).contentShape(.rect)
}
.padding(4)
}
.operationConfirmation(
.init(AppStrings.Settings.deleteCustomThemeConfirmation),
isPresented: $deleting.isPresenting { $0.id == theme.id },
actions: [
.init(AppStrings.Common.delete, role: .destructive) {
model.deleteCustomTheme(id: theme.id)
deleting = nil
},
.init(AppStrings.Common.cancel, role: .cancel) { deleting = nil },
],
message: .init(AppStrings.Settings.deleteCustomThemeWarning)
)
}
}
}
.navigationTitle(AppStrings.Settings.customThemes.swiftUIKey)
.searchable(
text: $query, placement: .navigationBarDrawer(displayMode: .always),
prompt: Text(AppStrings.Settings.searchThemes)
)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
let dark = colorScheme == .dark
let options = TerminalThemeOptions(
preferences: model.preferences, isDark: dark, library: model.themeLibrary)
draft = .init(
.init(name: "", appearance: dark ? .dark : .light, baseThemeName: options.baseTheme.name),
isNew: true)
} label: {
Label(AppStrings.Settings.newCustomTheme.swiftUIKey, systemImage: "plus").labelStyle(.iconOnly)
}
}
}
.sheet(item: $draft) { draft in
MobileCustomThemeEditorSheet(draft: draft, model: model)
}
}
@ViewBuilder private func actions(_ theme: TerminalCustomTheme) -> some View {
Button(AppStrings.Common.edit.swiftUIKey, systemImage: "pencil") {
draft = .init(theme, isNew: false)
}
Button(AppStrings.Common.duplicate.swiftUIKey, systemImage: "doc.on.doc") {
var copy = theme
copy.id = UUID()
copy.name = AppLocalization.resolve(AppStrings.Settings.customThemeCopy(theme.name), locale: locale)
draft = .init(copy, isNew: true)
}
Divider()
Button(AppStrings.Common.delete.swiftUIKey, systemImage: "trash", role: .destructive) { deleting = theme }
}
}
@@ -16,6 +16,13 @@ struct MobileTerminalSettingsView: View {
Form {
themeSection(dark: false)
themeSection(dark: true)
Section {
NavigationLink {
MobileCustomThemesView(model: model)
} label: {
Text(AppStrings.Settings.customThemes)
}
}
Section(AppStrings.Settings.font.swiftUIKey) {
TerminalFontFields(preferences: $font, families: TerminalFontCatalog.shared.families)
}
@@ -32,7 +39,7 @@ struct MobileTerminalSettingsView: View {
}
private func themeSection(dark: Bool) -> some View {
let options = TerminalThemeOptions(preferences: model.preferences, isDark: dark)
let options = TerminalThemeOptions(preferences: model.preferences, isDark: dark, library: model.themeLibrary)
return Section {
NavigationLink {
MobileTerminalThemeView(model: model, isDark: dark)
@@ -10,110 +10,64 @@ struct MobileTerminalThemeView: View {
@Environment(\.dismiss) private var dismiss
@State private var query = ""
@State private var hoveredTheme: String?
@State private var isEditing = false
@State private var customText = ""
@TerminalFontSettings private var font
private var options: TerminalThemeOptions { .init(preferences: model.preferences, isDark: isDark) }
private var options: TerminalThemeOptions {
.init(preferences: model.preferences, isDark: isDark, library: model.themeLibrary)
}
var body: some View {
MobileCardPage {
if let error = model.storageError { Text(error).foregroundStyle(.red) }
TerminalThemeAvailabilityNotice(options: options)
MobileCardGrid {
ForEach(options.themes(matching: query)) { theme in
tile(theme: theme, custom: false)
if !options.customThemes(matching: query).isEmpty {
Text(AppStrings.Settings.customThemes).font(.headline)
MobileCardGrid {
ForEach(options.customThemes(matching: query)) { theme in
tile(theme: theme.baseTheme, custom: theme)
}
}
tile(theme: options.baseTheme, custom: true)
}
if options.themes(matching: query).isEmpty {
if !options.themes(matching: query).isEmpty {
Text(AppStrings.Settings.builtInThemes).font(.headline)
MobileCardGrid {
ForEach(options.themes(matching: query)) { theme in tile(theme: theme) }
}
} else if options.customThemes(matching: query).isEmpty {
Text(AppStrings.Settings.noMatchingThemes).foregroundStyle(.secondary)
}
}
.navigationTitle(options.title)
.searchable(text: $query, prompt: Text(AppStrings.Settings.searchThemes))
.sheet(isPresented: $isEditing) {
MobileEditorSheet(
title: String(
localized: isDark
? AppStrings.Settings.darkCustomConfiguration : AppStrings.Settings.lightCustomConfiguration),
isDirty: customText != options.customText,
save: {
guard model.set(customText, for: isDark ? \.darkCustomConfiguration : \.lightCustomConfiguration)
else { throw MobileEditorError.message(model.storageError ?? "") }
}
) {
Text(AppStrings.Settings.terminalConfigurationExplanation)
.font(.caption).foregroundStyle(.secondary)
TerminalConfigurationEditorLayout {
TextEditor(text: $customText)
.font(.system(.body, design: .monospaced))
.textInputAutocapitalization(.never).autocorrectionDisabled()
.frame(minHeight: 280)
.accessibilityLabel(AppStrings.Settings.customConfiguration.swiftUIKey)
} preview: {
TerminalConfigurationPreview(
themeName: options.name, text: customText, isDark: isDark,
font: font.resolvingFamily(in: TerminalFontCatalog.shared.families))
}
}
}
.searchable(
text: $query, placement: .navigationBarDrawer(displayMode: .always),
prompt: Text(AppStrings.Settings.searchThemes))
}
private func tile(theme: GhosttyThemeDefinition, custom: Bool) -> some View {
let hoverID = custom ? "__custom__" : theme.name
let selected = custom ? options.isCustom : options.isSelected(theme.name)
private func tile(theme: GhosttyThemeDefinition, custom: TerminalCustomTheme? = nil) -> some View {
let hoverID = custom?.id.uuidString ?? theme.name
let selected = custom.map { options.selectedCustomTheme?.id == $0.id } ?? options.isSelected(theme.name)
return MobileActionCard(alignment: .leading) {
let saved =
custom
? model.selectCustomTheme(isDark: isDark)
: model.selectTheme(named: theme.name, isDark: isDark)
let saved: Bool
if let custom {
saved = model.selectCustomTheme(id: custom.id, isDark: isDark)
} else {
saved = model.selectTheme(named: theme.name, isDark: isDark)
}
if saved { dismiss() }
} content: {
HStack(spacing: 12) {
TerminalThemeThumbnail(
theme: theme, customText: custom ? options.customText : "", showsPalette: hoveredTheme == hoverID
theme: theme, customText: custom?.configuration ?? "", showsPalette: hoveredTheme == hoverID
)
.frame(width: 96)
if custom {
Text(AppStrings.Settings.customTheme).font(.headline)
} else {
Text(verbatim: theme.name).font(.headline)
}
Text(verbatim: custom?.name ?? theme.name).font(.headline)
Spacer(minLength: 0)
if selected {
Image(systemName: "checkmark").foregroundStyle(.tint).accessibilityHidden(true)
}
if selected { Image(systemName: "checkmark").foregroundStyle(.tint).accessibilityHidden(true) }
}
.padding(.trailing, custom ? 32 : 0)
} actions: {
if custom {
Button(AppStrings.Common.edit.swiftUIKey, systemImage: "pencil") {
customText = options.customText
isEditing = true
}
}
}
.onHover { hovering in
if hovering { hoveredTheme = hoverID } else if hoveredTheme == hoverID { hoveredTheme = nil }
}
.accessibilityAddTraits(selected ? [.isSelected] : [])
.overlay(alignment: .topTrailing) {
if custom {
Button {
customText = options.customText
isEditing = true
} label: {
Label(AppStrings.Common.edit.swiftUIKey, systemImage: "pencil")
.labelStyle(.iconOnly)
.frame(width: 44, height: 44)
.contentShape(.rect)
}
.buttonStyle(.plain)
.foregroundStyle(.tint)
.padding(4)
}
}
}
}
@@ -455,10 +455,17 @@ struct MobileWorkspaceView: View {
)
.id(workspace.id)
.onChange(of: workspace.terminalSettings.preferences, initial: true) { _, value in
workspace.terminal.applyPreferences(value, font: resolvedFont)
workspace.terminal.applyPreferences(
value, font: resolvedFont, library: workspace.terminalSettings.themeLibrary)
}
.onChange(of: workspace.terminalSettings.themeLibrary) { _, library in
workspace.terminal.applyPreferences(
workspace.terminalSettings.preferences, font: resolvedFont, library: library)
}
.onChange(of: resolvedFont) { _, value in
workspace.terminal.applyPreferences(workspace.terminalSettings.preferences, font: value)
workspace.terminal.applyPreferences(
workspace.terminalSettings.preferences, font: value,
library: workspace.terminalSettings.themeLibrary)
}
.onAppear { TerminalFontCatalog.shared.refresh() }
}.opacity(showsJourney ? 0 : 1)
+130 -1
View File
@@ -1,3 +1,4 @@
import GhosttyTerminal
import SwiftUI
import TermindApplication
import TermindDomain
@@ -9,6 +10,22 @@ import UIKit
@Suite(.serialized) @MainActor
struct MobileTerminalThemeTests {
@Test func editorSeparatesDestructiveDeleteFromImportOnlyForSavedThemes() throws {
let model = TerminalSettingsModel()
let theme = TerminalCustomTheme(name: "Personal", appearance: .both, baseThemeName: "Termind Dark")
let draft = TerminalCustomThemeDraft(theme, isNew: true)
let editor = MobileCustomThemeEditorSheet(draft: draft, model: model)
#expect(editor.editorActions.map(\.id) == ["import"])
#expect(draft.save(in: model))
let actions = editor.editorActions
#expect(actions.map(\.id) == ["import", "delete"])
let delete = try #require(actions.last)
#expect(delete.role == .destructive)
#expect(delete.startsGroup)
model.deleteCustomTheme(id: theme.id)
#expect(editor.editorActions.map(\.id) == ["import"])
}
@Test(
.requiresKeyWindow,
arguments: ["en", "zh-Hans", "zh-Hant"], [false, true])
@@ -17,7 +34,10 @@ struct MobileTerminalThemeTests {
let previous = scene.keyWindow
let window = UIWindow(windowScene: scene)
let model = TerminalSettingsModel()
model.selectCustomTheme(isDark: isDark)
let theme = TerminalCustomTheme(
name: "Personal", appearance: .both, baseThemeName: "Termind Dark", configuration: "background = 123456")
model.saveCustomTheme(theme, isNew: true)
model.selectCustomTheme(id: theme.id, isDark: isDark)
let initial = model.preferences
let controller = UIHostingController(
rootView: NavigationStack { MobileTerminalThemeView(model: model, isDark: isDark) }
@@ -45,6 +65,86 @@ struct MobileTerminalThemeTests {
to: directory.appendingPathComponent("mobile-themes-\(language)-\(isDark ? "dark" : "light").png"))
}
@Test(.requiresKeyWindow, arguments: ["en", "zh-Hans"], [false, true])
func customThemeManagerAndEditorSnapshots(language: String, editor: Bool) async throws {
let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
let previous = scene.keyWindow
let window = UIWindow(windowScene: scene)
let model = TerminalSettingsModel()
let theme = TerminalCustomTheme(
name: "Ocean", appearance: .both, baseThemeName: "Termind Dark",
configuration: "background = 152033\nforeground = E7EDF7\npalette = 2=#7BD88F")
model.saveCustomTheme(theme, isNew: true)
model.saveCustomTheme(
.init(name: "Daylight", appearance: .light, baseThemeName: "Termind Light"), isNew: true)
model.selectCustomTheme(id: theme.id, isDark: true)
let initial = model.themeLibrary
let draft = TerminalCustomThemeDraft(theme, isNew: false)
let content = Group {
if editor {
EditorSheetPreview(model: model, draft: draft)
} else {
NavigationStack { MobileCustomThemesView(model: model) }
}
}
.environment(\.locale, Locale(identifier: language))
.mobileOperationConfirmations()
let controller = UIHostingController(rootView: content)
window.rootViewController = controller
window.makeKeyAndVisible()
defer {
window.isHidden = true
window.rootViewController = nil
previous?.makeKey()
}
controller.view.layoutIfNeeded()
try await Task.sleep(for: .milliseconds(700))
if editor {
let sheet = try #require(controller.presentedViewController)
#expect(sheet.sheetPresentationController != nil)
let navigation = try #require(navigationController(in: sheet))
#expect(
navigation.topViewController?.navigationItem.title
== AppLocalization.resolve(AppStrings.Settings.customThemes, locale: Locale(identifier: language)))
} else {
let navigation = try #require(navigationController(in: controller))
let search = try #require(navigation.topViewController?.navigationItem.searchController)
#expect(
search.searchBar.placeholder
== AppLocalization.resolve(AppStrings.Settings.searchThemes, locale: Locale(identifier: language)))
#expect(navigation.topViewController?.navigationItem.hidesSearchBarWhenScrolling == false)
}
let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in
window.drawHierarchy(in: window.bounds, afterScreenUpdates: true)
}
let data = try #require(image.pngData())
let name = "mobile-custom-\(editor ? "editor" : "library")-\(language).png"
Attachment.record(data, named: name)
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("termind-theme-previews")
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try data.write(to: directory.appendingPathComponent(name))
if editor, let sheet = controller.presentedViewController {
let form = try #require(scrollViews(in: sheet.view).max { $0.bounds.height < $1.bounds.height })
let bottom = max(-form.adjustedContentInset.top, form.contentSize.height - form.bounds.height)
form.setContentOffset(CGPoint(x: 0, y: bottom), animated: false)
try await Task.sleep(for: .milliseconds(250))
let terminal = try #require(terminalView(in: sheet.view))
if case .inMemory(let session) = terminal.configuration.backend {
let text = try #require(session.readViewportText())
#expect(text.contains("user@server"))
#expect(text.contains("Aa Bb"))
} else {
Issue.record("A theme preview must not create a shell session.")
}
let previewData = UIGraphicsImageRenderer(bounds: window.bounds).pngData { _ in
window.drawHierarchy(in: window.bounds, afterScreenUpdates: true)
}
Attachment.record(previewData, named: "mobile-custom-preview-\(language).png")
}
#expect(model.themeLibrary == initial)
#expect(!draft.isDirty)
}
@Test func previewingDraftDoesNotApplyOrEnableIt() {
let model = TerminalSettingsModel()
let initial = model.preferences
@@ -56,4 +156,33 @@ struct MobileTerminalThemeTests {
#expect(!model.preferences.lightCustomEnabled)
#expect(!preview.terminal.isFocused)
}
private struct EditorSheetPreview: View {
let model: TerminalSettingsModel
let draft: TerminalCustomThemeDraft
@State private var isPresented = false
var body: some View {
NavigationStack { MobileCustomThemesView(model: model) }
.sheet(isPresented: $isPresented) {
MobileCustomThemeEditorSheet(draft: draft, model: model)
}
.onAppear { isPresented = true }
}
}
private func navigationController(in controller: UIViewController) -> UINavigationController? {
if let navigation = controller as? UINavigationController { return navigation }
return controller.children.lazy.compactMap { navigationController(in: $0) }.first
}
private func scrollViews(in view: UIView) -> [UIScrollView] {
let scroll = (view as? UIScrollView).map { [$0] } ?? []
return scroll + view.subviews.flatMap { scrollViews(in: $0) }
}
private func terminalView(in view: UIView) -> UITerminalView? {
if let terminal = view as? UITerminalView { return terminal }
return view.subviews.lazy.compactMap { terminalView(in: $0) }.first
}
}
+15
View File
@@ -54,6 +54,20 @@ final class AppModel {
}
}
var hasUnsavedTerminalThemes: Bool {
windows.values.contains {
guard let window = $0.value, !window.isClosed else { return false }
return window.hasUnsavedTerminalThemeChanges
}
}
func saveTerminalThemeChanges() {
for entry in windows.values {
guard let window = entry.value, !window.isClosed else { continue }
window.saveTerminalThemeChanges()
}
}
private final class WeakWindow {
weak var value: WindowModel?
init(_ value: WindowModel) { self.value = value }
@@ -447,6 +461,7 @@ final class AppModel {
self?.ai.applyRemoteSnapshot(snapshot.configuration, webSearch: snapshot.webSearch)
self?.skills.applyRemoteSnapshot(snapshot.skills)
self?.terminalSettings.applyRemoteSnapshot(snapshot.terminal)
self?.terminalSettings.applyRemoteThemes(snapshot.terminalThemes)
self?.sshSettings.applyRemoteSnapshot(snapshot.ssh)
self?.sftpSettings.applyRemoteSnapshot(snapshot.sftp)
self?.workspaceSettings.applyRemoteSnapshot(snapshot.workspace)
+1 -1
View File
@@ -120,7 +120,7 @@ struct RootView: View {
MetricsDetailPagePicker()
}
ToolbarItem(placement: .primaryAction) {
WindowToolbarSearchSlot()
WindowToolbarTrailingActions()
}
}
.sheet(
+6
View File
@@ -469,6 +469,12 @@ struct TermindApp: App {
if awaitingTermination { return .terminateLater }
awaitingTermination = true
Task { @MainActor [weak self] in
app.saveTerminalThemeChanges()
if app.hasUnsavedTerminalThemes, !WorkspaceCloseConfirmation.confirmDiscardTerminalTheme() {
self?.awaitingTermination = false
NSApp.reply(toApplicationShouldTerminate: false)
return
}
if app.hasOpenWorkspaces, !WorkspaceCloseConfirmation.application.confirm() {
self?.awaitingTermination = false
NSApp.reply(toApplicationShouldTerminate: false)
+5 -22
View File
@@ -7,6 +7,7 @@ import TermindPresentation
struct ResolvedTerminalAppearance: Equatable {
var preferences: TerminalPreferences
var font: TerminalFontPreferences
var library: TerminalThemeLibrary
}
/// The live terminal appearance selected in Settings.
@@ -14,32 +15,14 @@ struct ResolvedTerminalAppearance: Equatable {
/// Only settings changes reconfigure the surface. Its live background is observed separately.
@propertyWrapper
struct TerminalAppearanceSettings: DynamicProperty {
@Environment(AppModel.self) private var app
@TerminalFontSettings private var font
@AppStorage(SettingsKey.terminalThemeDark) private var darkTheme =
SettingsKey.defaultTerminalThemeDark
@AppStorage(SettingsKey.terminalThemeLight) private var lightTheme =
SettingsKey.defaultTerminalThemeLight
@AppStorage(SettingsKey.terminalCustomConfigurationDarkEnabled)
private var darkCustomEnabled = false
@AppStorage(SettingsKey.terminalCustomConfigurationLightEnabled)
private var lightCustomEnabled = false
@AppStorage(SettingsKey.terminalCustomConfigurationDark)
private var darkCustomConfiguration = ""
@AppStorage(SettingsKey.terminalCustomConfigurationLight)
private var lightCustomConfiguration = ""
var wrappedValue: ResolvedTerminalAppearance {
let preferences = TerminalPreferences(
darkTheme: darkTheme,
lightTheme: lightTheme,
darkCustomEnabled: darkCustomEnabled,
lightCustomEnabled: lightCustomEnabled,
darkCustomConfiguration: darkCustomConfiguration,
lightCustomConfiguration: lightCustomConfiguration
)
return ResolvedTerminalAppearance(
preferences: preferences,
font: font.resolvingFamily(in: TerminalFontCatalog.shared.families)
preferences: app.terminalSettings.preferences,
font: font.resolvingFamily(in: TerminalFontCatalog.shared.families),
library: app.terminalSettings.themeLibrary
)
}
}
+46 -8
View File
@@ -67,7 +67,8 @@ enum SettingsRoute: Hashable {
case localMCPServer
case skill(AgentSkill.ID, isNew: Bool)
case terminalThemes(TerminalCustomConfigurationScheme)
case terminalConfiguration(TerminalCustomConfigurationScheme)
case terminalCustomThemes
case terminalCustomTheme(UUID)
case logging
var parent: SettingsRoute? {
@@ -88,8 +89,10 @@ enum SettingsRoute: Hashable {
.category(.ai)
case .terminalThemes:
.category(.terminal)
case .terminalConfiguration(let scheme):
.terminalThemes(scheme)
case .terminalCustomThemes:
.category(.terminal)
case .terminalCustomTheme:
.terminalCustomThemes
case .logging:
.index
}
@@ -111,6 +114,21 @@ struct SettingsPageView: View {
.onDisappear {
cleanUpDraft(from: window.settingsRoute, movingTo: .index)
}
.confirmationDialog(
window.terminalThemeDraft?.isNew == true
? AppStrings.Settings.discardNewCustomThemeQuestion : AppStrings.Common.discardChangesQuestion,
isPresented: Binding(
get: { window.showsDiscardTerminalTheme },
set: { if !$0 { window.showsDiscardTerminalTheme = false } }),
titleVisibility: .visible
) {
Button(AppStrings.Common.discardChanges.swiftUIKey, role: .destructive) {
window.discardTerminalThemeAndNavigate()
}
Button(AppStrings.Common.cancel.swiftUIKey, role: .cancel) {
window.cancelTerminalThemeNavigation()
}
}
}
@ViewBuilder
@@ -148,9 +166,16 @@ struct SettingsPageView: View {
case .skill(let id, _):
SkillEditorPage(skillID: id, deleted: { navigate(to: .category(.ai)) })
case .terminalThemes(let scheme):
TerminalThemePage(scheme: scheme, editConfiguration: { navigate(to: .terminalConfiguration(scheme)) })
case .terminalConfiguration(let scheme):
TerminalCustomConfigurationEditorPage(scheme: scheme)
TerminalThemePage(scheme: scheme)
case .terminalCustomThemes:
TerminalCustomThemesPage()
case .terminalCustomTheme:
if let draft = window.terminalThemeDraft {
TerminalCustomConfigurationEditorPage(
draft: draft, model: app.terminalSettings,
availableFontFamilies: TerminalFontCatalog.shared.families
)
}
case .logging:
SettingsFormPage { LoggingFilesSettings() }
}
@@ -175,7 +200,8 @@ struct SettingsPageView: View {
case .terminal:
SettingsFormPage {
TerminalSettings(
chooseTheme: { navigate(to: .terminalThemes($0)) }
chooseTheme: { navigate(to: .terminalThemes($0)) },
manageCustomThemes: { navigate(to: .terminalCustomThemes) }
)
}
case .connections:
@@ -590,6 +616,7 @@ private struct ConnectionSettings: View {
private struct TerminalSettings: View {
let chooseTheme: (TerminalCustomConfigurationScheme) -> Void
let manageCustomThemes: () -> Void
@Environment(AppModel.self) private var app
@TerminalFontSettings private var font
@Environment(\.scenePhase) private var scenePhase
@@ -601,7 +628,9 @@ private struct TerminalSettings: View {
}
private func themeRow(_ scheme: TerminalCustomConfigurationScheme) -> some View {
let options = TerminalThemeOptions(preferences: app.terminalSettings.preferences, isDark: scheme == .dark)
let options = TerminalThemeOptions(
preferences: app.terminalSettings.preferences, isDark: scheme == .dark,
library: app.terminalSettings.themeLibrary)
return Button {
chooseTheme(scheme)
} label: {
@@ -628,6 +657,15 @@ private struct TerminalSettings: View {
Section(AppStrings.Settings.theme.swiftUIKey) {
themeRow(.light)
themeRow(.dark)
Button(action: manageCustomThemes) {
HStack {
Text(AppStrings.Settings.customThemes)
Spacer()
Image(systemName: "chevron.right").font(.caption).foregroundStyle(.tertiary)
.accessibilityHidden(true)
}
}
.buttonStyle(.plain)
}
Section(AppStrings.Settings.font.swiftUIKey) {
TerminalFontFields(preferences: $font, families: TerminalFontCatalog.shared.families)
@@ -0,0 +1,183 @@
import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
import UniformTypeIdentifiers
struct TerminalCustomConfigurationEditorPage: View {
@Bindable private var draft: TerminalCustomThemeDraft
private let model: TerminalSettingsModel
private let availableFontFamilies: [String]
@Environment(WindowModel.self) private var window
@Environment(\.dynamicTypeSize) private var typeSize
@Environment(\.locale) private var locale
@TerminalFontSettings private var font
@State private var importError: String?
@State private var fieldsHeight: CGFloat = 0
@State private var headingHeight: CGFloat = 0
init(
draft: TerminalCustomThemeDraft, model: TerminalSettingsModel, availableFontFamilies: [String] = []
) {
self.draft = draft
self.model = model
self.availableFontFamilies = availableFontFamilies
}
var body: some View {
@Bindable var window = window
GeometryReader { geometry in
let contentWidth = geometry.size.width - 8
let sideBySide = contentWidth >= 700 && !typeSize.isAccessibilitySize
let panelHeight = max(460, geometry.size.height - fieldsHeight - 24)
let layout =
sideBySide
? AnyLayout(HStackLayout(alignment: .top, spacing: 16))
: AnyLayout(VStackLayout(alignment: .leading, spacing: 16))
ScrollView {
VStack(alignment: .leading, spacing: 16) {
fields(width: contentWidth)
.onGeometryChange(for: CGFloat.self) {
$0.size.height
} action: {
fieldsHeight = $0
}
layout {
VStack(alignment: .leading, spacing: 12) {
Text(AppStrings.Settings.customConfiguration)
.font(.headline)
.onGeometryChange(for: CGFloat.self) {
$0.size.height
} action: {
headingHeight = $0
}
TextEditor(text: $draft.value.configuration)
.font(.system(.body, design: .monospaced))
.textContentType(.none)
.autocorrectionDisabled()
.frame(maxWidth: .infinity)
.frame(height: sideBySide ? panelHeight - headingHeight - 12 : 360)
.clipShape(.rect(cornerRadius: 8))
.overlay {
RoundedRectangle(cornerRadius: 8)
.strokeBorder(.separator, lineWidth: 1)
.allowsHitTesting(false)
}
.accessibilityLabel(AppStrings.Settings.customConfiguration.swiftUIKey)
}
.frame(maxWidth: .infinity)
TerminalConfigurationPreview(
themeName: draft.value.baseTheme.name, text: draft.value.configuration,
isDark: draft.value.baseTheme.isDark,
font: font.resolvingFamily(in: availableFontFamilies)
)
.frame(maxWidth: .infinity)
.frame(height: sideBySide ? panelHeight : nil)
}
}
// Native text-field focus rings extend outside the control's layout bounds.
.padding(4)
}
}
.padding(12)
.background(TermindColor.windowSurface)
.onAppear {
guard window.terminalThemeDraft === draft else { return }
window.terminalThemeSettings = model
}
.task(id: draft.value) {
do { try await Task.sleep(for: .milliseconds(300)) } catch { return }
// A discarded or deleted draft must never be saved by a pending task.
guard !Task.isCancelled, window.terminalThemeDraft === draft else { return }
window.saveTerminalThemeChanges(allowNameTrimming: false)
}
.onChange(of: model.themeLibrary) { _, _ in draft.refresh(from: model) }
.fileImporter(isPresented: $window.isImportingTerminalTheme, allowedContentTypes: [.plainText, .data]) {
result in
do { try importFile(result.get()) } catch { importError = error.localizedDescription }
}
.dropDestination(for: URL.self) { urls, _ in
guard let url = urls.first else { return false }
do { try importFile(url) } catch { importError = error.localizedDescription }
return true
}
.alert(
AppStrings.Settings.deleteCustomThemeConfirmation,
isPresented: $window.isDeletingTerminalTheme
) {
Button(AppStrings.Common.delete.swiftUIKey, role: .destructive) {
guard model.deleteCustomTheme(id: draft.id) else { return }
window.clearTerminalThemeEditor()
window.settingsRoute = .terminalCustomThemes
}
Button(AppStrings.Common.cancel.swiftUIKey, role: .cancel) {}
} message: {
Text(AppStrings.Settings.deleteCustomThemeWarning)
}
}
private func fields(width: CGFloat) -> some View {
let compact = width < 520 || typeSize.isAccessibilitySize
let layout =
compact
? AnyLayout(VStackLayout(alignment: .leading, spacing: 12))
: AnyLayout(HStackLayout(alignment: .top, spacing: 16))
return VStack(alignment: .leading, spacing: 12) {
layout {
VStack(alignment: .leading, spacing: 6) {
Text(AppStrings.Common.name).font(.subheadline).foregroundStyle(.secondary)
TextField(AppStrings.Common.name.swiftUIKey, text: $draft.value.name)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("custom-theme-name")
}
.frame(maxWidth: .infinity, alignment: .leading)
VStack(alignment: .leading, spacing: 6) {
Text(AppStrings.Settings.appearance).font(.subheadline).foregroundStyle(.secondary)
Picker(AppStrings.Settings.appearance.swiftUIKey, selection: $draft.value.appearance) {
ForEach(TerminalThemeAppearance.allCases, id: \.self) { appearance in
Text(appearance.title).tag(appearance)
}
}
.labelsHidden()
.pickerStyle(.menu)
.accessibilityIdentifier("custom-theme-appearance")
}
.frame(minWidth: 150, maxWidth: compact ? .infinity : nil, alignment: .leading)
}
.controlSize(.regular)
.frame(maxWidth: compact ? .infinity : 640, alignment: .leading)
if TermindTerminalThemeCatalog.theme(named: draft.value.baseThemeName) == nil {
Text(AppStrings.Settings.unavailableThemeFallback(draft.value.baseTheme.name))
.font(.caption).foregroundStyle(.secondary)
}
if draft.isDeleted(in: model) {
Text(AppStrings.Settings.customThemeDeleted).font(.caption).foregroundStyle(.orange)
Button(AppStrings.Settings.saveThemeCopy.swiftUIKey, systemImage: "doc.on.doc") {
draft.makeCopy(
name: AppLocalization.resolve(
AppStrings.Settings.customThemeCopy(draft.value.name), locale: locale))
window.saveTerminalThemeChanges()
}
.disabled(!draft.value.isValid)
}
if let error = importError ?? model.storageError {
Text(error).font(.caption).foregroundStyle(.red).textSelection(.enabled)
}
Text(AppStrings.Settings.terminalConfigurationExplanation)
.font(.caption).foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private func importFile(_ url: URL) throws {
let access = url.startAccessingSecurityScopedResource()
defer { if access { url.stopAccessingSecurityScopedResource() } }
let data = try Data(contentsOf: url)
guard let text = String(data: data, encoding: .utf8) else {
importError = AppLocalization.resolve(AppStrings.Settings.fileIsNotValidUtf8Text, locale: locale)
return
}
draft.value.configuration = text
importError = nil
}
}
@@ -1,172 +0,0 @@
import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
import UniformTypeIdentifiers
nonisolated enum TerminalCustomConfigurationScheme: String, Identifiable, Hashable, Sendable {
case light
case dark
var id: String { rawValue }
var preferenceKeyPath: WritableKeyPath<TerminalPreferences, String> {
switch self {
case .light: \.lightCustomConfiguration
case .dark: \.darkCustomConfiguration
}
}
var title: String {
switch self {
case .light: String(localized: AppStrings.Settings.lightCustomConfiguration)
case .dark: String(localized: AppStrings.Settings.darkCustomConfiguration)
}
}
}
struct TerminalCustomConfigurationEditorPage: View {
@Environment(AppModel.self) private var app
@Environment(\.dynamicTypeSize) private var typeSize
let scheme: TerminalCustomConfigurationScheme
@TerminalFontSettings private var font
@State private var content = ""
@State private var lastSavedContent = ""
@State private var isLoaded = false
@State private var isImporting = false
@State private var importError: String?
@State private var saveTask: Task<Void, Never>?
var body: some View {
VStack(spacing: 0) {
if let importError = importError ?? app.terminalSettings.storageError {
HStack(spacing: Metrics.Space.m) {
Label(importError, systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(.orange)
Spacer()
Button {
self.importError = nil
} label: {
Image(systemName: "xmark")
}
.buttonStyle(.plain)
.accessibilityLabel(Text(AppStrings.Settings.dismissError))
}
.padding(.horizontal, Metrics.Space.xl)
.padding(.vertical, Metrics.Space.s)
.background(Color.orange.opacity(0.08))
Divider()
}
HStack {
Spacer()
Button(AppStrings.Settings.importFromFile.swiftUIKey) { isImporting = true }
}
.padding(.horizontal, Metrics.Space.l)
.padding(.vertical, Metrics.Space.s)
Divider()
Text(AppStrings.Settings.terminalConfigurationExplanation)
.font(.caption).foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, Metrics.Space.m)
GeometryReader { geometry in
let sideBySide = geometry.size.width >= 700 && !typeSize.isAccessibilitySize
let editorHeight = sideBySide ? max(360, geometry.size.height) : 360
let layout =
sideBySide
? AnyLayout(HStackLayout(alignment: .top, spacing: 16))
: AnyLayout(VStackLayout(alignment: .leading, spacing: 16))
// Bound the editor before the scroll view proposes an unlimited content height.
// Keep the same children when resizing so selection and the preview surface survive.
ScrollView {
layout {
TextEditor(text: $content)
.font(.system(.body, design: .monospaced))
.textContentType(.none)
.autocorrectionDisabled()
.frame(maxWidth: .infinity)
.frame(height: editorHeight)
.accessibilityLabel(AppStrings.Settings.customConfiguration.swiftUIKey)
TerminalConfigurationPreview(
themeName: scheme == .dark
? app.terminalSettings.preferences.darkTheme
: app.terminalSettings.preferences.lightTheme,
text: content, isDark: scheme == .dark,
font: font.resolvingFamily(in: TerminalFontCatalog.shared.families)
)
.frame(maxWidth: .infinity)
.frame(height: sideBySide ? editorHeight : nil)
}
}
}
.padding(Metrics.Space.m)
}
.onAppear {
content = app.terminalSettings.preferences[keyPath: scheme.preferenceKeyPath]
lastSavedContent = content
isLoaded = true
}
.onChange(of: content) { _, newValue in scheduleSave(newValue) }
.onChange(of: app.terminalSettings.preferences[keyPath: scheme.preferenceKeyPath]) { _, value in
if content == lastSavedContent {
lastSavedContent = value
content = value
}
}
.onDisappear { saveNow() }
.dropDestination(for: URL.self) { urls, _ in
guard let url = urls.first else { return false }
importFile(url)
return true
}
.fileImporter(
isPresented: $isImporting,
allowedContentTypes: [.plainText, .data],
allowsMultipleSelection: false
) { result in
do {
guard let url = try result.get().first else { return }
importFile(url)
} catch {
importError = error.localizedDescription
}
}
}
private func scheduleSave(_ value: String) {
saveTask?.cancel()
guard isLoaded, value != lastSavedContent else { return }
saveTask = Task { @MainActor in
try? await Task.sleep(for: .milliseconds(300))
guard !Task.isCancelled else { return }
if app.terminalSettings.set(value, for: scheme.preferenceKeyPath) { lastSavedContent = value }
}
}
private func saveNow() {
saveTask?.cancel()
guard isLoaded, content != lastSavedContent else { return }
if app.terminalSettings.set(content, for: scheme.preferenceKeyPath) { lastSavedContent = content }
}
private func importFile(_ url: URL) {
let hasAccess = url.startAccessingSecurityScopedResource()
defer { if hasAccess { url.stopAccessingSecurityScopedResource() } }
do {
let data = try Data(contentsOf: url)
guard let imported = String(data: data, encoding: .utf8) else {
importError = String(localized: AppStrings.Settings.fileIsNotValidUtf8Text)
return
}
content = imported
} catch {
importError = error.localizedDescription
}
}
}
@@ -0,0 +1,85 @@
import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
struct TerminalCustomThemesPage: View {
@Environment(AppModel.self) private var app
@Environment(WindowModel.self) private var window
@Environment(\.locale) private var locale
@State private var deleting: TerminalCustomTheme?
var body: some View {
Group {
if app.terminalSettings.themeLibrary.themes.isEmpty {
ContentUnavailableView {
Label(AppStrings.Settings.customThemes.swiftUIKey, systemImage: "paintpalette")
} description: {
Text(AppStrings.Settings.noCustomThemes)
}
} else if app.terminalSettings.themeLibrary.themes(matching: window.customThemeQuery).isEmpty {
ContentUnavailableView {
Label(AppStrings.Settings.noMatchingThemes.swiftUIKey, systemImage: "magnifyingglass")
}
} else {
CardPage {
CardGrid {
ForEach(app.terminalSettings.themeLibrary.themes(matching: window.customThemeQuery)) { theme in
CardTile(activate: { edit(theme) }, activatesOnReturn: true, activatesOnSingleClick: true) {
TerminalCustomThemeSummary(theme: theme, library: app.terminalSettings.themeLibrary)
.padding(.trailing, 24)
}
.contextMenu { actions(theme) }
.overlay(alignment: .topTrailing) {
Menu {
actions(theme)
} label: {
Label(AppStrings.Common.edit.swiftUIKey, systemImage: "ellipsis").labelStyle(
.iconOnly)
}
.menuStyle(.borderlessButton)
.fixedSize()
.padding(10)
}
}
}
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(TermindColor.windowSurface)
.safeAreaInset(edge: .top) {
if let error = app.terminalSettings.storageError {
Text(error).font(.caption).foregroundStyle(.red).padding()
}
}
.alert(
AppStrings.Settings.deleteCustomThemeConfirmation,
isPresented: Binding(get: { deleting != nil }, set: { if !$0 { deleting = nil } })
) {
Button(AppStrings.Common.delete.swiftUIKey, role: .destructive) {
if let deleting { app.terminalSettings.deleteCustomTheme(id: deleting.id) }
deleting = nil
}
Button(AppStrings.Common.cancel.swiftUIKey, role: .cancel) { deleting = nil }
} message: {
Text(AppStrings.Settings.deleteCustomThemeWarning)
}
}
@ViewBuilder private func actions(_ theme: TerminalCustomTheme) -> some View {
Button(AppStrings.Common.edit.swiftUIKey, systemImage: "pencil") { edit(theme) }
Button(AppStrings.Common.duplicate.swiftUIKey, systemImage: "doc.on.doc") {
var copy = theme
copy.id = UUID()
copy.name = AppLocalization.resolve(AppStrings.Settings.customThemeCopy(theme.name), locale: locale)
edit(copy, isNew: true)
}
Divider()
Button(AppStrings.Common.delete.swiftUIKey, systemImage: "trash", role: .destructive) { deleting = theme }
}
private func edit(_ theme: TerminalCustomTheme, isNew: Bool = false) {
window.beginEditingTerminalTheme(theme, isNew: isNew, in: app.terminalSettings)
}
}
+30 -49
View File
@@ -1,66 +1,65 @@
import GhosttyTheme
import SwiftUI
import TermindDomain
import TermindPresentation
struct TerminalThemePage: View {
let scheme: TerminalCustomConfigurationScheme
let editConfiguration: () -> Void
@Environment(AppModel.self) private var app
@Environment(WindowModel.self) private var window
private var query: String { window.terminalThemeQueries[scheme, default: ""] }
@State private var hoveredTheme: String?
private var query: String { window.terminalThemeQueries[scheme, default: ""] }
private var options: TerminalThemeOptions {
.init(preferences: app.terminalSettings.preferences, isDark: scheme == .dark)
.init(
preferences: app.terminalSettings.preferences, isDark: scheme == .dark,
library: app.terminalSettings.themeLibrary)
}
var body: some View {
CardPage {
if let error = app.terminalSettings.storageError {
Text(error).foregroundStyle(.red)
}
if let error = app.terminalSettings.storageError { Text(error).foregroundStyle(.red) }
TerminalThemeAvailabilityNotice(options: options)
CardGrid {
ForEach(options.themes(matching: query)) { theme in
tile(theme: theme, custom: false)
if !options.customThemes(matching: query).isEmpty {
Text(AppStrings.Settings.customThemes).font(.headline)
CardGrid {
ForEach(options.customThemes(matching: query)) { theme in
tile(theme: theme.baseTheme, custom: theme)
}
}
tile(theme: options.baseTheme, custom: true)
}
if options.themes(matching: query).isEmpty {
if !options.themes(matching: query).isEmpty {
Text(AppStrings.Settings.builtInThemes).font(.headline)
CardGrid {
ForEach(options.themes(matching: query)) { theme in tile(theme: theme) }
}
} else if options.customThemes(matching: query).isEmpty {
Text(AppStrings.Settings.noMatchingThemes).foregroundStyle(.secondary)
}
}
}
private func tile(theme: GhosttyThemeDefinition, custom: Bool) -> some View {
let hoverID = custom ? "__custom__" : theme.name
let selected = custom ? options.isCustom : options.isSelected(theme.name)
private func tile(theme: GhosttyThemeDefinition, custom: TerminalCustomTheme? = nil) -> some View {
let hoverID = custom?.id.uuidString ?? theme.name
let selected = custom.map { options.selectedCustomTheme?.id == $0.id } ?? options.isSelected(theme.name)
let select: () -> Void = {
let saved =
custom
? app.terminalSettings.selectCustomTheme(isDark: scheme == .dark)
: app.terminalSettings.selectTheme(named: theme.name, isDark: scheme == .dark)
let saved: Bool
if let custom {
saved = app.terminalSettings.selectCustomTheme(id: custom.id, isDark: scheme == .dark)
} else {
saved = app.terminalSettings.selectTheme(named: theme.name, isDark: scheme == .dark)
}
if saved { window.navigateBackInSettings() }
}
return CardTile(
isSelected: selected,
activate: select, activatesOnReturn: true, activatesOnSingleClick: true
) {
return CardTile(isSelected: selected, activate: select, activatesOnReturn: true, activatesOnSingleClick: true) {
HStack(spacing: Metrics.Space.m) {
TerminalThemeThumbnail(
theme: theme, customText: custom ? options.customText : "", showsPalette: hoveredTheme == hoverID
theme: theme, customText: custom?.configuration ?? "", showsPalette: hoveredTheme == hoverID
)
.frame(width: 108)
if custom {
Text(AppStrings.Settings.customTheme).font(.headline)
} else {
Text(verbatim: theme.name).font(.headline)
}
Text(verbatim: custom?.name ?? theme.name).font(.headline)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.trailing, custom ? Metrics.Space.xl : 0)
}
.onHover { hovering in
if hovering { hoveredTheme = hoverID } else if hoveredTheme == hoverID { hoveredTheme = nil }
@@ -71,23 +70,5 @@ struct TerminalThemePage: View {
select()
return .handled
}
.contextMenu {
if custom {
Button(
AppStrings.Common.edit.swiftUIKey, systemImage: "pencil",
action: editConfiguration)
}
}
.overlay(alignment: .topTrailing) {
if custom {
Button(action: editConfiguration) {
Label(AppStrings.Common.edit.swiftUIKey, systemImage: "pencil")
.labelStyle(.iconOnly)
}
.buttonStyle(.borderless)
.help(Text(AppStrings.Common.edit))
.padding(Metrics.Space.m)
}
}
}
}
@@ -0,0 +1,8 @@
import Foundation
nonisolated enum TerminalCustomConfigurationScheme: String, Identifiable, Hashable, Sendable {
case light
case dark
var id: String { rawValue }
}
+2 -2
View File
@@ -72,8 +72,8 @@ struct SettingsWindowTitleProvider: WindowTitleProviding {
case .terminalThemes(let scheme):
return AppLocalization.resolve(
scheme == .dark ? AppStrings.Settings.darkTheme : AppStrings.Settings.lightTheme, locale: locale)
case .terminalConfiguration(let scheme):
return scheme.title
case .terminalCustomThemes, .terminalCustomTheme:
return AppLocalization.resolve(AppStrings.Settings.customThemes, locale: locale)
case .logging:
return AppLocalization.resolve(AppStrings.Settings.logging, locale: locale)
}
@@ -108,10 +108,10 @@ private struct DesktopTerminalChrome: ViewModifier {
content
.background { TerminalBackground(state: state) }
.onAppear {
state.applyPreferences(appearance.preferences, font: appearance.font)
state.applyPreferences(appearance.preferences, font: appearance.font, library: appearance.library)
}
.onChange(of: appearance) { _, value in
state.applyPreferences(value.preferences, font: value.font)
state.applyPreferences(value.preferences, font: value.font, library: value.library)
}
}
}
+77 -1
View File
@@ -33,6 +33,22 @@ final class WindowModel {
/// Workspaces. This is the single source of truth for workspace selection.
var selectedDestination: WindowDestination = .vault {
didSet {
if oldValue == .settings, selectedDestination != oldValue {
saveTerminalThemeChanges()
}
if oldValue == .settings, selectedDestination != oldValue, hasUnsavedTerminalThemeChanges {
let next = selectedDestination
selectedDestination = oldValue
pendingTerminalThemeNavigation = { [weak self] in
self?.settingsRoute = .terminalCustomThemes
self?.selectedDestination = next
}
showsDiscardTerminalTheme = true
return
}
if oldValue == .settings, selectedDestination != oldValue, case .terminalCustomTheme = settingsRoute {
settingsRoute = .terminalCustomThemes
}
if selectedDestination != .assistant {
assistantReturnDestination = selectedDestination
}
@@ -76,7 +92,31 @@ final class WindowModel {
}
}
var metricsSelection: Set<UUID> = []
var settingsRoute: SettingsRoute = .index
var settingsRoute: SettingsRoute = .index {
didSet {
guard oldValue != settingsRoute, case .terminalCustomTheme = oldValue else { return }
saveTerminalThemeChanges()
if hasUnsavedTerminalThemeChanges {
let next = settingsRoute
settingsRoute = oldValue
pendingTerminalThemeNavigation = { [weak self] in self?.settingsRoute = next }
showsDiscardTerminalTheme = true
} else {
clearTerminalThemeEditor()
}
}
}
var terminalThemeDraft: TerminalCustomThemeDraft?
var hasUnsavedTerminalThemeChanges: Bool {
guard let draft = terminalThemeDraft else { return false }
return draft.isDirty || (draft.isNew && draft.value.isValid)
}
@ObservationIgnored weak var terminalThemeSettings: TerminalSettingsModel?
var isImportingTerminalTheme = false
var isDeletingTerminalTheme = false
var customThemeQuery = ""
var showsDiscardTerminalTheme = false
@ObservationIgnored private var pendingTerminalThemeNavigation: (() -> Void)?
var terminalThemeQueries: [TerminalCustomConfigurationScheme: String] = [:]
var vaultDestination: VaultDestination = .welcome
var vaultWorkRoute: VaultWorkRoute?
@@ -400,6 +440,7 @@ final class WindowModel {
return metricsHostID == nil ? .metrics : .processes
case .settings:
if case .terminalThemes = settingsRoute { return .terminalThemes }
if case .terminalCustomThemes = settingsRoute { return .terminalThemes }
return nil
case .workspace:
if focusedSSHSession != nil || focusedTelnetSession != nil || focusedSerialSession != nil {
@@ -608,6 +649,41 @@ final class WindowModel {
}
}
func discardTerminalThemeAndNavigate() {
let navigate = pendingTerminalThemeNavigation
clearTerminalThemeEditor()
cancelTerminalThemeNavigation()
navigate?()
}
func beginEditingTerminalTheme(_ theme: TerminalCustomTheme, isNew: Bool, in model: TerminalSettingsModel) {
terminalThemeDraft = TerminalCustomThemeDraft(theme, isNew: isNew)
terminalThemeSettings = model
settingsRoute = .terminalCustomTheme(theme.id)
}
func saveTerminalThemeChanges(allowNameTrimming: Bool = true) {
guard let draft = terminalThemeDraft, let model = terminalThemeSettings,
draft.isNew || draft.isDirty, draft.value.isValid, !draft.isDeleted(in: model)
else { return }
// Do not remove a space between words while the user is still typing a name.
guard allowNameTrimming || draft.value.name == draft.value.name.trimmingCharacters(in: .whitespacesAndNewlines)
else { return }
_ = draft.save(in: model)
}
func clearTerminalThemeEditor() {
terminalThemeDraft = nil
terminalThemeSettings = nil
isImportingTerminalTheme = false
isDeletingTerminalTheme = false
}
func cancelTerminalThemeNavigation() {
pendingTerminalThemeNavigation = nil
showsDiscardTerminalTheme = false
}
/// Promotes a compact Sidebar preview into the main Assistant surface while
/// keeping the conversation navigator visible in the shared Sidebar.
func openAssistantFullScreen(_ id: ConversationModel.ID? = nil) {
+27 -3
View File
@@ -27,6 +27,14 @@ enum WorkspaceCloseConfirmation {
func confirm(present: (NSAlert) -> NSApplication.ModalResponse = { $0.runModal() }) -> Bool {
present(makeAlert()) == .alertFirstButtonReturn
}
static func confirmDiscardTerminalTheme() -> Bool {
let alert = NSAlert()
alert.messageText = String(localized: AppStrings.Common.discardChangesQuestion)
alert.addButton(withTitle: String(localized: AppStrings.Common.cancel))
alert.addButton(withTitle: String(localized: AppStrings.Common.discardChanges))
return alert.runModal() == .alertSecondButtonReturn
}
}
/// Window dismissal and application termination have different scopes. Keep the
@@ -36,12 +44,25 @@ nonisolated final class WorkspaceWindowCloseDelegate: NSObject, NSWindowDelegate
@MainActor private weak var window: NSWindow?
@MainActor private let model: WindowModel
@MainActor private let confirmClose: () -> Bool
@MainActor private let confirmDiscardTheme: () -> Bool
@MainActor private var isConfirming = false
@MainActor
init(model: WindowModel, confirmClose: @escaping () -> Bool = { WorkspaceCloseConfirmation.window.confirm() }) {
convenience init(
model: WindowModel, confirmClose: @escaping () -> Bool = { WorkspaceCloseConfirmation.window.confirm() }
) {
self.init(
model: model, confirmClose: confirmClose,
confirmDiscardTheme: { WorkspaceCloseConfirmation.confirmDiscardTerminalTheme() })
}
@MainActor
init(
model: WindowModel, confirmClose: @escaping () -> Bool, confirmDiscardTheme: @escaping () -> Bool
) {
self.model = model
self.confirmClose = confirmClose
self.confirmDiscardTheme = confirmDiscardTheme
super.init()
}
@@ -69,9 +90,12 @@ nonisolated final class WorkspaceWindowCloseDelegate: NSObject, NSWindowDelegate
func windowShouldClose(_ sender: NSWindow) -> Bool {
guard !isConfirming else { return false }
guard forwardedDelegate?.windowShouldClose?(sender) != false else { return false }
guard !model.isClosed, !model.workspaces.isEmpty else { return true }
isConfirming = true
defer { isConfirming = false }
return confirmClose()
model.saveTerminalThemeChanges()
if model.hasUnsavedTerminalThemeChanges, !confirmDiscardTheme() { return false }
if !model.isClosed, !model.workspaces.isEmpty, !confirmClose() { return false }
model.clearTerminalThemeEditor()
return true
}
}
+65 -4
View File
@@ -433,7 +433,7 @@ struct WindowToolbarSearchHost: View {
var body: some View {
if #available(macOS 26.0, *) {
Group {
if window.findKind != nil {
if window.findKind != nil && window.findKind != .terminalThemes {
searchableHost
} else {
Color.clear
@@ -574,12 +574,16 @@ struct WindowToolbarFindField: View {
@Environment(\.locale) private var locale
@State private var searchFieldOwner = NativeSearchFieldOwner()
private var prompt: LocalizedStringResource {
window.findKind == .terminalThemes ? AppStrings.Settings.searchThemes : AppStrings.Common.search
}
var body: some View {
Group {
if let context = window.findContext() {
NativeSearchField(
text: context.query,
placeholder: AppLocalization.resolve(AppStrings.Common.search, locale: locale),
placeholder: AppLocalization.resolve(prompt, locale: locale),
owner: searchFieldOwner,
focusRequest: context.focusRequest,
maintainsKeyboardFocus: window.isToolbarFindFocused,
@@ -589,9 +593,9 @@ struct WindowToolbarFindField: View {
onFocusChange: { window.isToolbarFindFocused = $0 }
).singleLinePaste()
.frame(width: Metrics.Toolbar.searchFieldWidth)
.help(Text(AppLocalization.resolve(AppStrings.Common.search, locale: locale)))
.help(Text(AppLocalization.resolve(prompt, locale: locale)))
.accessibilityLabel(
Text(AppLocalization.resolve(AppStrings.Common.search, locale: locale))
Text(AppLocalization.resolve(prompt, locale: locale))
)
} else {
// Zero width, full height. A toolbar with nothing in it is
@@ -618,6 +622,7 @@ struct WindowToolbarLeadingActions: View {
@Environment(AppModel.self) private var app
@Environment(WindowModel.self) private var window
@Environment(\.locale) private var locale
@Environment(\.colorScheme) private var colorScheme
var body: some View {
let actions = window.creationActions
@@ -642,6 +647,21 @@ struct WindowToolbarLeadingActions: View {
}
.keyboardShortcut(.cancelAction)
.help(Text(AppStrings.Common.back))
if window.settingsRoute == .terminalCustomThemes {
Button {
let dark = colorScheme == .dark
let options = TerminalThemeOptions(
preferences: app.terminalSettings.preferences, isDark: dark,
library: app.terminalSettings.themeLibrary)
window.beginEditingTerminalTheme(
.init(name: "", appearance: dark ? .dark : .light, baseThemeName: options.baseTheme.name),
isNew: true, in: app.terminalSettings)
} label: {
Label(AppStrings.Settings.newCustomTheme.swiftUIKey, systemImage: "plus")
.labelStyle(.iconOnly)
}
.help(Text(AppStrings.Settings.newCustomTheme))
}
} else if actions.count == 1, let only = actions.first {
Button(action: only.perform) {
Label(
@@ -671,6 +691,42 @@ struct WindowToolbarLeadingActions: View {
}
}
struct WindowToolbarTrailingActions: View {
@Environment(AppModel.self) private var app
@Environment(WindowModel.self) private var window
var body: some View {
if window.selectedDestination == .settings,
case .terminalCustomTheme = window.settingsRoute, let draft = window.terminalThemeDraft
{
HStack {
Button {
window.isImportingTerminalTheme = true
} label: {
Label(AppStrings.Settings.importFromFile.swiftUIKey, systemImage: "square.and.arrow.down")
.labelStyle(.iconOnly)
}
.help(Text(AppStrings.Settings.importFromFile))
if !draft.isNew, !draft.isDeleted(in: app.terminalSettings) {
Button(role: .destructive) {
window.isDeletingTerminalTheme = true
} label: {
Label(AppStrings.Common.delete.swiftUIKey, systemImage: "trash")
.labelStyle(.iconOnly)
}
.help(Text(AppStrings.Common.delete))
}
}
.frame(height: Metrics.Toolbar.height)
} else if window.findKind == .terminalThemes {
// Theme libraries keep a visible field, including their empty and no-results states.
WindowToolbarFindField()
} else {
WindowToolbarSearchSlot()
}
}
}
/// One thing the titlebar's New control can make.
struct WindowToolbarCreationAction: Identifiable {
let id: String
@@ -835,6 +891,11 @@ extension WindowModel {
guard let kind = findKind else { return nil }
switch kind {
case .terminalThemes:
if case .terminalCustomThemes = settingsRoute {
return WindowToolbarFindContext(
query: Bindable(self).projectedValue.customThemeQuery,
focusRequest: findFocusRequest)
}
guard case .terminalThemes(let scheme) = settingsRoute else { return nil }
return WindowToolbarFindContext(
query: Binding(
+1 -1
View File
@@ -80,7 +80,7 @@ struct CardPageLayoutReviewTests {
("metrics", AnyView(CardPage { ServerMetricsCard(server: server, model: app.serverMetrics) })),
(
"themes",
AnyView(TerminalThemePage(scheme: scheme == .dark ? .dark : .light, editConfiguration: {}))
AnyView(TerminalThemePage(scheme: scheme == .dark ? .dark : .light))
),
]
for (name, page) in pages {
@@ -10,11 +10,97 @@ import Testing
/// Offscreen hosting leaves the user's windows and keyboard focus untouched.
@Suite(.serialized) @MainActor
struct TerminalThemePageRenderingTests {
@Test(
.enabled(if: ProcessInfo.processInfo.environment["TERMIND_THEME_SCREENSHOTS"] == "1"),
arguments: ["en", "zh-Hans"], [CGFloat(650), CGFloat(1000)])
func captureCustomThemeChrome(language: String, width: CGFloat) async throws {
let model = TerminalSettingsModel()
let app = AppModel(hosts: HostLibraryModel(), terminalSettings: model)
defer { app.shutdownForTermination() }
let navigation = WindowModel(workspaces: [])
navigation.openSettings(route: .terminalCustomThemes)
let dark = language == "zh-Hans"
let content = NavigationStack {
SettingsPageView()
.background { WindowToolbarSearchHost() }
.toolbar {
ToolbarItemGroup(placement: .navigation) { WindowToolbarLeadingActions() }
ToolbarItem(placement: .primaryAction) { WindowToolbarTrailingActions() }
}
}
.environment(app).environment(navigation)
.environment(\.locale, Locale(identifier: language))
.environment(\.colorScheme, dark ? .dark : .light)
let controller = NSHostingController(rootView: content)
controller.sizingOptions = []
let window = NSWindow(
contentRect: CGRect(x: 0, y: 0, width: width, height: 720),
styleMask: [.titled, .closable, .resizable, .fullSizeContentView], backing: .buffered, defer: false)
window.isReleasedWhenClosed = false
window.appearance = NSAppearance(named: dark ? .darkAqua : .aqua)
window.contentViewController = controller
window.setContentSize(CGSize(width: width, height: 720))
defer {
window.contentViewController = nil
window.close()
}
let theme = TerminalCustomTheme(
name: "Ocean", appearance: .both, baseThemeName: "Termind Dark",
configuration: "background = 152033\nforeground = E7EDF7\npalette = 2=#7BD88F")
for page in ["empty", "library", "search", "editor"] {
if page == "library" { model.saveCustomTheme(theme, isNew: true) }
if page == "search" { navigation.customThemeQuery = "missing" }
if page == "editor" { navigation.beginEditingTerminalTheme(theme, isNew: false, in: model) }
controller.view.layoutSubtreeIfNeeded()
try await Task.sleep(for: .milliseconds(450))
let frame = try #require(window.contentView?.superview)
frame.layoutSubtreeIfNeeded()
#expect(window.toolbar != nil)
let searchFields = descendants(in: frame, ofType: NSSearchField.self)
if page == "editor" {
#expect(searchFields.isEmpty)
} else {
let search = try #require(searchFields.first)
#expect(searchFields.count == 1)
#expect(search.bounds.width > 100)
#expect(search.isEnabled)
#expect(
search.placeholderString
== AppLocalization.resolve(
AppStrings.Settings.searchThemes, locale: Locale(identifier: language)))
#expect(search.stringValue == navigation.customThemeQuery)
if page == "library" {
search.stringValue = " oCEAn "
search.delegate?.controlTextDidChange?(
Notification(name: NSControl.textDidChangeNotification, object: search))
#expect(model.themeLibrary.themes(matching: navigation.customThemeQuery).map(\.id) == [theme.id])
#expect(navigation.customThemeQuery == " oCEAn ")
search.stringValue = ""
search.delegate?.controlTextDidChange?(
Notification(name: NSControl.textDidChangeNotification, object: search))
#expect(navigation.customThemeQuery.isEmpty)
}
}
let bitmap = try #require(frame.bitmapImageRepForCachingDisplay(in: frame.bounds))
frame.cacheDisplay(in: frame.bounds, to: bitmap)
let data = try #require(bitmap.representation(using: .png, properties: [:]))
Attachment.record(data, named: "custom-\(page)-\(language)-\(Int(width)).png")
}
#expect(!window.isVisible)
}
private func descendants<T: NSView>(in view: NSView, ofType type: T.Type) -> [T] {
(view as? T).map { [$0] } ?? view.subviews.flatMap { descendants(in: $0, ofType: type) }
}
@Test(.requiresVisibleWindow, arguments: ["en", "zh-Hans"])
func nativeThemeBrowserAndEditor(language: String) async throws {
let model = TerminalSettingsModel()
model.selectCustomTheme(isDark: true)
model.set("background = 152033\nforeground = E7EDF7\npalette = 2=#7BD88F", for: \.darkCustomConfiguration)
let theme = TerminalCustomTheme(
name: "Ocean", appearance: .both, baseThemeName: "Termind Dark",
configuration: "background = 152033\nforeground = E7EDF7\npalette = 2=#7BD88F")
model.saveCustomTheme(theme, isNew: true)
model.selectCustomTheme(id: theme.id, isDark: true)
let app = AppModel(hosts: HostLibraryModel(), terminalSettings: model)
defer { app.shutdownForTermination() }
let navigation = WindowModel(workspaces: [])
@@ -38,13 +124,16 @@ struct TerminalThemePageRenderingTests {
window.close()
}
for editor in [false, true] {
if editor { navigation.settingsRoute = .terminalConfiguration(.dark) }
if editor {
navigation.terminalThemeDraft = .init(theme, isNew: false)
navigation.settingsRoute = .terminalCustomTheme(theme.id)
}
controller.view.layoutSubtreeIfNeeded()
try await Task.sleep(for: .milliseconds(800))
// The screenshot runner captures the compositor output: cacheDisplay cannot capture Liquid Glass.
print("TERMIND_THEME_WINDOW|\(window.windowNumber)|\(language)-\(editor ? "editor" : "browser")")
try await Task.sleep(for: .seconds(3))
#expect(navigation.settingsRoute == (editor ? .terminalConfiguration(.dark) : .terminalThemes(.dark)))
#expect(navigation.settingsRoute == (editor ? .terminalCustomTheme(theme.id) : .terminalThemes(.dark)))
}
}
@@ -54,9 +143,15 @@ struct TerminalThemePageRenderingTests {
.enabled(if: ProcessInfo.processInfo.environment["TERMIND_THEME_SCREENSHOTS"] == "1"),
arguments: ["en", "zh-Hans", "zh-Hant"], [false, true])
func captureBrowserAppearances(language: String, isDark: Bool) async throws {
let initial = TerminalPreferences(darkCustomEnabled: true, lightCustomEnabled: true)
let model = TerminalSettingsModel()
model.applyRemoteSnapshot(initial)
let theme = TerminalCustomTheme(
name: "Ocean", appearance: .both, baseThemeName: "Termind Dark",
configuration: "background = 152033\nforeground = E7EDF7\npalette = 2=#7BD88F")
model.saveCustomTheme(theme, isNew: true)
model.saveCustomTheme(
.init(name: "Daylight", appearance: .light, baseThemeName: "Termind Light"), isNew: true)
model.selectCustomTheme(id: theme.id, isDark: isDark)
let initial = model.themeLibrary
let app = AppModel(hosts: HostLibraryModel(), terminalSettings: model)
defer { app.shutdownForTermination() }
let windowModel = WindowModel(workspaces: [])
@@ -77,18 +172,25 @@ struct TerminalThemePageRenderingTests {
window.contentViewController = nil
window.close()
}
controller.view.layoutSubtreeIfNeeded()
try await Task.sleep(for: .milliseconds(250))
controller.view.layoutSubtreeIfNeeded()
let bitmap = try #require(controller.view.bitmapImageRepForCachingDisplay(in: controller.view.bounds))
controller.view.cacheDisplay(in: controller.view.bounds, to: bitmap)
let data = try #require(bitmap.representation(using: .png, properties: [:]))
let name = "theme-browser-\(language)-\(isDark ? "dark" : "light").png"
Attachment.record(data, named: name)
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("termind-theme-previews")
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try data.write(to: directory.appendingPathComponent(name))
#expect(model.preferences == initial)
for page in ["browser", "library", "editor"] {
if page == "library" { windowModel.settingsRoute = .terminalCustomThemes }
if page == "editor" {
windowModel.terminalThemeDraft = .init(theme, isNew: false)
windowModel.settingsRoute = .terminalCustomTheme(theme.id)
}
controller.view.layoutSubtreeIfNeeded()
try await Task.sleep(for: .milliseconds(350))
controller.view.layoutSubtreeIfNeeded()
let bitmap = try #require(controller.view.bitmapImageRepForCachingDisplay(in: controller.view.bounds))
controller.view.cacheDisplay(in: controller.view.bounds, to: bitmap)
let data = try #require(bitmap.representation(using: .png, properties: [:]))
let name = "theme-\(page)-\(language)-\(isDark ? "dark" : "light").png"
Attachment.record(data, named: name)
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("termind-theme-previews")
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try data.write(to: directory.appendingPathComponent(name))
}
#expect(model.themeLibrary == initial)
#expect(!window.isVisible)
}
}
+3 -3
View File
@@ -10,14 +10,14 @@ import Testing
struct SettingsDetailControlTests {
@Test func themeSelectionPreservesIndependentTextAndPendingEdits() {
let model = TerminalSettingsModel()
#expect(model.set("background = ffffff", for: TerminalCustomConfigurationScheme.light.preferenceKeyPath))
#expect(model.set("background = 000000", for: TerminalCustomConfigurationScheme.dark.preferenceKeyPath))
#expect(model.set("background = ffffff", for: \TerminalPreferences.lightCustomConfiguration))
#expect(model.set("background = 000000", for: \TerminalPreferences.darkCustomConfiguration))
#expect(model.selectCustomTheme(isDark: false))
#expect(model.preferences.lightCustomEnabled)
#expect(!model.preferences.darkCustomEnabled)
// The editor's pending text arrives after selecting a preset.
#expect(model.selectTheme(named: SettingsKey.defaultTerminalThemeLight, isDark: false))
#expect(model.set("background = eeeeee", for: TerminalCustomConfigurationScheme.light.preferenceKeyPath))
#expect(model.set("background = eeeeee", for: \TerminalPreferences.lightCustomConfiguration))
#expect(!model.preferences.lightCustomEnabled)
#expect(model.preferences.lightCustomConfiguration == "background = eeeeee")
#expect(model.preferences.darkCustomConfiguration == "background = 000000")
+132 -3
View File
@@ -8,6 +8,126 @@ import Testing
@testable import Termind
struct SettingsNavigationTests {
@Test @MainActor
func autosaveDoesNotRemoveTheSpaceBetweenWordsWhileTypingAName() throws {
let model = TerminalSettingsModel()
let window = WindowModel()
let theme = TerminalCustomTheme(name: "", appearance: .both, baseThemeName: "Termind Dark")
window.openSettings(route: .terminalCustomThemes)
window.beginEditingTerminalTheme(theme, isNew: true, in: model)
let draft = try #require(window.terminalThemeDraft)
draft.value.name = "Ocean "
window.saveTerminalThemeChanges(allowNameTrimming: false)
#expect(draft.value.name == "Ocean ")
draft.value.name += "Blue "
window.navigateBackInSettings()
#expect(model.themeLibrary.themes.first?.name == "Ocean Blue")
#expect(!window.showsDiscardTerminalTheme)
}
@Test(arguments: [false, true]) @MainActor
func themeEditsSaveBeforeBackOrChangingDestinations(changesDestination: Bool) throws {
let model = TerminalSettingsModel()
let window = WindowModel()
let theme = TerminalCustomTheme(name: "Personal", appearance: .both, baseThemeName: "Termind Dark")
model.saveCustomTheme(theme, isNew: true)
window.openSettings(route: .terminalCustomThemes)
window.beginEditingTerminalTheme(theme, isNew: false, in: model)
let draft = try #require(window.terminalThemeDraft)
draft.value.configuration = "background = 123456"
if changesDestination { window.selectVault() } else { window.navigateBackInSettings() }
#expect(model.themeLibrary.themes.first?.configuration == "background = 123456")
#expect(!draft.isDirty)
#expect(!window.showsDiscardTerminalTheme)
#expect(window.terminalThemeDraft == nil)
#expect(window.settingsRoute == .terminalCustomThemes)
#expect(window.selectedDestination == (changesDestination ? .vault : .settings))
}
@Test @MainActor
func invalidOrDeletedDraftsRemainProtectedAndDiscardCannotResurrectThem() throws {
let model = TerminalSettingsModel()
let window = WindowModel()
let theme = TerminalCustomTheme(name: "Personal", appearance: .both, baseThemeName: "Termind Dark")
model.saveCustomTheme(theme, isNew: true)
window.openSettings(route: .terminalCustomThemes)
window.beginEditingTerminalTheme(theme, isNew: false, in: model)
let draft = try #require(window.terminalThemeDraft)
draft.value.name = " "
window.navigateBackInSettings()
#expect(window.showsDiscardTerminalTheme)
#expect(model.themeLibrary.themes.first?.name == "Personal")
window.cancelTerminalThemeNavigation()
draft.value.name = "Recovered"
model.deleteCustomTheme(id: theme.id)
window.navigateBackInSettings()
#expect(window.showsDiscardTerminalTheme)
window.discardTerminalThemeAndNavigate()
window.saveTerminalThemeChanges()
#expect(model.themeLibrary.themes.isEmpty)
#expect(window.terminalThemeDraft == nil)
}
@Test @MainActor
func failedAutosavePreservesDraftAndProtectsNavigationUntilRetry() throws {
let store = UnavailableThemeStore()
let model = TerminalSettingsModel(persistence: store)
let window = WindowModel()
let theme = TerminalCustomTheme(name: "", appearance: .both, baseThemeName: "Termind Dark")
window.openSettings(route: .terminalCustomThemes)
window.beginEditingTerminalTheme(theme, isNew: true, in: model)
let draft = try #require(window.terminalThemeDraft)
draft.value.name = "New"
window.navigateBackInSettings()
#expect(window.showsDiscardTerminalTheme)
#expect(draft.isDirty)
#expect(model.themeLibrary.themes.isEmpty)
#expect(model.storageError != nil)
window.cancelTerminalThemeNavigation()
store.fails = false
window.navigateBackInSettings()
#expect(!window.showsDiscardTerminalTheme)
#expect(window.terminalThemeDraft == nil)
#expect(model.themeLibrary.themes.first?.name == "New")
}
@Test @MainActor
func failedDuplicateSaveIsProtectedEvenBeforeFurtherEdits() {
let store = UnavailableThemeStore()
let model = TerminalSettingsModel(persistence: store)
let window = WindowModel()
let theme = TerminalCustomTheme(name: "Personal Copy", appearance: .both, baseThemeName: "Termind Dark")
window.openSettings(route: .terminalCustomThemes)
window.beginEditingTerminalTheme(theme, isNew: true, in: model)
#expect(window.terminalThemeDraft?.isDirty == false)
window.navigateBackInSettings()
#expect(window.hasUnsavedTerminalThemeChanges)
#expect(window.showsDiscardTerminalTheme)
#expect(window.settingsRoute == .terminalCustomTheme(theme.id))
}
@Test @MainActor
func dirtyThemeProtectsBackNavigationAndChangingDestinations() {
let window = WindowModel()
let theme = TerminalCustomTheme(name: "Personal", appearance: .both, baseThemeName: "Termind Dark")
window.openSettings(route: .terminalCustomThemes)
window.terminalThemeDraft = .init(theme, isNew: false)
window.settingsRoute = .terminalCustomTheme(theme.id)
window.terminalThemeDraft?.value.name = "Unsaved"
window.navigateBackInSettings()
#expect(window.settingsRoute == .terminalCustomTheme(theme.id))
#expect(window.showsDiscardTerminalTheme)
window.cancelTerminalThemeNavigation()
#expect(window.terminalThemeDraft?.value.name == "Unsaved")
window.selectVault()
#expect(window.selectedDestination == .settings)
#expect(window.showsDiscardTerminalTheme)
window.discardTerminalThemeAndNavigate()
#expect(window.selectedDestination == .vault)
#expect(window.terminalThemeDraft == nil)
#expect(!window.showsDiscardTerminalTheme)
}
@Test(
arguments: [
EntitlementStatus.free, .purchased(.purchased), .purchased(.familyShared),
@@ -86,7 +206,7 @@ struct SettingsNavigationTests {
)
#expect(SettingsRoute.mcpServer(serverID, isNew: false).parent == .category(.ai))
#expect(SettingsRoute.localMCPServer.parent == .category(.ai))
#expect(SettingsRoute.terminalConfiguration(.dark).parent == .terminalThemes(.dark))
#expect(SettingsRoute.terminalCustomTheme(UUID()).parent == .terminalCustomThemes)
#expect(SettingsRoute.terminalThemes(.dark).parent == .category(.terminal))
#expect(SettingsRoute.logging.parent == .index)
}
@@ -120,10 +240,10 @@ struct SettingsNavigationTests {
@Test @MainActor
func settingsToolbarBackWalksTheRouteThenRestoresContent() {
let window = WindowModel()
window.openSettings(route: .terminalConfiguration(.dark))
window.openSettings(route: .terminalCustomTheme(UUID()))
window.navigateBackInSettings()
#expect(window.settingsRoute == .terminalThemes(.dark))
#expect(window.settingsRoute == .terminalCustomThemes)
window.navigateBackInSettings()
#expect(window.settingsRoute == .category(.terminal))
@@ -309,3 +429,12 @@ struct SettingsNavigationTests {
#expect(store.mcpServer(server.id)?.tools[0].accessPolicy == .approve)
}
}
@MainActor private final class UnavailableThemeStore: TerminalPreferencesPersisting {
var fails = true
func loadTerminal() throws -> TerminalPreferences { .init() }
func saveTerminal(_ value: TerminalPreferences) throws {}
func saveTerminalThemes(_ value: TerminalThemeLibrary) throws {
if fails { throw CocoaError(.fileWriteUnknown) }
}
}
@@ -3,12 +3,36 @@ import GhosttyTerminal
import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
import Testing
@testable import Termind
@Suite(.serialized) @MainActor
struct TerminalCustomConfigurationEditorLayoutTests {
@Test(arguments: [CGFloat(650), CGFloat(1000)])
func nameFieldLeavesRoomForItsNativeFocusRing(width: CGFloat) async throws {
let fixture = Fixture()
defer { fixture.close() }
fixture.window.setContentSize(CGSize(width: width, height: 800))
await fixture.layout()
let field = try #require(fixture.descendant(NSTextField.self))
let scroll = try #require(field.enclosingScrollView)
let focusFrame = field.convert(field.bounds.insetBy(dx: -3, dy: -3), to: scroll.contentView)
#expect(scroll.contentView.bounds.contains(focusFrame))
}
@Test func discardedDraftCannotBeSavedByThePendingAutosaveTask() async throws {
let fixture = Fixture()
defer { fixture.close() }
await fixture.layout()
fixture.draft.value.configuration = "background = 123456"
fixture.model.deleteCustomTheme(id: fixture.draft.id)
fixture.navigation.clearTerminalThemeEditor()
await fixture.layout()
#expect(fixture.model.themeLibrary.themes.isEmpty)
}
@Test(arguments: ["en", "zh-Hans", "zh-Hant"], [false, true])
func editorAndPreviewFillThePageRegardlessOfTextLength(language: String, isDark: Bool) async throws {
let fixture = Fixture(language: language, isDark: isDark)
@@ -19,10 +43,12 @@ struct TerminalCustomConfigurationEditorLayoutTests {
let preview = try #require(fixture.descendant(AppTerminalView.self))
let editorHeight = scroll.bounds.height
let previewHeight = preview.bounds.height
#expect(editorHeight > 600)
#expect(editorHeight > 400)
for text in ["", "background = 152033", Self.longConfiguration, ""] {
fixture.model.set(text, for: fixture.scheme.preferenceKeyPath)
var updated = fixture.draft.value
updated.configuration = text
fixture.model.saveCustomTheme(updated, isNew: false)
await fixture.layout()
#expect(editor.string == text)
#expect(abs(scroll.bounds.height - editorHeight) <= 1)
@@ -78,7 +104,8 @@ struct TerminalCustomConfigurationEditorLayoutTests {
#expect(abs(editorFrame.minX - previewFrame.minX) <= 1)
}
}
#expect(fixture.model.preferences.darkCustomConfiguration == Self.longConfiguration)
#expect(fixture.draft.value.configuration == Self.longConfiguration)
#expect(fixture.model.themeLibrary.themes.first?.configuration == Self.longConfiguration)
#expect(!fixture.window.isVisible)
}
@@ -108,18 +135,27 @@ struct TerminalCustomConfigurationEditorLayoutTests {
private final class Fixture {
let model = TerminalSettingsModel()
let app: AppModel
let scheme: TerminalCustomConfigurationScheme
let navigation = WindowModel(workspaces: [])
let draft: TerminalCustomThemeDraft
let controller: NSHostingController<AnyView>
let window: NSWindow
var view: NSView { controller.view }
init(language: String = "en", isDark: Bool = true, typeSize: DynamicTypeSize = .large) {
scheme = isDark ? .dark : .light
let theme = TerminalCustomTheme(
name: "Layout", appearance: isDark ? .dark : .light,
baseThemeName: isDark ? "Termind Dark" : "Termind Light")
model.saveCustomTheme(theme, isNew: true)
draft = .init(theme, isNew: false)
app = AppModel(hosts: HostLibraryModel(), terminalSettings: model)
navigation.openSettings(route: .terminalCustomThemes)
navigation.terminalThemeDraft = draft
navigation.settingsRoute = .terminalCustomTheme(theme.id)
controller = NSHostingController(
rootView: AnyView(
TerminalCustomConfigurationEditorPage(scheme: scheme)
TerminalCustomConfigurationEditorPage(draft: draft, model: model)
.environment(app)
.environment(navigation)
.environment(\.locale, Locale(identifier: language))
.environment(\.colorScheme, isDark ? .dark : .light)
.environment(\.dynamicTypeSize, typeSize)))
@@ -153,8 +189,13 @@ struct TerminalCustomConfigurationEditorLayoutTests {
let previewFrame = preview.convert(preview.bounds, to: view)
let editorBottom = view.isFlipped ? view.bounds.maxY - editorFrame.maxY : editorFrame.minY
let previewBottom = view.isFlipped ? view.bounds.maxY - previewFrame.maxY : previewFrame.minY
#expect(abs(editorBottom - Metrics.Space.m) <= 1)
#expect(abs(previewBottom - Metrics.Space.m) <= 1)
#expect(abs(editorBottom - 16) <= 1)
// The terminal and color specimens share one panel; specimens sit below the native surface.
#expect(previewBottom > editorBottom)
#expect(previewFrame.height >= 220)
let editorTop = view.isFlipped ? editorFrame.minY : editorFrame.maxY
let previewTop = view.isFlipped ? previewFrame.minY : previewFrame.maxY
#expect(abs(editorTop - previewTop) <= 1)
#expect(previewFrame.minX > editorFrame.maxX)
}
+76 -2
View File
@@ -7,6 +7,62 @@ import Testing
@Suite @MainActor
struct TerminalThemeBrowserTests {
@Test func customThemeSearchIncludesAllAppearancesButSelectionSearchKeepsItsScope() {
let light = TerminalCustomTheme(name: "Ocean Day", appearance: .light, baseThemeName: "Termind Light")
let dark = TerminalCustomTheme(name: "Ocean Night", appearance: .dark, baseThemeName: "Termind Dark")
let both = TerminalCustomTheme(name: "海洋", appearance: .both, baseThemeName: "Termind Dark")
let library = TerminalThemeLibrary(themes: [dark, both, light])
#expect(library.themes(matching: " oCEAn\n").map(\.id) == [light.id, dark.id])
#expect(library.themes(matching: "海").map(\.id) == [both.id])
#expect(library.themes(matching: "missing").isEmpty)
#expect(library.themes(matching: " \n ").count == 3)
for isDark in [false, true] {
let options = TerminalThemeOptions(preferences: .init(), isDark: isDark, library: library)
#expect(options.customThemes(matching: "Ocean").map(\.id) == [isDark ? dark.id : light.id])
#expect(options.customThemes(matching: "海").map(\.id) == [both.id])
}
}
@Test func bothUsesExactlyTheSamePaletteInEitherAppearanceAndKeepsTheSavedName() {
let theme = TerminalCustomTheme(
name: "Shared palette", appearance: .both, baseThemeName: "Termind Dark",
configuration: "background = 123456")
let library = TerminalThemeLibrary(
themes: [theme],
selections: [
.init(appearance: .light, builtInThemeName: "Termind Light", customThemeID: theme.id),
.init(appearance: .dark, builtInThemeName: "Termind Dark", customThemeID: theme.id),
])
for isDark in [false, true] {
let options = TerminalThemeOptions(preferences: .init(), isDark: isDark, library: library)
#expect(options.isAvailable)
#expect(options.isCustom)
#expect(options.baseTheme.name == "Termind Dark")
#expect(options.customThemes(matching: " SHARED ").map(\.id) == [theme.id])
#expect(options.selectedCustomTheme?.name == "Shared palette")
}
let preview = TerminalConfigurationPreviewModel()
preview.terminal.applyPreferences(.init(), library: library)
#expect(preview.terminal.theme.light == preview.terminal.theme.dark)
#expect(preview.terminal.theme.light.rendered.contains("background = 123456"))
}
@Test func unavailableBothBaseFallsBackConsistentlyWithoutRewritingTheTheme() {
let custom = TerminalCustomTheme(name: "Missing", appearance: .both, baseThemeName: "Not installed")
let library = TerminalThemeLibrary(
themes: [custom],
selections: [
.init(appearance: .light, builtInThemeName: "Termind Light", customThemeID: custom.id),
.init(appearance: .dark, builtInThemeName: "Termind Dark", customThemeID: custom.id),
])
for isDark in [false, true] {
let options = TerminalThemeOptions(preferences: .init(), isDark: isDark, library: library)
#expect(!options.isAvailable)
#expect(options.baseTheme == TermindTerminalThemeCatalog.defaultDark)
#expect(options.name == "Not installed")
}
}
@Test(arguments: [false, true])
func searchKeepsAppearanceAndCustomSelectionIndependent(isDark: Bool) {
let options = TerminalThemeOptions(
@@ -40,6 +96,7 @@ struct TerminalThemeBrowserTests {
background = 405060
foreground = #AABBCC
cursor-color = 112233
cursor-text = FFEEDD
selection-background = 223344
selection-foreground = 334455
palette = 2=#FF0000
@@ -50,6 +107,7 @@ struct TerminalThemeBrowserTests {
#expect(palette.background.hex == "405060")
#expect(palette.foreground.hex == "AABBCC")
#expect(palette.cursor.hex == "112233")
#expect(palette.cursorText.hex == "FFEEDD")
#expect(palette.selection.hex == "223344")
#expect(palette.selectionForeground.hex == "334455")
#expect(palette.ansi[2].hex == "00FF00")
@@ -62,16 +120,32 @@ struct TerminalThemeBrowserTests {
let model = TerminalConfigurationPreviewModel()
let controller = model.terminal.controller
let name = TermindTerminalThemeCatalog.fallbackTheme(isDark: isDark).name
#expect(model.update(themeName: name, text: "background = 123456", isDark: isDark, font: .init()))
let text = """
background = 123456
cursor-color = AABBCC
cursor-text = 112233
selection-background = 445566
selection-foreground = FFEEDD
palette = 15=#ABCDEF
"""
#expect(model.update(themeName: name, text: text, isDark: isDark, font: .init()))
let lastValid = model.terminal.theme
let lastPalette = model.palette
#expect(lastPalette.cursor.hex == "AABBCC")
#expect(lastPalette.cursorText.hex == "112233")
#expect(lastPalette.selection.hex == "445566")
#expect(lastPalette.selectionForeground.hex == "FFEEDD")
#expect(lastPalette.ansi[15].hex == "ABCDEF")
#expect(lastValid.dark == lastValid.light)
#expect(lastValid.dark.rendered.contains("background = 123456"))
#expect(!model.update(themeName: name, text: "background = not-a-color", isDark: isDark, font: .init()))
#expect(model.issue != nil)
#expect(model.terminal.theme == lastValid)
#expect(model.update(themeName: name, text: "background = 123456", isDark: isDark, font: .init()))
#expect(model.palette == lastPalette)
#expect(model.update(themeName: name, text: text, isDark: isDark, font: .init()))
#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,
+6 -1
View File
@@ -36,9 +36,14 @@ struct WindowToolbarFindTests {
let dark = try #require(window.findContext())
#expect(dark.query.wrappedValue.isEmpty)
dark.query.wrappedValue = "Night"
window.openSettings(route: .terminalConfiguration(.dark))
window.openSettings(route: .terminalCustomThemes)
let custom = try #require(window.findContext())
custom.query.wrappedValue = "Personal"
window.openSettings(route: .terminalCustomTheme(UUID()))
#expect(window.findContext() == nil)
window.navigateBackInSettings()
#expect(window.findContext()?.query.wrappedValue == "Personal")
window.openSettings(route: .terminalThemes(.dark))
#expect(window.findContext()?.query.wrappedValue == "Night")
window.openSettings(route: .terminalThemes(.light))
#expect(window.findContext()?.query.wrappedValue == "Day")
@@ -8,6 +8,72 @@ import Testing
@Suite(.serialized) @MainActor
struct WorkspaceCloseConfirmationTests {
@Test func windowCloseFlushesThemeEditsWithoutADiscardPrompt() throws {
let settings = TerminalSettingsModel()
let model = WindowModel()
let theme = TerminalCustomTheme(name: "New", appearance: .both, baseThemeName: "Termind Dark")
model.openSettings(route: .terminalCustomThemes)
model.beginEditingTerminalTheme(theme, isNew: true, in: settings)
let draft = try #require(model.terminalThemeDraft)
draft.value.configuration = "background = 123456"
let native = makeWindow()
defer { native.close() }
var prompted = false
let delegate = WorkspaceWindowCloseDelegate(
model: model, confirmClose: { true },
confirmDiscardTheme: {
prompted = true
return false
})
#expect(delegate.windowShouldClose(native))
#expect(!prompted)
#expect(settings.themeLibrary.themes.first?.configuration == "background = 123456")
#expect(model.terminalThemeDraft == nil)
}
@Test func quitFlushesThemeEditsAcrossRegisteredWindows() throws {
let settings = TerminalSettingsModel()
let app = AppModel(hosts: HostLibraryModel(), terminalSettings: settings)
defer { app.shutdownForTermination() }
let windows = [WindowModel(), WindowModel()]
for (index, window) in windows.enumerated() {
app.registerWindow(window)
let theme = TerminalCustomTheme(name: "", appearance: .both, baseThemeName: "Termind Dark")
window.openSettings(route: .terminalCustomThemes)
window.beginEditingTerminalTheme(theme, isNew: true, in: settings)
window.terminalThemeDraft?.value.name = "Theme \(index)"
}
#expect(app.hasUnsavedTerminalThemes)
app.saveTerminalThemeChanges()
#expect(!app.hasUnsavedTerminalThemes)
#expect(settings.themeLibrary.themes.count == 2)
}
@Test func emptyWindowStillProtectsAnUnsavedThemeDraft() {
let model = WindowModel()
let app = AppModel(hosts: HostLibraryModel())
app.registerWindow(model)
let draft = TerminalCustomThemeDraft(
.init(name: "Theme", appearance: .both, baseThemeName: "Termind Dark"), isNew: true)
model.terminalThemeDraft = draft
draft.value.configuration = "background = 123456"
let native = makeWindow()
var allowsDiscard = false
let delegate = WorkspaceWindowCloseDelegate(
model: model, confirmClose: { true }, confirmDiscardTheme: { allowsDiscard })
defer {
native.close()
app.shutdownForTermination()
}
#expect(app.hasUnsavedTerminalThemes)
#expect(!delegate.windowShouldClose(native))
#expect(model.terminalThemeDraft === draft)
allowsDiscard = true
#expect(delegate.windowShouldClose(native))
#expect(model.terminalThemeDraft == nil)
#expect(!app.hasUnsavedTerminalThemes)
}
@Test
func quitAndWindowCloseUseDifferentTitlesActionsAndScope() {
let quit = WorkspaceCloseConfirmation.application.makeAlert()