204 lines
8.6 KiB
Swift
204 lines
8.6 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
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?
|
|
@Published public private(set) var palette: TerminalThemeThumbnailPalette
|
|
|
|
public init(
|
|
themeName: String = TermindTerminalThemeCatalog.defaultDark.name,
|
|
isDark: Bool = true, font: TerminalFontPreferences = .init()
|
|
) {
|
|
palette = TerminalThemeThumbnailPalette(
|
|
theme: TermindTerminalThemeCatalog.resolvedTheme(named: themeName, isDark: isDark))
|
|
let backend = InMemoryTerminalSession(write: { _ in }, resize: { _ in })
|
|
terminal = TerminalViewState(configSource: .none)
|
|
terminal.configuration = TerminalSurfaceOptions(backend: .inMemory(backend), resizeThrottleMilliseconds: 0)
|
|
terminal.setTerminalConfiguration(
|
|
.init { builder in
|
|
builder.withCustom("notify-on-command-finish", "never")
|
|
builder.withWindowPaddingX(16)
|
|
builder.withWindowPaddingY(16)
|
|
})
|
|
let lines = [
|
|
"\u{1B}[36muser@server\u{1B}[0m \u{1B}[34m~/termind\u{1B}[0m",
|
|
"$ git status --short",
|
|
"\u{1B}[33m M\u{1B}[0m theme.conf",
|
|
"\u{1B}[32m A\u{1B}[0m themes/ocean",
|
|
"",
|
|
"$ git diff",
|
|
"\u{1B}[31m- background = 1e1e1e\u{1B}[0m",
|
|
"\u{1B}[32m+ background = 152033\u{1B}[0m",
|
|
"",
|
|
"\u{1B}[1mAa Bb\u{1B}[0m \u{1B}[3mAa Bb\u{1B}[0m \u{1B}[4mAa Bb\u{1B}[0m",
|
|
"$ ",
|
|
]
|
|
backend.receive(lines.joined(separator: "\r\n"))
|
|
update(themeName: themeName, text: "", isDark: isDark, font: font)
|
|
}
|
|
|
|
@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
|
|
if success {
|
|
palette = TerminalThemeThumbnailPalette(
|
|
theme: TermindTerminalThemeCatalog.resolvedTheme(named: themeName, isDark: isDark), customText: text)
|
|
}
|
|
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)
|
|
VStack(spacing: 0) {
|
|
TerminalSurfaceView(context: model.terminal)
|
|
.environment(\.colorScheme, isDark ? .dark : .light)
|
|
.allowsHitTesting(false)
|
|
.disabled(true)
|
|
.accessibilityHidden(true)
|
|
.frame(minHeight: 260)
|
|
colorSamples
|
|
}
|
|
.background(model.palette.background.color)
|
|
.clipShape(.rect(cornerRadius: 8))
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.strokeBorder(.separator, lineWidth: 1)
|
|
.allowsHitTesting(false)
|
|
}
|
|
.accessibilityIdentifier("custom-theme-preview")
|
|
if let issue = model.issue {
|
|
Text(AppStrings.Settings.themePreviewError(issue))
|
|
.font(.caption).foregroundStyle(.red)
|
|
.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 var colorSamples: some View {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
ViewThatFits(in: .horizontal) {
|
|
HStack(spacing: 20) { textSamples }
|
|
VStack(alignment: .leading, spacing: 12) { textSamples }
|
|
}
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text(AppStrings.Settings.themePreviewPalette)
|
|
.font(.caption.weight(.medium))
|
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 8), spacing: 8) {
|
|
ForEach(0..<16, id: \.self) { index in
|
|
VStack(spacing: 4) {
|
|
RoundedRectangle(cornerRadius: 3)
|
|
.fill(model.palette.ansi[index].color)
|
|
.frame(height: 20)
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: 3)
|
|
.strokeBorder(model.palette.foreground.color.opacity(0.2), lineWidth: 1)
|
|
}
|
|
Text(verbatim: "\(index)")
|
|
.font(.caption2.monospacedDigit())
|
|
}
|
|
.accessibilityElement(children: .ignore)
|
|
.accessibilityLabel(Text(verbatim: "\(index): #\(model.palette.ansi[index].hex)"))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.foregroundStyle(model.palette.foreground.color)
|
|
.padding(16)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var textSamples: some View {
|
|
HStack(spacing: 8) {
|
|
Text(AppStrings.Settings.themePreviewCursor).font(.caption)
|
|
Text(verbatim: "A")
|
|
.font(.body.monospaced())
|
|
.foregroundStyle(model.palette.cursorText.color)
|
|
.padding(.horizontal, 4).padding(.vertical, 2)
|
|
.background(model.palette.cursor.color, in: .rect(cornerRadius: 2))
|
|
}
|
|
Text(AppStrings.Settings.themePreviewSelection)
|
|
.font(.body.monospaced())
|
|
.foregroundStyle(model.palette.selectionForeground.color)
|
|
.padding(.horizontal, 6).padding(.vertical, 2)
|
|
.background(model.palette.selection.color, in: .rect(cornerRadius: 2))
|
|
}
|
|
|
|
private struct Request: Equatable {
|
|
let themeName: String
|
|
let text: String
|
|
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
|
|
}
|
|
}
|
|
}
|