mirror of
https://github.com/h3pdesign/Neon-Vision-Editor
synced 2026-04-21 13:27:16 +00:00
2340 lines
92 KiB
Swift
2340 lines
92 KiB
Swift
import SwiftUI
|
||
import Foundation
|
||
import UniformTypeIdentifiers
|
||
#if os(macOS)
|
||
import AppKit
|
||
#endif
|
||
|
||
|
||
|
||
/// MARK: - Types
|
||
|
||
enum NeonUIStyle {
|
||
static let accentBlue = Color(red: 0.17, green: 0.49, blue: 0.98)
|
||
static let accentBlueSoft = Color(red: 0.44, green: 0.72, blue: 0.99)
|
||
static let accentBlueStrong = Color(red: 0.05, green: 0.44, blue: 0.98)
|
||
|
||
static func surfaceFill(for scheme: ColorScheme) -> LinearGradient {
|
||
let top = scheme == .dark
|
||
? Color(red: 0.09, green: 0.14, blue: 0.23).opacity(0.82)
|
||
: Color(red: 0.94, green: 0.97, blue: 1.00).opacity(0.94)
|
||
let bottom = scheme == .dark
|
||
? Color(red: 0.06, green: 0.10, blue: 0.18).opacity(0.88)
|
||
: Color(red: 0.88, green: 0.94, blue: 1.00).opacity(0.96)
|
||
return LinearGradient(colors: [top, bottom], startPoint: .topLeading, endPoint: .bottomTrailing)
|
||
}
|
||
|
||
static func surfaceStroke(for scheme: ColorScheme) -> Color {
|
||
scheme == .dark
|
||
? accentBlueSoft.opacity(0.34)
|
||
: accentBlue.opacity(0.22)
|
||
}
|
||
|
||
static func searchMatchFill(for scheme: ColorScheme) -> Color {
|
||
scheme == .dark
|
||
? accentBlueSoft.opacity(0.32)
|
||
: accentBlue.opacity(0.18)
|
||
}
|
||
|
||
static func selectedRowFill(for scheme: ColorScheme) -> Color {
|
||
scheme == .dark
|
||
? accentBlueSoft.opacity(0.20)
|
||
: accentBlue.opacity(0.10)
|
||
}
|
||
}
|
||
|
||
private struct SearchPanelSurfaceModifier: ViewModifier {
|
||
@AppStorage("EnableTranslucentWindow") private var enableTranslucentWindow: Bool = false
|
||
@Environment(\.colorScheme) private var colorScheme
|
||
|
||
func body(content: Content) -> some View {
|
||
content
|
||
.background(surfaceBackground)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||
.stroke(Color.white.opacity(enableTranslucentWindow ? 0.12 : 0.08), lineWidth: 0.8)
|
||
)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var surfaceBackground: some View {
|
||
let fallback = colorScheme == .dark ? Color.black.opacity(0.16) : Color.white.opacity(0.78)
|
||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||
.fill(enableTranslucentWindow ? AnyShapeStyle(.thinMaterial) : AnyShapeStyle(fallback))
|
||
}
|
||
}
|
||
|
||
private extension View {
|
||
func subtleSearchPanelSurface() -> some View {
|
||
modifier(SearchPanelSurfaceModifier())
|
||
}
|
||
}
|
||
|
||
private struct SearchPanelSectionCardModifier: ViewModifier {
|
||
@AppStorage("EnableTranslucentWindow") private var enableTranslucentWindow: Bool = false
|
||
@Environment(\.colorScheme) private var colorScheme
|
||
|
||
func body(content: Content) -> some View {
|
||
content
|
||
.background(sectionBackground)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||
.stroke(Color.white.opacity(enableTranslucentWindow ? 0.1 : 0.07), lineWidth: 0.8)
|
||
)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var sectionBackground: some View {
|
||
let fallback = colorScheme == .dark
|
||
? Color.black.opacity(0.11)
|
||
: Color.white.opacity(0.58)
|
||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||
.fill(enableTranslucentWindow ? AnyShapeStyle(.thinMaterial) : AnyShapeStyle(fallback))
|
||
}
|
||
}
|
||
|
||
private extension View {
|
||
func subtleSearchSectionCard() -> some View {
|
||
modifier(SearchPanelSectionCardModifier())
|
||
}
|
||
}
|
||
|
||
private struct SearchPanelActionButtonModifier: ViewModifier {
|
||
let prominent: Bool
|
||
@Environment(\.isEnabled) private var isEnabled
|
||
@Environment(\.colorScheme) private var colorScheme
|
||
|
||
func body(content: Content) -> some View {
|
||
content
|
||
.foregroundStyle(foregroundColor)
|
||
.padding(.horizontal, prominent ? 14 : 12)
|
||
.padding(.vertical, 8)
|
||
.background(backgroundShape)
|
||
.overlay(strokeShape)
|
||
.clipShape(Capsule())
|
||
}
|
||
|
||
private var foregroundColor: Color {
|
||
if prominent {
|
||
return .white.opacity(isEnabled ? 1 : 0.92)
|
||
}
|
||
if isEnabled {
|
||
return colorScheme == .dark ? .white.opacity(0.96) : .primary
|
||
}
|
||
return colorScheme == .dark ? Color.white.opacity(0.56) : Color.primary.opacity(0.45)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var backgroundShape: some View {
|
||
Capsule()
|
||
.fill(backgroundColor)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var strokeShape: some View {
|
||
Capsule()
|
||
.stroke(strokeColor, lineWidth: 0.8)
|
||
}
|
||
|
||
private var backgroundColor: Color {
|
||
if prominent {
|
||
return isEnabled
|
||
? NeonUIStyle.accentBlue
|
||
: NeonUIStyle.accentBlue.opacity(colorScheme == .dark ? 0.55 : 0.40)
|
||
}
|
||
if colorScheme == .dark {
|
||
return isEnabled
|
||
? Color.white.opacity(0.12)
|
||
: Color.white.opacity(0.08)
|
||
}
|
||
return isEnabled
|
||
? Color.black.opacity(0.05)
|
||
: Color.black.opacity(0.03)
|
||
}
|
||
|
||
private var strokeColor: Color {
|
||
if prominent {
|
||
return isEnabled ? Color.white.opacity(0.10) : Color.white.opacity(0.08)
|
||
}
|
||
return colorScheme == .dark ? Color.white.opacity(0.14) : Color.black.opacity(0.06)
|
||
}
|
||
}
|
||
|
||
private extension View {
|
||
func searchPanelActionButton(prominent: Bool = false) -> some View {
|
||
modifier(SearchPanelActionButtonModifier(prominent: prominent))
|
||
}
|
||
}
|
||
|
||
struct PlainTextDocument: FileDocument {
|
||
static var readableContentTypes: [UTType] { [.plainText, .text, .sourceCode] }
|
||
|
||
var text: String
|
||
|
||
init(text: String = "") {
|
||
self.text = text
|
||
}
|
||
|
||
init(configuration: ReadConfiguration) throws {
|
||
if let data = configuration.file.regularFileContents,
|
||
let decoded = String(data: data, encoding: .utf8) {
|
||
text = decoded
|
||
} else {
|
||
text = ""
|
||
}
|
||
}
|
||
|
||
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
|
||
let data = text.data(using: .utf8) ?? Data()
|
||
return FileWrapper(regularFileWithContents: data)
|
||
}
|
||
}
|
||
|
||
struct PDFExportDocument: FileDocument {
|
||
static var readableContentTypes: [UTType] { [.pdf] }
|
||
|
||
var data: Data
|
||
|
||
init(data: Data = Data()) {
|
||
self.data = data
|
||
}
|
||
|
||
init(configuration: ReadConfiguration) throws {
|
||
self.data = configuration.file.regularFileContents ?? Data()
|
||
}
|
||
|
||
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
|
||
FileWrapper(regularFileWithContents: data)
|
||
}
|
||
}
|
||
|
||
struct APISupportSettingsView: View {
|
||
@Binding var grokAPIToken: String
|
||
@Binding var openAIAPIToken: String
|
||
@Binding var geminiAPIToken: String
|
||
@Binding var anthropicAPIToken: String
|
||
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 16) {
|
||
Text("AI Provider API Keys").font(.headline)
|
||
Group {
|
||
LabeledContent("Grok") {
|
||
SecureField("sk-…", text: $grokAPIToken)
|
||
.textFieldStyle(.roundedBorder)
|
||
.frame(maxWidth: .infinity)
|
||
.onChange(of: grokAPIToken) { _, new in
|
||
SecureTokenStore.setToken(new, for: .grok)
|
||
}
|
||
}
|
||
LabeledContent("OpenAI") {
|
||
SecureField("sk-…", text: $openAIAPIToken)
|
||
.textFieldStyle(.roundedBorder)
|
||
.frame(maxWidth: .infinity)
|
||
.onChange(of: openAIAPIToken) { _, new in
|
||
SecureTokenStore.setToken(new, for: .openAI)
|
||
}
|
||
}
|
||
LabeledContent("Gemini") {
|
||
SecureField("AIza…", text: $geminiAPIToken)
|
||
.textFieldStyle(.roundedBorder)
|
||
.frame(maxWidth: .infinity)
|
||
.onChange(of: geminiAPIToken) { _, new in
|
||
SecureTokenStore.setToken(new, for: .gemini)
|
||
}
|
||
}
|
||
LabeledContent("Anthropic") {
|
||
SecureField("sk-ant-…", text: $anthropicAPIToken)
|
||
.textFieldStyle(.roundedBorder)
|
||
.frame(maxWidth: .infinity)
|
||
.onChange(of: anthropicAPIToken) { _, new in
|
||
SecureTokenStore.setToken(new, for: .anthropic)
|
||
}
|
||
}
|
||
}
|
||
.labelStyle(.titleAndIcon)
|
||
|
||
HStack {
|
||
Spacer()
|
||
Button("Close") {
|
||
dismiss()
|
||
}
|
||
}
|
||
}
|
||
.padding(20)
|
||
.frame(minWidth: 460)
|
||
}
|
||
}
|
||
|
||
struct FindReplacePanel: View {
|
||
@Binding var findQuery: String
|
||
@Binding var replaceQuery: String
|
||
@Binding var useRegex: Bool
|
||
@Binding var caseSensitive: Bool
|
||
@Binding var matchCount: Int
|
||
@Binding var statusMessage: String
|
||
var onPreviewChanged: () -> Void
|
||
var onFindNext: () -> Void
|
||
var onJumpToMatch: () -> Void
|
||
var onReplace: () -> Void
|
||
var onReplaceAll: () -> Void
|
||
var onClose: () -> Void
|
||
@FocusState private var findFieldFocused: Bool
|
||
|
||
private var usesCompactPhoneLayout: Bool {
|
||
#if os(iOS)
|
||
UIDevice.current.userInterfaceIdiom == .phone
|
||
#else
|
||
false
|
||
#endif
|
||
}
|
||
|
||
private var usesPadLayout: Bool {
|
||
#if os(iOS)
|
||
UIDevice.current.userInterfaceIdiom == .pad
|
||
#else
|
||
false
|
||
#endif
|
||
}
|
||
|
||
private var matchSummaryText: String {
|
||
matchCount == 1
|
||
? String.localizedStringWithFormat(NSLocalizedString("%lld match", comment: ""), Int64(matchCount))
|
||
: String.localizedStringWithFormat(NSLocalizedString("%lld matches", comment: ""), Int64(matchCount))
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var centeredTitleHeader: some View {
|
||
ZStack {
|
||
Text(NSLocalizedString("Find & Replace", comment: ""))
|
||
.font(.headline)
|
||
.frame(maxWidth: .infinity, alignment: .center)
|
||
|
||
if usesCompactPhoneLayout {
|
||
HStack {
|
||
Spacer()
|
||
Button(NSLocalizedString("Close", comment: "")) { onClose() }
|
||
.buttonStyle(.plain)
|
||
.font(.caption.weight(.semibold))
|
||
.searchPanelActionButton()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var phoneFieldsSection: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
phoneFieldRow(
|
||
title: NSLocalizedString("Find", comment: ""),
|
||
placeholder: NSLocalizedString("Search text", comment: ""),
|
||
text: $findQuery,
|
||
isFocused: true
|
||
)
|
||
phoneFieldRow(
|
||
title: NSLocalizedString("Replace", comment: ""),
|
||
placeholder: NSLocalizedString("Replacement", comment: ""),
|
||
text: $replaceQuery
|
||
)
|
||
}
|
||
.padding(14)
|
||
.subtleSearchSectionCard()
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func phoneFieldRow(
|
||
title: String,
|
||
placeholder: String,
|
||
text: Binding<String>,
|
||
isFocused: Bool = false,
|
||
labelWidth: CGFloat = 76
|
||
) -> some View {
|
||
HStack(alignment: .center, spacing: 12) {
|
||
Text(title)
|
||
.frame(width: labelWidth, alignment: .leading)
|
||
|
||
if isFocused {
|
||
TextField(placeholder, text: text)
|
||
.textFieldStyle(.roundedBorder)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.focused($findFieldFocused)
|
||
.onSubmit { onFindNext() }
|
||
} else {
|
||
TextField(placeholder, text: text)
|
||
.textFieldStyle(.roundedBorder)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var padFieldsSection: some View {
|
||
VStack(alignment: .leading, spacing: 14) {
|
||
phoneFieldRow(
|
||
title: NSLocalizedString("Find", comment: ""),
|
||
placeholder: NSLocalizedString("Search text", comment: ""),
|
||
text: $findQuery,
|
||
isFocused: true,
|
||
labelWidth: 88
|
||
)
|
||
phoneFieldRow(
|
||
title: NSLocalizedString("Replace", comment: ""),
|
||
placeholder: NSLocalizedString("Replacement", comment: ""),
|
||
text: $replaceQuery,
|
||
labelWidth: 88
|
||
)
|
||
}
|
||
.padding(18)
|
||
.subtleSearchSectionCard()
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var phoneOptionsSection: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
Toggle(NSLocalizedString("Use Regex", comment: ""), isOn: $useRegex)
|
||
Toggle(NSLocalizedString("Case Sensitive", comment: ""), isOn: $caseSensitive)
|
||
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text(String.localizedStringWithFormat(NSLocalizedString("Matches: %@", comment: ""), matchSummaryText))
|
||
.font(.caption.weight(.medium))
|
||
.foregroundColor(matchCount > 0 ? .primary : Color.secondary)
|
||
if !statusMessage.isEmpty {
|
||
Text(statusMessage)
|
||
.font(.caption)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
}
|
||
}
|
||
.padding(14)
|
||
.subtleSearchSectionCard()
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var padOptionsSection: some View {
|
||
VStack(alignment: .leading, spacing: 14) {
|
||
Toggle(NSLocalizedString("Use Regex", comment: ""), isOn: $useRegex)
|
||
Toggle(NSLocalizedString("Case Sensitive", comment: ""), isOn: $caseSensitive)
|
||
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text(String.localizedStringWithFormat(NSLocalizedString("Matches: %@", comment: ""), matchSummaryText))
|
||
.font(.caption.weight(.medium))
|
||
.foregroundColor(matchCount > 0 ? .primary : Color.secondary)
|
||
if !statusMessage.isEmpty {
|
||
Text(statusMessage)
|
||
.font(.caption)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
}
|
||
}
|
||
.padding(18)
|
||
.subtleSearchSectionCard()
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var padActionSection: some View {
|
||
HStack(spacing: 10) {
|
||
Button(NSLocalizedString("Find Next", comment: "")) { onFindNext() }
|
||
.buttonStyle(.plain)
|
||
.font(.caption.weight(.semibold))
|
||
.frame(maxWidth: .infinity)
|
||
.searchPanelActionButton(prominent: true)
|
||
|
||
Button(NSLocalizedString("Jump to Match", comment: "")) {
|
||
onJumpToMatch()
|
||
onClose()
|
||
}
|
||
.buttonStyle(.plain)
|
||
.font(.caption.weight(.medium))
|
||
.frame(maxWidth: .infinity)
|
||
.searchPanelActionButton()
|
||
.disabled(findQuery.isEmpty || matchCount == 0)
|
||
|
||
Button(NSLocalizedString("Replace", comment: "")) { onReplace() }
|
||
.buttonStyle(.plain)
|
||
.font(.caption.weight(.medium))
|
||
.frame(maxWidth: .infinity)
|
||
.searchPanelActionButton()
|
||
.disabled(findQuery.isEmpty)
|
||
|
||
Button(NSLocalizedString("Replace All", comment: "")) { onReplaceAll() }
|
||
.buttonStyle(.plain)
|
||
.font(.caption.weight(.medium))
|
||
.frame(maxWidth: .infinity)
|
||
.searchPanelActionButton()
|
||
.disabled(findQuery.isEmpty)
|
||
|
||
Button(NSLocalizedString("Close", comment: "")) { onClose() }
|
||
.buttonStyle(.plain)
|
||
.font(.caption.weight(.medium))
|
||
.frame(maxWidth: .infinity)
|
||
.searchPanelActionButton()
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var phoneActionSection: some View {
|
||
HStack(spacing: 6) {
|
||
compactPhoneActionButton(
|
||
NSLocalizedString("Find Next", comment: ""),
|
||
prominent: true,
|
||
disabled: false
|
||
) { onFindNext() }
|
||
|
||
compactPhoneActionButton(
|
||
NSLocalizedString("Jump to Match", comment: ""),
|
||
disabled: findQuery.isEmpty || matchCount == 0
|
||
) {
|
||
onJumpToMatch()
|
||
onClose()
|
||
}
|
||
|
||
compactPhoneActionButton(
|
||
NSLocalizedString("Replace", comment: ""),
|
||
disabled: findQuery.isEmpty
|
||
) { onReplace() }
|
||
|
||
compactPhoneActionButton(
|
||
NSLocalizedString("Replace All", comment: ""),
|
||
disabled: findQuery.isEmpty
|
||
) { onReplaceAll() }
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func compactPhoneActionButton(
|
||
_ title: String,
|
||
prominent: Bool = false,
|
||
disabled: Bool,
|
||
action: @escaping () -> Void
|
||
) -> some View {
|
||
if prominent {
|
||
Button(title, action: action)
|
||
.buttonStyle(.plain)
|
||
.font(.caption.weight(.semibold))
|
||
.lineLimit(1)
|
||
.minimumScaleFactor(0.76)
|
||
.frame(maxWidth: .infinity)
|
||
.disabled(disabled)
|
||
.searchPanelActionButton(prominent: true)
|
||
} else {
|
||
Button(title, action: action)
|
||
.buttonStyle(.plain)
|
||
.font(.caption.weight(.medium))
|
||
.lineLimit(1)
|
||
.minimumScaleFactor(0.76)
|
||
.frame(maxWidth: .infinity)
|
||
.disabled(disabled)
|
||
.searchPanelActionButton()
|
||
}
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
if usesCompactPhoneLayout {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
Spacer(minLength: 18)
|
||
|
||
VStack(alignment: .leading, spacing: 16) {
|
||
centeredTitleHeader
|
||
phoneFieldsSection
|
||
phoneOptionsSection
|
||
phoneActionSection
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, minHeight: 424, alignment: .top)
|
||
} else if usesPadLayout {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
Spacer(minLength: 22)
|
||
|
||
VStack(alignment: .leading, spacing: 18) {
|
||
centeredTitleHeader
|
||
padFieldsSection
|
||
padOptionsSection
|
||
padActionSection
|
||
}
|
||
|
||
Spacer(minLength: 18)
|
||
}
|
||
.frame(maxWidth: .infinity, minHeight: 540, alignment: .top)
|
||
} else {
|
||
centeredTitleHeader
|
||
LabeledContent(NSLocalizedString("Find", comment: "")) {
|
||
TextField(NSLocalizedString("Search text", comment: ""), text: $findQuery)
|
||
.textFieldStyle(.roundedBorder)
|
||
.focused($findFieldFocused)
|
||
.onSubmit { onFindNext() }
|
||
}
|
||
LabeledContent(NSLocalizedString("Replace", comment: "")) {
|
||
TextField(NSLocalizedString("Replacement", comment: ""), text: $replaceQuery)
|
||
.textFieldStyle(.roundedBorder)
|
||
}
|
||
Toggle(NSLocalizedString("Use Regex", comment: ""), isOn: $useRegex)
|
||
Toggle(NSLocalizedString("Case Sensitive", comment: ""), isOn: $caseSensitive)
|
||
Text(String.localizedStringWithFormat(NSLocalizedString("Matches: %@", comment: ""), matchSummaryText))
|
||
.font(.caption.weight(.medium))
|
||
.foregroundColor(matchCount > 0 ? .primary : Color.secondary)
|
||
if !statusMessage.isEmpty {
|
||
Text(statusMessage)
|
||
.font(.caption)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
HStack {
|
||
Button(NSLocalizedString("Find Next", comment: "")) { onFindNext() }
|
||
.buttonStyle(.plain)
|
||
.font(.caption.weight(.semibold))
|
||
.searchPanelActionButton(prominent: true)
|
||
Button(NSLocalizedString("Jump to Match", comment: "")) {
|
||
onJumpToMatch()
|
||
onClose()
|
||
}
|
||
.buttonStyle(.plain)
|
||
.font(.caption.weight(.medium))
|
||
.searchPanelActionButton()
|
||
.disabled(findQuery.isEmpty || matchCount == 0)
|
||
Button(NSLocalizedString("Replace", comment: "")) { onReplace() }
|
||
.buttonStyle(.plain)
|
||
.font(.caption.weight(.medium))
|
||
.searchPanelActionButton()
|
||
.disabled(findQuery.isEmpty)
|
||
Button(NSLocalizedString("Replace All", comment: "")) { onReplaceAll() }
|
||
.buttonStyle(.plain)
|
||
.font(.caption.weight(.medium))
|
||
.searchPanelActionButton()
|
||
.disabled(findQuery.isEmpty)
|
||
Spacer()
|
||
Button(NSLocalizedString("Close", comment: "")) { onClose() }
|
||
.buttonStyle(.plain)
|
||
.font(.caption.weight(.medium))
|
||
.searchPanelActionButton()
|
||
}
|
||
}
|
||
}
|
||
.padding(.horizontal, usesPadLayout ? 8 : 16)
|
||
.padding(.vertical, 16)
|
||
#if os(iOS)
|
||
.frame(maxWidth: usesPadLayout ? 460 : .infinity)
|
||
#else
|
||
.frame(minWidth: 560, idealWidth: 620)
|
||
#endif
|
||
.subtleSearchPanelSurface()
|
||
.onAppear {
|
||
findFieldFocused = true
|
||
onPreviewChanged()
|
||
}
|
||
.onChange(of: findQuery) { _, _ in onPreviewChanged() }
|
||
.onChange(of: useRegex) { _, _ in onPreviewChanged() }
|
||
.onChange(of: caseSensitive) { _, _ in onPreviewChanged() }
|
||
}
|
||
}
|
||
|
||
#if os(macOS)
|
||
struct FindReplaceWindowPresenter: NSViewRepresentable {
|
||
@Binding var isPresented: Bool
|
||
@Binding var findQuery: String
|
||
@Binding var replaceQuery: String
|
||
@Binding var useRegex: Bool
|
||
@Binding var caseSensitive: Bool
|
||
@Binding var matchCount: Int
|
||
@Binding var statusMessage: String
|
||
let onPreviewChanged: () -> Void
|
||
let onFindNext: () -> Void
|
||
let onJumpToMatch: () -> Void
|
||
let onReplace: () -> Void
|
||
let onReplaceAll: () -> Void
|
||
let onClose: () -> Void
|
||
|
||
final class Coordinator: NSObject, NSWindowDelegate {
|
||
var parent: FindReplaceWindowPresenter
|
||
weak var hostWindow: NSWindow?
|
||
var window: NSPanel?
|
||
var hostingController: NSHostingController<FindReplacePanel>?
|
||
|
||
init(parent: FindReplaceWindowPresenter) {
|
||
self.parent = parent
|
||
}
|
||
|
||
func panelContent() -> FindReplacePanel {
|
||
FindReplacePanel(
|
||
findQuery: parent.$findQuery,
|
||
replaceQuery: parent.$replaceQuery,
|
||
useRegex: parent.$useRegex,
|
||
caseSensitive: parent.$caseSensitive,
|
||
matchCount: parent.$matchCount,
|
||
statusMessage: parent.$statusMessage,
|
||
onPreviewChanged: parent.onPreviewChanged,
|
||
onFindNext: parent.onFindNext,
|
||
onJumpToMatch: parent.onJumpToMatch,
|
||
onReplace: parent.onReplace,
|
||
onReplaceAll: parent.onReplaceAll,
|
||
onClose: parent.onClose
|
||
)
|
||
}
|
||
|
||
func presentIfNeeded() {
|
||
if let window, let hostingController {
|
||
hostingController.rootView = panelContent()
|
||
window.makeKeyAndOrderFront(nil)
|
||
NSApp.activate(ignoringOtherApps: true)
|
||
return
|
||
}
|
||
|
||
let controller = NSHostingController(rootView: panelContent())
|
||
let panel = NSPanel(
|
||
contentRect: NSRect(x: 0, y: 0, width: 620, height: 300),
|
||
styleMask: [.titled, .fullSizeContentView, .utilityWindow],
|
||
backing: .buffered,
|
||
defer: false
|
||
)
|
||
panel.contentViewController = controller
|
||
panel.title = NSLocalizedString("Find & Replace", comment: "")
|
||
panel.isReleasedWhenClosed = false
|
||
panel.tabbingMode = .disallowed
|
||
panel.hidesOnDeactivate = false
|
||
panel.isFloatingPanel = true
|
||
panel.level = .floating
|
||
panel.delegate = self
|
||
panel.minSize = NSSize(width: 620, height: 300)
|
||
panel.isOpaque = false
|
||
panel.backgroundColor = .clear
|
||
panel.hasShadow = true
|
||
panel.titleVisibility = .hidden
|
||
panel.titlebarAppearsTransparent = true
|
||
panel.isMovableByWindowBackground = true
|
||
panel.standardWindowButton(.closeButton)?.isHidden = true
|
||
panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
|
||
panel.standardWindowButton(.zoomButton)?.isHidden = true
|
||
|
||
if let hostWindow {
|
||
let hostFrame = hostWindow.frame
|
||
let size = panel.frame.size
|
||
let origin = NSPoint(
|
||
x: hostFrame.midX - (size.width / 2),
|
||
y: hostFrame.midY - (size.height / 2)
|
||
)
|
||
panel.setFrameOrigin(origin)
|
||
} else {
|
||
panel.center()
|
||
}
|
||
|
||
self.window = panel
|
||
self.hostingController = controller
|
||
panel.makeKeyAndOrderFront(nil)
|
||
NSApp.activate(ignoringOtherApps: true)
|
||
}
|
||
|
||
func dismissIfNeeded() {
|
||
guard let window else { return }
|
||
window.orderOut(nil)
|
||
window.close()
|
||
self.window = nil
|
||
self.hostingController = nil
|
||
}
|
||
|
||
func windowWillClose(_ notification: Notification) {
|
||
self.window = nil
|
||
self.hostingController = nil
|
||
DispatchQueue.main.async {
|
||
self.parent.isPresented = false
|
||
}
|
||
}
|
||
}
|
||
|
||
func makeCoordinator() -> Coordinator {
|
||
Coordinator(parent: self)
|
||
}
|
||
|
||
func makeNSView(context: Context) -> NSView {
|
||
let view = NSView(frame: .zero)
|
||
DispatchQueue.main.async {
|
||
context.coordinator.hostWindow = view.window
|
||
if isPresented {
|
||
context.coordinator.presentIfNeeded()
|
||
}
|
||
}
|
||
return view
|
||
}
|
||
|
||
func updateNSView(_ nsView: NSView, context: Context) {
|
||
context.coordinator.parent = self
|
||
context.coordinator.hostWindow = nsView.window
|
||
if isPresented {
|
||
context.coordinator.presentIfNeeded()
|
||
} else {
|
||
context.coordinator.dismissIfNeeded()
|
||
}
|
||
}
|
||
}
|
||
#endif
|
||
|
||
struct QuickFileSwitcherPanel: View {
|
||
struct Item: Identifiable {
|
||
let id: String
|
||
let title: String
|
||
let subtitle: String
|
||
let isPinned: Bool
|
||
let canTogglePin: Bool
|
||
}
|
||
|
||
@Binding var query: String
|
||
let items: [Item]
|
||
let statusMessage: String
|
||
let onSelect: (Item) -> Void
|
||
let onTogglePin: (Item) -> Void
|
||
@Environment(\.dismiss) private var dismiss
|
||
@Environment(\.colorScheme) private var colorScheme
|
||
@FocusState private var queryFieldFocused: Bool
|
||
@State private var selectedItemID: Item.ID?
|
||
|
||
private var selectedItem: Item? {
|
||
guard let selectedItemID else { return items.first }
|
||
return items.first(where: { $0.id == selectedItemID }) ?? items.first
|
||
}
|
||
|
||
private func selectPrimaryItem() {
|
||
guard let item = selectedItem else { return }
|
||
onSelect(item)
|
||
dismiss()
|
||
}
|
||
|
||
private func syncSelectionToVisibleItems() {
|
||
guard let selectedItemID,
|
||
items.contains(where: { $0.id == selectedItemID }) else {
|
||
self.selectedItemID = items.first?.id
|
||
return
|
||
}
|
||
}
|
||
|
||
private func moveSelection(by delta: Int) {
|
||
guard !items.isEmpty else { return }
|
||
syncSelectionToVisibleItems()
|
||
let currentIndex = items.firstIndex(where: { $0.id == selectedItemID }) ?? 0
|
||
let nextIndex = min(max(currentIndex + delta, 0), items.count - 1)
|
||
selectedItemID = items[nextIndex].id
|
||
}
|
||
|
||
private var normalizedQuery: String {
|
||
query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
}
|
||
|
||
private func highlightedText(_ text: String, useSecondaryTone: Bool) -> Text {
|
||
let searchTerm = normalizedQuery
|
||
let baseColor: Color = useSecondaryTone
|
||
? Color.primary.opacity(colorScheme == .dark ? 0.82 : 0.72)
|
||
: .primary
|
||
guard !searchTerm.isEmpty else { return Text(text).foregroundColor(baseColor) }
|
||
|
||
let compareOptions: String.CompareOptions = [.caseInsensitive]
|
||
var rendered = Text("")
|
||
var remaining = text[...]
|
||
|
||
while let range = remaining.range(of: searchTerm, options: compareOptions) {
|
||
let prefix = String(remaining[..<range.lowerBound])
|
||
let match = String(remaining[range])
|
||
if !prefix.isEmpty {
|
||
rendered = rendered + Text(prefix).foregroundColor(baseColor)
|
||
}
|
||
rendered = rendered + Text(match)
|
||
.foregroundColor(NeonUIStyle.accentBlueStrong)
|
||
.fontWeight(.semibold)
|
||
.underline()
|
||
remaining = remaining[range.upperBound...]
|
||
}
|
||
|
||
if !remaining.isEmpty {
|
||
rendered = rendered + Text(String(remaining)).foregroundColor(baseColor)
|
||
}
|
||
return rendered
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func applyMoveCommand<Content: View>(to content: Content) -> some View {
|
||
#if os(macOS)
|
||
content.onMoveCommand { direction in
|
||
switch direction {
|
||
case .down:
|
||
moveSelection(by: 1)
|
||
case .up:
|
||
moveSelection(by: -1)
|
||
default:
|
||
break
|
||
}
|
||
}
|
||
#else
|
||
content
|
||
#endif
|
||
}
|
||
|
||
var body: some View {
|
||
ScrollViewReader { proxy in
|
||
applyMoveCommand(to:
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
Text(NSLocalizedString("Command Palette", comment: ""))
|
||
.font(.headline)
|
||
TextField(NSLocalizedString("Search commands, files, and tabs", comment: ""), text: $query)
|
||
.textFieldStyle(.roundedBorder)
|
||
.onSubmit { selectPrimaryItem() }
|
||
.accessibilityLabel(NSLocalizedString("Command Palette Search", comment: ""))
|
||
.accessibilityHint(NSLocalizedString("Type to search commands, files, and tabs. Use Up and Down Arrow to move through results.", comment: ""))
|
||
.focused($queryFieldFocused)
|
||
|
||
List(items) { item in
|
||
HStack(spacing: 10) {
|
||
Button {
|
||
selectedItemID = item.id
|
||
onSelect(item)
|
||
dismiss()
|
||
} label: {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
highlightedText(item.title, useSecondaryTone: false)
|
||
.lineLimit(1)
|
||
highlightedText(item.subtitle, useSecondaryTone: true)
|
||
.font(.caption)
|
||
.lineLimit(1)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel(item.title)
|
||
.accessibilityValue(item.subtitle)
|
||
.accessibilityHint(NSLocalizedString("Opens the selected item", comment: ""))
|
||
|
||
if item.canTogglePin {
|
||
Button {
|
||
selectedItemID = item.id
|
||
onTogglePin(item)
|
||
} label: {
|
||
Image(systemName: item.isPinned ? "star.fill" : "star")
|
||
.foregroundStyle(item.isPinned ? Color.yellow : .secondary)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel(item.isPinned ? NSLocalizedString("Unpin recent file", comment: "") : NSLocalizedString("Pin recent file", comment: ""))
|
||
.accessibilityHint(NSLocalizedString("Keeps this file near the top of recent results", comment: ""))
|
||
}
|
||
}
|
||
.id(item.id)
|
||
.contentShape(Rectangle())
|
||
.listRowBackground(
|
||
selectedItemID == item.id
|
||
? NeonUIStyle.selectedRowFill(for: colorScheme)
|
||
: Color.clear
|
||
)
|
||
.onTapGesture {
|
||
selectedItemID = item.id
|
||
}
|
||
}
|
||
.listStyle(.plain)
|
||
.accessibilityLabel(NSLocalizedString("Command Palette Results", comment: ""))
|
||
|
||
HStack {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(String.localizedStringWithFormat(NSLocalizedString("%lld results", comment: ""), Int64(items.count)))
|
||
if !statusMessage.isEmpty {
|
||
Text(statusMessage)
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
Spacer()
|
||
Button(NSLocalizedString("Close", comment: "")) { dismiss() }
|
||
}
|
||
}
|
||
#if os(iOS)
|
||
.background(
|
||
DirectionalKeyCommandBridge(
|
||
onMoveUp: { moveSelection(by: -1) },
|
||
onMoveDown: { moveSelection(by: 1) }
|
||
)
|
||
.frame(width: 0, height: 0)
|
||
)
|
||
#endif
|
||
.padding(16)
|
||
.frame(minWidth: 520, minHeight: 380)
|
||
.onAppear {
|
||
queryFieldFocused = true
|
||
syncSelectionToVisibleItems()
|
||
}
|
||
.onChange(of: items.map(\.id)) { _, _ in
|
||
syncSelectionToVisibleItems()
|
||
}
|
||
.onChange(of: selectedItemID) { _, newValue in
|
||
guard let newValue else { return }
|
||
proxy.scrollTo(newValue, anchor: .center)
|
||
}
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
struct FindInFilesMatch: Identifiable, Hashable {
|
||
let id: String
|
||
let fileURL: URL
|
||
let line: Int
|
||
let column: Int
|
||
let snippet: String
|
||
let rangeLocation: Int
|
||
let rangeLength: Int
|
||
}
|
||
|
||
struct FindInFilesPanel: View {
|
||
@Binding var query: String
|
||
@Binding var caseSensitive: Bool
|
||
let results: [FindInFilesMatch]
|
||
let statusMessage: String
|
||
let sourceMessage: String
|
||
let onSearch: () -> Void
|
||
let onClear: () -> Void
|
||
let onSelect: (FindInFilesMatch) -> Void
|
||
let onClose: () -> Void
|
||
@Environment(\.dismiss) private var dismiss
|
||
@Environment(\.colorScheme) private var colorScheme
|
||
@FocusState private var queryFieldFocused: Bool
|
||
@State private var selectedMatchID: FindInFilesMatch.ID?
|
||
|
||
private var usesCompactPhoneLayout: Bool {
|
||
#if os(iOS)
|
||
UIDevice.current.userInterfaceIdiom == .phone
|
||
#else
|
||
false
|
||
#endif
|
||
}
|
||
|
||
private var hasSearched: Bool {
|
||
!normalizedQuery.isEmpty
|
||
}
|
||
|
||
private struct MatchGroup: Identifiable {
|
||
let fileURL: URL
|
||
let matches: [FindInFilesMatch]
|
||
|
||
var id: String { fileURL.standardizedFileURL.path }
|
||
var matchCountText: String { matches.count == 1 ? "1 hit" : "\(matches.count) hits" }
|
||
}
|
||
|
||
private func submitPrimaryAction() {
|
||
guard let match = selectedMatch else {
|
||
onSearch()
|
||
return
|
||
}
|
||
onSelect(match)
|
||
onClose()
|
||
}
|
||
|
||
private var selectedMatch: FindInFilesMatch? {
|
||
guard let selectedMatchID else { return results.first }
|
||
return results.first(where: { $0.id == selectedMatchID }) ?? results.first
|
||
}
|
||
|
||
private func syncSelectionToVisibleResults() {
|
||
guard let selectedMatchID,
|
||
results.contains(where: { $0.id == selectedMatchID }) else {
|
||
self.selectedMatchID = results.first?.id
|
||
return
|
||
}
|
||
}
|
||
|
||
private func moveSelection(by delta: Int) {
|
||
guard !results.isEmpty else { return }
|
||
syncSelectionToVisibleResults()
|
||
let currentIndex = results.firstIndex(where: { $0.id == selectedMatchID }) ?? 0
|
||
let nextIndex = min(max(currentIndex + delta, 0), results.count - 1)
|
||
selectedMatchID = results[nextIndex].id
|
||
}
|
||
|
||
private var normalizedQuery: String {
|
||
query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
}
|
||
|
||
private var usesPadLayout: Bool {
|
||
#if os(iOS)
|
||
UIDevice.current.userInterfaceIdiom == .pad
|
||
#else
|
||
false
|
||
#endif
|
||
}
|
||
|
||
private var groupedResults: [MatchGroup] {
|
||
var groups: [MatchGroup] = []
|
||
var groupedMatches: [String: [FindInFilesMatch]] = [:]
|
||
|
||
for match in results {
|
||
let key = match.fileURL.standardizedFileURL.path
|
||
groupedMatches[key, default: []].append(match)
|
||
}
|
||
|
||
var seen: Set<String> = []
|
||
for match in results {
|
||
let key = match.fileURL.standardizedFileURL.path
|
||
guard !seen.contains(key), let matches = groupedMatches[key] else { continue }
|
||
groups.append(MatchGroup(fileURL: match.fileURL, matches: matches))
|
||
seen.insert(key)
|
||
}
|
||
|
||
return groups
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var centeredTitleHeader: some View {
|
||
Text(NSLocalizedString("Find in Files", comment: ""))
|
||
.font(.headline)
|
||
.frame(maxWidth: .infinity, alignment: .center)
|
||
}
|
||
|
||
private func highlightedText(_ text: String) -> Text {
|
||
let searchTerm = normalizedQuery
|
||
let baseColor = Color.primary.opacity(colorScheme == .dark ? 0.88 : 0.78)
|
||
guard !searchTerm.isEmpty else { return Text(text).foregroundColor(baseColor) }
|
||
|
||
let compareOptions: String.CompareOptions = caseSensitive ? [] : [.caseInsensitive]
|
||
var rendered = Text("")
|
||
var remaining = text[...]
|
||
|
||
while let range = remaining.range(of: searchTerm, options: compareOptions) {
|
||
let prefix = String(remaining[..<range.lowerBound])
|
||
let match = String(remaining[range])
|
||
if !prefix.isEmpty {
|
||
rendered = rendered + Text(prefix).foregroundColor(baseColor)
|
||
}
|
||
rendered = rendered + Text(match)
|
||
.foregroundColor(NeonUIStyle.accentBlueStrong)
|
||
.fontWeight(.semibold)
|
||
.underline()
|
||
remaining = remaining[range.upperBound...]
|
||
}
|
||
|
||
if !remaining.isEmpty {
|
||
rendered = rendered + Text(String(remaining)).foregroundColor(baseColor)
|
||
}
|
||
return rendered
|
||
}
|
||
|
||
private func abbreviatedPath(for fileURL: URL) -> String {
|
||
let components = fileURL.standardizedFileURL.pathComponents
|
||
guard components.count > 4 else { return fileURL.deletingLastPathComponent().path }
|
||
let suffix = components.dropLast().suffix(4).joined(separator: "/")
|
||
return "…/\(suffix)"
|
||
}
|
||
|
||
private func groupHeaderSubtitle(for fileURL: URL) -> String {
|
||
let directoryPath = fileURL.deletingLastPathComponent()
|
||
let components = directoryPath.standardizedFileURL.pathComponents
|
||
guard components.count > 4 else { return directoryPath.path }
|
||
let suffix = components.suffix(4).joined(separator: "/")
|
||
return "…/\(suffix)"
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var phoneSearchSection: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
TextField(NSLocalizedString("Search project files", comment: ""), text: $query)
|
||
.textFieldStyle(.roundedBorder)
|
||
.onSubmit { submitPrimaryAction() }
|
||
.accessibilityLabel(NSLocalizedString("Find in Files Search", comment: ""))
|
||
.accessibilityHint(NSLocalizedString("Enter text to search across project files. Use Up and Down Arrow to move through results. Press Return to open the selected result.", comment: ""))
|
||
.focused($queryFieldFocused)
|
||
|
||
Toggle(NSLocalizedString("Case Sensitive", comment: ""), isOn: $caseSensitive)
|
||
.accessibilityLabel(NSLocalizedString("Case Sensitive Search", comment: ""))
|
||
}
|
||
.padding(14)
|
||
.subtleSearchSectionCard()
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var phoneStatusSection: some View {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text(statusMessage)
|
||
.font(.caption.weight(.medium))
|
||
.foregroundStyle(.primary)
|
||
if !sourceMessage.isEmpty {
|
||
Text(sourceMessage)
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(14)
|
||
.subtleSearchSectionCard()
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var phoneActionSection: some View {
|
||
HStack(spacing: 10) {
|
||
Button(NSLocalizedString("Search", comment: "")) { onSearch() }
|
||
.buttonStyle(.plain)
|
||
.font(.caption.weight(.semibold))
|
||
.disabled(query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||
.frame(maxWidth: .infinity)
|
||
.searchPanelActionButton(prominent: true)
|
||
|
||
Button(NSLocalizedString("Clear", comment: "")) { onClear() }
|
||
.buttonStyle(.plain)
|
||
.font(.caption.weight(.medium))
|
||
.frame(maxWidth: .infinity)
|
||
.searchPanelActionButton()
|
||
|
||
Button(NSLocalizedString("Close", comment: "")) { onClose() }
|
||
.buttonStyle(.plain)
|
||
.font(.caption.weight(.medium))
|
||
.frame(maxWidth: .infinity)
|
||
.searchPanelActionButton()
|
||
}
|
||
.controlSize(.small)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var padSearchSection: some View {
|
||
VStack(alignment: .leading, spacing: 14) {
|
||
HStack(alignment: .center, spacing: 10) {
|
||
TextField(NSLocalizedString("Search project files", comment: ""), text: $query)
|
||
.textFieldStyle(.roundedBorder)
|
||
.onSubmit { submitPrimaryAction() }
|
||
.accessibilityLabel(NSLocalizedString("Find in Files Search", comment: ""))
|
||
.accessibilityHint(NSLocalizedString("Enter text to search across project files. Use Up and Down Arrow to move through results. Press Return to open the selected result.", comment: ""))
|
||
.focused($queryFieldFocused)
|
||
|
||
Button(NSLocalizedString("Search", comment: "")) { onSearch() }
|
||
.buttonStyle(.plain)
|
||
.font(.caption.weight(.semibold))
|
||
.searchPanelActionButton(prominent: true)
|
||
.disabled(query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||
|
||
Button(NSLocalizedString("Clear", comment: "")) { onClear() }
|
||
.buttonStyle(.plain)
|
||
.font(.caption.weight(.medium))
|
||
.searchPanelActionButton()
|
||
.accessibilityLabel(NSLocalizedString("Clear Search", comment: ""))
|
||
}
|
||
|
||
Toggle(NSLocalizedString("Case Sensitive", comment: ""), isOn: $caseSensitive)
|
||
.accessibilityLabel(NSLocalizedString("Case Sensitive Search", comment: ""))
|
||
}
|
||
.padding(18)
|
||
.subtleSearchSectionCard()
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var padFooterSection: some View {
|
||
HStack(alignment: .center, spacing: 12) {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(statusMessage)
|
||
if !sourceMessage.isEmpty {
|
||
Text(sourceMessage)
|
||
.font(.caption2)
|
||
}
|
||
}
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
|
||
Spacer()
|
||
|
||
Button(NSLocalizedString("Clear", comment: "")) { onClear() }
|
||
.buttonStyle(.plain)
|
||
.font(.caption.weight(.medium))
|
||
.searchPanelActionButton()
|
||
|
||
Button(NSLocalizedString("Close", comment: "")) { onClose() }
|
||
.buttonStyle(.plain)
|
||
.font(.caption.weight(.medium))
|
||
.searchPanelActionButton()
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func applyMoveCommand<Content: View>(to content: Content) -> some View {
|
||
#if os(macOS)
|
||
content.onMoveCommand { direction in
|
||
switch direction {
|
||
case .down:
|
||
moveSelection(by: 1)
|
||
case .up:
|
||
moveSelection(by: -1)
|
||
default:
|
||
break
|
||
}
|
||
}
|
||
#else
|
||
content
|
||
#endif
|
||
}
|
||
|
||
var body: some View {
|
||
ScrollViewReader { proxy in
|
||
applyMoveCommand(to:
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
centeredTitleHeader
|
||
|
||
if usesCompactPhoneLayout {
|
||
phoneSearchSection
|
||
phoneStatusSection
|
||
} else if usesPadLayout {
|
||
padSearchSection
|
||
} else {
|
||
HStack(spacing: 8) {
|
||
TextField(NSLocalizedString("Search project files", comment: ""), text: $query)
|
||
.textFieldStyle(.roundedBorder)
|
||
.onSubmit { submitPrimaryAction() }
|
||
.accessibilityLabel(NSLocalizedString("Find in Files Search", comment: ""))
|
||
.accessibilityHint(NSLocalizedString("Enter text to search across project files. Use Up and Down Arrow to move through results. Press Return to open the selected result.", comment: ""))
|
||
.focused($queryFieldFocused)
|
||
|
||
Button(NSLocalizedString("Search", comment: "")) { onSearch() }
|
||
.disabled(query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||
.accessibilityLabel(NSLocalizedString("Search Files", comment: ""))
|
||
.buttonStyle(.plain)
|
||
.font(.caption.weight(.semibold))
|
||
.searchPanelActionButton(prominent: true)
|
||
|
||
Button(NSLocalizedString("Clear", comment: "")) { onClear() }
|
||
.accessibilityLabel(NSLocalizedString("Clear Search", comment: ""))
|
||
.buttonStyle(.plain)
|
||
.font(.caption.weight(.medium))
|
||
.searchPanelActionButton()
|
||
}
|
||
|
||
Toggle(NSLocalizedString("Case Sensitive", comment: ""), isOn: $caseSensitive)
|
||
.accessibilityLabel(NSLocalizedString("Case Sensitive Search", comment: ""))
|
||
}
|
||
|
||
List {
|
||
if groupedResults.isEmpty, hasSearched {
|
||
ContentUnavailableView(
|
||
NSLocalizedString("No Matches Found", comment: ""),
|
||
systemImage: "text.magnifyingglass",
|
||
description: Text(statusMessage)
|
||
)
|
||
.listRowBackground(Color.clear)
|
||
} else {
|
||
ForEach(groupedResults) { group in
|
||
Section {
|
||
ForEach(group.matches) { match in
|
||
Button {
|
||
selectedMatchID = match.id
|
||
onSelect(match)
|
||
onClose()
|
||
} label: {
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
Text("Line \(match.line), Column \(match.column)")
|
||
.font(.system(size: 12, weight: .semibold, design: .monospaced))
|
||
highlightedText(match.snippet)
|
||
.font(.system(size: 12, design: .monospaced))
|
||
.lineLimit(2)
|
||
}
|
||
}
|
||
.id(match.id)
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel("\(group.fileURL.lastPathComponent) line \(match.line) column \(match.column)")
|
||
.accessibilityValue(match.snippet)
|
||
.accessibilityHint(NSLocalizedString("Open match in editor", comment: ""))
|
||
.listRowBackground(
|
||
selectedMatchID == match.id
|
||
? NeonUIStyle.selectedRowFill(for: colorScheme)
|
||
: Color.clear
|
||
)
|
||
.onTapGesture {
|
||
selectedMatchID = match.id
|
||
}
|
||
}
|
||
} header: {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
HStack(alignment: .center, spacing: 8) {
|
||
Text(group.fileURL.lastPathComponent)
|
||
.font(.caption.weight(.semibold))
|
||
.foregroundStyle(.primary)
|
||
Text(group.matchCountText)
|
||
.font(.caption2.weight(.semibold))
|
||
.foregroundStyle(NeonUIStyle.accentBlueStrong)
|
||
.padding(.horizontal, 7)
|
||
.padding(.vertical, 3)
|
||
.background(
|
||
Capsule()
|
||
.fill(NeonUIStyle.searchMatchFill(for: colorScheme))
|
||
)
|
||
}
|
||
Text(groupHeaderSubtitle(for: group.fileURL))
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
.lineLimit(1)
|
||
}
|
||
.padding(.top, 4)
|
||
.accessibilityElement(children: .combine)
|
||
.accessibilityLabel(group.fileURL.lastPathComponent)
|
||
.accessibilityValue(groupHeaderSubtitle(for: group.fileURL))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
.listStyle(.plain)
|
||
.scrollContentBackground(.hidden)
|
||
.background(Color.clear)
|
||
.accessibilityLabel(NSLocalizedString("Find in Files Results", comment: ""))
|
||
.subtleSearchSectionCard()
|
||
|
||
if usesCompactPhoneLayout {
|
||
phoneActionSection
|
||
} else if usesPadLayout {
|
||
padFooterSection
|
||
} else {
|
||
HStack {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(statusMessage)
|
||
if !sourceMessage.isEmpty {
|
||
Text(sourceMessage)
|
||
.font(.caption2)
|
||
}
|
||
}
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
Spacer()
|
||
Button(NSLocalizedString("Clear", comment: "")) { onClear() }
|
||
.buttonStyle(.plain)
|
||
.font(.caption.weight(.medium))
|
||
.searchPanelActionButton()
|
||
Button(NSLocalizedString("Close", comment: "")) { onClose() }
|
||
.buttonStyle(.plain)
|
||
.font(.caption.weight(.medium))
|
||
.searchPanelActionButton()
|
||
}
|
||
}
|
||
}
|
||
#if os(iOS)
|
||
.background(
|
||
DirectionalKeyCommandBridge(
|
||
onMoveUp: { moveSelection(by: -1) },
|
||
onMoveDown: { moveSelection(by: 1) }
|
||
)
|
||
.frame(width: 0, height: 0)
|
||
)
|
||
#endif
|
||
.padding(16)
|
||
#if os(macOS)
|
||
.frame(minWidth: 620, minHeight: 560)
|
||
#else
|
||
.frame(maxWidth: .infinity, minHeight: usesCompactPhoneLayout ? 460 : (usesPadLayout ? 620 : 420))
|
||
#endif
|
||
.subtleSearchPanelSurface()
|
||
.onAppear {
|
||
queryFieldFocused = true
|
||
syncSelectionToVisibleResults()
|
||
}
|
||
.onChange(of: results.map(\.id)) { _, _ in
|
||
syncSelectionToVisibleResults()
|
||
}
|
||
.onChange(of: selectedMatchID) { _, newValue in
|
||
guard let newValue else { return }
|
||
proxy.scrollTo(newValue, anchor: .center)
|
||
}
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
#if canImport(UIKit)
|
||
private struct DirectionalKeyCommandBridge: UIViewRepresentable {
|
||
let onMoveUp: () -> Void
|
||
let onMoveDown: () -> Void
|
||
|
||
func makeUIView(context: Context) -> DirectionalKeyCommandView {
|
||
let view = DirectionalKeyCommandView()
|
||
view.onMoveUp = onMoveUp
|
||
view.onMoveDown = onMoveDown
|
||
return view
|
||
}
|
||
|
||
func updateUIView(_ uiView: DirectionalKeyCommandView, context: Context) {
|
||
uiView.onMoveUp = onMoveUp
|
||
uiView.onMoveDown = onMoveDown
|
||
uiView.refreshFirstResponderStatus()
|
||
}
|
||
}
|
||
|
||
private final class DirectionalKeyCommandView: UIView {
|
||
var onMoveUp: (() -> Void)?
|
||
var onMoveDown: (() -> Void)?
|
||
|
||
override var canBecomeFirstResponder: Bool { true }
|
||
|
||
override var keyCommands: [UIKeyCommand]? {
|
||
guard UIDevice.current.userInterfaceIdiom == .pad else { return [] }
|
||
let upCommand = UIKeyCommand(input: UIKeyCommand.inputUpArrow, modifierFlags: [], action: #selector(handleMoveUp))
|
||
upCommand.discoverabilityTitle = "Move Up"
|
||
let downCommand = UIKeyCommand(input: UIKeyCommand.inputDownArrow, modifierFlags: [], action: #selector(handleMoveDown))
|
||
downCommand.discoverabilityTitle = "Move Down"
|
||
return [upCommand, downCommand]
|
||
}
|
||
|
||
override func didMoveToWindow() {
|
||
super.didMoveToWindow()
|
||
refreshFirstResponderStatus()
|
||
}
|
||
|
||
func refreshFirstResponderStatus() {
|
||
guard window != nil, UIDevice.current.userInterfaceIdiom == .pad else { return }
|
||
DispatchQueue.main.async { [weak self] in
|
||
_ = self?.becomeFirstResponder()
|
||
}
|
||
}
|
||
|
||
@objc private func handleMoveUp() {
|
||
onMoveUp?()
|
||
}
|
||
|
||
@objc private func handleMoveDown() {
|
||
onMoveDown?()
|
||
}
|
||
}
|
||
#endif
|
||
|
||
struct WelcomeTourView: View {
|
||
@Environment(\.colorScheme) private var colorScheme
|
||
@Environment(\.openURL) private var openURL
|
||
@EnvironmentObject private var supportPurchaseManager: SupportPurchaseManager
|
||
|
||
static var releaseID: String {
|
||
let short = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0"
|
||
let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "0"
|
||
return "\(short) (\(build))"
|
||
}
|
||
|
||
struct ToolbarItemInfo: Identifiable {
|
||
let id = UUID()
|
||
let title: String
|
||
let description: String
|
||
let shortcutMac: String
|
||
let shortcutPad: String
|
||
let iconName: String
|
||
}
|
||
|
||
struct TourPage: Identifiable {
|
||
let id = UUID()
|
||
let title: String
|
||
let subtitle: String
|
||
let bullets: [String]
|
||
let iconName: String
|
||
let colors: [Color]
|
||
let toolbarItems: [ToolbarItemInfo]
|
||
}
|
||
|
||
let onFinish: () -> Void
|
||
@State private var selectedIndex: Int = 0
|
||
|
||
private let pages: [TourPage] = [
|
||
TourPage(
|
||
title: "What’s New in This Release",
|
||
subtitle: "Major changes since v0.6.0:",
|
||
bullets: [
|
||
"The project sidebar is now more complete for day-to-day file management with better structure controls and direct item actions.",
|
||
"Markdown Preview toolbar controls are cleaner and more discoverable with dedicated export/style actions plus localized labels.",
|
||
"Added project sidebar item actions for creating files/folders, plus rename, duplicate, and delete flows.",
|
||
"Refined project sidebar visual hierarchy and interaction density for clearer navigation in large trees."
|
||
],
|
||
iconName: "sparkles.rectangle.stack",
|
||
colors: [Color(red: 0.40, green: 0.28, blue: 0.90), Color(red: 0.96, green: 0.46, blue: 0.55)],
|
||
toolbarItems: []
|
||
),
|
||
TourPage(
|
||
title: "Support Neo Vision Editor",
|
||
subtitle: "Keep it free, sustainable, and improving.",
|
||
bullets: [
|
||
"Neo Vision Editor will always stay free to use.",
|
||
"No subscriptions and no paywalls.",
|
||
"Keeping the app alive still has real costs: Apple Developer Program fee, maintenance, updates, and long-term support.",
|
||
"⭐ Optional Support Tip (Consumable) — $4.99",
|
||
"Tip can be purchased multiple times.",
|
||
"Your support helps cover: Apple developer fees, bug fixes and updates, future improvements and features, and long-term support.",
|
||
"Thank you for helping keep Neo Vision Editor free for everyone."
|
||
],
|
||
iconName: "heart.circle.fill",
|
||
colors: [Color(red: 0.98, green: 0.33, blue: 0.49), Color(red: 1.00, green: 0.64, blue: 0.30)],
|
||
toolbarItems: []
|
||
),
|
||
TourPage(
|
||
title: "A Fast, Focused Editor",
|
||
subtitle: "Built for quick edits and flow.",
|
||
bullets: [
|
||
"Tabbed editing with per-file language support",
|
||
"Automatic syntax highlighting for many formats",
|
||
"Word count, caret status, and complete toolbar options",
|
||
"Large-file scrolling and highlighting tuned with shared regex caching and incremental refresh paths",
|
||
"Line-number gutter performance improved on macOS and iOS for long documents"
|
||
],
|
||
iconName: "doc.text.magnifyingglass",
|
||
colors: [Color(red: 0.96, green: 0.48, blue: 0.28), Color(red: 0.99, green: 0.78, blue: 0.35)],
|
||
toolbarItems: []
|
||
),
|
||
TourPage(
|
||
title: "Smart Assistance",
|
||
subtitle: "Use local or cloud AI models when you want.",
|
||
bullets: [
|
||
"Apple Intelligence integration (when available)",
|
||
"Optional Grok, OpenAI, Gemini, and Anthropic providers",
|
||
"AI providers are used for simple code completion and suggestions",
|
||
"API keys stored securely in Keychain",
|
||
"Curated popular built-in themes: Dracula, One Dark Pro, Nord, Tokyo Night, and Gruvbox",
|
||
"Neon Glow readability and token colors tuned for both Light and Dark appearance"
|
||
],
|
||
iconName: "sparkles",
|
||
colors: [Color(red: 0.20, green: 0.55, blue: 0.95), Color(red: 0.21, green: 0.86, blue: 0.78)],
|
||
toolbarItems: []
|
||
),
|
||
TourPage(
|
||
title: "Power User Features",
|
||
subtitle: "Navigate large projects quickly.",
|
||
bullets: [
|
||
"Quick Open with Cmd+P",
|
||
"All sidebars: document outline and project structure",
|
||
"Find & Replace and full editor/view toolbar actions",
|
||
"Lightweight Vim-style workflow support on macOS"
|
||
],
|
||
iconName: "bolt.circle",
|
||
colors: [Color(red: 0.22, green: 0.72, blue: 0.43), Color(red: 0.08, green: 0.42, blue: 0.73)],
|
||
toolbarItems: []
|
||
),
|
||
TourPage(
|
||
title: "Toolbar Map",
|
||
subtitle: "Every button, plus the quickest way to reach it.",
|
||
bullets: [
|
||
"Shortcuts are shown where available",
|
||
"iPad hardware-keyboard shortcuts are shown where supported; no shortcut? the toolbar is the fastest path"
|
||
],
|
||
iconName: "slider.horizontal.3",
|
||
colors: [Color(red: 0.36, green: 0.32, blue: 0.92), Color(red: 0.92, green: 0.49, blue: 0.64)],
|
||
toolbarItems: [
|
||
ToolbarItemInfo(title: "New Window", description: "New Window", shortcutMac: "Cmd+N", shortcutPad: "None", iconName: "macwindow.badge.plus"),
|
||
ToolbarItemInfo(title: "New Tab", description: "New Tab", shortcutMac: "Cmd+T", shortcutPad: "Cmd+T", iconName: "plus.square.on.square"),
|
||
ToolbarItemInfo(title: "Open File…", description: "Open File…", shortcutMac: "Cmd+O", shortcutPad: "Cmd+O", iconName: "folder"),
|
||
ToolbarItemInfo(title: "Save File", description: "Save File", shortcutMac: "Cmd+S", shortcutPad: "Cmd+S", iconName: "square.and.arrow.down"),
|
||
ToolbarItemInfo(title: "Settings", description: "Settings", shortcutMac: "Cmd+,", shortcutPad: "None", iconName: "gearshape"),
|
||
ToolbarItemInfo(title: "Insert Template", description: "Insert Template for Current Language", shortcutMac: "None", shortcutPad: "None", iconName: "doc.badge.plus"),
|
||
ToolbarItemInfo(title: "Language", description: "Language", shortcutMac: "None", shortcutPad: "None", iconName: "textformat"),
|
||
ToolbarItemInfo(title: "AI Model & Settings", description: "AI Model & Settings", shortcutMac: "None", shortcutPad: "None", iconName: "brain.head.profile"),
|
||
ToolbarItemInfo(title: "Code Completion", description: "Enable Code Completion / Disable Code Completion", shortcutMac: "None", shortcutPad: "None", iconName: "bolt.horizontal.circle"),
|
||
ToolbarItemInfo(title: "Find & Replace", description: "Find & Replace", shortcutMac: "Cmd+F", shortcutPad: "Cmd+F", iconName: "magnifyingglass"),
|
||
ToolbarItemInfo(title: "Toggle Sidebar", description: "Toggle Sidebar", shortcutMac: "Cmd+Opt+S", shortcutPad: "Cmd+Opt+S", iconName: "sidebar.left"),
|
||
ToolbarItemInfo(title: "Project Sidebar", description: "Toggle Project Structure Sidebar", shortcutMac: "None", shortcutPad: "None", iconName: "sidebar.right"),
|
||
ToolbarItemInfo(title: "Line Wrap", description: "Enable Wrap / Disable Wrap", shortcutMac: "Cmd+Opt+L", shortcutPad: "Cmd+Opt+L", iconName: "text.justify"),
|
||
ToolbarItemInfo(title: "Clear Editor", description: "Clear Editor", shortcutMac: "None", shortcutPad: "None", iconName: "eraser")
|
||
]
|
||
)
|
||
]
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
TabView(selection: $selectedIndex) {
|
||
ForEach(Array(pages.enumerated()), id: \.offset) { idx, page in
|
||
tourCard(for: page)
|
||
.tag(idx)
|
||
.padding(.horizontal, 24)
|
||
.padding(.vertical, 18)
|
||
}
|
||
}
|
||
#if os(iOS)
|
||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||
#else
|
||
.tabViewStyle(.automatic)
|
||
#endif
|
||
|
||
HStack(spacing: 8) {
|
||
ForEach(0..<pages.count, id: \.self) { idx in
|
||
Capsule()
|
||
.fill(idx == selectedIndex ? Color.accentColor : Color.secondary.opacity(0.3))
|
||
.frame(width: idx == selectedIndex ? 14 : 6, height: 5)
|
||
}
|
||
}
|
||
.padding(.top, 4)
|
||
.padding(.bottom, 10)
|
||
|
||
HStack {
|
||
Button("Skip") { onFinish() }
|
||
.buttonStyle(.plain)
|
||
.foregroundStyle(.secondary)
|
||
|
||
Spacer()
|
||
|
||
if selectedIndex < pages.count - 1 {
|
||
Button("Next") {
|
||
withAnimation(.easeInOut(duration: 0.25)) {
|
||
selectedIndex += 1
|
||
}
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
} else {
|
||
Button("Get Started") { onFinish() }
|
||
.buttonStyle(.borderedProminent)
|
||
}
|
||
}
|
||
.padding(.horizontal, 24)
|
||
.padding(.vertical, 18)
|
||
}
|
||
.padding(14)
|
||
.background(
|
||
LinearGradient(
|
||
colors: colorScheme == .dark
|
||
? [Color(red: 0.09, green: 0.10, blue: 0.14), Color(red: 0.13, green: 0.16, blue: 0.22)]
|
||
: [Color(red: 0.98, green: 0.99, blue: 1.00), Color(red: 0.93, green: 0.96, blue: 0.99)],
|
||
startPoint: .topLeading,
|
||
endPoint: .bottomTrailing
|
||
)
|
||
)
|
||
#if os(macOS)
|
||
.frame(minWidth: 920, minHeight: 680)
|
||
#else
|
||
.presentationDetents([.large])
|
||
#endif
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func tourCard(for page: TourPage) -> some View {
|
||
let displayBullets = page.bullets.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("![") }
|
||
ScrollView(.vertical, showsIndicators: true) {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
HStack(spacing: 14) {
|
||
ZStack {
|
||
Circle()
|
||
.fill(LinearGradient(colors: page.colors, startPoint: .topLeading, endPoint: .bottomTrailing))
|
||
.frame(width: 56, height: 56)
|
||
Image(systemName: page.iconName)
|
||
.font(.system(size: 24, weight: .semibold))
|
||
.foregroundStyle(.white)
|
||
}
|
||
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text(page.title)
|
||
.font(.system(size: 28, weight: .bold))
|
||
Text(page.subtitle)
|
||
.font(.system(size: 16))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.padding(.bottom, 10)
|
||
|
||
if page.title == "Toolbar Map" && displayBullets.count >= 2 {
|
||
HStack(alignment: .firstTextBaseline, spacing: 18) {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
bulletRow(displayBullets[0])
|
||
Text("scroll for viewing all toolbar options.")
|
||
.font(.system(size: 12, weight: .medium))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
bulletRow(displayBullets[1])
|
||
}
|
||
.padding(.bottom, 0)
|
||
} else {
|
||
ForEach(displayBullets, id: \.self) { bullet in
|
||
bulletRow(bullet)
|
||
}
|
||
}
|
||
|
||
if page.title == "Support Neo Vision Editor" {
|
||
supportPurchaseCard
|
||
.padding(.top, 6)
|
||
}
|
||
|
||
if !page.toolbarItems.isEmpty {
|
||
toolbarGrid(items: page.toolbarItems)
|
||
.padding(.top, page.title == "Toolbar Map" ? -8 : 0)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||
}
|
||
.padding(22)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
||
.fill(colorScheme == .dark ? .regularMaterial : .ultraThinMaterial)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
||
.stroke(
|
||
colorScheme == .dark ? Color.white.opacity(0.16) : Color.white.opacity(0.55),
|
||
lineWidth: 1
|
||
)
|
||
)
|
||
.shadow(
|
||
color: Color.black.opacity(colorScheme == .dark ? 0.35 : 0.08),
|
||
radius: 18,
|
||
x: 0,
|
||
y: 8
|
||
)
|
||
)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var supportPurchaseCard: some View {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
Button {
|
||
Task { await supportPurchaseManager.purchaseSupport() }
|
||
} label: {
|
||
HStack(spacing: 8) {
|
||
Image(systemName: "heart.fill")
|
||
Text("Send Support Tip — \(supportPurchaseManager.supportPriceLabel)")
|
||
}
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
.disabled(
|
||
shouldDisableSupportPurchaseButton
|
||
)
|
||
|
||
if let status = supportPurchaseManager.statusMessage, !status.isEmpty {
|
||
Text(status)
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
|
||
if !supportPurchaseManager.canUseInAppPurchases {
|
||
Text(NSLocalizedString("In-App Purchases are currently unavailable on this device. Check App Store login and Screen Time restrictions.", comment: ""))
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
|
||
if let externalURL = SupportPurchaseManager.externalSupportURL {
|
||
Button {
|
||
openURL(externalURL)
|
||
} label: {
|
||
Label(NSLocalizedString("Support via Patreon", comment: ""), systemImage: "safari")
|
||
}
|
||
.buttonStyle(.bordered)
|
||
}
|
||
}
|
||
.padding(12)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||
.fill(colorScheme == .dark ? Color.white.opacity(0.06) : Color.black.opacity(0.04))
|
||
)
|
||
}
|
||
|
||
private var shouldDisableSupportPurchaseButton: Bool {
|
||
#if os(iOS)
|
||
supportPurchaseManager.isPurchasing
|
||
#else
|
||
supportPurchaseManager.isPurchasing
|
||
|| supportPurchaseManager.isLoadingProducts
|
||
|| !supportPurchaseManager.canUseInAppPurchases
|
||
#endif
|
||
}
|
||
|
||
private func toolbarGrid(items: [ToolbarItemInfo]) -> some View {
|
||
return GeometryReader { proxy in
|
||
let isCompact = proxy.size.width < 640
|
||
let columns = isCompact
|
||
? [GridItem(.flexible(), spacing: 12)]
|
||
: [GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12)]
|
||
let dynamicMax = max(240, min(520, proxy.size.height * 0.6))
|
||
let maxGridHeight: CGFloat = isCompact ? min(dynamicMax, 360) : dynamicMax
|
||
let innerHeight = maxGridHeight + 180
|
||
let innerFill = Color.white.opacity(colorScheme == .dark ? 0.02 : 0.25)
|
||
let innerStroke = Color.white.opacity(colorScheme == .dark ? 0.12 : 0.15)
|
||
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
Text("Toolbar buttons")
|
||
.font(.system(size: 16, weight: .semibold))
|
||
|
||
ScrollView(.vertical, showsIndicators: true) {
|
||
LazyVGrid(columns: columns, alignment: .leading, spacing: 12) {
|
||
ForEach(items) { item in
|
||
toolbarItemRow(item)
|
||
}
|
||
}
|
||
.padding(.bottom, 4)
|
||
}
|
||
.frame(maxHeight: maxGridHeight)
|
||
.clipped()
|
||
}
|
||
.padding(.horizontal, 14)
|
||
.padding(.vertical, 14)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 32, style: .continuous)
|
||
.fill(innerFill)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 32, style: .continuous)
|
||
.stroke(innerStroke, lineWidth: 1)
|
||
)
|
||
)
|
||
.frame(height: innerHeight)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
}
|
||
|
||
private func bulletRow(_ text: String) -> some View {
|
||
HStack(alignment: .top, spacing: 10) {
|
||
Circle()
|
||
.fill(Color.accentColor.opacity(0.85))
|
||
.frame(width: 7, height: 7)
|
||
.padding(.top, 7)
|
||
Text(text)
|
||
.font(.system(size: 15))
|
||
}
|
||
}
|
||
|
||
private func toolbarItemRow(_ item: ToolbarItemInfo) -> some View {
|
||
let cardFill = colorScheme == .dark ? Color.white.opacity(0.06) : Color.white.opacity(0.8)
|
||
let cardStroke = colorScheme == .dark ? Color.white.opacity(0.12) : Color.black.opacity(0.08)
|
||
let shortcut = isPadShortcut ? item.shortcutPad : item.shortcutMac
|
||
|
||
return HStack(alignment: .top, spacing: 12) {
|
||
Image(systemName: item.iconName)
|
||
.font(.system(size: 15, weight: .semibold))
|
||
.foregroundStyle(Color.accentColor)
|
||
.frame(width: 22)
|
||
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
HStack(spacing: 8) {
|
||
Text(item.title)
|
||
.font(.system(size: 14, weight: .semibold))
|
||
shortcutCapsule(shortcut)
|
||
}
|
||
Text(item.description)
|
||
.font(.system(size: 12))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.padding(12)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||
.fill(cardFill)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||
.stroke(cardStroke, lineWidth: 1)
|
||
)
|
||
)
|
||
}
|
||
|
||
private var isPadShortcut: Bool {
|
||
#if os(iOS)
|
||
return UIDevice.current.userInterfaceIdiom == .pad
|
||
#else
|
||
return false
|
||
#endif
|
||
}
|
||
|
||
private func shortcutCapsule(_ shortcut: String) -> some View {
|
||
let trimmed = shortcut.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
let isNone = trimmed.isEmpty || trimmed.caseInsensitiveCompare("none") == .orderedSame
|
||
let parts = trimmed.split(separator: "+").map { String($0) }
|
||
|
||
return Group {
|
||
if !isNone {
|
||
HStack(spacing: 4) {
|
||
ForEach(parts, id: \.self) { part in
|
||
Text(part)
|
||
.font(.system(size: 11, weight: .semibold, design: .monospaced))
|
||
.padding(.horizontal, 6)
|
||
.padding(.vertical, 2)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
||
.fill(colorScheme == .dark ? Color.white.opacity(0.12) : Color.black.opacity(0.08))
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
struct SupportPromptSheetView: View {
|
||
@Environment(\.colorScheme) private var colorScheme
|
||
@Environment(\.openURL) private var openURL
|
||
@EnvironmentObject private var supportPurchaseManager: SupportPurchaseManager
|
||
let onDismiss: () -> Void
|
||
|
||
private let bullets: [String] = [
|
||
"Neo Vision Editor will always stay free to use.",
|
||
"No subscriptions and no paywalls.",
|
||
"Keeping the app alive still has real costs: Apple Developer Program fee, maintenance, updates, and long-term support.",
|
||
"Your support helps cover: Apple developer fees, bug fixes and updates, future improvements and features, and long-term support."
|
||
]
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 16) {
|
||
HStack(spacing: 12) {
|
||
Image(systemName: "heart.circle.fill")
|
||
.font(.system(size: 28, weight: .semibold))
|
||
.foregroundStyle(.pink)
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text("Support Neo Vision Editor")
|
||
.font(.title2.weight(.bold))
|
||
Text("Keep it free, sustainable, and improving.")
|
||
.font(.subheadline)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
Spacer()
|
||
}
|
||
|
||
ForEach(bullets, id: \.self) { bullet in
|
||
HStack(alignment: .top, spacing: 8) {
|
||
Circle()
|
||
.fill(Color.accentColor.opacity(0.9))
|
||
.frame(width: 6, height: 6)
|
||
.padding(.top, 6)
|
||
Text(bullet)
|
||
.font(.body)
|
||
}
|
||
}
|
||
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
Button {
|
||
Task { await supportPurchaseManager.purchaseSupport() }
|
||
} label: {
|
||
Label("Send Support Tip — \(supportPurchaseManager.supportPriceLabel)", systemImage: "heart.fill")
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
.disabled(supportPurchaseManager.isPurchasing || supportPurchaseManager.isLoadingProducts)
|
||
|
||
if let externalURL = SupportPurchaseManager.externalSupportURL {
|
||
Button {
|
||
openURL(externalURL)
|
||
} label: {
|
||
Label("Support via Patreon", systemImage: "safari")
|
||
}
|
||
.buttonStyle(.bordered)
|
||
}
|
||
|
||
if let status = supportPurchaseManager.statusMessage, !status.isEmpty {
|
||
Text(status)
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.padding(12)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||
.fill(colorScheme == .dark ? Color.white.opacity(0.07) : Color.black.opacity(0.04))
|
||
)
|
||
|
||
HStack {
|
||
Spacer()
|
||
Button("Not Now") {
|
||
onDismiss()
|
||
}
|
||
.buttonStyle(.bordered)
|
||
}
|
||
}
|
||
.padding(22)
|
||
.onAppear {
|
||
Task { await supportPurchaseManager.refreshPrice() }
|
||
}
|
||
#if os(macOS)
|
||
.frame(minWidth: 560, minHeight: 420)
|
||
#else
|
||
.presentationDetents([.medium, .large])
|
||
#endif
|
||
}
|
||
}
|
||
|
||
struct EditorHelpView: View {
|
||
@Environment(\.colorScheme) private var colorScheme
|
||
|
||
private let toolbarItems: [WelcomeTourView.ToolbarItemInfo] = [
|
||
.init(title: "New Window", description: "Open a new editor window.", shortcutMac: "Cmd+N", shortcutPad: "None", iconName: "macwindow.badge.plus"),
|
||
.init(title: "New Tab", description: "Create a new tab in the current window.", shortcutMac: "Cmd+T", shortcutPad: "Cmd+T", iconName: "plus.square.on.square"),
|
||
.init(title: "Open File…", description: "Open a single file.", shortcutMac: "Cmd+O", shortcutPad: "Cmd+O", iconName: "folder"),
|
||
.init(title: "Save File", description: "Save current file.", shortcutMac: "Cmd+S", shortcutPad: "Cmd+S", iconName: "square.and.arrow.down"),
|
||
.init(title: "Settings", description: "Open app settings.", shortcutMac: "Cmd+,", shortcutPad: "None", iconName: "gearshape"),
|
||
.init(title: "Insert Template", description: "Insert template for current language.", shortcutMac: "None", shortcutPad: "None", iconName: "doc.badge.plus"),
|
||
.init(title: "Language", description: "Change syntax language mode.", shortcutMac: "None", shortcutPad: "None", iconName: "textformat"),
|
||
.init(title: "AI Model & Settings", description: "Select AI model and provider setup.", shortcutMac: "None", shortcutPad: "None", iconName: "brain.head.profile"),
|
||
.init(title: "Code Completion", description: "Enable or disable AI-assisted completion.", shortcutMac: "None", shortcutPad: "None", iconName: "bolt.horizontal.circle"),
|
||
.init(title: "Find & Replace", description: "Search and replace within the current file.", shortcutMac: "Cmd+F", shortcutPad: "Cmd+F", iconName: "magnifyingglass"),
|
||
.init(title: "Quick Open", description: "Open file quickly by name.", shortcutMac: "Cmd+P", shortcutPad: "Cmd+P", iconName: "magnifyingglass.circle"),
|
||
.init(title: "Toggle Sidebar", description: "Show or hide file sidebar.", shortcutMac: "Cmd+Opt+S", shortcutPad: "Cmd+Opt+S", iconName: "sidebar.left"),
|
||
.init(title: "Project Sidebar", description: "Toggle project structure sidebar.", shortcutMac: "None", shortcutPad: "None", iconName: "sidebar.right"),
|
||
.init(title: "Line Wrap", description: "Enable or disable line wrapping.", shortcutMac: "Cmd+Opt+L", shortcutPad: "Cmd+Opt+L", iconName: "text.justify"),
|
||
.init(title: "Clear Editor", description: "Clear current editor content.", shortcutMac: "None", shortcutPad: "None", iconName: "eraser")
|
||
]
|
||
|
||
let onDismiss: () -> Void
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: 16) {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
Text("Editor Help")
|
||
.font(.largeTitle.weight(.bold))
|
||
Text("All core editor actions and keyboard shortcuts in one place.")
|
||
.font(.subheadline)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
|
||
ForEach(toolbarItems) { item in
|
||
HStack(alignment: .top, spacing: 12) {
|
||
Image(systemName: item.iconName)
|
||
.font(.system(size: 15, weight: .semibold))
|
||
.foregroundStyle(.accent)
|
||
.frame(width: 24)
|
||
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text(item.title)
|
||
.font(.headline)
|
||
Text(item.description)
|
||
.font(.subheadline)
|
||
.foregroundStyle(.secondary)
|
||
HStack(spacing: 8) {
|
||
shortcutCapsule("macOS: \(item.shortcutMac)")
|
||
shortcutCapsule("iPad: \(item.shortcutPad)")
|
||
}
|
||
}
|
||
}
|
||
.padding(12)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||
.fill(colorScheme == .dark ? Color.white.opacity(0.06) : Color.black.opacity(0.03))
|
||
)
|
||
}
|
||
}
|
||
.padding(20)
|
||
}
|
||
.toolbar {
|
||
#if os(macOS)
|
||
ToolbarItem(placement: .automatic) {
|
||
Button("Done") { onDismiss() }
|
||
}
|
||
#else
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
Button("Done") { onDismiss() }
|
||
}
|
||
#endif
|
||
}
|
||
}
|
||
#if os(macOS)
|
||
.frame(minWidth: 760, minHeight: 620)
|
||
#else
|
||
.presentationDetents([.large])
|
||
#endif
|
||
}
|
||
|
||
private func shortcutCapsule(_ text: String) -> some View {
|
||
Text(text)
|
||
.font(.system(size: 11, weight: .semibold, design: .monospaced))
|
||
.padding(.horizontal, 8)
|
||
.padding(.vertical, 4)
|
||
.background(
|
||
Capsule(style: .continuous)
|
||
.fill(colorScheme == .dark ? Color.white.opacity(0.12) : Color.black.opacity(0.08))
|
||
)
|
||
}
|
||
}
|
||
|
||
extension Notification.Name {
|
||
static let moveCursorToLine = Notification.Name("moveCursorToLine")
|
||
static let caretPositionDidChange = Notification.Name("caretPositionDidChange")
|
||
static let pastedText = Notification.Name("pastedText")
|
||
static let toggleTranslucencyRequested = Notification.Name("toggleTranslucencyRequested")
|
||
static let clearEditorRequested = Notification.Name("clearEditorRequested")
|
||
static let showFindReplaceRequested = Notification.Name("showFindReplaceRequested")
|
||
static let findNextRequested = Notification.Name("findNextRequested")
|
||
static let toggleProjectStructureSidebarRequested = Notification.Name("toggleProjectStructureSidebarRequested")
|
||
static let openProjectFolderRequested = Notification.Name("openProjectFolderRequested")
|
||
static let showAPISettingsRequested = Notification.Name("showAPISettingsRequested")
|
||
static let selectAIModelRequested = Notification.Name("selectAIModelRequested")
|
||
static let showQuickSwitcherRequested = Notification.Name("showQuickSwitcherRequested")
|
||
static let showFindInFilesRequested = Notification.Name("showFindInFilesRequested")
|
||
static let showWelcomeTourRequested = Notification.Name("showWelcomeTourRequested")
|
||
static let showEditorHelpRequested = Notification.Name("showEditorHelpRequested")
|
||
static let showSupportPromptRequested = Notification.Name("showSupportPromptRequested")
|
||
static let moveCursorToRange = Notification.Name("moveCursorToRange")
|
||
static let toggleVimModeRequested = Notification.Name("toggleVimModeRequested")
|
||
static let vimModeStateDidChange = Notification.Name("vimModeStateDidChange")
|
||
static let droppedFileURL = Notification.Name("droppedFileURL")
|
||
static let droppedFileLoadStarted = Notification.Name("droppedFileLoadStarted")
|
||
static let droppedFileLoadProgress = Notification.Name("droppedFileLoadProgress")
|
||
static let droppedFileLoadFinished = Notification.Name("droppedFileLoadFinished")
|
||
static let toggleSidebarRequested = Notification.Name("toggleSidebarRequested")
|
||
static let toggleBrainDumpModeRequested = Notification.Name("toggleBrainDumpModeRequested")
|
||
static let zoomEditorFontRequested = Notification.Name("zoomEditorFontRequested")
|
||
static let inspectWhitespaceScalarsRequested = Notification.Name("inspectWhitespaceScalarsRequested")
|
||
static let addNextMatchRequested = Notification.Name("addNextMatchRequested")
|
||
static let whitespaceScalarInspectionResult = Notification.Name("whitespaceScalarInspectionResult")
|
||
static let insertBracketHelperTokenRequested = Notification.Name("insertBracketHelperTokenRequested")
|
||
static let keyboardAccessoryBarVisibilityChanged = Notification.Name("keyboardAccessoryBarVisibilityChanged")
|
||
static let showUpdaterRequested = Notification.Name("showUpdaterRequested")
|
||
static let showSettingsRequested = Notification.Name("showSettingsRequested")
|
||
static let closeSelectedTabRequested = Notification.Name("closeSelectedTabRequested")
|
||
static let openRecentFileRequested = Notification.Name("openRecentFileRequested")
|
||
static let recentFilesDidChange = Notification.Name("recentFilesDidChange")
|
||
}
|
||
|
||
extension NSRange {
|
||
func toOptional() -> NSRange? { self.location == NSNotFound ? nil : self }
|
||
}
|
||
|
||
enum EditorCommandUserInfo {
|
||
static let windowNumber = "targetWindowNumber"
|
||
static let inspectionMessage = "inspectionMessage"
|
||
static let rangeLocation = "rangeLocation"
|
||
static let rangeLength = "rangeLength"
|
||
static let focusEditor = "focusEditor"
|
||
static let bracketToken = "bracketToken"
|
||
static let updaterCheckNow = "updaterCheckNow"
|
||
}
|
||
|
||
#if os(macOS)
|
||
private final class WeakEditorViewModelRef {
|
||
weak var value: EditorViewModel?
|
||
init(_ value: EditorViewModel) { self.value = value }
|
||
}
|
||
|
||
@MainActor
|
||
final class WindowViewModelRegistry {
|
||
static let shared = WindowViewModelRegistry()
|
||
private var storage: [Int: WeakEditorViewModelRef] = [:]
|
||
|
||
private init() {}
|
||
|
||
func register(_ viewModel: EditorViewModel, for windowNumber: Int) {
|
||
storage[windowNumber] = WeakEditorViewModelRef(viewModel)
|
||
}
|
||
|
||
func unregister(windowNumber: Int) {
|
||
storage.removeValue(forKey: windowNumber)
|
||
}
|
||
|
||
func viewModel(for windowNumber: Int?) -> EditorViewModel? {
|
||
guard let windowNumber else { return nil }
|
||
if let vm = storage[windowNumber]?.value {
|
||
return vm
|
||
}
|
||
storage.removeValue(forKey: windowNumber)
|
||
return nil
|
||
}
|
||
|
||
func activeViewModel() -> EditorViewModel? {
|
||
viewModel(for: NSApp.keyWindow?.windowNumber ?? NSApp.mainWindow?.windowNumber)
|
||
}
|
||
|
||
func viewModel(containing url: URL) -> (windowNumber: Int, viewModel: EditorViewModel)? {
|
||
let target = url.resolvingSymlinksInPath().standardizedFileURL
|
||
for (number, ref) in storage {
|
||
guard let vm = ref.value else { continue }
|
||
if vm.hasOpenFile(url: target) {
|
||
return (number, vm)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
}
|
||
|
||
private final class WindowObserverView: NSView {
|
||
var onWindowChange: ((NSWindow?) -> Void)?
|
||
|
||
override func viewDidMoveToWindow() {
|
||
super.viewDidMoveToWindow()
|
||
onWindowChange?(window)
|
||
}
|
||
}
|
||
|
||
struct WindowAccessor: NSViewRepresentable {
|
||
let onWindowChange: (NSWindow?) -> Void
|
||
|
||
func makeNSView(context: Context) -> NSView {
|
||
let view = WindowObserverView(frame: .zero)
|
||
view.onWindowChange = onWindowChange
|
||
DispatchQueue.main.async {
|
||
onWindowChange(view.window)
|
||
}
|
||
return view
|
||
}
|
||
|
||
func updateNSView(_ nsView: NSView, context: Context) {
|
||
guard let view = nsView as? WindowObserverView else { return }
|
||
view.onWindowChange = onWindowChange
|
||
DispatchQueue.main.async {
|
||
onWindowChange(view.window)
|
||
}
|
||
}
|
||
}
|
||
|
||
struct WelcomeTourWindowPresenter: NSViewRepresentable {
|
||
@Binding var isPresented: Bool
|
||
let makeContent: () -> WelcomeTourView
|
||
|
||
final class Coordinator: NSObject, NSWindowDelegate {
|
||
var parent: WelcomeTourWindowPresenter
|
||
weak var hostWindow: NSWindow?
|
||
var window: NSWindow?
|
||
|
||
init(parent: WelcomeTourWindowPresenter) {
|
||
self.parent = parent
|
||
}
|
||
|
||
func presentIfNeeded() {
|
||
guard window == nil else {
|
||
window?.makeKeyAndOrderFront(nil)
|
||
return
|
||
}
|
||
|
||
let controller = NSHostingController(rootView: parent.makeContent())
|
||
let window = NSWindow(contentViewController: controller)
|
||
window.title = "What\u{2019}s New"
|
||
window.styleMask = [.titled, .closable, .miniaturizable, .resizable]
|
||
window.isReleasedWhenClosed = false
|
||
window.tabbingMode = .disallowed
|
||
window.minSize = NSSize(width: 920, height: 680)
|
||
window.delegate = self
|
||
window.isMovableByWindowBackground = false
|
||
window.titleVisibility = .hidden
|
||
window.titlebarAppearsTransparent = false
|
||
|
||
if let hostWindow {
|
||
let hostFrame = hostWindow.frame
|
||
let size = window.frame.size
|
||
let origin = NSPoint(
|
||
x: hostFrame.midX - (size.width / 2),
|
||
y: hostFrame.midY - (size.height / 2)
|
||
)
|
||
window.setFrameOrigin(origin)
|
||
} else {
|
||
window.center()
|
||
}
|
||
|
||
self.window = window
|
||
window.makeKeyAndOrderFront(nil)
|
||
NSApp.activate(ignoringOtherApps: true)
|
||
}
|
||
|
||
func dismissIfNeeded() {
|
||
guard let window else { return }
|
||
window.close()
|
||
self.window = nil
|
||
}
|
||
|
||
func windowWillClose(_ notification: Notification) {
|
||
self.window = nil
|
||
DispatchQueue.main.async {
|
||
self.parent.isPresented = false
|
||
}
|
||
}
|
||
}
|
||
|
||
func makeCoordinator() -> Coordinator {
|
||
Coordinator(parent: self)
|
||
}
|
||
|
||
func makeNSView(context: Context) -> NSView {
|
||
let view = NSView(frame: .zero)
|
||
DispatchQueue.main.async {
|
||
context.coordinator.hostWindow = view.window
|
||
if isPresented {
|
||
context.coordinator.presentIfNeeded()
|
||
}
|
||
}
|
||
return view
|
||
}
|
||
|
||
func updateNSView(_ nsView: NSView, context: Context) {
|
||
context.coordinator.parent = self
|
||
context.coordinator.hostWindow = nsView.window
|
||
if isPresented {
|
||
context.coordinator.presentIfNeeded()
|
||
} else {
|
||
context.coordinator.dismissIfNeeded()
|
||
}
|
||
}
|
||
}
|
||
#endif
|