SPB Git

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%
8.3 KB · 230 lines swift
Raw Blame History
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