feat(themes): add synced custom themes and native editors
This commit is contained in:
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) }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -120,7 +120,7 @@ struct RootView: View {
|
||||
MetricsDetailPagePicker()
|
||||
}
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
WindowToolbarSearchSlot()
|
||||
WindowToolbarTrailingActions()
|
||||
}
|
||||
}
|
||||
.sheet(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user