git.iko.soy/parseable-macos
on master help sign in
Make log table columns resizable and auto-fit to content (#4)

Replace static hard-coded column widths with dynamic content-based sizing: - Columns auto-size to fit their content on data load by measuring header text and sampling up to 200 rows using NSFont text measurement - Add draggable resize handles on column header borders with live feedback and resize cursor on hover - Right-click column headers for "Auto-fit Column" / "Auto-fit All Columns" context menu options - Column widths bounded between 40-1000px (auto-fit: 50-600px) - Update tests to cover the new measurement-based width functions https://claude.ai/code/session_014bXvNynNNwA8XP82mZWjyp Co-authored-by: Claude <noreply@anthropic.com>

commit9fee70b48a97c3d86aba254bf92b7ee0bfe0c834 authoriko <mail@iko.soy> dateSun 15 Feb 2026, 12:08 (8 months ago) parent2024794
2 files changed+177−55
ParseableViewer/Views/LogTableView.swift+136−36 ParseableViewerTests/LogTableHelperTests.swift+41−19
ParseableViewer/Views/LogTableView.swift+136−36
@@ -1,17 +1,39 @@
11import SwiftUI
22
3−/// Shared column width logic used by both header and row views.
4−func columnWidth(for column: String) -> CGFloat {
5− switch column {
6− case "p_timestamp", "timestamp", "@timestamp", "time":
7− return 200
8− case "level", "severity", "log_level":
9− return 80
10− case "message", "msg", "body", "log":
11− return 400
12− default:
13− return 160
3+/// Computes the display width needed for a text string using the given font.
4+private func measureTextWidth(_ text: String, font: NSFont) -> CGFloat {
5+ let attributes: [NSAttributedString.Key: Any] = [.font: font]
6+ return ceil((text as NSString).size(withAttributes: attributes).width)
7+}
8+
9+/// Font used for header text measurement.
10+private let headerMeasureFont = NSFont.systemFont(ofSize: 10, weight: .semibold)
11+/// Font used for cell text measurement.
12+private let cellMeasureFont = NSFont.monospacedSystemFont(ofSize: 10, weight: .regular)
13+
14+/// Computes the ideal width for a single column by sampling record content.
15+func idealColumnWidth(for column: String, records: [LogRecord]) -> CGFloat {
16+ let padding: CGFloat = 28 // horizontal padding (6*2) + sort indicator + buffer
17+ let minWidth: CGFloat = 50
18+ let maxWidth: CGFloat = 600
19+ let sampleCount = min(records.count, 200)
20+
21+ var widest = measureTextWidth(column, font: headerMeasureFont)
22+ for i in 0..<sampleCount {
23+ let text = records[i][column]?.displayString ?? ""
24+ widest = max(widest, measureTextWidth(text, font: cellMeasureFont))
1425 }
26+
27+ return min(max(widest + padding, minWidth), maxWidth)
28+}
29+
30+/// Computes ideal widths for all columns by sampling record content.
31+func computeColumnWidths(columns: [String], records: [LogRecord]) -> [String: CGFloat] {
32+ var widths: [String: CGFloat] = [:]
33+ for column in columns {
34+ widths[column] = idealColumnWidth(for: column, records: records)
35+ }
36+ return widths
1537}
1638
1739/// Shared log-level color mapping.
@@ -25,6 +47,8 @@ func levelColor(for value: String) -> Color {
2547 }
2648}
2749
50+// MARK: - LogTableView
51+
2852struct LogTableView: View {
2953 let records: [LogRecord]
3054 let columns: [String]
@@ -34,6 +58,7 @@ struct LogTableView: View {
3458 @State private var selectedIndex: Int?
3559 @State private var cachedSorted: [LogRecord] = []
3660 @State private var sortTask: Task<Void, Never>?
61+ @State private var columnWidths: [String: CGFloat] = [:]
3762
3863 var body: some View {
3964 if records.isEmpty {
@@ -54,6 +79,7 @@ struct LogTableView: View {
5479 LogRowView(
5580 record: cachedSorted[index],
5681 columns: columns,
82+ columnWidths: columnWidths,
5783 isSelected: selectedIndex == index,
5884 isAlternate: index % 2 == 1
5985 )
@@ -66,7 +92,9 @@ struct LogTableView: View {
6692 LogHeaderView(
6793 columns: columns,
6894 sortColumn: $sortColumn,
69− sortAscending: $sortAscending
95+ sortAscending: $sortAscending,
96+ columnWidths: $columnWidths,
97+ records: records
7098 )
7199 }
72100 }
@@ -78,10 +106,16 @@ struct LogTableView: View {
78106 .frame(minWidth: 300, idealWidth: 350)
79107 }
80108 }
81− .onChange(of: records) { _, _ in debouncedRebuildSort() }
109+ .onChange(of: records) { _, _ in
110+ columnWidths = computeColumnWidths(columns: columns, records: records)
111+ debouncedRebuildSort()
112+ }
82113 .onChange(of: sortColumn) { _, _ in debouncedRebuildSort() }
83114 .onChange(of: sortAscending) { _, _ in debouncedRebuildSort() }
84− .onAppear { rebuildSort() }
115+ .onAppear {
116+ columnWidths = computeColumnWidths(columns: columns, records: records)
117+ rebuildSort()
118+ }
85119 }
86120 }
87121
@@ -113,39 +147,102 @@ struct LogTableView: View {
113147 }
114148}
115149
150+// MARK: - ColumnResizeHandle
151+
152+struct ColumnResizeHandle: View {
153+ @Binding var columnWidth: CGFloat
154+ @State private var isHovering = false
155+ @State private var initialWidth: CGFloat?
156+
157+ var body: some View {
158+ Color.clear
159+ .frame(width: 6)
160+ .overlay(
161+ Rectangle()
162+ .fill(Color.secondary.opacity(isHovering ? 0.5 : 0.2))
163+ .frame(width: 1)
164+ )
165+ .contentShape(Rectangle())
166+ .onHover { hovering in
167+ isHovering = hovering
168+ if hovering {
169+ NSCursor.resizeLeftRight.push()
170+ } else {
171+ NSCursor.pop()
172+ }
173+ }
174+ .gesture(
175+ DragGesture(minimumDistance: 1)
176+ .onChanged { gesture in
177+ if initialWidth == nil {
178+ initialWidth = columnWidth
179+ }
180+ let newWidth = (initialWidth ?? columnWidth) + gesture.translation.width
181+ columnWidth = min(max(newWidth, 40), 1000)
182+ }
183+ .onEnded { _ in
184+ initialWidth = nil
185+ }
186+ )
187+ }
188+}
189+
190+// MARK: - LogHeaderView
191+
116192struct LogHeaderView: View {
117193 let columns: [String]
118194 @Binding var sortColumn: String?
119195 @Binding var sortAscending: Bool
196+ @Binding var columnWidths: [String: CGFloat]
197+ let records: [LogRecord]
120198
121199 var body: some View {
122200 HStack(spacing: 0) {
123201 ForEach(columns, id: \.self) { column in
124− Button {
125− if sortColumn == column {
126− sortAscending.toggle()
127− } else {
128− sortColumn = column
129− sortAscending = true
130− }
131− } label: {
132− HStack(spacing: 2) {
133− Text(column)
134− .font(.caption.weight(.semibold))
135− .lineLimit(1)
202+ HStack(spacing: 0) {
203+ Button {
136204 if sortColumn == column {
137− Image(systemName: sortAscending ? "chevron.up" : "chevron.down")
138− .font(.caption2)
205+ sortAscending.toggle()
206+ } else {
207+ sortColumn = column
208+ sortAscending = true
139209 }
210+ } label: {
211+ HStack(spacing: 2) {
212+ Text(column)
213+ .font(.caption.weight(.semibold))
214+ .lineLimit(1)
215+ if sortColumn == column {
216+ Image(systemName: sortAscending ? "chevron.up" : "chevron.down")
217+ .font(.caption2)
218+ }
219+ }
220+ }
221+ .buttonStyle(.plain)
222+ .padding(.horizontal, 6)
223+ .padding(.vertical, 4)
224+ .accessibilityLabel(sortColumn == column
225+ ? "Sort by \(column), \(sortAscending ? "ascending" : "descending")"
226+ : "Sort by \(column)")
227+
228+ Spacer(minLength: 0)
229+
230+ ColumnResizeHandle(
231+ columnWidth: Binding(
232+ get: { columnWidths[column] ?? 120 },
233+ set: { columnWidths[column] = $0 }
234+ )
235+ )
236+ }
237+ .frame(width: columnWidths[column] ?? 120, alignment: .leading)
238+ .contextMenu {
239+ Button("Auto-fit Column") {
240+ columnWidths[column] = idealColumnWidth(for: column, records: records)
241+ }
242+ Button("Auto-fit All Columns") {
243+ columnWidths = computeColumnWidths(columns: columns, records: records)
140244 }
141245 }
142− .buttonStyle(.plain)
143− .frame(width: columnWidth(for: column), alignment: .leading)
144− .padding(.horizontal, 6)
145− .padding(.vertical, 4)
146− .accessibilityLabel(sortColumn == column
147− ? "Sort by \(column), \(sortAscending ? "ascending" : "descending")"
148− : "Sort by \(column)")
149246 }
150247 }
151248 .background(Color(nsColor: .controlBackgroundColor))
@@ -153,9 +250,12 @@ struct LogHeaderView: View {
153250 }
154251}
155252
253+// MARK: - LogRowView
254+
156255struct LogRowView: View {
157256 let record: LogRecord
158257 let columns: [String]
258+ let columnWidths: [String: CGFloat]
159259 let isSelected: Bool
160260 let isAlternate: Bool
161261
@@ -166,7 +266,7 @@ struct LogRowView: View {
166266 Text(value?.displayString ?? "")
167267 .font(.system(.caption, design: .monospaced))
168268 .lineLimit(1)
169− .frame(width: columnWidth(for: column), alignment: .leading)
269+ .frame(width: columnWidths[column] ?? 120, alignment: .leading)
170270 .padding(.horizontal, 6)
171271 .padding(.vertical, 3)
172272 .foregroundStyle(colorForValue(column: column, value: value))
ParseableViewerTests/LogTableHelperTests.swift+41−19
@@ -3,32 +3,54 @@ import SwiftUI
33@testable import ParseableViewer
44
55final class LogTableHelperTests: XCTestCase {
6− // MARK: - columnWidth
6+ // MARK: - idealColumnWidth
77
8− func testColumnWidthTimestampFields() {
9− XCTAssertEqual(columnWidth(for: "p_timestamp"), 200)
10− XCTAssertEqual(columnWidth(for: "timestamp"), 200)
11− XCTAssertEqual(columnWidth(for: "@timestamp"), 200)
12− XCTAssertEqual(columnWidth(for: "time"), 200)
8+ func testIdealColumnWidthRespectsMinimum() {
9+ // A very short column name with no records should still meet the minimum width (50).
10+ let width = idealColumnWidth(for: "x", records: [])
11+ XCTAssertGreaterThanOrEqual(width, 50)
1312 }
1413
15− func testColumnWidthLevelFields() {
16− XCTAssertEqual(columnWidth(for: "level"), 80)
17− XCTAssertEqual(columnWidth(for: "severity"), 80)
18− XCTAssertEqual(columnWidth(for: "log_level"), 80)
14+ func testIdealColumnWidthRespectsMaximum() {
15+ // A record with extremely long content should be capped at 600.
16+ let longString = String(repeating: "A", count: 2000)
17+ let records: [LogRecord] = [["wide": .string(longString)]]
18+ let width = idealColumnWidth(for: "wide", records: records)
19+ XCTAssertLessThanOrEqual(width, 600)
1920 }
2021
21− func testColumnWidthMessageFields() {
22− XCTAssertEqual(columnWidth(for: "message"), 400)
23− XCTAssertEqual(columnWidth(for: "msg"), 400)
24− XCTAssertEqual(columnWidth(for: "body"), 400)
25− XCTAssertEqual(columnWidth(for: "log"), 400)
22+ func testIdealColumnWidthGrowsWithContent() {
23+ let shortRecords: [LogRecord] = [["col": .string("hi")]]
24+ let longRecords: [LogRecord] = [["col": .string("a much longer cell value here")]]
25+ let shortWidth = idealColumnWidth(for: "col", records: shortRecords)
26+ let longWidth = idealColumnWidth(for: "col", records: longRecords)
27+ XCTAssertLessThan(shortWidth, longWidth)
2628 }
2729
28− func testColumnWidthDefault() {
29− XCTAssertEqual(columnWidth(for: "custom_field"), 160)
30− XCTAssertEqual(columnWidth(for: "unknown"), 160)
31− XCTAssertEqual(columnWidth(for: ""), 160)
30+ func testIdealColumnWidthConsidersHeaderName() {
31+ // A long column name should produce a wider column even with no records.
32+ let shortHeader = idealColumnWidth(for: "id", records: [])
33+ let longHeader = idealColumnWidth(for: "a_very_long_column_name_here", records: [])
34+ XCTAssertLessThan(shortHeader, longHeader)
35+ }
36+
37+ // MARK: - computeColumnWidths
38+
39+ func testComputeColumnWidthsReturnsAllColumns() {
40+ let columns = ["a", "b", "c"]
41+ let records: [LogRecord] = [["a": .string("x"), "b": .int(1), "c": .null]]
42+ let widths = computeColumnWidths(columns: columns, records: records)
43+ XCTAssertEqual(Set(widths.keys), Set(columns))
44+ }
45+
46+ func testComputeColumnWidthsEmptyRecords() {
47+ let columns = ["col1", "col2"]
48+ let widths = computeColumnWidths(columns: columns, records: [])
49+ // Should still return widths based on header names
50+ XCTAssertEqual(widths.count, 2)
51+ for (_, width) in widths {
52+ XCTAssertGreaterThanOrEqual(width, 50)
53+ }
3254 }
3355
3456 // MARK: - levelColor