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%

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>
simon-pierre boucher committed 6 days ago (Aug 5, 2026) parent f33a4d4

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