From 58d8440ca596fcd949974097acb895f95cf002fd Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Thu, 1 Oct 2026 23:43:34 +0800 Subject: [PATCH] feat(settings): add theme browser and live configuration preview --- .../TerminalThemeSelectionTests.swift | 73 ++++++++++ Frameworks/Resources/Localizable.xcstrings | 119 +++++++++++++--- .../FeatureModels/TerminalSettingsModel.swift | 23 +++ .../Strings/Settings.swift | 18 ++- .../TerminalConfigurationPreview.swift | 132 ++++++++++++++++++ .../Terminal/TerminalThemeOptions.swift | 57 ++++++++ .../Terminal/TerminalThemePicker.swift | 44 ------ .../Terminal/TerminalThemeThumbnail.swift | 97 +++++++++++++ .../Settings/MobileTerminalSettingsView.swift | 50 ++----- .../Settings/MobileTerminalThemeView.swift | 117 ++++++++++++++++ TFITests/MobileTerminalThemeTests.swift | 60 ++++++++ TFM/Features/Settings/SettingsPageView.swift | 53 ++++--- ...rminalCustomConfigurationEditorSheet.swift | 32 +++-- TFM/Features/Settings/TerminalThemePage.swift | 93 ++++++++++++ TFM/Features/Shell/WindowTitle.swift | 3 + TFM/Features/Workspaces/WindowModel.swift | 2 + TFM/UI/Components/WindowToolbar.swift | 10 ++ TFMTests/SettingsNavigationTests.swift | 6 +- TFMTests/TerminalThemeBrowserTests.swift | 110 +++++++++++++++ .../TerminalThemePageRenderingTests.swift | 90 ++++++++++++ TFMTests/WindowToolbarFindTests.swift | 24 ++++ 21 files changed, 1074 insertions(+), 139 deletions(-) create mode 100644 FrameworkTests/TerminalThemeSelectionTests.swift create mode 100644 Frameworks/TermindPresentation/Terminal/TerminalConfigurationPreview.swift create mode 100644 Frameworks/TermindPresentation/Terminal/TerminalThemeOptions.swift delete mode 100644 Frameworks/TermindPresentation/Terminal/TerminalThemePicker.swift create mode 100644 Frameworks/TermindPresentation/Terminal/TerminalThemeThumbnail.swift create mode 100644 TFI/Features/Settings/MobileTerminalThemeView.swift create mode 100644 TFITests/MobileTerminalThemeTests.swift create mode 100644 TFM/Features/Settings/TerminalThemePage.swift create mode 100644 TFMTests/TerminalThemeBrowserTests.swift create mode 100644 TFMTests/TerminalThemePageRenderingTests.swift diff --git a/FrameworkTests/TerminalThemeSelectionTests.swift b/FrameworkTests/TerminalThemeSelectionTests.swift new file mode 100644 index 00000000..156ccd7e --- /dev/null +++ b/FrameworkTests/TerminalThemeSelectionTests.swift @@ -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) + } +} diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index 7ce26a33..37abda80 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -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" : { diff --git a/Frameworks/TermindApplication/FeatureModels/TerminalSettingsModel.swift b/Frameworks/TermindApplication/FeatureModels/TerminalSettingsModel.swift index 8858a469..ccf9d586 100644 --- a/Frameworks/TermindApplication/FeatureModels/TerminalSettingsModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/TerminalSettingsModel.swift @@ -19,6 +19,29 @@ public final class TerminalSettingsModel { public func set(_ value: Value, for keyPath: WritableKeyPath) -> 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) diff --git a/Frameworks/TermindPresentation/Strings/Settings.swift b/Frameworks/TermindPresentation/Strings/Settings.swift index 4a41904f..ab91663e 100644 --- a/Frameworks/TermindPresentation/Strings/Settings.swift +++ b/Frameworks/TermindPresentation/Strings/Settings.swift @@ -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" } diff --git a/Frameworks/TermindPresentation/Terminal/TerminalConfigurationPreview.swift b/Frameworks/TermindPresentation/Terminal/TerminalConfigurationPreview.swift new file mode 100644 index 00000000..c4e96190 --- /dev/null +++ b/Frameworks/TermindPresentation/Terminal/TerminalConfigurationPreview.swift @@ -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: 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 + } + } +} diff --git a/Frameworks/TermindPresentation/Terminal/TerminalThemeOptions.swift b/Frameworks/TermindPresentation/Terminal/TerminalThemeOptions.swift new file mode 100644 index 00000000..5bb95958 --- /dev/null +++ b/Frameworks/TermindPresentation/Terminal/TerminalThemeOptions.swift @@ -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) + } + } +} diff --git a/Frameworks/TermindPresentation/Terminal/TerminalThemePicker.swift b/Frameworks/TermindPresentation/Terminal/TerminalThemePicker.swift deleted file mode 100644 index 7279761e..00000000 --- a/Frameworks/TermindPresentation/Terminal/TerminalThemePicker.swift +++ /dev/null @@ -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, 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) - } - } -} diff --git a/Frameworks/TermindPresentation/Terminal/TerminalThemeThumbnail.swift b/Frameworks/TermindPresentation/Terminal/TerminalThemeThumbnail.swift new file mode 100644 index 00000000..cd15ae18 --- /dev/null +++ b/Frameworks/TermindPresentation/Terminal/TerminalThemeThumbnail.swift @@ -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) + } +} diff --git a/TFI/Features/Settings/MobileTerminalSettingsView.swift b/TFI/Features/Settings/MobileTerminalSettingsView.swift index 7cd7e868..efdd5b71 100644 --- a/TFI/Features/Settings/MobileTerminalSettingsView.swift +++ b/TFI/Features/Settings/MobileTerminalSettingsView.swift @@ -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) + } } } } diff --git a/TFI/Features/Settings/MobileTerminalThemeView.swift b/TFI/Features/Settings/MobileTerminalThemeView.swift new file mode 100644 index 00000000..93c3418e --- /dev/null +++ b/TFI/Features/Settings/MobileTerminalThemeView.swift @@ -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) + } + } + } +} diff --git a/TFITests/MobileTerminalThemeTests.swift b/TFITests/MobileTerminalThemeTests.swift new file mode 100644 index 00000000..3d147e95 --- /dev/null +++ b/TFITests/MobileTerminalThemeTests.swift @@ -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) + } +} diff --git a/TFM/Features/Settings/SettingsPageView.swift b/TFM/Features/Settings/SettingsPageView.swift index 06e23dbc..ea29a8bd 100644 --- a/TFM/Features/Settings/SettingsPageView.swift +++ b/TFM/Features/Settings/SettingsPageView.swift @@ -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) diff --git a/TFM/Features/Settings/TerminalCustomConfigurationEditorSheet.swift b/TFM/Features/Settings/TerminalCustomConfigurationEditorSheet.swift index 0eced172..1ac1536b 100644 --- a/TFM/Features/Settings/TerminalCustomConfigurationEditorSheet.swift +++ b/TFM/Features/Settings/TerminalCustomConfigurationEditorSheet.swift @@ -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] diff --git a/TFM/Features/Settings/TerminalThemePage.swift b/TFM/Features/Settings/TerminalThemePage.swift new file mode 100644 index 00000000..91fd70bd --- /dev/null +++ b/TFM/Features/Settings/TerminalThemePage.swift @@ -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) + } + } + } +} diff --git a/TFM/Features/Shell/WindowTitle.swift b/TFM/Features/Shell/WindowTitle.swift index 44c3138c..6fba6d92 100644 --- a/TFM/Features/Shell/WindowTitle.swift +++ b/TFM/Features/Shell/WindowTitle.swift @@ -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: diff --git a/TFM/Features/Workspaces/WindowModel.swift b/TFM/Features/Workspaces/WindowModel.swift index 770c6ccc..02d24ad6 100644 --- a/TFM/Features/Workspaces/WindowModel.swift +++ b/TFM/Features/Workspaces/WindowModel.swift @@ -84,6 +84,7 @@ final class WindowModel { } var metricsSelection: Set = [] 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 { diff --git a/TFM/UI/Components/WindowToolbar.swift b/TFM/UI/Components/WindowToolbar.swift index daa0a24b..59880b09 100644 --- a/TFM/UI/Components/WindowToolbar.swift +++ b/TFM/UI/Components/WindowToolbar.swift @@ -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 }), diff --git a/TFMTests/SettingsNavigationTests.swift b/TFMTests/SettingsNavigationTests.swift index 32917203..8e3434a8 100644 --- a/TFMTests/SettingsNavigationTests.swift +++ b/TFMTests/SettingsNavigationTests.swift @@ -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) diff --git a/TFMTests/TerminalThemeBrowserTests.swift b/TFMTests/TerminalThemeBrowserTests.swift new file mode 100644 index 00000000..de423d51 --- /dev/null +++ b/TFMTests/TerminalThemeBrowserTests.swift @@ -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) + } +} diff --git a/TFMTests/TerminalThemePageRenderingTests.swift b/TFMTests/TerminalThemePageRenderingTests.swift new file mode 100644 index 00000000..26d74a7a --- /dev/null +++ b/TFMTests/TerminalThemePageRenderingTests.swift @@ -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) + } +} diff --git a/TFMTests/WindowToolbarFindTests.swift b/TFMTests/WindowToolbarFindTests.swift index 6cabc388..d1819134 100644 --- a/TFMTests/WindowToolbarFindTests.swift +++ b/TFMTests/WindowToolbarFindTests.swift @@ -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()