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, 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) } } .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() } } 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" } } }