SPB Git

Repository Public

Commit

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 · Aug 5, 2026 parent f33a4d4
8 changed files +670 −6 Browse files
modified Metrika.xcodeproj/project.pbxproj +8 −0
@@ -7,6 +7,7 @@
7 objects = { 7 objects = {
8 8
9 /* Begin PBXBuildFile section */ 9 /* Begin PBXBuildFile section */
10+ 0BD0F927FC614CF19EF3D59D /* DoFileEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9BB0AC3BAA378743FD57FF7 /* DoFileEditorView.swift */; };
10 0F7000A49C9879593F09A0D3 /* MetrikaKit in Frameworks */ = {isa = PBXBuildFile; productRef = 498C4115F45BADDF16191A57 /* MetrikaKit */; }; 11 0F7000A49C9879593F09A0D3 /* MetrikaKit in Frameworks */ = {isa = PBXBuildFile; productRef = 498C4115F45BADDF16191A57 /* MetrikaKit */; };
11 3084AE2A328ED6746D01F46E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0404FDEAF8A57414EFB3C649 /* Assets.xcassets */; }; 12 3084AE2A328ED6746D01F46E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0404FDEAF8A57414EFB3C649 /* Assets.xcassets */; };
12 7E936A23CCC90823D6B08BCC /* SessionModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0453FB0F256E853D2C2F481C /* SessionModel.swift */; }; 13 7E936A23CCC90823D6B08BCC /* SessionModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0453FB0F256E853D2C2F481C /* SessionModel.swift */; };
@@ -14,6 +15,7 @@
14 9BE65709781719FAE0CA740A /* MetrikaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C5393DC050C555E4FB69FC0 /* MetrikaApp.swift */; }; 15 9BE65709781719FAE0CA740A /* MetrikaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C5393DC050C555E4FB69FC0 /* MetrikaApp.swift */; };
15 CB602D8A4EDDF5D0FBB0CFB7 /* ConsoleSmokeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7ECE59F86249B3A25B17A4 /* ConsoleSmokeTests.swift */; }; 16 CB602D8A4EDDF5D0FBB0CFB7 /* ConsoleSmokeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7ECE59F86249B3A25B17A4 /* ConsoleSmokeTests.swift */; };
16 DDCC2DC487AF9FAC7D6B8E3E /* PlotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AD551AA707CD1853C3D6D21 /* PlotView.swift */; }; 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 /* End PBXBuildFile section */ 19 /* End PBXBuildFile section */
18 20
19 /* Begin PBXContainerItemProxy section */ 21 /* Begin PBXContainerItemProxy section */
@@ -32,11 +34,13 @@
32 159695CF89D6BB25DF883F07 /* ConsoleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsoleView.swift; sourceTree = "<group>"; }; 34 159695CF89D6BB25DF883F07 /* ConsoleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsoleView.swift; sourceTree = "<group>"; };
33 24D84DAB354D702D8575D587 /* MetrikaKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = MetrikaKit; path = MetrikaKit; sourceTree = SOURCE_ROOT; }; 35 24D84DAB354D702D8575D587 /* MetrikaKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = MetrikaKit; path = MetrikaKit; sourceTree = SOURCE_ROOT; };
34 2B7CDF1616657B2E1118696A /* MetrikaUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MetrikaUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 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 4AD551AA707CD1853C3D6D21 /* PlotView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlotView.swift; sourceTree = "<group>"; }; 38 4AD551AA707CD1853C3D6D21 /* PlotView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlotView.swift; sourceTree = "<group>"; };
36 5A0213CC9FE05A339DEEFA28 /* Metrika.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Metrika.app; sourceTree = BUILT_PRODUCTS_DIR; }; 39 5A0213CC9FE05A339DEEFA28 /* Metrika.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Metrika.app; sourceTree = BUILT_PRODUCTS_DIR; };
37 6E94C5817E96C83B888FB579 /* Metrika.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Metrika.entitlements; sourceTree = "<group>"; }; 40 6E94C5817E96C83B888FB579 /* Metrika.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Metrika.entitlements; sourceTree = "<group>"; };
38 9C5393DC050C555E4FB69FC0 /* MetrikaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetrikaApp.swift; sourceTree = "<group>"; }; 41 9C5393DC050C555E4FB69FC0 /* MetrikaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetrikaApp.swift; sourceTree = "<group>"; };
39 AB7ECE59F86249B3A25B17A4 /* ConsoleSmokeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsoleSmokeTests.swift; sourceTree = "<group>"; }; 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 /* End PBXFileReference section */ 44 /* End PBXFileReference section */
41 45
42 /* Begin PBXFrameworksBuildPhase section */ 46 /* Begin PBXFrameworksBuildPhase section */
@@ -72,6 +76,8 @@
72 isa = PBXGroup; 76 isa = PBXGroup;
73 children = ( 77 children = (
74 159695CF89D6BB25DF883F07 /* ConsoleView.swift */, 78 159695CF89D6BB25DF883F07 /* ConsoleView.swift */,
79+ 2B8E64F5B2DE7930640AEE29 /* DataBrowserView.swift */,
80+ C9BB0AC3BAA378743FD57FF7 /* DoFileEditorView.swift */,
75 9C5393DC050C555E4FB69FC0 /* MetrikaApp.swift */, 81 9C5393DC050C555E4FB69FC0 /* MetrikaApp.swift */,
76 4AD551AA707CD1853C3D6D21 /* PlotView.swift */, 82 4AD551AA707CD1853C3D6D21 /* PlotView.swift */,
77 0453FB0F256E853D2C2F481C /* SessionModel.swift */, 83 0453FB0F256E853D2C2F481C /* SessionModel.swift */,
@@ -222,6 +228,8 @@
222 buildActionMask = 2147483647; 228 buildActionMask = 2147483647;
223 files = ( 229 files = (
224 8B0CDB213D64E3EE87593808 /* ConsoleView.swift in Sources */, 230 8B0CDB213D64E3EE87593808 /* ConsoleView.swift in Sources */,
231+ F0A49DC471FA794BE5948A37 /* DataBrowserView.swift in Sources */,
232+ 0BD0F927FC614CF19EF3D59D /* DoFileEditorView.swift in Sources */,
225 9BE65709781719FAE0CA740A /* MetrikaApp.swift in Sources */, 233 9BE65709781719FAE0CA740A /* MetrikaApp.swift in Sources */,
226 DDCC2DC487AF9FAC7D6B8E3E /* PlotView.swift in Sources */, 234 DDCC2DC487AF9FAC7D6B8E3E /* PlotView.swift in Sources */,
227 7E936A23CCC90823D6B08BCC /* SessionModel.swift in Sources */, 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 struct ContentView: View { 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 @Environment(SessionModel.self) private var model 33 @Environment(SessionModel.self) private var model
34+ @State private var pane: Pane = .console
28 35
29 var body: some View { 36 var body: some View {
30 NavigationSplitView { 37 NavigationSplitView {
31 VariablesSidebar() 38 VariablesSidebar()
32 } detail: { 39 } detail: {
33− HSplitView { 40+ Group {
34− ConsoleView() 41+ switch pane {
35− .frame(minWidth: 480) 42+ case .console:
36− if model.lastPlot != nil { 43+ HSplitView {
37− PlotView() 44+ ConsoleView()
38− .frame(minWidth: 300) 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 private(set) var lastPlot: ZQPlotSpec? 38 private(set) var lastPlot: ZQPlotSpec?
39 private(set) var isRunning = false 39 private(set) var isRunning = false
40 private(set) var history: [String] = [] 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 private var session: ZQSession? 44 private var session: ZQSession?
43 45
@@ -82,6 +84,7 @@ final class SessionModel {
82 private func refreshDatasetState() async { 84 private func refreshDatasetState() async {
83 guard let session else { return } 85 guard let session else { return }
84 let frame = await session.frame 86 let frame = await session.frame
87+ self.frame = frame
85 observationCount = frame.rowCount 88 observationCount = frame.rowCount
86 variables = frame.columns.map { column in 89 variables = frame.columns.map { column in
87 VariableInfo( 90 VariableInfo(
@@ -92,4 +95,40 @@ final class SessionModel {
92 } 95 }
93 lastPlot = await session.lastPlot 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 return ZQResult(text: "(plot created: \(pointCount) points)") 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 // MARK: - Qualifiers 1216 // MARK: - Qualifiers
1206 1217
1207 /// Combined if/in keep-mask over current observations. 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 self.verbTable = verbTable 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 /// Parses a single line. Returns nil for blank lines and comments. 42 /// Parses a single line. Returns nil for blank lines and comments.
27 public func parse(_ line: String) throws(ZQParseError) -> ZQCommand? { 43 public func parse(_ line: String) throws(ZQParseError) -> ZQCommand? {
28 let trimmed = line.trimmingCharacters(in: .whitespaces) 44 let trimmed = line.trimmingCharacters(in: .whitespaces)
modified Tests/UITests/ConsoleSmokeTests.swift +66 −0
@@ -70,4 +70,70 @@ final class ConsoleSmokeTests: XCTestCase {
70 let sidebar = staticText(containing: "log_rev", in: app) 70 let sidebar = staticText(containing: "log_rev", in: app)
71 XCTAssertTrue(sidebar.waitForExistence(timeout: 5), "sidebar missing log_rev") 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
Menu

Theme