164 lines
6.3 KiB
Swift
164 lines
6.3 KiB
Swift
import SwiftUI
|
|
import UIKit
|
|
import Runestone
|
|
|
|
// MARK: - CharacterPair concrete implementation
|
|
// CharacterPair is a protocol in Runestone — no concrete BasicCharacterPair exists.
|
|
|
|
private struct EditorCharacterPair: CharacterPair {
|
|
let leading: String
|
|
let trailing: String
|
|
}
|
|
|
|
// MARK: - RunestoneEditorView
|
|
|
|
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) {
|
|
// Reload state if content changed externally OR if theme changed (font size, theme switch).
|
|
let themeChanged = type(of: tv.theme) != type(of: theme) ||
|
|
tv.theme.font.pointSize != theme.font.pointSize
|
|
if (tv.text != content || themeChanged) && !context.coordinator.isEditing {
|
|
loadState(into: tv)
|
|
}
|
|
tv.showLineNumbers = showLineNumbers
|
|
tv.isLineWrappingEnabled = lineWrapping
|
|
tv.indentStrategy = indentWidth == 0
|
|
? .tab(length: 4)
|
|
: .space(length: indentWidth)
|
|
applyDiagnostics(to: tv)
|
|
}
|
|
|
|
private func loadState(into tv: Runestone.TextView) {
|
|
let lang = LanguageDetector.language(for: filePath)
|
|
let capturedText = content
|
|
let capturedTheme = theme
|
|
Task.detached(priority: .userInitiated) {
|
|
// TextViewState init takes TreeSitterLanguage (non-optional).
|
|
// Use two separate inits: one with language, one without (plain text).
|
|
let state: TextViewState
|
|
if let lang = lang {
|
|
state = TextViewState(text: capturedText, theme: capturedTheme, language: lang)
|
|
} else {
|
|
state = TextViewState(text: capturedText, theme: capturedTheme)
|
|
}
|
|
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
|
|
// highlightSelectedLine was replaced by lineSelectionDisplayType.
|
|
// .line highlights the active line in the text area only.
|
|
tv.lineSelectionDisplayType = .line
|
|
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 = [
|
|
EditorCharacterPair(leading: "(", trailing: ")"),
|
|
EditorCharacterPair(leading: "[", trailing: "]"),
|
|
EditorCharacterPair(leading: "{", trailing: "}"),
|
|
EditorCharacterPair(leading: "\"", trailing: "\""),
|
|
]
|
|
tv.characterPairTrailingComponentDeletionMode = .immediatelyFollowingLeadingComponent
|
|
tv.indentStrategy = indentWidth == 0
|
|
? .tab(length: 4)
|
|
: .space(length: indentWidth)
|
|
}
|
|
|
|
private func applyDiagnostics(to tv: Runestone.TextView) {
|
|
guard !diagnosticRanges.isEmpty else {
|
|
tv.highlightedRanges = []
|
|
return
|
|
}
|
|
var highlights: [HighlightedRange] = []
|
|
for diag in diagnosticRanges {
|
|
// TextLocation is 0-indexed; compiler diagnostics are 1-indexed.
|
|
let textLoc = TextLocation(lineNumber: diag.line - 1, column: diag.column - 1)
|
|
guard let charOffset = tv.location(at: textLoc) else { continue }
|
|
let nsRange = NSRange(location: charOffset, length: max(1, diag.length))
|
|
let color: UIColor = diag.severity == .error
|
|
? UIColor(hex: "#F44747")
|
|
: UIColor(hex: "#CCA700")
|
|
// HighlightedRange only takes range and color — no cornerRadius/underlineStyle.
|
|
highlights.append(HighlightedRange(range: nsRange, color: color))
|
|
}
|
|
tv.highlightedRanges = highlights
|
|
}
|
|
|
|
// MARK: - Coordinator
|
|
|
|
@MainActor
|
|
final class Coordinator: NSObject, @preconcurrency TextViewDelegate {
|
|
var parent: RunestoneEditorView
|
|
var isEditing: Bool = 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) {
|
|
let sel = tv.selectedRange
|
|
if let loc = tv.textLocation(at: sel.location) {
|
|
parent.onCursorPositionChange?(loc.lineNumber + 1, loc.column + 1)
|
|
}
|
|
if !isEditing { parent.completionController.dismiss() }
|
|
}
|
|
|
|
func textViewDidBeginEditing(_ tv: Runestone.TextView) {
|
|
isEditing = true
|
|
}
|
|
|
|
func textViewDidEndEditing(_ tv: Runestone.TextView) {
|
|
isEditing = false
|
|
parent.completionController.dismiss()
|
|
}
|
|
}
|
|
}
|