PadXcode-iPad/Editor/RunestoneEditorView.swift

165 lines
6.3 KiB
Swift
Raw Permalink Normal View History

2026-04-12 00:46:30 -07:00
import SwiftUI
import UIKit
import Runestone
2026-04-12 22:07:35 -07:00
// 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
2026-04-12 00:46:30 -07:00
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()
2026-04-12 22:07:35 -07:00
configureAppearance(tv)
configureEditing(tv)
2026-04-12 00:46:30 -07:00
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) {
2026-04-12 22:07:35 -07:00
// 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)
}
2026-04-12 00:46:30 -07:00
tv.showLineNumbers = showLineNumbers
tv.isLineWrappingEnabled = lineWrapping
2026-04-12 22:07:35 -07:00
tv.indentStrategy = indentWidth == 0
? .tab(length: 4)
: .space(length: indentWidth)
2026-04-12 00:46:30 -07:00
applyDiagnostics(to: tv)
}
private func loadState(into tv: Runestone.TextView) {
2026-04-12 22:07:35 -07:00
let lang = LanguageDetector.language(for: filePath)
let capturedText = content
let capturedTheme = theme
2026-04-12 00:46:30 -07:00
Task.detached(priority: .userInitiated) {
2026-04-12 22:07:35 -07:00
// 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)
}
2026-04-12 00:46:30 -07:00
await MainActor.run { tv.setState(state) }
}
}
private func configureAppearance(_ tv: Runestone.TextView) {
tv.backgroundColor = UIColor(hex: "#1E1E1E")
tv.showLineNumbers = showLineNumbers
2026-04-12 22:07:35 -07:00
tv.showSpaces = false
tv.showTabs = false
tv.showLineBreaks = false
2026-04-12 00:46:30 -07:00
tv.isLineWrappingEnabled = lineWrapping
2026-04-12 22:07:35 -07:00
// 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
2026-04-12 00:46:30 -07:00
tv.contentInset = UIEdgeInsets(top: 8, left: 0, bottom: 120, right: 0)
tv.verticalOverscrollFactor = 0.5
}
private func configureEditing(_ tv: Runestone.TextView) {
2026-04-12 22:07:35 -07:00
tv.isEditable = true
tv.autocorrectionType = .no
tv.autocapitalizationType = .none
tv.smartDashesType = .no
tv.smartQuotesType = .no
tv.smartInsertDeleteType = .no
tv.spellCheckingType = .no
2026-04-12 00:46:30 -07:00
tv.characterPairs = [
2026-04-12 22:07:35 -07:00
EditorCharacterPair(leading: "(", trailing: ")"),
EditorCharacterPair(leading: "[", trailing: "]"),
EditorCharacterPair(leading: "{", trailing: "}"),
EditorCharacterPair(leading: "\"", trailing: "\""),
2026-04-12 00:46:30 -07:00
]
tv.characterPairTrailingComponentDeletionMode = .immediatelyFollowingLeadingComponent
2026-04-12 22:07:35 -07:00
tv.indentStrategy = indentWidth == 0
? .tab(length: 4)
: .space(length: indentWidth)
2026-04-12 00:46:30 -07:00
}
private func applyDiagnostics(to tv: Runestone.TextView) {
2026-04-12 22:07:35 -07:00
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))
2026-04-12 00:46:30 -07:00
}
2026-04-12 22:07:35 -07:00
tv.highlightedRanges = highlights
2026-04-12 00:46:30 -07:00
}
// MARK: - Coordinator
2026-04-12 22:07:35 -07:00
@MainActor
final class Coordinator: NSObject, @preconcurrency TextViewDelegate {
var parent: RunestoneEditorView
var isEditing: Bool = false
2026-04-12 00:46:30 -07:00
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) {
2026-04-12 22:07:35 -07:00
let sel = tv.selectedRange
if let loc = tv.textLocation(at: sel.location) {
parent.onCursorPositionChange?(loc.lineNumber + 1, loc.column + 1)
}
2026-04-12 00:46:30 -07:00
if !isEditing { parent.completionController.dismiss() }
}
2026-04-12 22:07:35 -07:00
func textViewDidBeginEditing(_ tv: Runestone.TextView) {
isEditing = true
}
func textViewDidEndEditing(_ tv: Runestone.TextView) {
isEditing = false
parent.completionController.dismiss()
}
2026-04-12 00:46:30 -07:00
}
}