Improve JSON editor for macOS
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
}
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.profileEditor) private var profileEditor
|
||||
|
||||
public var body: some View {
|
||||
viewBuilder {
|
||||
@@ -29,30 +30,10 @@
|
||||
}
|
||||
}
|
||||
} else {
|
||||
#if os(iOS)
|
||||
RunestoneTextView(
|
||||
text: readOnly ? .constant(viewModel.profileContent) : $viewModel.profileContent,
|
||||
isEditable: !readOnly
|
||||
)
|
||||
editorView
|
||||
.onChangeCompat(of: viewModel.profileContent) {
|
||||
viewModel.markAsChanged()
|
||||
}
|
||||
#elseif os(macOS)
|
||||
viewBuilder {
|
||||
if readOnly {
|
||||
TextEditor(text: .constant(viewModel.profileContent))
|
||||
} else {
|
||||
TextEditor(text: $viewModel.profileContent)
|
||||
}
|
||||
}
|
||||
.font(Font.system(.caption2, design: .monospaced))
|
||||
.autocorrectionDisabled(true)
|
||||
.textContentType(.init(rawValue: ""))
|
||||
.padding()
|
||||
.onChangeCompat(of: viewModel.profileContent) {
|
||||
viewModel.markAsChanged()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.alertBinding($viewModel.alert)
|
||||
@@ -105,6 +86,38 @@
|
||||
return String(localized: "Edit Content")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var editorView: some View {
|
||||
if let profileEditor {
|
||||
profileEditor(
|
||||
readOnly ? .constant(viewModel.profileContent) : $viewModel.profileContent,
|
||||
!readOnly
|
||||
)
|
||||
#if os(macOS)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
#endif
|
||||
} else {
|
||||
defaultEditorView
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var defaultEditorView: some View {
|
||||
viewBuilder {
|
||||
if readOnly {
|
||||
TextEditor(text: .constant(viewModel.profileContent))
|
||||
} else {
|
||||
TextEditor(text: $viewModel.profileContent)
|
||||
}
|
||||
}
|
||||
.font(Font.system(.caption2, design: .monospaced))
|
||||
.autocorrectionDisabled(true)
|
||||
#if os(macOS)
|
||||
.textContentType(.init(rawValue: ""))
|
||||
.padding()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
@@ -21,6 +21,14 @@
|
||||
isChanged = true
|
||||
}
|
||||
|
||||
public func reset() {
|
||||
isLoading = true
|
||||
profile = nil
|
||||
profileContent = ""
|
||||
isChanged = false
|
||||
alert = nil
|
||||
}
|
||||
|
||||
public func loadContent() async {
|
||||
do {
|
||||
try await loadContentBackground()
|
||||
|
||||
@@ -10,7 +10,16 @@ public struct EditProfileView: View {
|
||||
@StateObject private var viewModel = EditProfileViewModel()
|
||||
|
||||
public init() {}
|
||||
|
||||
public var body: some View {
|
||||
#if os(macOS)
|
||||
macOSBody
|
||||
#else
|
||||
iOSBody
|
||||
#endif
|
||||
}
|
||||
|
||||
private var formContent: some View {
|
||||
FormView {
|
||||
FormItem(String(localized: "Name")) {
|
||||
TextField("Name", text: $profile.name, prompt: Text("Required"))
|
||||
@@ -60,48 +69,61 @@ public struct EditProfileView: View {
|
||||
ProfileActionToolbar(profile: profile, viewModel: viewModel)
|
||||
#endif
|
||||
}
|
||||
#if os(macOS)
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
ProfileActionToolbar(profile: profile, viewModel: viewModel)
|
||||
}
|
||||
#endif
|
||||
.onChangeCompat(of: profile.name) {
|
||||
viewModel.markAsChanged()
|
||||
}
|
||||
.onChangeCompat(of: profile.remoteURL) {
|
||||
viewModel.markAsChanged()
|
||||
}
|
||||
.onChangeCompat(of: profile.autoUpdate) {
|
||||
viewModel.markAsChanged()
|
||||
}
|
||||
.disabled(viewModel.isLoading)
|
||||
#if os(macOS)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .navigation) {
|
||||
Button {
|
||||
viewModel.isLoading = true
|
||||
Task {
|
||||
await viewModel.saveProfile(profile, environments: environments)
|
||||
}
|
||||
} label: {
|
||||
Image("save", bundle: ApplicationLibrary.bundle, label: Text("Save"))
|
||||
}
|
||||
.disabled(viewModel.isLoading || !viewModel.isChanged)
|
||||
}
|
||||
}
|
||||
#elseif os(iOS)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Save") {
|
||||
viewModel.isLoading = true
|
||||
Task {
|
||||
await viewModel.saveProfile(profile, environments: environments)
|
||||
}
|
||||
}.disabled(!viewModel.isChanged)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
.alertBinding($viewModel.alert)
|
||||
.navigationTitle("Edit Profile")
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
private var macOSBody: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("Edit Profile")
|
||||
.font(.headline)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 12)
|
||||
|
||||
formContent
|
||||
}
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
ProfileActionToolbar(profile: profile, viewModel: viewModel)
|
||||
}
|
||||
.onChangeCompat(of: profile.name) {
|
||||
viewModel.markAsChanged()
|
||||
}
|
||||
.onChangeCompat(of: profile.remoteURL) {
|
||||
viewModel.markAsChanged()
|
||||
}
|
||||
.onChangeCompat(of: profile.autoUpdate) {
|
||||
viewModel.markAsChanged()
|
||||
}
|
||||
.disabled(viewModel.isLoading)
|
||||
.alertBinding($viewModel.alert)
|
||||
}
|
||||
#else
|
||||
private var iOSBody: some View {
|
||||
formContent
|
||||
.onChangeCompat(of: profile.name) {
|
||||
viewModel.markAsChanged()
|
||||
}
|
||||
.onChangeCompat(of: profile.remoteURL) {
|
||||
viewModel.markAsChanged()
|
||||
}
|
||||
.onChangeCompat(of: profile.autoUpdate) {
|
||||
viewModel.markAsChanged()
|
||||
}
|
||||
.disabled(viewModel.isLoading)
|
||||
#if os(iOS)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Save") {
|
||||
viewModel.isLoading = true
|
||||
Task {
|
||||
await viewModel.saveProfile(profile, environments: environments)
|
||||
}
|
||||
}.disabled(!viewModel.isChanged)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
.alertBinding($viewModel.alert)
|
||||
.navigationTitle("Edit Profile")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -25,6 +25,14 @@ public struct NewProfileView: View {
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
#if os(macOS)
|
||||
macOSBody
|
||||
#else
|
||||
iOSBody
|
||||
#endif
|
||||
}
|
||||
|
||||
private var formContent: some View {
|
||||
FormView {
|
||||
FormItem(String(localized: "Name")) {
|
||||
TextField("Name", text: $viewModel.profileName, prompt: Text("Required"))
|
||||
@@ -113,8 +121,19 @@ public struct NewProfileView: View {
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.navigationTitle("New Profile")
|
||||
#if os(macOS)
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
private var macOSBody: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("New Profile")
|
||||
.font(.headline)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 12)
|
||||
|
||||
formContent
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
@@ -138,10 +157,8 @@ public struct NewProfileView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
.disabled(viewModel.isSaving)
|
||||
.alertBinding($viewModel.alert)
|
||||
#if os(iOS) || os(macOS)
|
||||
.fileImporter(
|
||||
isPresented: $viewModel.pickerPresented,
|
||||
allowedContentTypes: [.json],
|
||||
@@ -157,6 +174,30 @@ public struct NewProfileView: View {
|
||||
return
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
#else
|
||||
private var iOSBody: some View {
|
||||
formContent
|
||||
.navigationTitle("New Profile")
|
||||
.disabled(viewModel.isSaving)
|
||||
.alertBinding($viewModel.alert)
|
||||
#if os(iOS)
|
||||
.fileImporter(
|
||||
isPresented: $viewModel.pickerPresented,
|
||||
allowedContentTypes: [.json],
|
||||
allowsMultipleSelection: false
|
||||
) { result in
|
||||
do {
|
||||
let urls = try result.get()
|
||||
if !urls.isEmpty {
|
||||
viewModel.fileURL = urls[0]
|
||||
}
|
||||
} catch {
|
||||
viewModel.alert = Alert(error)
|
||||
return
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ import SwiftUI
|
||||
public struct ProfileActionToolbar: View {
|
||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
#if os(macOS)
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
#endif
|
||||
@ObservedObject private var profile: Profile
|
||||
@ObservedObject private var viewModel: EditProfileViewModel
|
||||
|
||||
@@ -69,16 +72,12 @@ public struct ProfileActionToolbar: View {
|
||||
|
||||
HStack(spacing: 12) {
|
||||
if profile.type != .remote {
|
||||
NavigationLink {
|
||||
EditProfileContentView(EditProfileContentView.Context(profileID: profile.id!, readOnly: false))
|
||||
} label: {
|
||||
Text("Edit Content")
|
||||
Button("Edit Content") {
|
||||
openWindow(value: EditProfileContentView.Context(profileID: profile.id!, readOnly: false))
|
||||
}
|
||||
} else {
|
||||
NavigationLink {
|
||||
EditProfileContentView(EditProfileContentView.Context(profileID: profile.id!, readOnly: true))
|
||||
} label: {
|
||||
Text("View Content")
|
||||
Button("View Content") {
|
||||
openWindow(value: EditProfileContentView.Context(profileID: profile.id!, readOnly: true))
|
||||
}
|
||||
|
||||
Button {
|
||||
@@ -92,14 +91,27 @@ public struct ProfileActionToolbar: View {
|
||||
.disabled(viewModel.isLoading)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Delete", role: .destructive) {
|
||||
Task {
|
||||
await viewModel.deleteProfile(profile, environments: environments, dismiss: dismiss)
|
||||
}
|
||||
}
|
||||
.foregroundColor(.red)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
|
||||
Button("Save") {
|
||||
viewModel.isLoading = true
|
||||
Task {
|
||||
await viewModel.saveProfile(profile, environments: environments)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(viewModel.isLoading || !viewModel.isChanged)
|
||||
}
|
||||
.padding()
|
||||
.background(Color(NSColor.controlBackgroundColor))
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
#if os(iOS) || os(macOS)
|
||||
import SwiftUI
|
||||
|
||||
public struct ProfileEditorKey: EnvironmentKey {
|
||||
public static let defaultValue: ((Binding<String>, Bool) -> AnyView)? = nil
|
||||
}
|
||||
|
||||
public extension EnvironmentValues {
|
||||
var profileEditor: ((Binding<String>, Bool) -> AnyView)? {
|
||||
get { self[ProfileEditorKey.self] }
|
||||
set { self[ProfileEditorKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,73 +0,0 @@
|
||||
#if os(iOS)
|
||||
import Runestone
|
||||
import UIKit
|
||||
|
||||
final class ProfileEditorTheme: Theme {
|
||||
let font: UIFont = .monospacedSystemFont(ofSize: 14, weight: .regular)
|
||||
let textColor: UIColor = .label
|
||||
|
||||
let gutterBackgroundColor: UIColor = .secondarySystemBackground
|
||||
let gutterHairlineColor: UIColor = .separator
|
||||
|
||||
let lineNumberColor: UIColor = .secondaryLabel
|
||||
let lineNumberFont: UIFont = .monospacedSystemFont(ofSize: 14, weight: .regular)
|
||||
|
||||
let selectedLineBackgroundColor: UIColor = .systemFill
|
||||
let selectedLinesLineNumberColor: UIColor = .label
|
||||
let selectedLinesGutterBackgroundColor: UIColor = .secondarySystemBackground
|
||||
|
||||
let invisibleCharactersColor: UIColor = .tertiaryLabel
|
||||
|
||||
let pageGuideHairlineColor: UIColor = .separator
|
||||
let pageGuideBackgroundColor: UIColor = .secondarySystemBackground
|
||||
|
||||
let markedTextBackgroundColor: UIColor = .systemFill
|
||||
let markedTextBackgroundCornerRadius: CGFloat = 4
|
||||
|
||||
func textColor(for rawHighlightName: String) -> UIColor? {
|
||||
guard let highlightName = HighlightName(rawHighlightName) else {
|
||||
return nil
|
||||
}
|
||||
switch highlightName {
|
||||
case .comment:
|
||||
return .secondaryLabel
|
||||
case .property:
|
||||
return .systemCyan
|
||||
case .string:
|
||||
return .systemGreen
|
||||
case .number:
|
||||
return .systemOrange
|
||||
case .constantBuiltin:
|
||||
return .systemPurple
|
||||
case .error:
|
||||
return .systemRed
|
||||
}
|
||||
}
|
||||
|
||||
func fontTraits(for _: String) -> FontTraits {
|
||||
[]
|
||||
}
|
||||
}
|
||||
|
||||
private enum HighlightName: String {
|
||||
case comment
|
||||
case property
|
||||
case string
|
||||
case number
|
||||
case constantBuiltin = "constant.builtin"
|
||||
case error
|
||||
|
||||
init?(_ rawHighlightName: String) {
|
||||
var components = rawHighlightName.split(separator: ".")
|
||||
while !components.isEmpty {
|
||||
let candidateRawHighlightName = components.joined(separator: ".")
|
||||
if let highlightName = Self(rawValue: candidateRawHighlightName) {
|
||||
self = highlightName
|
||||
return
|
||||
}
|
||||
components.removeLast()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -18,14 +18,14 @@ public enum SheetSize {
|
||||
|
||||
@MainActor
|
||||
public struct NavigationSheet<Content: View>: View {
|
||||
private let title: String
|
||||
private let title: String?
|
||||
private let size: SheetSize
|
||||
private let showDoneButton: Bool
|
||||
private let onDismiss: (() -> Void)?
|
||||
private let content: () -> Content
|
||||
|
||||
public init(
|
||||
title: String,
|
||||
title: String? = nil,
|
||||
size: SheetSize = .large,
|
||||
showDoneButton: Bool = false,
|
||||
onDismiss: (() -> Void)? = nil,
|
||||
@@ -39,13 +39,25 @@ public struct NavigationSheet<Content: View>: View {
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
NavigationStackCompat {
|
||||
content()
|
||||
.navigationTitle(title)
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
#if os(macOS)
|
||||
#if os(macOS)
|
||||
macOSBody
|
||||
#else
|
||||
iOSBody
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
private var macOSBody: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
if let title {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
content()
|
||||
}
|
||||
.toolbar {
|
||||
if showDoneButton {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
@@ -55,12 +67,19 @@ public struct NavigationSheet<Content: View>: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#if os(iOS) || os(tvOS)
|
||||
.sheetDetent(size)
|
||||
#endif
|
||||
}
|
||||
#else
|
||||
private var iOSBody: some View {
|
||||
NavigationStackCompat {
|
||||
content()
|
||||
.navigationTitle(title ?? "")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
}
|
||||
.sheetDetent(size)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS) || os(tvOS)
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
#if os(iOS)
|
||||
import Runestone
|
||||
import SwiftUI
|
||||
import TreeSitterJSON5Runestone
|
||||
|
||||
struct RunestoneTextView: UIViewRepresentable {
|
||||
@Binding var text: String
|
||||
let isEditable: Bool
|
||||
|
||||
func makeUIView(context: Context) -> TextView {
|
||||
let textView = TextView()
|
||||
|
||||
textView.showLineNumbers = true
|
||||
textView.isLineWrappingEnabled = false
|
||||
textView.showTabs = false
|
||||
textView.showSpaces = false
|
||||
textView.showLineBreaks = false
|
||||
textView.showSoftLineBreaks = false
|
||||
textView.showNonBreakingSpaces = false
|
||||
|
||||
textView.autocorrectionType = .no
|
||||
textView.autocapitalizationType = .none
|
||||
textView.smartDashesType = .no
|
||||
textView.smartQuotesType = .no
|
||||
textView.smartInsertDeleteType = .no
|
||||
|
||||
textView.backgroundColor = .secondarySystemGroupedBackground
|
||||
textView.contentInsetAdjustmentBehavior = .always
|
||||
textView.alwaysBounceVertical = true
|
||||
|
||||
textView.kern = 0.3
|
||||
textView.lineHeightMultiplier = 1.3
|
||||
|
||||
textView.characterPairs = [
|
||||
BasicCharacterPair(leading: "{", trailing: "}"),
|
||||
BasicCharacterPair(leading: "[", trailing: "]"),
|
||||
BasicCharacterPair(leading: "\"", trailing: "\""),
|
||||
]
|
||||
|
||||
let theme = ProfileEditorTheme()
|
||||
let state = TextViewState(text: text, theme: theme, language: .json5)
|
||||
textView.setState(state)
|
||||
|
||||
textView.isEditable = isEditable
|
||||
textView.editorDelegate = context.coordinator
|
||||
|
||||
return textView
|
||||
}
|
||||
|
||||
func updateUIView(_ textView: TextView, context _: Context) {
|
||||
if textView.text != text {
|
||||
textView.text = text
|
||||
}
|
||||
if textView.isEditable != isEditable {
|
||||
textView.isEditable = isEditable
|
||||
}
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(self)
|
||||
}
|
||||
|
||||
final class Coordinator: TextViewDelegate {
|
||||
var parent: RunestoneTextView
|
||||
|
||||
init(_ parent: RunestoneTextView) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
func textViewDidChange(_ textView: TextView) {
|
||||
parent.text = textView.text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class BasicCharacterPair: CharacterPair {
|
||||
let leading: String
|
||||
let trailing: String
|
||||
|
||||
init(leading: String, trailing: String) {
|
||||
self.leading = leading
|
||||
self.trailing = trailing
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Reference in New Issue
Block a user