// // DataBrowserView.swift // Metrika // // Author: Simon-Pierre Boucher // Contact: contact@spboucher.ai // Copyright © 2026 Simon-Pierre Boucher. All rights reserved. // import AppKit import SwiftUI import ZQData /// Data browser pane (CLAUDE.md §7, pane 3): virtualized grid over the /// working dataset via an NSTableView bridge (SwiftUI Table degrades past /// ~100k rows), sortable columns, and a filter bar that compiles to an /// `if` expression through the real parser. struct DataBrowserPane: View { @Environment(SessionModel.self) private var model @State private var filterText = "" @State private var filterError: String? @State private var filteredRows: [Int]? @State private var sortColumn: String? @State private var sortAscending = true var body: some View { VStack(spacing: 0) { HStack(spacing: 8) { Image(systemName: "line.3.horizontal.decrease.circle") .foregroundStyle(.secondary) TextField( "Filter — e.g. revenue > 100 & region == 2", text: $filterText ) .textFieldStyle(.plain) .fontDesign(.monospaced) .onSubmit { Task { await applyFilter() } } if filteredRows != nil { Button("Clear") { filterText = "" filteredRows = nil filterError = nil } .controlSize(.small) } } .padding(8) if let filterError { Text(filterError) .font(.caption) .foregroundStyle(.red) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 10) .padding(.bottom, 4) } Divider() DataGrid( frame: model.frame, rows: visibleRows, sortColumn: $sortColumn, sortAscending: $sortAscending ) Divider() Text(statusLine) .font(.caption) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) .padding(6) } } private var statusLine: String { let total = model.frame.rowCount if let filteredRows { return "\(filteredRows.count) of \(total) observations" } return "\(total) observations, \(model.frame.columns.count) variables" } /// Filtered (or all) row indices, in the current sort order. private var visibleRows: [Int] { let base = filteredRows ?? Array(0.. values[b] } case .string(let values): return base.sorted { a, b in switch (values[a], values[b]) { case (nil, nil): return false case (nil, _): return false case (_, nil): return true case (let x?, let y?): return sortAscending ? x < y : x > y } } } } private func applyFilter() async { let text = filterText.trimmingCharacters(in: .whitespaces) guard !text.isEmpty else { filteredRows = nil filterError = nil return } switch await model.filterMask(text) { case .success(let mask): filteredRows = (0.. Coordinator { Coordinator(self) } func makeNSView(context: Context) -> NSScrollView { let table = NSTableView() table.usesAlternatingRowBackgroundColors = true table.allowsColumnReordering = false table.rowHeight = 20 table.dataSource = context.coordinator table.delegate = context.coordinator context.coordinator.table = table let scroll = NSScrollView() scroll.documentView = table scroll.hasVerticalScroller = true scroll.hasHorizontalScroller = true return scroll } func updateNSView(_ scroll: NSScrollView, context: Context) { let coordinator = context.coordinator coordinator.parent = self guard let table = coordinator.table else { return } // Rebuild columns only when the variable list changes. let names = frame.columnNames if coordinator.columnNames != names { coordinator.columnNames = names for column in table.tableColumns.reversed() { table.removeTableColumn(column) } let indexColumn = NSTableColumn( identifier: NSUserInterfaceItemIdentifier("__obs") ) indexColumn.title = "" indexColumn.width = 52 table.addTableColumn(indexColumn) for name in names { let column = NSTableColumn( identifier: NSUserInterfaceItemIdentifier(name) ) column.title = name column.width = 110 column.sortDescriptorPrototype = NSSortDescriptor( key: name, ascending: true ) table.addTableColumn(column) } } table.reloadData() } @MainActor final class Coordinator: NSObject, NSTableViewDataSource, NSTableViewDelegate { var parent: DataGrid weak var table: NSTableView? var columnNames: [String] = [] init(_ parent: DataGrid) { self.parent = parent } func numberOfRows(in tableView: NSTableView) -> Int { parent.rows.count } func tableView( _ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int ) -> NSView? { guard let tableColumn, row < parent.rows.count else { return nil } let identifier = tableColumn.identifier let field: NSTextField if let reused = tableView.makeView( withIdentifier: identifier, owner: self ) as? NSTextField { field = reused } else { field = NSTextField(labelWithString: "") field.identifier = identifier field.font = .monospacedSystemFont(ofSize: 11, weight: .regular) field.lineBreakMode = .byTruncatingTail } let dataRow = parent.rows[row] if identifier.rawValue == "__obs" { field.stringValue = "\(dataRow + 1)" field.textColor = .secondaryLabelColor return field } guard let column = parent.frame.column(named: identifier.rawValue) else { field.stringValue = "" return field } switch column.data { case .float64(let values, let missing): if missing[dataRow] { field.stringValue = "." field.textColor = .secondaryLabelColor } else { field.stringValue = Self.format(values[dataRow]) field.textColor = .labelColor } field.alignment = .right case .string(let values): field.stringValue = values[dataRow] ?? "" field.textColor = .labelColor field.alignment = .left } return field } func tableView( _ tableView: NSTableView, sortDescriptorsDidChange oldDescriptors: [NSSortDescriptor] ) { guard let descriptor = tableView.sortDescriptors.first, let key = descriptor.key else { return } parent.sortColumn = key parent.sortAscending = descriptor.ascending } static func format(_ value: Double) -> String { if value == value.rounded(), abs(value) < 1e15 { return String(Int64(value)) } return String(format: "%.6g", value) } } }