feat(settings): add theme browser and live configuration preview
This commit is contained in:
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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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()
|
||||
|
||||
Reference in new issue
Block a user