diff --git a/FrameworkTests/TerminalCustomThemeTests.swift b/FrameworkTests/TerminalCustomThemeTests.swift new file mode 100644 index 00000000..c12ca8de --- /dev/null +++ b/FrameworkTests/TerminalCustomThemeTests.swift @@ -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) } + } +} diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index 02d10d2a..25358a6f 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -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" : { diff --git a/Frameworks/TermindApplication/FeatureModels/TerminalCustomThemeDraft.swift b/Frameworks/TermindApplication/FeatureModels/TerminalCustomThemeDraft.swift new file mode 100644 index 00000000..de9c9f26 --- /dev/null +++ b/Frameworks/TermindApplication/FeatureModels/TerminalCustomThemeDraft.swift @@ -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 + } +} diff --git a/Frameworks/TermindApplication/FeatureModels/TerminalSettingsModel.swift b/Frameworks/TermindApplication/FeatureModels/TerminalSettingsModel.swift index ccf9d586..9d9fd953 100644 --- a/Frameworks/TermindApplication/FeatureModels/TerminalSettingsModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/TerminalSettingsModel.swift @@ -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 + } + } } diff --git a/Frameworks/TermindApplication/TerminalPreferencesPersisting.swift b/Frameworks/TermindApplication/TerminalPreferencesPersisting.swift index 40d0cc49..7871e48c 100644 --- a/Frameworks/TermindApplication/TerminalPreferencesPersisting.swift +++ b/Frameworks/TermindApplication/TerminalPreferencesPersisting.swift @@ -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) + } } diff --git a/Frameworks/TermindDomain/Sessions/TerminalCustomTheme.swift b/Frameworks/TermindDomain/Sessions/TerminalCustomTheme.swift new file mode 100644 index 00000000..9377dfc1 --- /dev/null +++ b/Frameworks/TermindDomain/Sessions/TerminalCustomTheme.swift @@ -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 + } + } + } +} diff --git a/Frameworks/TermindDomain/Settings/SettingsSyncTypes.swift b/Frameworks/TermindDomain/Settings/SettingsSyncTypes.swift index d18a8d8f..be18ab40 100644 --- a/Frameworks/TermindDomain/Settings/SettingsSyncTypes.swift +++ b/Frameworks/TermindDomain/Settings/SettingsSyncTypes.swift @@ -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 } diff --git a/Frameworks/TermindInfrastructure/Cloud/SettingsSyncCoordinator.swift b/Frameworks/TermindInfrastructure/Cloud/SettingsSyncCoordinator.swift index 0e9ecc2d..81ff71c6 100644 --- a/Frameworks/TermindInfrastructure/Cloud/SettingsSyncCoordinator.swift +++ b/Frameworks/TermindInfrastructure/Cloud/SettingsSyncCoordinator.swift @@ -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) diff --git a/Frameworks/TermindInfrastructure/Cloud/TerminalThemeCloudRecordV1.swift b/Frameworks/TermindInfrastructure/Cloud/TerminalThemeCloudRecordV1.swift new file mode 100644 index 00000000..cc614251 --- /dev/null +++ b/Frameworks/TermindInfrastructure/Cloud/TerminalThemeCloudRecordV1.swift @@ -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() + } +} diff --git a/Frameworks/TermindPersistence/HostPersistence.swift b/Frameworks/TermindPersistence/HostPersistence.swift index 307f21a1..ad276d31 100644 --- a/Frameworks/TermindPersistence/HostPersistence.swift +++ b/Frameworks/TermindPersistence/HostPersistence.swift @@ -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), ] } } diff --git a/Frameworks/TermindPersistence/SettingsSyncStore.swift b/Frameworks/TermindPersistence/SettingsSyncStore.swift index dc673c1e..9fbb8079 100644 --- a/Frameworks/TermindPersistence/SettingsSyncStore.swift +++ b/Frameworks/TermindPersistence/SettingsSyncStore.swift @@ -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 { diff --git a/Frameworks/TermindPersistence/TerminalThemePersistence.swift b/Frameworks/TermindPersistence/TerminalThemePersistence.swift new file mode 100644 index 00000000..bb858a58 --- /dev/null +++ b/Frameworks/TermindPersistence/TerminalThemePersistence.swift @@ -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()).map { try $0.value } + let selections = try context.fetch(FetchDescriptor()).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()) + let selections = try context.fetch(FetchDescriptor()) + 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 { + var changed = Set() + 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] + )) + } +} diff --git a/Frameworks/TermindPersistence/TermindPersistence.swift b/Frameworks/TermindPersistence/TermindPersistence.swift index 42cb5f7b..39a565a5 100644 --- a/Frameworks/TermindPersistence/TermindPersistence.swift +++ b/Frameworks/TermindPersistence/TermindPersistence.swift @@ -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") diff --git a/Frameworks/TermindPresentation/Strings/TerminalThemes.swift b/Frameworks/TermindPresentation/Strings/TerminalThemes.swift new file mode 100644 index 00000000..8058dc4a --- /dev/null +++ b/Frameworks/TermindPresentation/Strings/TerminalThemes.swift @@ -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" } +} diff --git a/Frameworks/TermindPresentation/Terminal/TerminalAppearance.swift b/Frameworks/TermindPresentation/Terminal/TerminalAppearance.swift index 55b4ce7b..5bb4e246 100644 --- a/Frameworks/TermindPresentation/Terminal/TerminalAppearance.swift +++ b/Frameworks/TermindPresentation/Terminal/TerminalAppearance.swift @@ -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( diff --git a/Frameworks/TermindPresentation/Terminal/TerminalConfigurationPreview.swift b/Frameworks/TermindPresentation/Terminal/TerminalConfigurationPreview.swift index c4e96190..62b71ae9 100644 --- a/Frameworks/TermindPresentation/Terminal/TerminalConfigurationPreview.swift +++ b/Frameworks/TermindPresentation/Terminal/TerminalConfigurationPreview.swift @@ -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 diff --git a/Frameworks/TermindPresentation/Terminal/TerminalCustomThemeSummary.swift b/Frameworks/TermindPresentation/Terminal/TerminalCustomThemeSummary.swift new file mode 100644 index 00000000..e04035b9 --- /dev/null +++ b/Frameworks/TermindPresentation/Terminal/TerminalCustomThemeSummary.swift @@ -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 + } + } +} diff --git a/Frameworks/TermindPresentation/Terminal/TerminalThemeOptions.swift b/Frameworks/TermindPresentation/Terminal/TerminalThemeOptions.swift index 5bb95958..b60b4b3e 100644 --- a/Frameworks/TermindPresentation/Terminal/TerminalThemeOptions.swift +++ b/Frameworks/TermindPresentation/Terminal/TerminalThemeOptions.swift @@ -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 { diff --git a/Frameworks/TermindPresentation/Terminal/TerminalThemeThumbnail.swift b/Frameworks/TermindPresentation/Terminal/TerminalThemeThumbnail.swift index cd15ae18..4065a915 100644 --- a/Frameworks/TermindPresentation/Terminal/TerminalThemeThumbnail.swift +++ b/Frameworks/TermindPresentation/Terminal/TerminalThemeThumbnail.swift @@ -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 } } diff --git a/TFI/App/MobileAppModel.swift b/TFI/App/MobileAppModel.swift index 9cf44c05..2df53dc7 100644 --- a/TFI/App/MobileAppModel.swift +++ b/TFI/App/MobileAppModel.swift @@ -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) diff --git a/TFI/Features/Settings/MobileCustomThemeEditorSheet.swift b/TFI/Features/Settings/MobileCustomThemeEditorSheet.swift new file mode 100644 index 00000000..1b9b5640 --- /dev/null +++ b/TFI/Features/Settings/MobileCustomThemeEditorSheet.swift @@ -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 + } +} diff --git a/TFI/Features/Settings/MobileCustomThemesView.swift b/TFI/Features/Settings/MobileCustomThemesView.swift new file mode 100644 index 00000000..eac42fd4 --- /dev/null +++ b/TFI/Features/Settings/MobileCustomThemesView.swift @@ -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 } + } +} diff --git a/TFI/Features/Settings/MobileTerminalSettingsView.swift b/TFI/Features/Settings/MobileTerminalSettingsView.swift index d3308a6b..eed8dc58 100644 --- a/TFI/Features/Settings/MobileTerminalSettingsView.swift +++ b/TFI/Features/Settings/MobileTerminalSettingsView.swift @@ -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) diff --git a/TFI/Features/Settings/MobileTerminalThemeView.swift b/TFI/Features/Settings/MobileTerminalThemeView.swift index 0dfba865..b0e3a730 100644 --- a/TFI/Features/Settings/MobileTerminalThemeView.swift +++ b/TFI/Features/Settings/MobileTerminalThemeView.swift @@ -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) - } - } } } diff --git a/TFI/Features/Workspaces/MobileWorkspaceView.swift b/TFI/Features/Workspaces/MobileWorkspaceView.swift index bcde5858..0a5320b4 100644 --- a/TFI/Features/Workspaces/MobileWorkspaceView.swift +++ b/TFI/Features/Workspaces/MobileWorkspaceView.swift @@ -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) diff --git a/TFITests/MobileTerminalThemeTests.swift b/TFITests/MobileTerminalThemeTests.swift index eb69c9c0..737ea181 100644 --- a/TFITests/MobileTerminalThemeTests.swift +++ b/TFITests/MobileTerminalThemeTests.swift @@ -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 + } } diff --git a/TFM/App/AppModel.swift b/TFM/App/AppModel.swift index 540380b5..5858c8ae 100644 --- a/TFM/App/AppModel.swift +++ b/TFM/App/AppModel.swift @@ -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) diff --git a/TFM/App/RootView.swift b/TFM/App/RootView.swift index a368f8f4..4d2fa010 100644 --- a/TFM/App/RootView.swift +++ b/TFM/App/RootView.swift @@ -120,7 +120,7 @@ struct RootView: View { MetricsDetailPagePicker() } ToolbarItem(placement: .primaryAction) { - WindowToolbarSearchSlot() + WindowToolbarTrailingActions() } } .sheet( diff --git a/TFM/App/TermindApp.swift b/TFM/App/TermindApp.swift index 7111ef1e..93bafbd5 100644 --- a/TFM/App/TermindApp.swift +++ b/TFM/App/TermindApp.swift @@ -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) diff --git a/TFM/Design/TerminalTheme.swift b/TFM/Design/TerminalTheme.swift index 1f07f1ed..cc3ada2c 100644 --- a/TFM/Design/TerminalTheme.swift +++ b/TFM/Design/TerminalTheme.swift @@ -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 ) } } diff --git a/TFM/Features/Settings/SettingsPageView.swift b/TFM/Features/Settings/SettingsPageView.swift index 152964ad..3e8ec50e 100644 --- a/TFM/Features/Settings/SettingsPageView.swift +++ b/TFM/Features/Settings/SettingsPageView.swift @@ -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) diff --git a/TFM/Features/Settings/TerminalCustomConfigurationEditorPage.swift b/TFM/Features/Settings/TerminalCustomConfigurationEditorPage.swift new file mode 100644 index 00000000..c2576228 --- /dev/null +++ b/TFM/Features/Settings/TerminalCustomConfigurationEditorPage.swift @@ -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 + } +} diff --git a/TFM/Features/Settings/TerminalCustomConfigurationEditorSheet.swift b/TFM/Features/Settings/TerminalCustomConfigurationEditorSheet.swift deleted file mode 100644 index 7b070685..00000000 --- a/TFM/Features/Settings/TerminalCustomConfigurationEditorSheet.swift +++ /dev/null @@ -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 { - 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? - - 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 - } - } -} diff --git a/TFM/Features/Settings/TerminalCustomThemesPage.swift b/TFM/Features/Settings/TerminalCustomThemesPage.swift new file mode 100644 index 00000000..e0115642 --- /dev/null +++ b/TFM/Features/Settings/TerminalCustomThemesPage.swift @@ -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) + } +} diff --git a/TFM/Features/Settings/TerminalThemePage.swift b/TFM/Features/Settings/TerminalThemePage.swift index 6ad590f7..5a95a171 100644 --- a/TFM/Features/Settings/TerminalThemePage.swift +++ b/TFM/Features/Settings/TerminalThemePage.swift @@ -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) - } - } } } diff --git a/TFM/Features/Settings/TerminalThemeScheme.swift b/TFM/Features/Settings/TerminalThemeScheme.swift new file mode 100644 index 00000000..6ce7d8af --- /dev/null +++ b/TFM/Features/Settings/TerminalThemeScheme.swift @@ -0,0 +1,8 @@ +import Foundation + +nonisolated enum TerminalCustomConfigurationScheme: String, Identifiable, Hashable, Sendable { + case light + case dark + + var id: String { rawValue } +} diff --git a/TFM/Features/Shell/WindowTitle.swift b/TFM/Features/Shell/WindowTitle.swift index 6fba6d92..ce7b0b29 100644 --- a/TFM/Features/Shell/WindowTitle.swift +++ b/TFM/Features/Shell/WindowTitle.swift @@ -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) } diff --git a/TFM/Features/Terminal/DesktopTerminalSurface.swift b/TFM/Features/Terminal/DesktopTerminalSurface.swift index a79859b7..4e20670b 100644 --- a/TFM/Features/Terminal/DesktopTerminalSurface.swift +++ b/TFM/Features/Terminal/DesktopTerminalSurface.swift @@ -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) } } } diff --git a/TFM/Features/Workspaces/WindowModel.swift b/TFM/Features/Workspaces/WindowModel.swift index d9100e35..53acabfe 100644 --- a/TFM/Features/Workspaces/WindowModel.swift +++ b/TFM/Features/Workspaces/WindowModel.swift @@ -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 = [] - 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) { diff --git a/TFM/Platform/WorkspaceCloseConfirmation.swift b/TFM/Platform/WorkspaceCloseConfirmation.swift index e978b1f6..62eece80 100644 --- a/TFM/Platform/WorkspaceCloseConfirmation.swift +++ b/TFM/Platform/WorkspaceCloseConfirmation.swift @@ -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 } } diff --git a/TFM/UI/Components/WindowToolbar.swift b/TFM/UI/Components/WindowToolbar.swift index 37d3e669..9b7d63f6 100644 --- a/TFM/UI/Components/WindowToolbar.swift +++ b/TFM/UI/Components/WindowToolbar.swift @@ -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( diff --git a/TFMTests/CardPageLayoutReviewTests.swift b/TFMTests/CardPageLayoutReviewTests.swift index 236cd388..4b220964 100644 --- a/TFMTests/CardPageLayoutReviewTests.swift +++ b/TFMTests/CardPageLayoutReviewTests.swift @@ -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 { diff --git a/TFMTests/Screenshots/TerminalThemePageRenderingTests.swift b/TFMTests/Screenshots/TerminalThemePageRenderingTests.swift index 09a0623f..e58ccafe 100644 --- a/TFMTests/Screenshots/TerminalThemePageRenderingTests.swift +++ b/TFMTests/Screenshots/TerminalThemePageRenderingTests.swift @@ -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(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) } } diff --git a/TFMTests/SettingsDetailControlTests.swift b/TFMTests/SettingsDetailControlTests.swift index 23e3ff67..9b650135 100644 --- a/TFMTests/SettingsDetailControlTests.swift +++ b/TFMTests/SettingsDetailControlTests.swift @@ -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") diff --git a/TFMTests/SettingsNavigationTests.swift b/TFMTests/SettingsNavigationTests.swift index 366d419d..1247873e 100644 --- a/TFMTests/SettingsNavigationTests.swift +++ b/TFMTests/SettingsNavigationTests.swift @@ -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) } + } +} diff --git a/TFMTests/TerminalCustomConfigurationEditorLayoutTests.swift b/TFMTests/TerminalCustomConfigurationEditorLayoutTests.swift index 359f3187..c9686e4b 100644 --- a/TFMTests/TerminalCustomConfigurationEditorLayoutTests.swift +++ b/TFMTests/TerminalCustomConfigurationEditorLayoutTests.swift @@ -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 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) } diff --git a/TFMTests/TerminalThemeBrowserTests.swift b/TFMTests/TerminalThemeBrowserTests.swift index 750ae78d..8b774d0a 100644 --- a/TFMTests/TerminalThemeBrowserTests.swift +++ b/TFMTests/TerminalThemeBrowserTests.swift @@ -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, diff --git a/TFMTests/WindowToolbarFindTests.swift b/TFMTests/WindowToolbarFindTests.swift index 059b7f5a..2a735c20 100644 --- a/TFMTests/WindowToolbarFindTests.swift +++ b/TFMTests/WindowToolbarFindTests.swift @@ -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") diff --git a/TFMTests/WorkspaceCloseConfirmationTests.swift b/TFMTests/WorkspaceCloseConfirmationTests.swift index a35d3e5f..cb9a8af5 100644 --- a/TFMTests/WorkspaceCloseConfirmationTests.swift +++ b/TFMTests/WorkspaceCloseConfirmationTests.swift @@ -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()