spb/metrika Public
Stata-class statistics, GPU-accelerated by Apple Silicon. Native Swift — no Electron, no Python runtime, no compromises.
Swift 92.4%
HTML 3.3%
R 3%
Shell 1.3%
1//2// DoFileEditorView.swift3// Metrika4//5// Author: Simon-Pierre Boucher6// Contact: contact@spboucher.ai7// Copyright © 2026 Simon-Pierre Boucher. All rights reserved.8//910import AppKit11import SwiftUI12import UniformTypeIdentifiers13import ZQParser1415/// Do-file editor pane (CLAUDE.md §7, pane 2): NSTextView bridge with ZQL16/// syntax highlighting; ⌘R runs the selection (or the whole file) through17/// the shared session execution path.18struct DoFileEditorPane: View {19 @Environment(SessionModel.self) private var model20 @State private var text = ""21 @State private var selection: NSRange = NSRange(location: 0, length: 0)22 @State private var showingOpen = false23 @State private var showingSave = false24 @State private var fileName = "untitled.zyq"2526 private static let zyqType =27 UTType(filenameExtension: "zyq", conformingTo: .plainText) ?? .plainText2829 var body: some View {30 VStack(spacing: 0) {31 HStack(spacing: 12) {32 Text(fileName)33 .font(.caption)34 .foregroundStyle(.secondary)35 Spacer()36 Button("Open…") { showingOpen = true }37 Button("Save…") { showingSave = true }38 Button {39 run()40 } label: {41 Label(42 selection.length > 0 ? "Run Selection" : "Run",43 systemImage: "play.fill"44 )45 }46 .keyboardShortcut("r", modifiers: .command)47 .disabled(model.isRunning || text.isEmpty)48 }49 .padding(8)50 Divider()51 ZQLTextEditor(text: $text, selection: $selection)52 }53 .fileImporter(54 isPresented: $showingOpen,55 allowedContentTypes: [Self.zyqType, .plainText]56 ) { result in57 guard case .success(let url) = result else { return }58 let scoped = url.startAccessingSecurityScopedResource()59 defer { if scoped { url.stopAccessingSecurityScopedResource() } }60 if let contents = try? String(contentsOf: url, encoding: .utf8) {61 text = contents62 fileName = url.lastPathComponent63 }64 }65 .fileExporter(66 isPresented: $showingSave,67 document: DoFileDocument(text: text),68 contentType: Self.zyqType,69 defaultFilename: fileName70 ) { result in71 if case .success(let url) = result {72 fileName = url.lastPathComponent73 }74 }75 }7677 private func run() {78 let script: String79 if selection.length > 0, let range = Range(selection, in: text) {80 script = String(text[range])81 } else {82 script = text83 }84 Task { await model.runScript(script) }85 }86}8788/// Plain-text document wrapper for the save panel.89struct DoFileDocument: FileDocument {90 static let readableContentTypes: [UTType] = [.plainText]91 var text: String9293 init(text: String) { self.text = text }9495 init(configuration: ReadConfiguration) throws {96 guard let data = configuration.file.regularFileContents else {97 throw CocoaError(.fileReadCorruptFile)98 }99 text = String(decoding: data, as: UTF8.self)100 }101102 func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {103 FileWrapper(regularFileWithContents: Data(text.utf8))104 }105}106107/// NSTextView bridge with lightweight ZQL highlighting: verbs (resolved108/// through the real verb table), comments, strings, and numbers.109struct ZQLTextEditor: NSViewRepresentable {110 @Binding var text: String111 @Binding var selection: NSRange112113 func makeCoordinator() -> Coordinator { Coordinator(self) }114115 func makeNSView(context: Context) -> NSScrollView {116 let textView = NSTextView()117 textView.isRichText = false118 textView.allowsUndo = true119 textView.font = .monospacedSystemFont(ofSize: 12, weight: .regular)120 textView.isAutomaticQuoteSubstitutionEnabled = false121 textView.isAutomaticDashSubstitutionEnabled = false122 textView.isAutomaticSpellingCorrectionEnabled = false123 textView.autoresizingMask = [.width]124 textView.delegate = context.coordinator125 context.coordinator.textView = textView126127 let scroll = NSScrollView()128 scroll.documentView = textView129 scroll.hasVerticalScroller = true130 return scroll131 }132133 func updateNSView(_ scroll: NSScrollView, context: Context) {134 context.coordinator.parent = self135 guard let textView = context.coordinator.textView else { return }136 if textView.string != text {137 textView.string = text138 context.coordinator.highlight(textView)139 }140 }141142 @MainActor143 final class Coordinator: NSObject, NSTextViewDelegate {144 var parent: ZQLTextEditor145 weak var textView: NSTextView?146 private let verbs = ZQVerbTable.builtin147148 init(_ parent: ZQLTextEditor) { self.parent = parent }149150 func textDidChange(_ notification: Notification) {151 guard let textView else { return }152 parent.text = textView.string153 highlight(textView)154 }155156 func textViewDidChangeSelection(_ notification: Notification) {157 guard let textView else { return }158 parent.selection = textView.selectedRange()159 }160161 func highlight(_ textView: NSTextView) {162 guard let storage = textView.textStorage else { return }163 let content = textView.string as NSString164 let fullRange = NSRange(location: 0, length: content.length)165166 storage.beginEditing()167 storage.setAttributes([168 .font: NSFont.monospacedSystemFont(ofSize: 12, weight: .regular),169 .foregroundColor: NSColor.labelColor,170 ], range: fullRange)171172 content.enumerateSubstrings(173 in: fullRange, options: [.byLines, .substringNotRequired]174 ) { _, lineRange, _, _ in175 self.highlightLine(content, lineRange, storage)176 }177 storage.endEditing()178 }179180 private func highlightLine(181 _ content: NSString, _ lineRange: NSRange, _ storage: NSTextStorage182 ) {183 let line = content.substring(with: lineRange)184 let trimmed = line.trimmingCharacters(in: .whitespaces)185186 // Whole-line comments.187 if trimmed.hasPrefix("//") || trimmed.hasPrefix("*") {188 storage.addAttribute(189 .foregroundColor, value: NSColor.systemGray, range: lineRange190 )191 return192 }193194 // Leading verb, resolved through the real table.195 if let verbMatch = line.range(of: #"^\s*([A-Za-z_]+)"#, options: .regularExpression) {196 let word = line[verbMatch].trimmingCharacters(in: .whitespaces)197 if verbs.resolve(word.lowercased()) != nil {198 let location = lineRange.location199 + line.distance(from: line.startIndex, to: verbMatch.lowerBound)200 storage.addAttributes([201 .foregroundColor: NSColor.systemBlue,202 .font: NSFont.monospacedSystemFont(ofSize: 12, weight: .semibold),203 ], range: NSRange(location: location, length: word.count))204 }205 }206207 // Strings and trailing comments via regex on the line.208 applyPattern(#""[^"]*""#, NSColor.systemRed, line, lineRange, storage)209 applyPattern(#"//.*$"#, NSColor.systemGray, line, lineRange, storage)210 }211212 private func applyPattern(213 _ pattern: String, _ color: NSColor,214 _ line: String, _ lineRange: NSRange, _ storage: NSTextStorage215 ) {216 guard let regex = try? NSRegularExpression(pattern: pattern) else { return }217 let range = NSRange(line.startIndex..<line.endIndex, in: line)218 for match in regex.matches(in: line, range: range) {219 storage.addAttribute(220 .foregroundColor, value: color,221 range: NSRange(222 location: lineRange.location + match.range.location,223 length: match.range.length224 )225 )226 }227 }228 }229}230