194 lines
9.3 KiB
Swift
194 lines
9.3 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import SwiftUI
|
|
import TermindApplication
|
|
import TermindDomain
|
|
import TermindPresentation
|
|
import UniformTypeIdentifiers
|
|
|
|
struct TerminalCustomConfigurationEditorPage: View {
|
|
@Bindable private var draft: TerminalCustomThemeDraft
|
|
private let model: TerminalSettingsModel
|
|
private let availableFontFamilies: [String]
|
|
@Environment(WindowModel.self) private var window
|
|
@Environment(\.dynamicTypeSize) private var typeSize
|
|
@Environment(\.locale) private var locale
|
|
@TerminalFontSettings private var font
|
|
@State private var importError: String?
|
|
@State private var fieldsHeight: CGFloat = 0
|
|
@State private var headingHeight: CGFloat = 0
|
|
@State private var footerHeight: CGFloat = 0
|
|
|
|
init(
|
|
draft: TerminalCustomThemeDraft, model: TerminalSettingsModel, availableFontFamilies: [String] = []
|
|
) {
|
|
self.draft = draft
|
|
self.model = model
|
|
self.availableFontFamilies = availableFontFamilies
|
|
}
|
|
|
|
var body: some View {
|
|
@Bindable var window = window
|
|
GeometryReader { geometry in
|
|
let contentWidth = geometry.size.width - 8
|
|
let sideBySide = contentWidth >= 700 && !typeSize.isAccessibilitySize
|
|
let panelHeight = max(460, geometry.size.height - fieldsHeight - footerHeight - 40)
|
|
let layout =
|
|
sideBySide
|
|
? AnyLayout(HStackLayout(alignment: .top, spacing: 16))
|
|
: AnyLayout(VStackLayout(alignment: .leading, spacing: 16))
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
fields(width: contentWidth)
|
|
.onGeometryChange(for: CGFloat.self) {
|
|
$0.size.height
|
|
} action: {
|
|
fieldsHeight = $0
|
|
}
|
|
layout {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text(AppStrings.Settings.customConfiguration)
|
|
.font(.headline)
|
|
.onGeometryChange(for: CGFloat.self) {
|
|
$0.size.height
|
|
} action: {
|
|
headingHeight = $0
|
|
}
|
|
TextEditor(text: $draft.value.configuration)
|
|
.autohidingScrollers()
|
|
.font(.system(.body, design: .monospaced))
|
|
.textContentType(.none)
|
|
.autocorrectionDisabled()
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: sideBySide ? panelHeight - headingHeight - 12 : 360)
|
|
.clipShape(.rect(cornerRadius: 8))
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.strokeBorder(.separator, lineWidth: 1)
|
|
.allowsHitTesting(false)
|
|
}
|
|
.accessibilityLabel(AppStrings.Settings.customConfiguration.swiftUIKey)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
TerminalConfigurationPreview(
|
|
themeName: draft.value.baseTheme.name, text: draft.value.configuration,
|
|
isDark: draft.value.baseTheme.isDark,
|
|
font: font.resolvingFamily(in: availableFontFamilies)
|
|
)
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: sideBySide ? panelHeight : nil)
|
|
}
|
|
Text(AppStrings.Settings.terminalConfigurationExplanation)
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.onGeometryChange(for: CGFloat.self) {
|
|
$0.size.height
|
|
} action: {
|
|
footerHeight = $0
|
|
}
|
|
}
|
|
// Native text-field focus rings extend outside the control's layout bounds.
|
|
.padding(4)
|
|
}
|
|
}
|
|
.padding(12)
|
|
.background(TermindColor.windowSurface)
|
|
.onAppear {
|
|
guard window.terminalThemeDraft === draft else { return }
|
|
window.terminalThemeSettings = model
|
|
}
|
|
.task(id: draft.value) {
|
|
do { try await Task.sleep(for: .milliseconds(300)) } catch { return }
|
|
// A discarded or deleted draft must never be saved by a pending task.
|
|
guard !Task.isCancelled, window.terminalThemeDraft === draft else { return }
|
|
window.saveTerminalThemeChanges(allowNameTrimming: false)
|
|
}
|
|
.onChange(of: model.themeLibrary) { _, _ in draft.refresh(from: model) }
|
|
.fileImporter(isPresented: $window.isImportingTerminalTheme, allowedContentTypes: [.plainText, .data]) {
|
|
result in
|
|
do { try importFile(result.get()) } catch { importError = error.localizedDescription }
|
|
}
|
|
.dropDestination(for: URL.self) { urls, _ in
|
|
guard let url = urls.first else { return false }
|
|
do { try importFile(url) } catch { importError = error.localizedDescription }
|
|
return true
|
|
}
|
|
.alert(
|
|
AppStrings.Settings.deleteCustomThemeConfirmation,
|
|
isPresented: $window.isDeletingTerminalTheme
|
|
) {
|
|
Button(AppStrings.Common.delete.swiftUIKey, role: .destructive) {
|
|
guard model.deleteCustomTheme(id: draft.id) else { return }
|
|
window.clearTerminalThemeEditor()
|
|
window.settingsRoute = .terminalCustomThemes
|
|
}
|
|
Button(AppStrings.Common.cancel.swiftUIKey, role: .cancel) {}
|
|
} message: {
|
|
Text(AppStrings.Settings.deleteCustomThemeWarning)
|
|
}
|
|
}
|
|
|
|
private func fields(width: CGFloat) -> some View {
|
|
let compact = width < 520 || typeSize.isAccessibilitySize
|
|
let layout =
|
|
compact
|
|
? AnyLayout(VStackLayout(alignment: .leading, spacing: 12))
|
|
: AnyLayout(HStackLayout(alignment: .top, spacing: 16))
|
|
return VStack(alignment: .leading, spacing: 12) {
|
|
layout {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(AppStrings.Common.name).font(.subheadline).foregroundStyle(.secondary)
|
|
TextField(AppStrings.Common.name.swiftUIKey, text: $draft.value.name)
|
|
.textFieldStyle(.roundedBorder)
|
|
.accessibilityIdentifier("custom-theme-name")
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(AppStrings.Settings.appearance).font(.subheadline).foregroundStyle(.secondary)
|
|
Picker(AppStrings.Settings.appearance.swiftUIKey, selection: $draft.value.appearance) {
|
|
ForEach(TerminalThemeAppearance.allCases, id: \.self) { appearance in
|
|
Text(appearance.title).tag(appearance)
|
|
}
|
|
}
|
|
.labelsHidden()
|
|
.pickerStyle(.menu)
|
|
.accessibilityIdentifier("custom-theme-appearance")
|
|
}
|
|
.frame(minWidth: 150, maxWidth: compact ? .infinity : nil, alignment: .leading)
|
|
}
|
|
.controlSize(.regular)
|
|
.frame(maxWidth: compact ? .infinity : 640, alignment: .leading)
|
|
if TermindTerminalThemeCatalog.theme(named: draft.value.baseThemeName) == nil {
|
|
Text(AppStrings.Settings.unavailableThemeFallback(draft.value.baseTheme.name))
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
if draft.isDeleted(in: model) {
|
|
Text(AppStrings.Settings.customThemeDeleted).font(.caption).foregroundStyle(.orange)
|
|
Button(AppStrings.Settings.saveThemeCopy.swiftUIKey, systemImage: "doc.on.doc") {
|
|
draft.makeCopy(
|
|
name: AppLocalization.resolve(
|
|
AppStrings.Settings.customThemeCopy(draft.value.name), locale: locale))
|
|
window.saveTerminalThemeChanges()
|
|
}
|
|
.disabled(!draft.value.isValid)
|
|
}
|
|
if let error = importError ?? model.storageError {
|
|
Text(error).font(.caption).foregroundStyle(.red).textSelection(.enabled)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func importFile(_ url: URL) throws {
|
|
let access = url.startAccessingSecurityScopedResource()
|
|
defer { if access { url.stopAccessingSecurityScopedResource() } }
|
|
let data = try Data(contentsOf: url)
|
|
guard let text = String(data: data, encoding: .utf8) else {
|
|
importError = AppLocalization.resolve(AppStrings.Settings.fileIsNotValidUtf8Text, locale: locale)
|
|
return
|
|
}
|
|
draft.value.configuration = text
|
|
importError = nil
|
|
}
|
|
}
|