feat(settings): add theme browser and live configuration preview

This commit is contained in:
Kaede Akino committed 2026-10-01 23:43:34 +08:00
1 parent f5ed001938
commit 58d8440ca5
21 files changed
+1074 -139

No files matched your search

@@ -0,0 +1,73 @@
import Foundation
import TermindApplication
import TermindDomain
import Testing
@Suite @MainActor
struct TerminalThemeSelectionTests {
@Test(arguments: [false, true])
func presetSelectionPersistsNameAndDisablesCustomInOneWrite(isDark: Bool) {
let initial = TerminalPreferences(
darkCustomEnabled: true, lightCustomEnabled: true,
darkCustomConfiguration: "background = 010203", lightCustomConfiguration: "foreground = 112233")
let store = ThemeStore(initial)
let model = TerminalSettingsModel(persistence: store)
#expect(model.selectTheme(named: "Chosen Theme", isDark: isDark))
var expected = initial
if isDark {
expected.darkTheme = "Chosen Theme"
expected.darkCustomEnabled = false
} else {
expected.lightTheme = "Chosen Theme"
expected.lightCustomEnabled = false
}
#expect(store.saved == [expected])
#expect(model.preferences == expected)
#expect(model.selectCustomTheme(isDark: isDark))
#expect(model.preferences.darkCustomEnabled)
#expect(model.preferences.lightCustomEnabled)
#expect(model.preferences.darkCustomConfiguration == initial.darkCustomConfiguration)
#expect(model.preferences.lightCustomConfiguration == initial.lightCustomConfiguration)
#expect((isDark ? model.preferences.darkTheme : model.preferences.lightTheme) == "Chosen Theme")
#expect(store.saved.count == 2)
#expect(model.selectCustomTheme(isDark: isDark))
#expect(store.saved.count == 2)
}
@Test(arguments: [false, true])
func failedSelectionKeepsBothFieldsAndCanBeRetried(isDark: Bool) {
let initial = TerminalPreferences(darkCustomEnabled: true, lightCustomEnabled: true)
let store = ThemeStore(initial)
store.fails = true
let model = TerminalSettingsModel(persistence: store)
#expect(!model.selectTheme(named: "New Theme", isDark: isDark))
#expect(model.preferences == initial)
#expect(model.storageError != nil)
store.fails = false
#expect(model.selectTheme(named: "New Theme", isDark: isDark))
#expect(model.storageError == nil)
#expect(store.saved.count == 1)
}
@Test func openingAndApplyingRemoteCustomSelectionNeverWrites() {
let initial = TerminalPreferences(darkTheme: "Missing", darkCustomEnabled: true)
let store = ThemeStore(initial)
let model = TerminalSettingsModel(persistence: store)
#expect(model.preferences == initial)
model.applyRemoteSnapshot(TerminalPreferences(lightCustomEnabled: true))
#expect(model.preferences.lightCustomEnabled)
#expect(store.saved.isEmpty)
}
}
@MainActor private final class ThemeStore: TerminalPreferencesPersisting {
let initial: TerminalPreferences
var saved: [TerminalPreferences] = []
var fails = false
init(_ initial: TerminalPreferences) { self.initial = initial }
func loadTerminal() throws -> TerminalPreferences { initial }
func saveTerminal(_ value: TerminalPreferences) throws {
if fails { throw CocoaError(.fileWriteNoPermission) }
saved.append(value)
}
}
+102 -17
View File
@@ -8242,6 +8242,23 @@
}
}
},
"Dark Theme" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "深色主题"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "深色主題"
}
}
}
},
"Data Bits" : {
"extractionState" : "manual",
"localizations" : {
@@ -10568,23 +10585,6 @@
}
}
},
"Edit Configuration" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "编辑配置"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "編輯設定"
}
}
}
},
"Edit Custom Configuration" : {
"extractionState" : "manual",
"localizations" : {
@@ -15377,6 +15377,23 @@
}
}
},
"Light Theme" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "浅色主题"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "淺色主題"
}
}
}
},
"Limits" : {
"extractionState" : "manual",
"localizations" : {
@@ -17808,6 +17825,23 @@
}
}
},
"No matching themes" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "没有匹配的主题"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "沒有符合的主題"
}
}
}
},
"No model" : {
"extractionState" : "manual",
"localizations" : {
@@ -24492,6 +24526,23 @@
}
}
},
"Search Themes" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "搜索主题"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "搜尋主題"
}
}
}
},
"Search hosts" : {
"extractionState" : "manual",
"localizations" : {
@@ -30075,6 +30126,23 @@
}
}
},
"Theme Preview" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "主题预览"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "主題預覽"
}
}
}
},
"These hosts require conflicting jump routes. Import the routes separately." : {
"extractionState" : "manual",
"localizations" : {
@@ -32044,6 +32112,23 @@
}
}
},
"Unable to update preview: %@" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "无法更新预览:%@"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "無法更新預覽:%@"
}
}
}
},
"Unable to update the login item." : {
"extractionState" : "manual",
"localizations" : {
@@ -19,6 +19,29 @@ public final class TerminalSettingsModel {
public func set<Value>(_ value: Value, for keyPath: WritableKeyPath<TerminalPreferences, Value>) -> Bool {
var next = preferences
next[keyPath: keyPath] = value
return save(next)
}
/// A theme choice is one persisted edit, including disabling the custom overlay.
@discardableResult
public func selectTheme(named name: String, isDark: Bool) -> Bool {
var next = preferences
if isDark {
next.darkTheme = name
next.darkCustomEnabled = false
} else {
next.lightTheme = name
next.lightCustomEnabled = false
}
return save(next)
}
@discardableResult
public func selectCustomTheme(isDark: Bool) -> Bool {
set(true, for: isDark ? \.darkCustomEnabled : \.lightCustomEnabled)
}
private func save(_ next: TerminalPreferences) -> Bool {
guard next != preferences else { return true }
do {
try persistence?.saveTerminal(next)
@@ -2,6 +2,20 @@ import Foundation
extension AppStrings {
public nonisolated enum Settings {
public static var lightTheme: LocalizedStringResource { "Light Theme" }
public static var darkTheme: LocalizedStringResource { "Dark Theme" }
public static var customTheme: LocalizedStringResource { "Custom" }
public static var searchThemes: LocalizedStringResource { "Search Themes" }
public static var noMatchingThemes: LocalizedStringResource { "No matching themes" }
public static var noThemeSelected: LocalizedStringResource { "No Theme Selected" }
public static var themePreview: LocalizedStringResource { "Theme Preview" }
public static func unavailableThemeFallback(_ name: String) -> LocalizedStringResource {
"This theme is unavailable for this appearance. Using “\(name)” instead."
}
public static func themePreviewError(_ error: String) -> LocalizedStringResource {
"Unable to update preview: \(error)"
}
public static var devices: LocalizedStringResource { "Devices" }
public static var optionalSync: LocalizedStringResource { "Optional Sync" }
public static var signInToSync: LocalizedStringResource { "Sign in to iCloud to sync." }
@@ -268,10 +282,6 @@ extension AppStrings {
"Edit \(displayTitle)"
}
public static var editConfiguration: LocalizedStringResource {
"Edit Configuration"
}
public static var editCustomConfiguration: LocalizedStringResource {
"Edit Custom Configuration"
}
@@ -0,0 +1,132 @@
import GhosttyTerminal
import GhosttyTheme
import SwiftUI
import TermindDomain
/// One in-memory surface per editor. Drafts only reconfigure it; no shell or remote session is created.
@MainActor
public final class TerminalConfigurationPreviewModel: ObservableObject {
public let terminal: TerminalViewState
@Published public private(set) var issue: String?
public init(
themeName: String = TermindTerminalThemeCatalog.defaultDark.name,
isDark: Bool = true, font: TerminalFontPreferences = .init()
) {
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)
})
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}[1mTermind\u{1B}[0m",
normalColors + "\u{1B}[0m",
brightColors + "\u{1B}[0m",
"",
"\u{1B}[36muser@server\u{1B}[0m \u{1B}[34m~\u{1B}[0m $ ",
]
backend.receive(lines.joined(separator: "\r\n"))
update(themeName: themeName, text: "", isDark: isDark, font: font)
}
@discardableResult
public func update(themeName: String, text: String, isDark: Bool, font: TerminalFontPreferences) -> Bool {
let configuration = TermindTerminalThemeCatalog.terminalConfiguration(
themeName: themeName, customEnabled: true, customText: text, isDark: isDark, font: font)
let theme = TerminalTheme(light: configuration, dark: configuration)
// Ghostty returns false for an unchanged theme. Returning to the last valid
// 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
return success
}
}
public struct TerminalConfigurationPreview: View {
private let themeName: String
private let text: String
private let isDark: Bool
private let font: TerminalFontPreferences
@StateObject private var model: TerminalConfigurationPreviewModel
public init(themeName: String, text: String, isDark: Bool, font: TerminalFontPreferences) {
self.themeName = themeName
self.text = text
self.isDark = isDark
self.font = font
_model = StateObject(
wrappedValue: TerminalConfigurationPreviewModel(themeName: themeName, isDark: isDark, font: font))
}
private var request: Request { Request(themeName: themeName, text: text, isDark: isDark, font: font) }
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))
if let issue = model.issue {
Text(AppStrings.Settings.themePreviewError(issue))
.font(.caption).foregroundStyle(.red)
.textSelection(.enabled)
}
}
.onAppear { model.terminal.isSurfaceVisible = true }
.onDisappear { model.terminal.isSurfaceVisible = false }
.task(id: request) {
do { try await Task.sleep(for: .milliseconds(200)) } catch { return }
guard !Task.isCancelled else { return }
model.update(themeName: themeName, text: text, isDark: isDark, font: font)
}
}
private struct Request: Equatable {
let themeName: String
let text: String
let isDark: Bool
let font: TerminalFontPreferences
}
}
/// Geometry only changes the layout, preserving editor and preview identity during resizing.
public struct TerminalConfigurationEditorLayout<Editor: View, Preview: View>: View {
@ViewBuilder private let editor: Editor
@ViewBuilder private let preview: Preview
@Environment(\.dynamicTypeSize) private var typeSize
@State private var width: CGFloat = 0
public init(@ViewBuilder editor: () -> Editor, @ViewBuilder preview: () -> Preview) {
self.editor = editor()
self.preview = preview()
}
public var body: some View {
let layout =
width >= 700 && !typeSize.isAccessibilitySize
? AnyLayout(HStackLayout(alignment: .top, spacing: 16))
: AnyLayout(VStackLayout(alignment: .leading, spacing: 16))
layout {
editor.frame(maxWidth: .infinity, minHeight: 280)
preview.frame(maxWidth: .infinity)
}
.onGeometryChange(for: CGFloat.self) {
$0.size.width
} action: {
width = $0
}
}
}
@@ -0,0 +1,57 @@
import GhosttyTheme
import SwiftUI
import TermindDomain
/// Read-only presentation of the existing light/dark preferences. Opening a browser never repairs storage.
public struct TerminalThemeOptions {
public let preferences: TerminalPreferences
public let isDark: Bool
public init(preferences: TerminalPreferences, isDark: Bool) {
self.preferences = preferences
self.isDark = isDark
}
public var name: String { isDark ? preferences.darkTheme : preferences.lightTheme }
public var isCustom: Bool { isDark ? preferences.darkCustomEnabled : preferences.lightCustomEnabled }
public var customText: String {
isDark ? preferences.darkCustomConfiguration : preferences.lightCustomConfiguration
}
public var title: LocalizedStringResource {
isDark ? AppStrings.Settings.darkTheme : AppStrings.Settings.lightTheme
}
public var summary: Text {
if isCustom {
Text(AppStrings.Settings.customTheme)
} else if name.isEmpty {
Text(AppStrings.Settings.noThemeSelected)
} else {
Text(verbatim: name)
}
}
public var isAvailable: Bool { TermindTerminalThemeCatalog.theme(named: name)?.isDark == isDark }
public var baseTheme: GhosttyThemeDefinition {
TermindTerminalThemeCatalog.resolvedTheme(named: name, isDark: isDark)
}
public func themes(matching query: String) -> [GhosttyThemeDefinition] {
let query = query.trimmingCharacters(in: .whitespacesAndNewlines)
return TermindTerminalThemeCatalog.themes(isDark: isDark).filter {
query.isEmpty || $0.name.localizedStandardContains(query)
}
}
public func isSelected(_ name: String) -> Bool { !isCustom && self.name == name }
}
public struct TerminalThemeAvailabilityNotice: View {
private let options: TerminalThemeOptions
public init(options: TerminalThemeOptions) { self.options = options }
public var body: some View {
if !options.isAvailable {
Text(AppStrings.Settings.unavailableThemeFallback(options.baseTheme.name))
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
@@ -1,44 +0,0 @@
import GhosttyTheme
import SwiftUI
public struct TerminalThemePicker: View {
@Binding private var selection: String
private let isDark: Bool
public init(selection: Binding<String>, isDark: Bool) {
_selection = selection
self.isDark = isDark
}
private var themes: [GhosttyThemeDefinition] {
TermindTerminalThemeCatalog.themes(isDark: isDark)
}
private var selectedThemeIsAvailable: Bool {
TermindTerminalThemeCatalog.theme(named: selection)?.isDark == isDark
}
private var fallbackName: String {
TermindTerminalThemeCatalog.fallbackTheme(isDark: isDark).name
}
public var body: some View {
Picker(AppStrings.Settings.theme.swiftUIKey, selection: $selection) {
if !selectedThemeIsAvailable {
if selection.isEmpty {
Text("No Theme Selected").tag(selection)
} else {
Text(verbatim: selection).tag(selection)
}
}
ForEach(themes, id: \.name) { theme in
Text(verbatim: theme.name).tag(theme.name)
}
}
if !selectedThemeIsAvailable {
Text("This theme is unavailable for this appearance. Using “\(Text(verbatim: fallbackName))” instead.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
@@ -0,0 +1,97 @@
import GhosttyTheme
import SwiftUI
import TermindDomain
/// A small color illustration, not a second implementation of Ghostty's configuration renderer.
public struct TerminalThemeThumbnailPalette: Equatable, Sendable {
public var background: TermindTerminalPalette.RGB
public var foreground: TermindTerminalPalette.RGB
public var selection: TermindTerminalPalette.RGB
public var selectionForeground: TermindTerminalPalette.RGB
public var cursor: TermindTerminalPalette.RGB
public var ansi: [TermindTerminalPalette.RGB]
public init(theme: GhosttyThemeDefinition, customText: String = "") {
let fallback = theme.isDark ? TermindTerminalPalette.termindDark : .termindLight
background = .init(hex: theme.background) ?? fallback.background
foreground = .init(hex: theme.foreground) ?? fallback.foreground
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
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" {
let parts = entry.value.split(separator: "=", maxSplits: 1).map {
$0.trimmingCharacters(in: .whitespaces)
}
if parts.count == 2, let index = Int(parts[0]), ansi.indices.contains(index),
let color = TermindTerminalPalette.RGB(hex: parts[1])
{
ansi[index] = color
}
continue
}
guard let color = TermindTerminalPalette.RGB(hex: entry.value) else { continue }
switch entry.key {
case "background": background = color
case "foreground": foreground = color
case "selection-background": selection = color
case "selection-foreground": selectionForeground = color
case "cursor-color": cursor = color
default: break
}
}
}
}
public struct TerminalThemeThumbnail: View {
private let palette: TerminalThemeThumbnailPalette
private let showsPalette: Bool
@Environment(\.accessibilityReduceMotion) private var reduceMotion
public init(theme: GhosttyThemeDefinition, customText: String = "", showsPalette: Bool = false) {
palette = TerminalThemeThumbnailPalette(theme: theme, customText: customText)
self.showsPalette = showsPalette
}
public var body: some View {
Canvas { context, size in
let sx = size.width / 144
let sy = size.height / 96
func bar(_ x: Double, _ y: Double, _ width: Double, _ height: Double, _ color: Color) {
let rect = CGRect(x: x * sx, y: y * sy, width: width * sx, height: height * sy)
context.fill(Path(roundedRect: rect, cornerRadius: min(3 * sy, rect.height / 2)), with: .color(color))
}
context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(palette.background.color))
bar(12, 14, 116, 7, palette.foreground.color)
bar(8, 28, 128, 17, palette.selection.color)
bar(12, 33, 69, 7, palette.selectionForeground.color)
bar(86, 33, 44, 7, palette.selectionForeground.color)
bar(12, 54, 30, 7, palette.ansi[2].color)
bar(47, 54, 33, 7, palette.foreground.color)
bar(85, 54, 18, 7, palette.foreground.color)
bar(109, 51, 5, 13, palette.cursor.color)
}
.aspectRatio(1.5, contentMode: .fit)
.overlay {
GeometryReader { geometry in
VStack(spacing: 0) {
Spacer(minLength: 0)
HStack(spacing: 0) {
ForEach(0..<8, id: \.self) { index in
Rectangle().fill(palette.ansi[index].color)
}
}
.frame(height: geometry.size.height / 12)
.offset(y: showsPalette || reduceMotion ? 0 : geometry.size.height / 12)
.opacity(showsPalette ? 1 : 0)
}
}
.allowsHitTesting(false)
}
.clipShape(.rect(cornerRadius: 8))
.overlay { RoundedRectangle(cornerRadius: 8).strokeBorder(.quaternary, lineWidth: 1) }
.animation(reduceMotion ? .easeInOut(duration: 0.1) : .easeInOut(duration: 0.18), value: showsPalette)
.accessibilityHidden(true)
}
}
@@ -5,8 +5,6 @@ import TermindPresentation
struct MobileTerminalSettingsView: View {
let model: TerminalSettingsModel
@State private var editingDark: Bool?
@State private var customText = ""
@TerminalFontSettings private var font
@Environment(\.scenePhase) private var scenePhase
@@ -27,47 +25,19 @@ struct MobileTerminalSettingsView: View {
.onChange(of: scenePhase) { _, phase in
if phase == .active { TerminalFontCatalog.shared.refresh() }
}
.sheet(isPresented: Binding(get: { editingDark != nil }, set: { if !$0 { editingDark = nil } })) {
if let dark = editingDark {
let original =
dark ? model.preferences.darkCustomConfiguration : model.preferences.lightCustomConfiguration
MobileEditorSheet(
title: String(localized: AppStrings.Settings.customConfiguration), isDirty: customText != original,
save: {
guard model.set(customText, for: dark ? \.darkCustomConfiguration : \.lightCustomConfiguration)
else {
throw MobileEditorError.message(model.storageError ?? "")
}
},
content: {
TextEditor(text: $customText).multilineTextAlignment(.leading).font(
.system(.body, design: .monospaced)
)
.textInputAutocapitalization(.never).autocorrectionDisabled().frame(minHeight: 280)
.accessibilityLabel(AppStrings.Settings.customConfiguration.swiftUIKey)
})
}
}
}
private func themeSection(dark: Bool) -> some View {
Section {
TerminalThemePicker(selection: binding(dark ? \.darkTheme : \.lightTheme), isDark: dark)
Toggle(
AppStrings.Settings.customConfiguration.swiftUIKey,
isOn: binding(dark ? \.darkCustomEnabled : \.lightCustomEnabled))
Button(AppStrings.Settings.editConfiguration.swiftUIKey, systemImage: "pencil") {
customText =
dark ? model.preferences.darkCustomConfiguration : model.preferences.lightCustomConfiguration
editingDark = dark
}
} header: {
Text(dark ? String(localized: AppStrings.Settings.dark) : String(localized: AppStrings.Settings.light))
} footer: {
if dark {
Text(
AppStrings.Settings.terminalConfigurationExplanation
)
let options = TerminalThemeOptions(preferences: model.preferences, isDark: dark)
return Section {
NavigationLink {
MobileTerminalThemeView(model: model, isDark: dark)
} label: {
HStack {
Text(options.title)
Spacer()
options.summary.foregroundStyle(.secondary)
}
}
}
}
@@ -0,0 +1,117 @@
import GhosttyTheme
import SwiftUI
import TermindApplication
import TermindPresentation
struct MobileTerminalThemeView: View {
let model: TerminalSettingsModel
let isDark: Bool
@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) }
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)
}
tile(theme: options.baseTheme, custom: true)
}
if options.themes(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))
}
}
}
}
private func tile(theme: GhosttyThemeDefinition, custom: Bool) -> some View {
let hoverID = custom ? "__custom__" : theme.name
let selected = custom ? options.isCustom : options.isSelected(theme.name)
return MobileActionCard {
if custom {
model.selectCustomTheme(isDark: isDark)
} else {
model.selectTheme(named: theme.name, isDark: isDark)
}
} content: {
HStack(spacing: 12) {
TerminalThemeThumbnail(
theme: theme, customText: custom ? options.customText : "", showsPalette: hoveredTheme == hoverID
)
.frame(width: 96)
if custom {
Text(AppStrings.Settings.customTheme).font(.headline)
} else {
Text(verbatim: theme.name).font(.headline)
}
Spacer(minLength: 0)
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)
}
}
}
}
+60
View File
@@ -0,0 +1,60 @@
import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
import Testing
import UIKit
@testable import TermindIOS
@Suite(.serialized) @MainActor
struct MobileTerminalThemeTests {
@Test(
.enabled(if: ProcessInfo.processInfo.environment["TERMIND_MOBILE_UI_TESTS"] == "1"),
arguments: ["en", "zh-Hans", "zh-Hant"], [false, true])
func themeBrowserSnapshots(language: String, isDark: 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()
model.selectCustomTheme(isDark: isDark)
let initial = model.preferences
let controller = UIHostingController(
rootView: NavigationStack { MobileTerminalThemeView(model: model, isDark: isDark) }
.environment(\.locale, Locale(identifier: language))
.environment(\.colorScheme, isDark ? .dark : .light)
.environment(\.dynamicTypeSize, language == "zh-Hant" ? .accessibility1 : .large))
window.rootViewController = controller
window.makeKeyAndVisible()
defer {
window.isHidden = true
window.rootViewController = nil
previous?.makeKey()
}
controller.view.layoutIfNeeded()
try await Task.sleep(for: .milliseconds(400))
let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in
window.drawHierarchy(in: window.bounds, afterScreenUpdates: true)
}
let data = try #require(image.pngData())
Attachment.record(data, named: "mobile-themes-\(language)-\(isDark ? "dark" : "light").png")
#expect(data.count > 10_000)
#expect(model.preferences == initial)
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("termind-theme-previews")
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try data.write(
to: directory.appendingPathComponent("mobile-themes-\(language)-\(isDark ? "dark" : "light").png"))
}
@Test func previewingDraftDoesNotApplyOrEnableIt() {
let model = TerminalSettingsModel()
let initial = model.preferences
let preview = TerminalConfigurationPreviewModel()
#expect(
preview.update(
themeName: initial.lightTheme, text: "background = 123456", isDark: false, font: .init()))
#expect(model.preferences == initial)
#expect(!model.preferences.lightCustomEnabled)
#expect(!preview.terminal.isFocused)
}
}
+31 -22
View File
@@ -66,6 +66,7 @@ enum SettingsRoute: Hashable {
case mcpServer(MCPServer.ID, isNew: Bool)
case localMCPServer
case skill(AgentSkill.ID, isNew: Bool)
case terminalThemes(TerminalCustomConfigurationScheme)
case terminalConfiguration(TerminalCustomConfigurationScheme)
case logging
@@ -85,8 +86,10 @@ enum SettingsRoute: Hashable {
.category(.ai)
case .skill:
.category(.ai)
case .terminalConfiguration:
case .terminalThemes:
.category(.terminal)
case .terminalConfiguration(let scheme):
.terminalThemes(scheme)
case .logging:
.index
}
@@ -144,6 +147,8 @@ struct SettingsPageView: View {
SettingsFormPage { MCPServerSettings() }
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)
case .logging:
@@ -170,7 +175,7 @@ struct SettingsPageView: View {
case .terminal:
SettingsFormPage {
TerminalSettings(
editConfiguration: { navigate(to: .terminalConfiguration($0)) }
chooseTheme: { navigate(to: .terminalThemes($0)) }
)
}
case .connections:
@@ -577,7 +582,7 @@ private struct ConnectionSettings: View {
}
private struct TerminalSettings: View {
let editConfiguration: (TerminalCustomConfigurationScheme) -> Void
let chooseTheme: (TerminalCustomConfigurationScheme) -> Void
@Environment(AppModel.self) private var app
@TerminalFontSettings private var font
@Environment(\.scenePhase) private var scenePhase
@@ -588,30 +593,34 @@ private struct TerminalSettings: View {
set: { app.terminalSettings.set($0, for: keyPath) })
}
private func themeRow(_ scheme: TerminalCustomConfigurationScheme) -> some View {
let options = TerminalThemeOptions(preferences: app.terminalSettings.preferences, isDark: scheme == .dark)
return Button {
chooseTheme(scheme)
} label: {
HStack {
Text(options.title)
Spacer()
options.summary.foregroundStyle(.secondary)
Image(systemName: "chevron.right").font(.caption).foregroundStyle(.tertiary)
.accessibilityHidden(true)
}
.contentShape(.rect)
}
.buttonStyle(.plain)
.accessibilityElement(children: .ignore)
.accessibilityLabel(Text(options.title))
.accessibilityValue(options.summary)
}
var body: some View {
Group {
if let error = app.terminalSettings.storageError {
Text(error).foregroundStyle(.orange)
}
Section(AppStrings.Settings.light.swiftUIKey) {
TerminalThemePicker(selection: binding(\.lightTheme), isDark: false)
Toggle(AppStrings.Settings.customConfiguration.swiftUIKey, isOn: binding(\.lightCustomEnabled))
FormNavigationAction(title: AppStrings.Settings.editCustomConfiguration) {
editConfiguration(.light)
}
}
Section {
TerminalThemePicker(selection: binding(\.darkTheme), isDark: true)
Toggle(AppStrings.Settings.customConfiguration.swiftUIKey, isOn: binding(\.darkCustomEnabled))
FormNavigationAction(title: AppStrings.Settings.editCustomConfiguration) {
editConfiguration(.dark)
}
} header: {
Text(AppStrings.Settings.dark)
} footer: {
Text(
AppStrings.Settings.terminalConfigurationExplanation
)
Section(AppStrings.Settings.theme.swiftUIKey) {
themeRow(.light)
themeRow(.dark)
}
Section(AppStrings.Settings.font.swiftUIKey) {
TerminalFontFields(preferences: $font, families: TerminalFontCatalog.shared.families)
@@ -36,6 +36,7 @@ struct TerminalCustomConfigurationEditorPage: View {
@Environment(AppModel.self) private var app
let scheme: TerminalCustomConfigurationScheme
@TerminalFontSettings private var font
@State private var content = ""
@State private var lastSavedContent = ""
@State private var isLoaded = false
@@ -66,13 +67,6 @@ struct TerminalCustomConfigurationEditorPage: View {
}
HStack {
Toggle(
AppStrings.Common.enabled.swiftUIKey,
isOn: Binding(
get: { app.terminalSettings.preferences[keyPath: scheme.enabledKeyPath] },
set: { app.terminalSettings.set($0, for: scheme.enabledKeyPath) })
)
.toggleStyle(.switch)
Spacer()
Button(AppStrings.Settings.importFromFile.swiftUIKey) { isImporting = true }
}
@@ -80,11 +74,27 @@ struct TerminalCustomConfigurationEditorPage: View {
.padding(.vertical, Metrics.Space.s)
Divider()
TextEditor(text: $content)
.font(.system(.body, design: .monospaced))
.textContentType(.none)
.autocorrectionDisabled()
ScrollView {
Text(AppStrings.Settings.terminalConfigurationExplanation)
.font(.caption).foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, Metrics.Space.m)
TerminalConfigurationEditorLayout {
TextEditor(text: $content)
.font(.system(.body, design: .monospaced))
.textContentType(.none)
.autocorrectionDisabled()
.frame(minHeight: 360)
.accessibilityLabel(AppStrings.Settings.customConfiguration.swiftUIKey)
} preview: {
TerminalConfigurationPreview(
themeName: scheme == .dark
? app.terminalSettings.preferences.darkTheme : app.terminalSettings.preferences.lightTheme,
text: content, isDark: scheme == .dark,
font: font.resolvingFamily(in: TerminalFontCatalog.shared.families))
}
.padding(Metrics.Space.m)
}
}
.onAppear {
content = app.terminalSettings.preferences[keyPath: scheme.preferenceKeyPath]
@@ -0,0 +1,93 @@
import GhosttyTheme
import SwiftUI
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 options: TerminalThemeOptions {
.init(preferences: app.terminalSettings.preferences, isDark: scheme == .dark)
}
var body: some View {
CardPage {
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)
}
tile(theme: options.baseTheme, custom: true)
}
if options.themes(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)
let select: () -> Void = {
if custom {
app.terminalSettings.selectCustomTheme(isDark: scheme == .dark)
} else {
app.terminalSettings.selectTheme(named: theme.name, isDark: scheme == .dark)
}
}
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
)
.frame(width: 108)
if custom {
Text(AppStrings.Settings.customTheme).font(.headline)
} else {
Text(verbatim: 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 }
}
.accessibilityAddTraits(selected ? [.isSelected] : [])
.accessibilityAction { select() }
.onKeyPress(.space) {
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)
}
}
}
}
+3
View File
@@ -69,6 +69,9 @@ struct SettingsWindowTitleProvider: WindowTitleProviding {
return AppLocalization.resolve(AppStrings.MCP.mcpServer, locale: locale)
case .skill:
return AppLocalization.resolve(AppStrings.Settings.skill, locale: locale)
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 .logging:
@@ -84,6 +84,7 @@ final class WindowModel {
}
var metricsSelection: Set<UUID> = []
var settingsRoute: SettingsRoute = .index
var terminalThemeQueries: [TerminalCustomConfigurationScheme: String] = [:]
var vaultDestination: VaultDestination = .welcome
var vaultWorkRoute: VaultWorkRoute?
let assistantLibrary: AssistantLibraryModel
@@ -430,6 +431,7 @@ final class WindowModel {
case .metrics:
return metricsHostID == nil ? .metrics : .processes
case .settings:
if case .terminalThemes = settingsRoute { return .terminalThemes }
return nil
case .workspace:
if focusedSSHSession != nil || focusedTelnetSession != nil || focusedSerialSession != nil {
+10
View File
@@ -15,6 +15,7 @@ import struct TermindDomain.Host
/// two of them could be on screen at once.
nonisolated enum WindowToolbarFindKind: String, Hashable, Sendable, CaseIterable {
case hosts
case terminalThemes
case metrics
case processes
case keychain
@@ -833,6 +834,15 @@ extension WindowModel {
func findContext() -> WindowToolbarFindContext? {
guard let kind = findKind else { return nil }
switch kind {
case .terminalThemes:
guard case .terminalThemes(let scheme) = settingsRoute else { return nil }
return WindowToolbarFindContext(
query: Binding(
get: { self.terminalThemeQueries[scheme, default: ""] },
set: { self.terminalThemeQueries[scheme] = $0 }
),
focusRequest: findFocusRequest
)
case .metrics:
return WindowToolbarFindContext(
query: Binding(get: { self.metricsQuery }, set: { self.metricsQuery = $0 }),
+5 -1
View File
@@ -89,7 +89,8 @@ struct SettingsNavigationTests {
)
#expect(SettingsRoute.mcpServer(serverID, isNew: false).parent == .category(.ai))
#expect(SettingsRoute.localMCPServer.parent == .category(.ai))
#expect(SettingsRoute.terminalConfiguration(.dark).parent == .category(.terminal))
#expect(SettingsRoute.terminalConfiguration(.dark).parent == .terminalThemes(.dark))
#expect(SettingsRoute.terminalThemes(.dark).parent == .category(.terminal))
#expect(SettingsRoute.logging.parent == .index)
}
@@ -124,6 +125,9 @@ struct SettingsNavigationTests {
let window = WindowModel()
window.openSettings(route: .terminalConfiguration(.dark))
window.navigateBackInSettings()
#expect(window.settingsRoute == .terminalThemes(.dark))
window.navigateBackInSettings()
#expect(window.settingsRoute == .category(.terminal))
#expect(window.selectedDestination == .settings)
+110
View File
@@ -0,0 +1,110 @@
import AppKit
import GhosttyTerminal
import SwiftUI
import TermindDomain
import TermindPresentation
import Testing
@Suite @MainActor
struct TerminalThemeBrowserTests {
@Test func hoverPaletteChangesOnlyTheBottomStripWithoutResizing() throws {
func render(showsPalette: Bool) throws -> NSBitmapImageRep {
let renderer = ImageRenderer(
content: TerminalThemeThumbnail(
theme: TermindTerminalThemeCatalog.defaultDark, showsPalette: showsPalette
)
.frame(width: 144, height: 96))
renderer.scale = 1
return NSBitmapImageRep(cgImage: try #require(renderer.cgImage))
}
let resting = try render(showsPalette: false)
let hovering = try render(showsPalette: true)
#expect(resting.pixelsWide == hovering.pixelsWide)
#expect(resting.pixelsHigh == hovering.pixelsHigh)
var changedPixels = 0
for y in 0..<96 {
for x in 0..<144 {
if resting.colorAt(x: x, y: y) != hovering.colorAt(x: x, y: y) {
changedPixels += 1
#expect(y >= 88)
}
}
}
#expect(changedPixels > 500)
}
@Test(arguments: [false, true])
func searchKeepsAppearanceAndCustomSelectionIndependent(isDark: Bool) {
let options = TerminalThemeOptions(
preferences: TerminalPreferences(darkCustomEnabled: true, lightCustomEnabled: true), isDark: isDark)
let results = options.themes(matching: " TERMIND ")
#expect(!results.isEmpty)
#expect(results.allSatisfy { $0.isDark == isDark && $0.name.contains("Termind") })
#expect(options.themes(matching: "no-such-theme-123456").isEmpty)
#expect(options.isCustom)
#expect(!options.isSelected(options.name))
}
@Test func unavailableSelectionRetainsItsNameAndResolvesOnlyThePreview() {
let preferences = TerminalPreferences(darkTheme: "Missing Theme")
let options = TerminalThemeOptions(preferences: preferences, isDark: true)
#expect(!options.isAvailable)
#expect(options.name == "Missing Theme")
#expect(options.baseTheme == TermindTerminalThemeCatalog.defaultDark)
#expect(!options.isSelected(options.baseTheme.name))
}
@Test func thumbnailUsesThemeColorsAndOrderedCustomOverrides() {
let theme = TermindTerminalThemeCatalog.defaultDark
let base = TerminalThemeThumbnailPalette(theme: theme)
#expect(base.background.hex == theme.background.uppercased())
#expect(base.ansi[2].hex == theme.palette[2]?.uppercased())
let palette = TerminalThemeThumbnailPalette(
theme: theme,
customText: """
background = #102030
background = 405060
foreground = #AABBCC
cursor-color = 112233
selection-background = 223344
selection-foreground = 334455
palette = 2=#FF0000
palette = 2=#00FF00
palette = 17=#FFFFFF
palette = invalid
""")
#expect(palette.background.hex == "405060")
#expect(palette.foreground.hex == "AABBCC")
#expect(palette.cursor.hex == "112233")
#expect(palette.selection.hex == "223344")
#expect(palette.selectionForeground.hex == "334455")
#expect(palette.ansi[2].hex == "00FF00")
#expect(palette.ansi[1] == base.ansi[1])
#expect(TerminalThemeThumbnailPalette(theme: theme, customText: "background = invalid") == base)
}
@Test(arguments: [false, true])
func draftPreviewReusesControllerAndRetainsLastValidConfiguration(isDark: Bool) {
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 lastValid = model.terminal.theme
#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.issue == nil)
#expect(model.terminal.theme == lastValid)
#expect(
model.update(
themeName: name, text: "font-size = 30\nbackground = ABCDEF", isDark: isDark,
font: .init(family: "Menlo", size: 18, sizeFollowsTheme: false)))
#expect(model.issue == nil)
#expect(model.terminal.controller === controller)
#expect(model.terminal.theme.dark.rendered.hasSuffix("font-family = \nfont-family = Menlo\nfont-size = 18"))
#expect(!model.terminal.isFocused)
}
}
@@ -0,0 +1,90 @@
import AppKit
import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
import Testing
@testable import Termind
/// Offscreen hosting leaves the user's windows and keyboard focus untouched.
@Suite(.serialized) @MainActor
struct TerminalThemePageRenderingTests {
@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 app = AppModel(hosts: HostLibraryModel(), terminalSettings: model)
defer { app.shutdownForTermination() }
let navigation = WindowModel(workspaces: [])
navigation.openSettings(route: .terminalThemes(.dark))
let controller = NSHostingController(
rootView: SettingsPageView().environment(app).environment(navigation)
.environment(\.locale, Locale(identifier: language))
.environment(\.colorScheme, .dark)
.frame(width: 900, height: 700))
let window = NSWindow(
contentRect: CGRect(x: 80, y: 80, width: 900, height: 700),
styleMask: [.titled, .closable], backing: .buffered, defer: false)
window.isReleasedWhenClosed = false
window.appearance = NSAppearance(named: .darkAqua)
window.contentViewController = controller
window.title = "Termind Theme Preview"
window.orderFront(nil)
defer {
window.orderOut(nil)
window.contentViewController = nil
window.close()
}
for editor in [false, true] {
if editor { navigation.settingsRoute = .terminalConfiguration(.dark) }
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)))
}
}
@Test(arguments: ["en", "zh-Hans", "zh-Hant"], [false, true])
func browserRendersBothAppearancesWithoutChangingTheSelection(language: String, isDark: Bool) async throws {
let initial = TerminalPreferences(darkCustomEnabled: true, lightCustomEnabled: true)
let model = TerminalSettingsModel()
model.applyRemoteSnapshot(initial)
let app = AppModel(hosts: HostLibraryModel(), terminalSettings: model)
defer { app.shutdownForTermination() }
let windowModel = WindowModel(workspaces: [])
windowModel.openSettings(route: .terminalThemes(isDark ? .dark : .light))
let content = SettingsPageView().environment(app).environment(windowModel)
.environment(\.locale, Locale(identifier: language))
.environment(\.colorScheme, isDark ? .dark : .light)
.frame(width: 760, height: 720)
let controller = NSHostingController(rootView: content)
let window = NSWindow(
contentRect: CGRect(x: 0, y: 0, width: 760, height: 720),
styleMask: [.borderless], backing: .buffered, defer: false)
window.isReleasedWhenClosed = false
window.appearance = NSAppearance(named: isDark ? .darkAqua : .aqua)
window.contentViewController = controller
defer {
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(data.count > 10_000)
#expect(model.preferences == initial)
#expect(!window.isVisible)
}
}
+24
View File
@@ -24,6 +24,30 @@ struct WindowToolbarFindTests {
TermindDomain.Host(name: name, host: "\(name).example")
}
@Test
func themeSearchUsesToolbarAndPreservesEachAppearanceQuery() throws {
let window = WindowModel()
window.openSettings(route: .terminalThemes(.light))
#expect(window.findKind == .terminalThemes)
let light = try #require(window.findContext())
light.query.wrappedValue = "Day"
window.focusToolbarFind()
#expect(window.isToolbarFindFocused)
window.openSettings(route: .terminalThemes(.dark))
let dark = try #require(window.findContext())
#expect(dark.query.wrappedValue.isEmpty)
dark.query.wrappedValue = "Night"
window.openSettings(route: .terminalConfiguration(.dark))
#expect(window.findContext() == nil)
window.navigateBackInSettings()
#expect(window.findContext()?.query.wrappedValue == "Night")
window.openSettings(route: .terminalThemes(.light))
#expect(window.findContext()?.query.wrappedValue == "Day")
window.openSettings(route: .category(.terminal))
#expect(window.findKind == nil)
}
@Test
func vaultPagesEachResolveToTheirOwnFind() {
let window = WindowModel()