144 lines
6.6 KiB
Swift
144 lines
6.6 KiB
Swift
|
|
import SwiftUI
|
||
|
|
import Runestone
|
||
|
|
|
||
|
|
struct CodeEditorView: View {
|
||
|
|
let filePath: String
|
||
|
|
@Binding var content: String
|
||
|
|
var diagnosticRanges: [DiagnosticRange] = []
|
||
|
|
let lspClient: LSPClient
|
||
|
|
var onSave: () -> Void
|
||
|
|
var onBuild: () -> Void
|
||
|
|
var onRun: () -> Void
|
||
|
|
var onToggleNavigator: () -> Void
|
||
|
|
var onToggleConsole: () -> Void
|
||
|
|
|
||
|
|
@EnvironmentObject var editorState: EditorState
|
||
|
|
|
||
|
|
@StateObject private var completionCtrl: CompletionController
|
||
|
|
@StateObject private var keyboardBridge = EditorKeyboardBridge()
|
||
|
|
|
||
|
|
@State private var activeTextView: Runestone.TextView?
|
||
|
|
@State private var cursorLine = 1
|
||
|
|
@State private var cursorColumn = 1
|
||
|
|
@State private var showJumpToLine = false
|
||
|
|
@State private var showFindInFile = false
|
||
|
|
|
||
|
|
init(filePath: String, content: Binding<String>, diagnosticRanges: [DiagnosticRange] = [],
|
||
|
|
lspClient: LSPClient, onSave: @escaping () -> Void, onBuild: @escaping () -> Void,
|
||
|
|
onRun: @escaping () -> Void, onToggleNavigator: @escaping () -> Void, onToggleConsole: @escaping () -> Void) {
|
||
|
|
self.filePath = filePath; self._content = content; self.diagnosticRanges = diagnosticRanges
|
||
|
|
self.lspClient = lspClient; self.onSave = onSave; self.onBuild = onBuild; self.onRun = onRun
|
||
|
|
self.onToggleNavigator = onToggleNavigator; self.onToggleConsole = onToggleConsole
|
||
|
|
_completionCtrl = StateObject(wrappedValue: CompletionController(lspClient: lspClient))
|
||
|
|
}
|
||
|
|
|
||
|
|
var body: some View {
|
||
|
|
ZStack(alignment: .topTrailing) {
|
||
|
|
RunestoneEditorView(
|
||
|
|
content: $content,
|
||
|
|
textViewRef: $activeTextView,
|
||
|
|
filePath: filePath,
|
||
|
|
theme: editorState.activeTheme,
|
||
|
|
showLineNumbers: editorState.showLineNumbers,
|
||
|
|
lineWrapping: editorState.lineWrapping,
|
||
|
|
indentWidth: editorState.indentWidth,
|
||
|
|
diagnosticRanges: diagnosticRanges,
|
||
|
|
onContentChange: { _ in },
|
||
|
|
onCursorPositionChange: { l, c in cursorLine = l; cursorColumn = c },
|
||
|
|
completionController: completionCtrl
|
||
|
|
)
|
||
|
|
.ignoresSafeArea(.keyboard, edges: .bottom)
|
||
|
|
.overlay {
|
||
|
|
if completionCtrl.isVisible && !completionCtrl.items.isEmpty {
|
||
|
|
GeometryReader { geo in
|
||
|
|
let x = min(completionCtrl.anchorRect.minX, geo.size.width - 340)
|
||
|
|
let y = completionCtrl.anchorRect.maxY + 80
|
||
|
|
CompletionPopover(items: completionCtrl.items) { item in
|
||
|
|
if let tv = activeTextView { completionCtrl.insert(item, into: tv) }
|
||
|
|
}
|
||
|
|
.position(x: x + 170, y: y)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if !diagnosticRanges.isEmpty {
|
||
|
|
GutterAnnotationOverlay(diagnostics: diagnosticRanges, textView: activeTextView)
|
||
|
|
.allowsHitTesting(true)
|
||
|
|
}
|
||
|
|
|
||
|
|
EditorCommandHandlerView(
|
||
|
|
textViewRef: $activeTextView,
|
||
|
|
onSave: onSave, onBuild: onBuild, onRun: onRun,
|
||
|
|
onToggleNavigator: onToggleNavigator, onToggleConsole: onToggleConsole,
|
||
|
|
onFind: { showFindInFile = true }, onJumpToLine: { showJumpToLine = true }
|
||
|
|
)
|
||
|
|
.frame(width: 0, height: 0)
|
||
|
|
|
||
|
|
EditorStatusBar(filePath: filePath, line: cursorLine, column: cursorColumn, diagnostics: diagnosticRanges)
|
||
|
|
.padding([.bottom, .trailing], 8).allowsHitTesting(false)
|
||
|
|
}
|
||
|
|
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||
|
|
EditorKeyboardToolbar(bridge: keyboardBridge)
|
||
|
|
}
|
||
|
|
.sheet(isPresented: $showJumpToLine) {
|
||
|
|
JumpToLineView(isPresented: $showJumpToLine, totalLines: content.components(separatedBy: "\n").count) { line in jumpTo(line) }
|
||
|
|
.presentationDetents([.height(160)])
|
||
|
|
}
|
||
|
|
.sheet(isPresented: $showFindInFile) {
|
||
|
|
FindInFileView(content: content) { range in navigate(to: range) }
|
||
|
|
}
|
||
|
|
.onChange(of: activeTextView) { _, tv in keyboardBridge.activeTextView = tv }
|
||
|
|
.onReceive(NotificationCenter.default.publisher(for: .navigateToRange)) { note in
|
||
|
|
if let range = note.userInfo?["range"] as? NSRange { navigate(to: range) }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private func jumpTo(_ line: Int) {
|
||
|
|
guard let tv = activeTextView else { return }
|
||
|
|
let lines = tv.text.components(separatedBy: "\n")
|
||
|
|
guard line >= 1, line <= lines.count else { return }
|
||
|
|
let offset = lines.prefix(line - 1).reduce(0) { $0 + $1.count + 1 }
|
||
|
|
let r = NSRange(location: offset, length: 0)
|
||
|
|
tv.selectedRange = r; tv.scrollRangeToVisible(r)
|
||
|
|
}
|
||
|
|
|
||
|
|
private func navigate(to range: NSRange) {
|
||
|
|
guard let tv = activeTextView else { return }
|
||
|
|
tv.selectedRange = range; tv.scrollRangeToVisible(range)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: - EditorStatusBar
|
||
|
|
|
||
|
|
struct EditorStatusBar: View {
|
||
|
|
let filePath: String
|
||
|
|
let line: Int
|
||
|
|
let column: Int
|
||
|
|
let diagnostics: [DiagnosticRange]
|
||
|
|
|
||
|
|
private var errorCount: Int { diagnostics.filter { $0.severity == .error }.count }
|
||
|
|
private var warningCount: Int { diagnostics.filter { $0.severity == .warning }.count }
|
||
|
|
|
||
|
|
var body: some View {
|
||
|
|
HStack(spacing: 6) {
|
||
|
|
if errorCount > 0 { Label("\(errorCount)", systemImage: "xmark.circle.fill").foregroundStyle(.red).font(.system(.caption2, design: .monospaced)) }
|
||
|
|
if warningCount > 0 { Label("\(warningCount)", systemImage: "exclamationmark.triangle.fill").foregroundStyle(.orange).font(.system(.caption2, design: .monospaced)) }
|
||
|
|
if errorCount > 0 || warningCount > 0 { Text("·").foregroundStyle(.tertiary).font(.caption2) }
|
||
|
|
Text(URL(fileURLWithPath: filePath).lastPathComponent).font(.system(.caption2, design: .monospaced)).foregroundStyle(.secondary)
|
||
|
|
Text("·").foregroundStyle(.tertiary).font(.caption2)
|
||
|
|
Text("Ln \(line), Col \(column)").font(.system(.caption2, design: .monospaced)).foregroundStyle(.secondary)
|
||
|
|
Text("·").foregroundStyle(.tertiary).font(.caption2)
|
||
|
|
Text(langLabel).font(.system(.caption2, design: .monospaced)).foregroundStyle(.secondary)
|
||
|
|
}
|
||
|
|
.padding(.horizontal, 10).padding(.vertical, 4)
|
||
|
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 6))
|
||
|
|
}
|
||
|
|
|
||
|
|
private var langLabel: String {
|
||
|
|
switch (filePath as NSString).pathExtension.lowercased() {
|
||
|
|
case "swift": return "Swift"; case "json": return "JSON"; case "md": return "Markdown"; default: return "Plain Text"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|