feat(ui): data browser and do-file editor panes
- DataBrowserPane (§7 pane 3): NSTableView bridge (natively virtualized), sortable columns with missing-last ordering, observation-number gutter, filter bar compiled to an if-expression through the real parser (ZQCommandParser.parseExpression + ZQSession.conditionMask are the new public entry points) - DoFileEditorPane (§7 pane 2): NSTextView bridge with ZQL highlighting (verbs resolved via ZQVerbTable, comments, strings), run selection or file with Cmd-R through the shared session path, open/save .zyq - ContentView: segmented Console / Data / Do-file switcher in the toolbar - UI smoke extended: pane switching, filter narrowing (2 of 5 obs), do-file run surfacing in the console and sidebar - 61 kit tests + 2 UI tests green Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 8 changed files with +670 and −6
modified
Metrika.xcodeproj/project.pbxproj
+8 −0
@@ -7,6 +7,7 @@ | ||
| 7 | 7 | objects = { |
| 8 | 8 | |
| 9 | 9 | /* Begin PBXBuildFile section */ |
| 10 | + 0BD0F927FC614CF19EF3D59D /* DoFileEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9BB0AC3BAA378743FD57FF7 /* DoFileEditorView.swift */; }; | |
| 10 | 11 | 0F7000A49C9879593F09A0D3 /* MetrikaKit in Frameworks */ = {isa = PBXBuildFile; productRef = 498C4115F45BADDF16191A57 /* MetrikaKit */; }; |
| 11 | 12 | 3084AE2A328ED6746D01F46E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0404FDEAF8A57414EFB3C649 /* Assets.xcassets */; }; |
| 12 | 13 | 7E936A23CCC90823D6B08BCC /* SessionModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0453FB0F256E853D2C2F481C /* SessionModel.swift */; }; |
@@ -14,6 +15,7 @@ | ||
| 14 | 15 | 9BE65709781719FAE0CA740A /* MetrikaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C5393DC050C555E4FB69FC0 /* MetrikaApp.swift */; }; |
| 15 | 16 | CB602D8A4EDDF5D0FBB0CFB7 /* ConsoleSmokeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7ECE59F86249B3A25B17A4 /* ConsoleSmokeTests.swift */; }; |
| 16 | 17 | DDCC2DC487AF9FAC7D6B8E3E /* PlotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AD551AA707CD1853C3D6D21 /* PlotView.swift */; }; |
| 18 | + F0A49DC471FA794BE5948A37 /* DataBrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B8E64F5B2DE7930640AEE29 /* DataBrowserView.swift */; }; | |
| 17 | 19 | /* End PBXBuildFile section */ |
| 18 | 20 | |
| 19 | 21 | /* Begin PBXContainerItemProxy section */ |
@@ -32,11 +34,13 @@ | ||
| 32 | 34 | 159695CF89D6BB25DF883F07 /* ConsoleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsoleView.swift; sourceTree = "<group>"; }; |
| 33 | 35 | 24D84DAB354D702D8575D587 /* MetrikaKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = MetrikaKit; path = MetrikaKit; sourceTree = SOURCE_ROOT; }; |
| 34 | 36 | 2B7CDF1616657B2E1118696A /* MetrikaUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MetrikaUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; |
| 37 | + 2B8E64F5B2DE7930640AEE29 /* DataBrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataBrowserView.swift; sourceTree = "<group>"; }; | |
| 35 | 38 | 4AD551AA707CD1853C3D6D21 /* PlotView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlotView.swift; sourceTree = "<group>"; }; |
| 36 | 39 | 5A0213CC9FE05A339DEEFA28 /* Metrika.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Metrika.app; sourceTree = BUILT_PRODUCTS_DIR; }; |
| 37 | 40 | 6E94C5817E96C83B888FB579 /* Metrika.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Metrika.entitlements; sourceTree = "<group>"; }; |
| 38 | 41 | 9C5393DC050C555E4FB69FC0 /* MetrikaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetrikaApp.swift; sourceTree = "<group>"; }; |
| 39 | 42 | AB7ECE59F86249B3A25B17A4 /* ConsoleSmokeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsoleSmokeTests.swift; sourceTree = "<group>"; }; |
| 43 | + C9BB0AC3BAA378743FD57FF7 /* DoFileEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoFileEditorView.swift; sourceTree = "<group>"; }; | |
| 40 | 44 | /* End PBXFileReference section */ |
| 41 | 45 | |
| 42 | 46 | /* Begin PBXFrameworksBuildPhase section */ |
@@ -72,6 +76,8 @@ | ||
| 72 | 76 | isa = PBXGroup; |
| 73 | 77 | children = ( |
| 74 | 78 | 159695CF89D6BB25DF883F07 /* ConsoleView.swift */, |
| 79 | + 2B8E64F5B2DE7930640AEE29 /* DataBrowserView.swift */, | |
| 80 | + C9BB0AC3BAA378743FD57FF7 /* DoFileEditorView.swift */, | |
| 75 | 81 | 9C5393DC050C555E4FB69FC0 /* MetrikaApp.swift */, |
| 76 | 82 | 4AD551AA707CD1853C3D6D21 /* PlotView.swift */, |
| 77 | 83 | 0453FB0F256E853D2C2F481C /* SessionModel.swift */, |
@@ -222,6 +228,8 @@ | ||
| 222 | 228 | buildActionMask = 2147483647; |
| 223 | 229 | files = ( |
| 224 | 230 | 8B0CDB213D64E3EE87593808 /* ConsoleView.swift in Sources */, |
| 231 | + F0A49DC471FA794BE5948A37 /* DataBrowserView.swift in Sources */, | |
| 232 | + 0BD0F927FC614CF19EF3D59D /* DoFileEditorView.swift in Sources */, | |
| 225 | 233 | 9BE65709781719FAE0CA740A /* MetrikaApp.swift in Sources */, |
| 226 | 234 | DDCC2DC487AF9FAC7D6B8E3E /* PlotView.swift in Sources */, |
| 227 | 235 | 7E936A23CCC90823D6B08BCC /* SessionModel.swift in Sources */, |
added
Metrika/Sources/DataBrowserView.swift
+269 −0
@@ -0,0 +1,269 @@ | ||
| 1 | +// | |
| 2 | +// DataBrowserView.swift | |
| 3 | +// Metrika | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// Copyright © 2026 Simon-Pierre Boucher. All rights reserved. | |
| 8 | +// | |
| 9 | + | |
| 10 | +import AppKit | |
| 11 | +import SwiftUI | |
| 12 | +import ZQData | |
| 13 | + | |
| 14 | +/// Data browser pane (CLAUDE.md §7, pane 3): virtualized grid over the | |
| 15 | +/// working dataset via an NSTableView bridge (SwiftUI Table degrades past | |
| 16 | +/// ~100k rows), sortable columns, and a filter bar that compiles to an | |
| 17 | +/// `if` expression through the real parser. | |
| 18 | +struct DataBrowserPane: View { | |
| 19 | + @Environment(SessionModel.self) private var model | |
| 20 | + @State private var filterText = "" | |
| 21 | + @State private var filterError: String? | |
| 22 | + @State private var filteredRows: [Int]? | |
| 23 | + @State private var sortColumn: String? | |
| 24 | + @State private var sortAscending = true | |
| 25 | + | |
| 26 | + var body: some View { | |
| 27 | + VStack(spacing: 0) { | |
| 28 | + HStack(spacing: 8) { | |
| 29 | + Image(systemName: "line.3.horizontal.decrease.circle") | |
| 30 | + .foregroundStyle(.secondary) | |
| 31 | + TextField( | |
| 32 | + "Filter — e.g. revenue > 100 & region == 2", | |
| 33 | + text: $filterText | |
| 34 | + ) | |
| 35 | + .textFieldStyle(.plain) | |
| 36 | + .fontDesign(.monospaced) | |
| 37 | + .onSubmit { Task { await applyFilter() } } | |
| 38 | + if filteredRows != nil { | |
| 39 | + Button("Clear") { | |
| 40 | + filterText = "" | |
| 41 | + filteredRows = nil | |
| 42 | + filterError = nil | |
| 43 | + } | |
| 44 | + .controlSize(.small) | |
| 45 | + } | |
| 46 | + } | |
| 47 | + .padding(8) | |
| 48 | + | |
| 49 | + if let filterError { | |
| 50 | + Text(filterError) | |
| 51 | + .font(.caption) | |
| 52 | + .foregroundStyle(.red) | |
| 53 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 54 | + .padding(.horizontal, 10) | |
| 55 | + .padding(.bottom, 4) | |
| 56 | + } | |
| 57 | + | |
| 58 | + Divider() | |
| 59 | + | |
| 60 | + DataGrid( | |
| 61 | + frame: model.frame, | |
| 62 | + rows: visibleRows, | |
| 63 | + sortColumn: $sortColumn, | |
| 64 | + sortAscending: $sortAscending | |
| 65 | + ) | |
| 66 | + | |
| 67 | + Divider() | |
| 68 | + Text(statusLine) | |
| 69 | + .font(.caption) | |
| 70 | + .foregroundStyle(.secondary) | |
| 71 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 72 | + .padding(6) | |
| 73 | + } | |
| 74 | + } | |
| 75 | + | |
| 76 | + private var statusLine: String { | |
| 77 | + let total = model.frame.rowCount | |
| 78 | + if let filteredRows { | |
| 79 | + return "\(filteredRows.count) of \(total) observations" | |
| 80 | + } | |
| 81 | + return "\(total) observations, \(model.frame.columns.count) variables" | |
| 82 | + } | |
| 83 | + | |
| 84 | + /// Filtered (or all) row indices, in the current sort order. | |
| 85 | + private var visibleRows: [Int] { | |
| 86 | + let base = filteredRows ?? Array(0..<model.frame.rowCount) | |
| 87 | + guard let sortColumn, | |
| 88 | + let column = model.frame.column(named: sortColumn) else { | |
| 89 | + return base | |
| 90 | + } | |
| 91 | + switch column.data { | |
| 92 | + case .float64(let values, let missing): | |
| 93 | + // Missing values sort last regardless of direction. | |
| 94 | + return base.sorted { a, b in | |
| 95 | + if missing[a] != missing[b] { return missing[b] } | |
| 96 | + if missing[a] { return false } | |
| 97 | + return sortAscending ? values[a] < values[b] : values[a] > values[b] | |
| 98 | + } | |
| 99 | + case .string(let values): | |
| 100 | + return base.sorted { a, b in | |
| 101 | + switch (values[a], values[b]) { | |
| 102 | + case (nil, nil): return false | |
| 103 | + case (nil, _): return false | |
| 104 | + case (_, nil): return true | |
| 105 | + case (let x?, let y?): | |
| 106 | + return sortAscending ? x < y : x > y | |
| 107 | + } | |
| 108 | + } | |
| 109 | + } | |
| 110 | + } | |
| 111 | + | |
| 112 | + private func applyFilter() async { | |
| 113 | + let text = filterText.trimmingCharacters(in: .whitespaces) | |
| 114 | + guard !text.isEmpty else { | |
| 115 | + filteredRows = nil | |
| 116 | + filterError = nil | |
| 117 | + return | |
| 118 | + } | |
| 119 | + switch await model.filterMask(text) { | |
| 120 | + case .success(let mask): | |
| 121 | + filteredRows = (0..<mask.count).filter { mask[$0] } | |
| 122 | + filterError = nil | |
| 123 | + case .failure(let error): | |
| 124 | + filterError = error.description | |
| 125 | + } | |
| 126 | + } | |
| 127 | +} | |
| 128 | + | |
| 129 | +/// NSTableView bridge. NSTableView virtualizes rows natively, so scrolling | |
| 130 | +/// stays smooth regardless of dataset size; cells are plain reused | |
| 131 | +/// NSTextFields indexing directly into the columnar buffers. | |
| 132 | +private struct DataGrid: NSViewRepresentable { | |
| 133 | + let frame: ZQDataFrame | |
| 134 | + let rows: [Int] | |
| 135 | + @Binding var sortColumn: String? | |
| 136 | + @Binding var sortAscending: Bool | |
| 137 | + | |
| 138 | + func makeCoordinator() -> Coordinator { | |
| 139 | + Coordinator(self) | |
| 140 | + } | |
| 141 | + | |
| 142 | + func makeNSView(context: Context) -> NSScrollView { | |
| 143 | + let table = NSTableView() | |
| 144 | + table.usesAlternatingRowBackgroundColors = true | |
| 145 | + table.allowsColumnReordering = false | |
| 146 | + table.rowHeight = 20 | |
| 147 | + table.dataSource = context.coordinator | |
| 148 | + table.delegate = context.coordinator | |
| 149 | + context.coordinator.table = table | |
| 150 | + | |
| 151 | + let scroll = NSScrollView() | |
| 152 | + scroll.documentView = table | |
| 153 | + scroll.hasVerticalScroller = true | |
| 154 | + scroll.hasHorizontalScroller = true | |
| 155 | + return scroll | |
| 156 | + } | |
| 157 | + | |
| 158 | + func updateNSView(_ scroll: NSScrollView, context: Context) { | |
| 159 | + let coordinator = context.coordinator | |
| 160 | + coordinator.parent = self | |
| 161 | + guard let table = coordinator.table else { return } | |
| 162 | + | |
| 163 | + // Rebuild columns only when the variable list changes. | |
| 164 | + let names = frame.columnNames | |
| 165 | + if coordinator.columnNames != names { | |
| 166 | + coordinator.columnNames = names | |
| 167 | + for column in table.tableColumns.reversed() { | |
| 168 | + table.removeTableColumn(column) | |
| 169 | + } | |
| 170 | + let indexColumn = NSTableColumn( | |
| 171 | + identifier: NSUserInterfaceItemIdentifier("__obs") | |
| 172 | + ) | |
| 173 | + indexColumn.title = "" | |
| 174 | + indexColumn.width = 52 | |
| 175 | + table.addTableColumn(indexColumn) | |
| 176 | + for name in names { | |
| 177 | + let column = NSTableColumn( | |
| 178 | + identifier: NSUserInterfaceItemIdentifier(name) | |
| 179 | + ) | |
| 180 | + column.title = name | |
| 181 | + column.width = 110 | |
| 182 | + column.sortDescriptorPrototype = NSSortDescriptor( | |
| 183 | + key: name, ascending: true | |
| 184 | + ) | |
| 185 | + table.addTableColumn(column) | |
| 186 | + } | |
| 187 | + } | |
| 188 | + table.reloadData() | |
| 189 | + } | |
| 190 | + | |
| 191 | + @MainActor | |
| 192 | + final class Coordinator: NSObject, NSTableViewDataSource, NSTableViewDelegate { | |
| 193 | + var parent: DataGrid | |
| 194 | + weak var table: NSTableView? | |
| 195 | + var columnNames: [String] = [] | |
| 196 | + | |
| 197 | + init(_ parent: DataGrid) { | |
| 198 | + self.parent = parent | |
| 199 | + } | |
| 200 | + | |
| 201 | + func numberOfRows(in tableView: NSTableView) -> Int { | |
| 202 | + parent.rows.count | |
| 203 | + } | |
| 204 | + | |
| 205 | + func tableView( | |
| 206 | + _ tableView: NSTableView, | |
| 207 | + viewFor tableColumn: NSTableColumn?, | |
| 208 | + row: Int | |
| 209 | + ) -> NSView? { | |
| 210 | + guard let tableColumn, row < parent.rows.count else { return nil } | |
| 211 | + let identifier = tableColumn.identifier | |
| 212 | + let field: NSTextField | |
| 213 | + if let reused = tableView.makeView( | |
| 214 | + withIdentifier: identifier, owner: self | |
| 215 | + ) as? NSTextField { | |
| 216 | + field = reused | |
| 217 | + } else { | |
| 218 | + field = NSTextField(labelWithString: "") | |
| 219 | + field.identifier = identifier | |
| 220 | + field.font = .monospacedSystemFont(ofSize: 11, weight: .regular) | |
| 221 | + field.lineBreakMode = .byTruncatingTail | |
| 222 | + } | |
| 223 | + | |
| 224 | + let dataRow = parent.rows[row] | |
| 225 | + if identifier.rawValue == "__obs" { | |
| 226 | + field.stringValue = "\(dataRow + 1)" | |
| 227 | + field.textColor = .secondaryLabelColor | |
| 228 | + return field | |
| 229 | + } | |
| 230 | + guard let column = parent.frame.column(named: identifier.rawValue) else { | |
| 231 | + field.stringValue = "" | |
| 232 | + return field | |
| 233 | + } | |
| 234 | + switch column.data { | |
| 235 | + case .float64(let values, let missing): | |
| 236 | + if missing[dataRow] { | |
| 237 | + field.stringValue = "." | |
| 238 | + field.textColor = .secondaryLabelColor | |
| 239 | + } else { | |
| 240 | + field.stringValue = Self.format(values[dataRow]) | |
| 241 | + field.textColor = .labelColor | |
| 242 | + } | |
| 243 | + field.alignment = .right | |
| 244 | + case .string(let values): | |
| 245 | + field.stringValue = values[dataRow] ?? "" | |
| 246 | + field.textColor = .labelColor | |
| 247 | + field.alignment = .left | |
| 248 | + } | |
| 249 | + return field | |
| 250 | + } | |
| 251 | + | |
| 252 | + func tableView( | |
| 253 | + _ tableView: NSTableView, | |
| 254 | + sortDescriptorsDidChange oldDescriptors: [NSSortDescriptor] | |
| 255 | + ) { | |
| 256 | + guard let descriptor = tableView.sortDescriptors.first, | |
| 257 | + let key = descriptor.key else { return } | |
| 258 | + parent.sortColumn = key | |
| 259 | + parent.sortAscending = descriptor.ascending | |
| 260 | + } | |
| 261 | + | |
| 262 | + static func format(_ value: Double) -> String { | |
| 263 | + if value == value.rounded(), abs(value) < 1e15 { | |
| 264 | + return String(Int64(value)) | |
| 265 | + } | |
| 266 | + return String(format: "%.6g", value) | |
| 267 | + } | |
| 268 | + } | |
| 269 | +} | |
added
Metrika/Sources/DoFileEditorView.swift
+229 −0
@@ -0,0 +1,229 @@ | ||
| 1 | +// | |
| 2 | +// DoFileEditorView.swift | |
| 3 | +// Metrika | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// Copyright © 2026 Simon-Pierre Boucher. All rights reserved. | |
| 8 | +// | |
| 9 | + | |
| 10 | +import AppKit | |
| 11 | +import SwiftUI | |
| 12 | +import UniformTypeIdentifiers | |
| 13 | +import ZQParser | |
| 14 | + | |
| 15 | +/// Do-file editor pane (CLAUDE.md §7, pane 2): NSTextView bridge with ZQL | |
| 16 | +/// syntax highlighting; ⌘R runs the selection (or the whole file) through | |
| 17 | +/// the shared session execution path. | |
| 18 | +struct DoFileEditorPane: View { | |
| 19 | + @Environment(SessionModel.self) private var model | |
| 20 | + @State private var text = "" | |
| 21 | + @State private var selection: NSRange = NSRange(location: 0, length: 0) | |
| 22 | + @State private var showingOpen = false | |
| 23 | + @State private var showingSave = false | |
| 24 | + @State private var fileName = "untitled.zyq" | |
| 25 | + | |
| 26 | + private static let zyqType = | |
| 27 | + UTType(filenameExtension: "zyq", conformingTo: .plainText) ?? .plainText | |
| 28 | + | |
| 29 | + 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 in | |
| 57 | + 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 = contents | |
| 62 | + fileName = url.lastPathComponent | |
| 63 | + } | |
| 64 | + } | |
| 65 | + .fileExporter( | |
| 66 | + isPresented: $showingSave, | |
| 67 | + document: DoFileDocument(text: text), | |
| 68 | + contentType: Self.zyqType, | |
| 69 | + defaultFilename: fileName | |
| 70 | + ) { result in | |
| 71 | + if case .success(let url) = result { | |
| 72 | + fileName = url.lastPathComponent | |
| 73 | + } | |
| 74 | + } | |
| 75 | + } | |
| 76 | + | |
| 77 | + private func run() { | |
| 78 | + let script: String | |
| 79 | + if selection.length > 0, let range = Range(selection, in: text) { | |
| 80 | + script = String(text[range]) | |
| 81 | + } else { | |
| 82 | + script = text | |
| 83 | + } | |
| 84 | + Task { await model.runScript(script) } | |
| 85 | + } | |
| 86 | +} | |
| 87 | + | |
| 88 | +/// Plain-text document wrapper for the save panel. | |
| 89 | +struct DoFileDocument: FileDocument { | |
| 90 | + static let readableContentTypes: [UTType] = [.plainText] | |
| 91 | + var text: String | |
| 92 | + | |
| 93 | + init(text: String) { self.text = text } | |
| 94 | + | |
| 95 | + 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 | + } | |
| 101 | + | |
| 102 | + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { | |
| 103 | + FileWrapper(regularFileWithContents: Data(text.utf8)) | |
| 104 | + } | |
| 105 | +} | |
| 106 | + | |
| 107 | +/// NSTextView bridge with lightweight ZQL highlighting: verbs (resolved | |
| 108 | +/// through the real verb table), comments, strings, and numbers. | |
| 109 | +struct ZQLTextEditor: NSViewRepresentable { | |
| 110 | + @Binding var text: String | |
| 111 | + @Binding var selection: NSRange | |
| 112 | + | |
| 113 | + func makeCoordinator() -> Coordinator { Coordinator(self) } | |
| 114 | + | |
| 115 | + func makeNSView(context: Context) -> NSScrollView { | |
| 116 | + let textView = NSTextView() | |
| 117 | + textView.isRichText = false | |
| 118 | + textView.allowsUndo = true | |
| 119 | + textView.font = .monospacedSystemFont(ofSize: 12, weight: .regular) | |
| 120 | + textView.isAutomaticQuoteSubstitutionEnabled = false | |
| 121 | + textView.isAutomaticDashSubstitutionEnabled = false | |
| 122 | + textView.isAutomaticSpellingCorrectionEnabled = false | |
| 123 | + textView.autoresizingMask = [.width] | |
| 124 | + textView.delegate = context.coordinator | |
| 125 | + context.coordinator.textView = textView | |
| 126 | + | |
| 127 | + let scroll = NSScrollView() | |
| 128 | + scroll.documentView = textView | |
| 129 | + scroll.hasVerticalScroller = true | |
| 130 | + return scroll | |
| 131 | + } | |
| 132 | + | |
| 133 | + func updateNSView(_ scroll: NSScrollView, context: Context) { | |
| 134 | + context.coordinator.parent = self | |
| 135 | + guard let textView = context.coordinator.textView else { return } | |
| 136 | + if textView.string != text { | |
| 137 | + textView.string = text | |
| 138 | + context.coordinator.highlight(textView) | |
| 139 | + } | |
| 140 | + } | |
| 141 | + | |
| 142 | + @MainActor | |
| 143 | + final class Coordinator: NSObject, NSTextViewDelegate { | |
| 144 | + var parent: ZQLTextEditor | |
| 145 | + weak var textView: NSTextView? | |
| 146 | + private let verbs = ZQVerbTable.builtin | |
| 147 | + | |
| 148 | + init(_ parent: ZQLTextEditor) { self.parent = parent } | |
| 149 | + | |
| 150 | + func textDidChange(_ notification: Notification) { | |
| 151 | + guard let textView else { return } | |
| 152 | + parent.text = textView.string | |
| 153 | + highlight(textView) | |
| 154 | + } | |
| 155 | + | |
| 156 | + func textViewDidChangeSelection(_ notification: Notification) { | |
| 157 | + guard let textView else { return } | |
| 158 | + parent.selection = textView.selectedRange() | |
| 159 | + } | |
| 160 | + | |
| 161 | + func highlight(_ textView: NSTextView) { | |
| 162 | + guard let storage = textView.textStorage else { return } | |
| 163 | + let content = textView.string as NSString | |
| 164 | + let fullRange = NSRange(location: 0, length: content.length) | |
| 165 | + | |
| 166 | + storage.beginEditing() | |
| 167 | + storage.setAttributes([ | |
| 168 | + .font: NSFont.monospacedSystemFont(ofSize: 12, weight: .regular), | |
| 169 | + .foregroundColor: NSColor.labelColor, | |
| 170 | + ], range: fullRange) | |
| 171 | + | |
| 172 | + content.enumerateSubstrings( | |
| 173 | + in: fullRange, options: [.byLines, .substringNotRequired] | |
| 174 | + ) { _, lineRange, _, _ in | |
| 175 | + self.highlightLine(content, lineRange, storage) | |
| 176 | + } | |
| 177 | + storage.endEditing() | |
| 178 | + } | |
| 179 | + | |
| 180 | + private func highlightLine( | |
| 181 | + _ content: NSString, _ lineRange: NSRange, _ storage: NSTextStorage | |
| 182 | + ) { | |
| 183 | + let line = content.substring(with: lineRange) | |
| 184 | + let trimmed = line.trimmingCharacters(in: .whitespaces) | |
| 185 | + | |
| 186 | + // Whole-line comments. | |
| 187 | + if trimmed.hasPrefix("//") || trimmed.hasPrefix("*") { | |
| 188 | + storage.addAttribute( | |
| 189 | + .foregroundColor, value: NSColor.systemGray, range: lineRange | |
| 190 | + ) | |
| 191 | + return | |
| 192 | + } | |
| 193 | + | |
| 194 | + // 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.location | |
| 199 | + + 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 | + } | |
| 206 | + | |
| 207 | + // Strings and trailing comments via regex on the line. | |
| 208 | + applyPattern(#""[^"]*""#, NSColor.systemRed, line, lineRange, storage) | |
| 209 | + applyPattern(#"//.*$"#, NSColor.systemGray, line, lineRange, storage) | |
| 210 | + } | |
| 211 | + | |
| 212 | + private func applyPattern( | |
| 213 | + _ pattern: String, _ color: NSColor, | |
| 214 | + _ line: String, _ lineRange: NSRange, _ storage: NSTextStorage | |
| 215 | + ) { | |
| 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.length | |
| 224 | + ) | |
| 225 | + ) | |
| 226 | + } | |
| 227 | + } | |
| 228 | + } | |
| 229 | +} | |
modified
Metrika/Sources/MetrikaApp.swift
+32 −6
@@ -24,18 +24,44 @@ struct MetrikaApp: App { | ||
| 24 | 24 | } |
| 25 | 25 | |
| 26 | 26 | struct ContentView: View { |
| 27 | + enum Pane: String, CaseIterable { | |
| 28 | + case console = "Console" | |
| 29 | + case data = "Data" | |
| 30 | + case doFile = "Do-file" | |
| 31 | + } | |
| 32 | + | |
| 27 | 33 | @Environment(SessionModel.self) private var model |
| 34 | + @State private var pane: Pane = .console | |
| 28 | 35 | |
| 29 | 36 | var body: some View { |
| 30 | 37 | NavigationSplitView { |
| 31 | 38 | VariablesSidebar() |
| 32 | 39 | } detail: { |
| 33 | − HSplitView { | |
| 34 | − ConsoleView() | |
| 35 | − .frame(minWidth: 480) | |
| 36 | − if model.lastPlot != nil { | |
| 37 | − PlotView() | |
| 38 | − .frame(minWidth: 300) | |
| 40 | + Group { | |
| 41 | + switch pane { | |
| 42 | + case .console: | |
| 43 | + HSplitView { | |
| 44 | + ConsoleView() | |
| 45 | + .frame(minWidth: 480) | |
| 46 | + if model.lastPlot != nil { | |
| 47 | + PlotView() | |
| 48 | + .frame(minWidth: 300) | |
| 49 | + } | |
| 50 | + } | |
| 51 | + case .data: | |
| 52 | + DataBrowserPane() | |
| 53 | + case .doFile: | |
| 54 | + DoFileEditorPane() | |
| 55 | + } | |
| 56 | + } | |
| 57 | + .toolbar { | |
| 58 | + ToolbarItem(placement: .principal) { | |
| 59 | + Picker("Pane", selection: $pane) { | |
| 60 | + ForEach(Pane.allCases, id: \.self) { pane in | |
| 61 | + Text(pane.rawValue).tag(pane) | |
| 62 | + } | |
| 63 | + } | |
| 64 | + .pickerStyle(.segmented) | |
| 39 | 65 | } |
| 40 | 66 | } |
| 41 | 67 | } |
modified
Metrika/Sources/SessionModel.swift
+39 −0
@@ -38,6 +38,8 @@ final class SessionModel { | ||
| 38 | 38 | private(set) var lastPlot: ZQPlotSpec? |
| 39 | 39 | private(set) var isRunning = false |
| 40 | 40 | private(set) var history: [String] = [] |
| 41 | + /// Snapshot of the working dataset for the data browser. | |
| 42 | + private(set) var frame = try! ZQDataFrame() // empty init cannot fail | |
| 41 | 43 | |
| 42 | 44 | private var session: ZQSession? |
| 43 | 45 | |
@@ -82,6 +84,7 @@ final class SessionModel { | ||
| 82 | 84 | private func refreshDatasetState() async { |
| 83 | 85 | guard let session else { return } |
| 84 | 86 | let frame = await session.frame |
| 87 | + self.frame = frame | |
| 85 | 88 | observationCount = frame.rowCount |
| 86 | 89 | variables = frame.columns.map { column in |
| 87 | 90 | VariableInfo( |
@@ -92,4 +95,40 @@ final class SessionModel { | ||
| 92 | 95 | } |
| 93 | 96 | lastPlot = await session.lastPlot |
| 94 | 97 | } |
| 98 | + | |
| 99 | + struct FilterError: Error, CustomStringConvertible { | |
| 100 | + let description: String | |
| 101 | + } | |
| 102 | + | |
| 103 | + /// Filter-bar support: compiles text to an `if` mask over the current | |
| 104 | + /// dataset. Fails with the parser's error message. | |
| 105 | + func filterMask(_ expression: String) async -> Result<[Bool], FilterError> { | |
| 106 | + guard let session else { return .failure(FilterError(description: "engine not running")) } | |
| 107 | + do { | |
| 108 | + return .success(try await session.conditionMask(expression)) | |
| 109 | + } catch { | |
| 110 | + return .failure(FilterError(description: "\(error)")) | |
| 111 | + } | |
| 112 | + } | |
| 113 | + | |
| 114 | + /// Do-file support: runs a whole script through the shared execution | |
| 115 | + /// path, appending its output to the console. | |
| 116 | + func runScript(_ text: String) async { | |
| 117 | + guard let session else { return } | |
| 118 | + isRunning = true | |
| 119 | + defer { isRunning = false } | |
| 120 | + do { | |
| 121 | + let result = try await session.executeScript(text) | |
| 122 | + entries.append(ConsoleEntry( | |
| 123 | + command: "do-file (\(text.split(separator: "\n").count) lines)", | |
| 124 | + output: result.text, | |
| 125 | + isError: false | |
| 126 | + )) | |
| 127 | + } catch { | |
| 128 | + entries.append(ConsoleEntry( | |
| 129 | + command: "do-file", output: "\(error)", isError: true | |
| 130 | + )) | |
| 131 | + } | |
| 132 | + await refreshDatasetState() | |
| 133 | + } | |
| 95 | 134 | } |
modified
MetrikaKit/Sources/ZQEngine/Session.swift
+11 −0
@@ -1202,6 +1202,17 @@ public actor ZQSession { | ||
| 1202 | 1202 | return ZQResult(text: "(plot created: \(pointCount) points)") |
| 1203 | 1203 | } |
| 1204 | 1204 | |
| 1205 | + // MARK: - UI support | |
| 1206 | + | |
| 1207 | + /// Compiles filter-bar text into a keep-mask over the current dataset | |
| 1208 | + /// (the data browser's filter compiles to an `if` expression, §7). | |
| 1209 | + /// Missing conditions exclude the observation, like `if` qualifiers. | |
| 1210 | + public func conditionMask(_ expressionText: String) throws -> [Bool] { | |
| 1211 | + let expression = try parser.parseExpression(expressionText) | |
| 1212 | + let evaluator = ExpressionEvaluator(frame: frame) | |
| 1213 | + return try evaluator.evaluateCondition(expression) | |
| 1214 | + } | |
| 1215 | + | |
| 1205 | 1216 | // MARK: - Qualifiers |
| 1206 | 1217 | |
| 1207 | 1218 | /// Combined if/in keep-mask over current observations. |
modified
MetrikaKit/Sources/ZQParser/Grammar/CommandParser.swift
+16 −0
@@ -23,6 +23,22 @@ public struct ZQCommandParser: Sendable { | ||
| 23 | 23 | self.verbTable = verbTable |
| 24 | 24 | } |
| 25 | 25 | |
| 26 | + /// Parses a standalone expression (used by the data browser's filter | |
| 27 | + /// bar, which compiles its text to an `if` condition — CLAUDE.md §7). | |
| 28 | + public func parseExpression(_ text: String) throws(ZQParseError) -> ZQExpression { | |
| 29 | + let lexer = ZQLexer() | |
| 30 | + let tokens = try lexer.tokenize(text) | |
| 31 | + var cursor = ExpressionParser(tokens: tokens) | |
| 32 | + let expression = try cursor.parseExpression() | |
| 33 | + guard cursor.isKind(.endOfLine) else { | |
| 34 | + throw ZQParseError( | |
| 35 | + message: "unexpected \(cursor.current.kind.describe) after expression", | |
| 36 | + column: cursor.current.column | |
| 37 | + ) | |
| 38 | + } | |
| 39 | + return expression | |
| 40 | + } | |
| 41 | + | |
| 26 | 42 | /// Parses a single line. Returns nil for blank lines and comments. |
| 27 | 43 | public func parse(_ line: String) throws(ZQParseError) -> ZQCommand? { |
| 28 | 44 | let trimmed = line.trimmingCharacters(in: .whitespaces) |
modified
Tests/UITests/ConsoleSmokeTests.swift
+66 −0
@@ -70,4 +70,70 @@ final class ConsoleSmokeTests: XCTestCase { | ||
| 70 | 70 | let sidebar = staticText(containing: "log_rev", in: app) |
| 71 | 71 | XCTAssertTrue(sidebar.waitForExistence(timeout: 5), "sidebar missing log_rev") |
| 72 | 72 | } |
| 73 | + | |
| 74 | + func testDataBrowserAndDoFile() throws { | |
| 75 | + continueAfterFailure = false | |
| 76 | + | |
| 77 | + let app = XCUIApplication() | |
| 78 | + app.launchEnvironment["METRIKA_UITEST_CSV"] = """ | |
| 79 | + revenue,price,region | |
| 80 | + 120.5,10.2,1 | |
| 81 | + 98.3,12.1,1 | |
| 82 | + 143.2,9.8,2 | |
| 83 | + 110.0,11.5,2 | |
| 84 | + 155.9,8.9,3 | |
| 85 | + """ | |
| 86 | + app.launch() | |
| 87 | + XCTAssertTrue( | |
| 88 | + staticText(containing: "5 observations", in: app).waitForExistence(timeout: 10), | |
| 89 | + "dataset did not load" | |
| 90 | + ) | |
| 91 | + | |
| 92 | + // Data pane: grid renders, filter compiles to an if-expression. | |
| 93 | + switchPane(to: "Data", in: app) | |
| 94 | + let grid = app.tables.firstMatch | |
| 95 | + XCTAssertTrue(grid.waitForExistence(timeout: 5), "data grid missing") | |
| 96 | + | |
| 97 | + let filter = app.textFields.firstMatch | |
| 98 | + XCTAssertTrue(filter.waitForExistence(timeout: 5), "filter bar missing") | |
| 99 | + filter.click() | |
| 100 | + filter.typeText("revenue > 115 & region != 3\n") | |
| 101 | + XCTAssertTrue( | |
| 102 | + staticText(containing: "2 of 5 observations", in: app) | |
| 103 | + .waitForExistence(timeout: 5), | |
| 104 | + "filter did not narrow rows" | |
| 105 | + ) | |
| 106 | + | |
| 107 | + // Do-file pane: type a script and run it with ⌘R. | |
| 108 | + switchPane(to: "Do-file", in: app) | |
| 109 | + let editor = app.textViews.firstMatch | |
| 110 | + XCTAssertTrue(editor.waitForExistence(timeout: 5), "editor missing") | |
| 111 | + editor.click() | |
| 112 | + editor.typeText("gen high = revenue > 120\ncount if high == 1") | |
| 113 | + app.typeKey("r", modifierFlags: .command) | |
| 114 | + | |
| 115 | + // Output lands in the shared console. | |
| 116 | + switchPane(to: "Console", in: app) | |
| 117 | + XCTAssertTrue( | |
| 118 | + staticText(containing: "do-file (2 lines)", in: app) | |
| 119 | + .waitForExistence(timeout: 10), | |
| 120 | + "do-file output missing from console" | |
| 121 | + ) | |
| 122 | + // Sidebar picked up the do-file's generated variable. | |
| 123 | + XCTAssertTrue( | |
| 124 | + staticText(containing: "high", in: app).waitForExistence(timeout: 5), | |
| 125 | + "sidebar missing variable generated by do-file" | |
| 126 | + ) | |
| 127 | + } | |
| 128 | + | |
| 129 | + /// The pane switcher is a segmented picker in the toolbar; SwiftUI | |
| 130 | + /// exposes segments as radio buttons (with a button fallback). | |
| 131 | + private func switchPane(to name: String, in app: XCUIApplication) { | |
| 132 | + let radio = app.radioButtons[name] | |
| 133 | + if radio.waitForExistence(timeout: 3) { | |
| 134 | + radio.click() | |
| 135 | + return | |
| 136 | + } | |
| 137 | + app.buttons[name].firstMatch.click() | |
| 138 | + } | |
| 73 | 139 | } |
| 74 | 140 | |