import SwiftUI import UIKit import Runestone struct RunestoneEditorView: UIViewRepresentable { @Binding var content: String @Binding var textViewRef: Runestone.TextView? let filePath: String let theme: any Theme let showLineNumbers: Bool let lineWrapping: Bool let indentWidth: Int var diagnosticRanges: [DiagnosticRange] = [] var onContentChange: ((String) -> Void)? var onCursorPositionChange: ((Int, Int) -> Void)? @ObservedObject var completionController: CompletionController func makeCoordinator() -> Coordinator { Coordinator(self) } func makeUIView(context: Context) -> Runestone.TextView { let tv = Runestone.TextView() configureAppearance(tv); configureEditing(tv) tv.editorDelegate = context.coordinator completionController.attach(to: tv) DispatchQueue.main.async { textViewRef = tv } loadState(into: tv) return tv } func updateUIView(_ tv: Runestone.TextView, context: Context) { if tv.text != content && !context.coordinator.isEditing { loadState(into: tv) } tv.showLineNumbers = showLineNumbers tv.isLineWrappingEnabled = lineWrapping tv.indentStrategy = indentWidth == 0 ? .tab : .space(length: indentWidth) applyDiagnostics(to: tv) } private func loadState(into tv: Runestone.TextView) { let lang = LanguageDetector.language(for: filePath) let capturedContent = content; let capturedTheme = theme Task.detached(priority: .userInitiated) { let state = TextViewState(text: capturedContent, theme: capturedTheme, language: lang.map { TreeSitterLanguageMode(language: $0) }) await MainActor.run { tv.setState(state) } } } private func configureAppearance(_ tv: Runestone.TextView) { tv.backgroundColor = UIColor(hex: "#1E1E1E") tv.showLineNumbers = showLineNumbers tv.showSpaces = false; tv.showTabs = false; tv.showLineBreaks = false tv.isLineWrappingEnabled = lineWrapping tv.highlightSelectedLine = true tv.lineHeightMultiplier = 1.4; tv.kern = 0.3 tv.contentInset = UIEdgeInsets(top: 8, left: 0, bottom: 120, right: 0) tv.verticalOverscrollFactor = 0.5 } private func configureEditing(_ tv: Runestone.TextView) { tv.isEditable = true; tv.autocorrectionType = .no; tv.autocapitalizationType = .none tv.smartDashesType = .no; tv.smartQuotesType = .no tv.smartInsertDeleteType = .no; tv.spellCheckingType = .no tv.characterPairs = [ BasicCharacterPair(leading: "(", trailing: ")"), BasicCharacterPair(leading: "[", trailing: "]"), BasicCharacterPair(leading: "{", trailing: "}"), BasicCharacterPair(leading: "\"", trailing: "\""), ] tv.characterPairTrailingComponentDeletionMode = .immediatelyFollowingLeadingComponent tv.indentStrategy = indentWidth == 0 ? .tab : .space(length: indentWidth) } private func applyDiagnostics(to tv: Runestone.TextView) { guard !diagnosticRanges.isEmpty else { tv.setHighlightedRanges([]); return } let highlights: [HighlightedRange] = diagnosticRanges.compactMap { diag in guard let range = tv.range(atLine: diag.line, column: diag.column) else { return nil } let color: UIColor = diag.severity == .error ? UIColor(hex: "#F44747") : UIColor(hex: "#CCA700") return HighlightedRange(range: range, color: color, cornerRadius: 0, underlineStyle: .single) } tv.setHighlightedRanges(highlights) } // MARK: - Coordinator final class Coordinator: NSObject, TextViewDelegate { var parent: RunestoneEditorView var isEditing = false init(_ parent: RunestoneEditorView) { self.parent = parent } func textViewDidChange(_ tv: Runestone.TextView) { isEditing = true parent.content = tv.text parent.onContentChange?(tv.text) parent.completionController.textDidChange(in: tv, filePath: parent.filePath) isEditing = false } func textViewDidChangeSelection(_ tv: Runestone.TextView) { guard let sel = tv.selectedRange, let loc = tv.textLocation(at: sel.location) else { return } parent.onCursorPositionChange?(loc.lineNumber, loc.column) if !isEditing { parent.completionController.dismiss() } } func textViewDidBeginEditing(_ tv: Runestone.TextView) { isEditing = true } func textViewDidEndEditing(_ tv: Runestone.TextView) { isEditing = false; parent.completionController.dismiss() } } }