diff --git a/ApplicationLibrary/Views/Profile/EditProfileContentViewModel.swift b/ApplicationLibrary/Views/Profile/EditProfileContentViewModel.swift index afb3fe1..35593b7 100644 --- a/ApplicationLibrary/Views/Profile/EditProfileContentViewModel.swift +++ b/ApplicationLibrary/Views/Profile/EditProfileContentViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import Libbox import Library import SwiftUI @@ -7,8 +8,10 @@ public final class EditProfileContentViewModel: BaseViewModel { @Published public var profile: Profile? @Published public var profileContent = "" @Published public var isChanged = false + @Published public var configurationError: String? private let profileID: Int64? + private var validationTask: Task? public init(profileID: Int64?) { self.profileID = profileID @@ -18,6 +21,7 @@ public final class EditProfileContentViewModel: BaseViewModel { public func markAsChanged() { isChanged = true + scheduleValidation() } public func reset() { @@ -25,9 +29,53 @@ public final class EditProfileContentViewModel: BaseViewModel { profile = nil profileContent = "" isChanged = false + configurationError = nil + validationTask?.cancel() + validationTask = nil alert = nil } + public func scheduleValidation() { + configurationError = nil + validationTask?.cancel() + validationTask = Task { + try? await Task.sleep(nanoseconds: 2 * NSEC_PER_SEC) + guard !Task.isCancelled else { return } + await checkConfiguration() + } + } + + private func checkConfiguration() async { + let content = profileContent + if content.isEmpty { return } + var error: NSError? + LibboxCheckConfig(content, &error) + if let error { + configurationError = error.localizedDescription + } else { + configurationError = nil + } + } + + public func formatConfiguration() async { + let content = profileContent + if content.isEmpty { return } + var error: NSError? + let result = LibboxFormatConfig(content, &error) + if let error { + configurationError = error.localizedDescription + return + } + if let formatted = result?.value, formatted != content { + profileContent = formatted + isChanged = true + } + } + + public func dismissConfigurationError() { + configurationError = nil + } + public func loadContent() async { do { try await loadContentBackground() diff --git a/ApplicationLibrary/Views/Profile/EditorToolbarView.swift b/ApplicationLibrary/Views/Profile/EditorToolbarView.swift new file mode 100644 index 0000000..6e98bc2 --- /dev/null +++ b/ApplicationLibrary/Views/Profile/EditorToolbarView.swift @@ -0,0 +1,149 @@ +import SwiftUI + +#if os(iOS) || os(macOS) + + public struct EditorToolbarView: View { + let canUndo: Bool + let canRedo: Bool + let onUndo: () -> Void + let onRedo: () -> Void + let onFormat: () -> Void + let onInsertSymbol: (String) -> Void + let configurationError: String? + let onDismissError: () -> Void + + public init( + canUndo: Bool, + canRedo: Bool, + onUndo: @escaping () -> Void, + onRedo: @escaping () -> Void, + onFormat: @escaping () -> Void, + onInsertSymbol: @escaping (String) -> Void, + configurationError: String?, + onDismissError: @escaping () -> Void + ) { + self.canUndo = canUndo + self.canRedo = canRedo + self.onUndo = onUndo + self.onRedo = onRedo + self.onFormat = onFormat + self.onInsertSymbol = onInsertSymbol + self.configurationError = configurationError + self.onDismissError = onDismissError + } + + public var body: some View { + VStack(spacing: 2) { + if let error = configurationError { + errorBanner(error) + } + symbolBar + } + } + + private func errorBanner(_ error: String) -> some View { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + Text(error) + .font(.footnote) + .lineLimit(2) + Spacer() + Button { + onDismissError() + } label: { + Image(systemName: "xmark") + .font(.footnote.weight(.semibold)) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + #if os(iOS) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12)) + #else + .background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 8)) + #endif + .padding(.horizontal, 8) + } + + private var symbolBar: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 4) { + actionButtons + Divider().frame(height: 24).padding(.horizontal, 4) + symbolButtons + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + } + #if os(iOS) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12)) + #else + .background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 8)) + #endif + .padding(.horizontal, 8) + .padding(.bottom, 8) + } + + private var actionButtons: some View { + HStack(spacing: 4) { + Button { + onUndo() + } label: { + Label("Undo", systemImage: "arrow.uturn.backward") + } + .disabled(!canUndo) + + Button { + onRedo() + } label: { + Label("Redo", systemImage: "arrow.uturn.forward") + } + .disabled(!canRedo) + + Button { + onFormat() + } label: { + Label("Format", systemImage: "text.alignleft") + } + } + .buttonStyle(.bordered) + } + + private var symbolButtons: some View { + HStack(spacing: 2) { + ForEach(primarySymbols, id: \.self) { symbol in + symbolButton(symbol) + } + ForEach(secondarySymbols, id: \.self) { symbol in + symbolButton(symbol) + } + } + .buttonStyle(.bordered) + } + + private var primarySymbols: [String] { + ["\"", ":", ",", "{", "}", "[", "]"] + } + + private var secondarySymbols: [String] { + ["true", "false"] + } + + private func symbolButton(_ symbol: String) -> some View { + Button { + onInsertSymbol(symbol) + } label: { + Text(symbol) + .font(.system(.body, design: .monospaced).weight(.medium)) + #if os(iOS) + .frame(minWidth: symbol.count > 1 ? nil : 32, minHeight: 32) + #else + .frame(minWidth: symbol.count > 1 ? nil : 24) + #endif + } + } + } + +#endif diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 57eab81..bb8da1f 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -1141,6 +1141,9 @@ } } } + }, + "Format" : { + }, "FPS" : { "shouldTranslate" : false @@ -2079,6 +2082,12 @@ } } } + }, + "Redo" : { + + }, + "Register task failed" : { + }, "Releases" : { "shouldTranslate" : false @@ -2671,6 +2680,9 @@ } } } + }, + "Undo" : { + }, "Unexpected message type %lld" : { "localizations" : { diff --git a/MacLibrary/CodeEditTextView.swift b/MacLibrary/CodeEditTextView.swift index 80d6851..04240f4 100644 --- a/MacLibrary/CodeEditTextView.swift +++ b/MacLibrary/CodeEditTextView.swift @@ -4,6 +4,36 @@ import CodeEditSourceEditor import CodeEditTextView import SwiftUI +@MainActor +public final class CodeEditEditorController: ObservableObject { + weak var controller: TextViewController? + + @Published public var canUndo = false + @Published public var canRedo = false + + public init() {} + + public func undo() { + controller?.textView.undoManager?.undo() + updateUndoState() + } + + public func redo() { + controller?.textView.undoManager?.redo() + updateUndoState() + } + + public func insertSymbol(_ symbol: String) { + guard let textView = controller?.textView else { return } + textView.insertText(symbol, replacementRange: textView.selectedRange()) + } + + func updateUndoState() { + canUndo = controller?.textView.undoManager?.canUndo ?? false + canRedo = controller?.textView.undoManager?.canRedo ?? false + } +} + private extension NSColor { var forEditor: NSColor { usingColorSpace(.sRGB) ?? self @@ -53,6 +83,13 @@ private func makeConfiguration(isEditable: Bool) -> SourceEditorConfiguration { struct CodeEditTextView: NSViewRepresentable { @Binding var text: String let isEditable: Bool + let editorController: CodeEditEditorController? + + init(text: Binding, isEditable: Bool, editorController: CodeEditEditorController? = nil) { + _text = text + self.isEditable = isEditable + self.editorController = editorController + } func makeNSView(context: Context) -> NSView { let controller = TextViewController( @@ -79,6 +116,11 @@ struct CodeEditTextView: NSViewRepresentable { context.coordinator.controller = controller context.coordinator.setupObservation() + editorController?.controller = controller + Task { @MainActor in + editorController?.updateUndoState() + } + return containerView } @@ -90,19 +132,22 @@ struct CodeEditTextView: NSViewRepresentable { if controller.configuration.behavior.isEditable != isEditable { controller.configuration = makeConfiguration(isEditable: isEditable) } + editorController?.controller = controller } func makeCoordinator() -> Coordinator { - Coordinator(text: $text) + Coordinator(text: $text, editorController: editorController) } class Coordinator: NSObject { var controller: TextViewController? @Binding var text: String private var observation: NSObjectProtocol? + private weak var editorController: CodeEditEditorController? - init(text: Binding) { + init(text: Binding, editorController: CodeEditEditorController?) { _text = text + self.editorController = editorController super.init() } @@ -115,6 +160,9 @@ struct CodeEditTextView: NSViewRepresentable { ) { [weak self] _ in guard let self, let controller = self.controller else { return } self.text = controller.text + Task { @MainActor in + self.editorController?.updateUndoState() + } } } diff --git a/MacLibrary/MacApplication.swift b/MacLibrary/MacApplication.swift index 43421cf..548924e 100644 --- a/MacLibrary/MacApplication.swift +++ b/MacLibrary/MacApplication.swift @@ -8,7 +8,7 @@ public struct MacApplication: Scene { @StateObject private var environments = ExtensionEnvironments() private let profileEditor: (Binding, Bool) -> AnyView = { text, isEditable in - AnyView(CodeEditTextView(text: text, isEditable: isEditable)) + AnyView(ProfileEditorWrapperView(text: text, isEditable: isEditable)) } public init() {} diff --git a/MacLibrary/MainView.swift b/MacLibrary/MainView.swift index ef9be57..03d9de4 100644 --- a/MacLibrary/MainView.swift +++ b/MacLibrary/MainView.swift @@ -13,7 +13,7 @@ public struct MainView: View { @State private var pendingSettingsPage: SettingsPage? private let profileEditor: (Binding, Bool) -> AnyView = { text, isEditable in - AnyView(CodeEditTextView(text: text, isEditable: isEditable)) + AnyView(ProfileEditorWrapperView(text: text, isEditable: isEditable)) } public init() {} diff --git a/MacLibrary/ProfileEditorWrapperView.swift b/MacLibrary/ProfileEditorWrapperView.swift new file mode 100644 index 0000000..e152163 --- /dev/null +++ b/MacLibrary/ProfileEditorWrapperView.swift @@ -0,0 +1,72 @@ +import ApplicationLibrary +import Libbox +import SwiftUI + +struct ProfileEditorWrapperView: View { + @Binding var text: String + let isEditable: Bool + + @StateObject private var controller = CodeEditEditorController() + @State private var configurationError: String? + @State private var validationTask: Task? + + var body: some View { + VStack(spacing: 0) { + CodeEditTextView(text: $text, isEditable: isEditable, editorController: controller) + + if isEditable { + EditorToolbarView( + canUndo: controller.canUndo, + canRedo: controller.canRedo, + onUndo: { controller.undo() }, + onRedo: { controller.redo() }, + onFormat: { formatConfiguration() }, + onInsertSymbol: { controller.insertSymbol($0) }, + configurationError: configurationError, + onDismissError: { configurationError = nil } + ) + } + } + .onChangeCompat(of: text) { + if isEditable { + scheduleValidation() + } + } + } + + private func scheduleValidation() { + configurationError = nil + validationTask?.cancel() + validationTask = Task { + try? await Task.sleep(nanoseconds: 2 * NSEC_PER_SEC) + guard !Task.isCancelled else { return } + await checkConfiguration() + } + } + + private func checkConfiguration() async { + let content = text + if content.isEmpty { return } + var error: NSError? + LibboxCheckConfig(content, &error) + if let error { + configurationError = error.localizedDescription + } else { + configurationError = nil + } + } + + private func formatConfiguration() { + let content = text + if content.isEmpty { return } + var error: NSError? + let result = LibboxFormatConfig(content, &error) + if let error { + configurationError = error.localizedDescription + return + } + if let formatted = result?.value, formatted != content { + text = formatted + } + } +} diff --git a/SFI/MainView.swift b/SFI/MainView.swift index 884493a..49912b5 100644 --- a/SFI/MainView.swift +++ b/SFI/MainView.swift @@ -17,7 +17,7 @@ struct MainView: View { @State private var buttonState = ButtonVisibilityState() private let profileEditor: (Binding, Bool) -> AnyView = { text, isEditable in - AnyView(RunestoneTextView(text: text, isEditable: isEditable)) + AnyView(ProfileEditorWrapperView(text: text, isEditable: isEditable)) } private var shouldShowBottomAccessory: Bool { diff --git a/SFI/ProfileEditorWrapperView.swift b/SFI/ProfileEditorWrapperView.swift new file mode 100644 index 0000000..e4a0815 --- /dev/null +++ b/SFI/ProfileEditorWrapperView.swift @@ -0,0 +1,72 @@ +import ApplicationLibrary +import Libbox +import SwiftUI + +struct ProfileEditorWrapperView: View { + @Binding var text: String + let isEditable: Bool + + @StateObject private var controller = RunestoneEditorController() + @State private var configurationError: String? + @State private var validationTask: Task? + + var body: some View { + VStack(spacing: 0) { + RunestoneTextView(text: $text, isEditable: isEditable, controller: controller) + + if isEditable { + EditorToolbarView( + canUndo: controller.canUndo, + canRedo: controller.canRedo, + onUndo: { controller.undo() }, + onRedo: { controller.redo() }, + onFormat: { formatConfiguration() }, + onInsertSymbol: { controller.insertSymbol($0) }, + configurationError: configurationError, + onDismissError: { configurationError = nil } + ) + } + } + .onChangeCompat(of: text) { + if isEditable { + scheduleValidation() + } + } + } + + private func scheduleValidation() { + configurationError = nil + validationTask?.cancel() + validationTask = Task { + try? await Task.sleep(nanoseconds: 2 * NSEC_PER_SEC) + guard !Task.isCancelled else { return } + await checkConfiguration() + } + } + + private func checkConfiguration() async { + let content = text + if content.isEmpty { return } + var error: NSError? + LibboxCheckConfig(content, &error) + if let error { + configurationError = error.localizedDescription + } else { + configurationError = nil + } + } + + private func formatConfiguration() { + let content = text + if content.isEmpty { return } + var error: NSError? + let result = LibboxFormatConfig(content, &error) + if let error { + configurationError = error.localizedDescription + return + } + if let formatted = result?.value, formatted != content { + controller.setText(formatted) + } + } +} diff --git a/SFI/RunestoneTextView.swift b/SFI/RunestoneTextView.swift index 623242f..836777d 100644 --- a/SFI/RunestoneTextView.swift +++ b/SFI/RunestoneTextView.swift @@ -2,9 +2,54 @@ import Runestone import SwiftUI import TreeSitterJSON5Runestone +@MainActor +public final class RunestoneEditorController: ObservableObject { + weak var textView: TextView? + + @Published public var canUndo = false + @Published public var canRedo = false + + public init() {} + + public func undo() { + textView?.undoManager?.undo() + updateUndoState() + } + + public func redo() { + textView?.undoManager?.redo() + updateUndoState() + } + + public func insertSymbol(_ symbol: String) { + guard let textView else { return } + textView.insertText(symbol) + } + + public func setText(_ newText: String) { + guard let textView else { return } + if let textRange = textView.textRange(from: textView.beginningOfDocument, to: textView.endOfDocument) { + textView.selectedTextRange = textRange + textView.insertText(newText) + } + } + + func updateUndoState() { + canUndo = textView?.undoManager?.canUndo ?? false + canRedo = textView?.undoManager?.canRedo ?? false + } +} + struct RunestoneTextView: UIViewRepresentable { @Binding var text: String let isEditable: Bool + let controller: RunestoneEditorController? + + init(text: Binding, isEditable: Bool, controller: RunestoneEditorController? = nil) { + _text = text + self.isEditable = isEditable + self.controller = controller + } func makeUIView(context: Context) -> TextView { let textView = TextView() @@ -43,6 +88,12 @@ struct RunestoneTextView: UIViewRepresentable { textView.isEditable = isEditable textView.editorDelegate = context.coordinator + context.coordinator.textView = textView + controller?.textView = textView + Task { @MainActor in + controller?.updateUndoState() + } + return textView } @@ -53,6 +104,7 @@ struct RunestoneTextView: UIViewRepresentable { if textView.isEditable != isEditable { textView.isEditable = isEditable } + controller?.textView = textView } func makeCoordinator() -> Coordinator { @@ -61,6 +113,7 @@ struct RunestoneTextView: UIViewRepresentable { final class Coordinator: TextViewDelegate { var parent: RunestoneTextView + weak var textView: TextView? init(_ parent: RunestoneTextView) { self.parent = parent @@ -68,6 +121,9 @@ struct RunestoneTextView: UIViewRepresentable { func textViewDidChange(_ textView: TextView) { parent.text = textView.text + Task { @MainActor in + parent.controller?.updateUndoState() + } } } }