PadXcode-iPad/Editor/RunestoneEditorView.swift
2026-04-12 22:07:35 -07:00

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()
}
}
}