PadXcode-iPad/Editor/CodeEditorView.swift

156 lines
7.2 KiB
Swift
Raw Normal View History

2026-04-12 00:46:30 -07:00
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) }
}
2026-04-12 22:07:35 -07:00
.onReceive(NotificationCenter.default.publisher(for: .editorFontSizeChange)) { note in
if let delta = note.userInfo?["delta"] as? Double {
let newSize = max(8, min(32, editorState.fontSizeStored + delta))
editorState.fontSizeStored = newSize
}
}
.onReceive(NotificationCenter.default.publisher(for: .editorFindNext)) { _ in
keyboardBridge.triggerFindNext()
}
.onReceive(NotificationCenter.default.publisher(for: .editorFindPrev)) { _ in
keyboardBridge.triggerFindPrev()
}
2026-04-12 00:46:30 -07:00
}
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"
}
}
}