diff --git a/apple/TableProMobile/TableProMobile/Localizable.xcstrings b/apple/TableProMobile/TableProMobile/Localizable.xcstrings index 8ec2eb210d..7d6a1d957a 100644 --- a/apple/TableProMobile/TableProMobile/Localizable.xcstrings +++ b/apple/TableProMobile/TableProMobile/Localizable.xcstrings @@ -4329,6 +4329,9 @@ "Car" : { "extractionState" : "manual", "generatesSymbol" : false + }, + "Cards" : { + }, "Cart" : { "extractionState" : "manual", @@ -12942,6 +12945,9 @@ "Layers" : { "extractionState" : "manual", "generatesSymbol" : false + }, + "Layout" : { + }, "Leaf" : { "extractionState" : "manual", diff --git a/apple/TableProMobile/TableProMobile/Platform/AppPreferences.swift b/apple/TableProMobile/TableProMobile/Platform/AppPreferences.swift index b7dac65eb5..109d9da615 100644 --- a/apple/TableProMobile/TableProMobile/Platform/AppPreferences.swift +++ b/apple/TableProMobile/TableProMobile/Platform/AppPreferences.swift @@ -8,6 +8,7 @@ nonisolated enum AppPreferences { static let defaultPageSizeKey = "com.TablePro.settings.defaultPageSize" static let defaultSafeModeKey = "com.TablePro.settings.defaultSafeMode" static let hideQueryPreviewInActivityKey = "com.TablePro.settings.hideQueryPreviewInActivity" + static let rowLayoutKey = "com.TablePro.settings.rowLayout" static let pageSizeOptions: [Int] = [50, 100, 200, 500] diff --git a/apple/TableProMobile/TableProMobile/Views/Components/RowCard.swift b/apple/TableProMobile/TableProMobile/Views/Components/RowCard.swift index 16d9a71b7f..ec6f1e6854 100644 --- a/apple/TableProMobile/TableProMobile/Views/Components/RowCard.swift +++ b/apple/TableProMobile/TableProMobile/Views/Components/RowCard.swift @@ -1,6 +1,12 @@ import SwiftUI import TableProModels +nonisolated enum RowKey { + static func columnIndex(columns: [ColumnInfo], primaryKeys: Set) -> Int? { + columns.firstIndex { primaryKeys.contains($0.name) } ?? (columns.isEmpty ? nil : 0) + } +} + struct RowCard: View { let columns: [ColumnInfo] let columnDetails: [ColumnInfo] @@ -12,12 +18,9 @@ struct RowCard: View { } private var titlePair: (name: String, value: String)? { - let pks = pkNames - for (col, val) in zip(columns, row) where pks.contains(col.name) { - return (col.name, val ?? "NULL") - } - guard let first = columns.first else { return nil } - return (first.name, row.first.flatMap { $0 } ?? "NULL") + guard let index = RowKey.columnIndex(columns: columns, primaryKeys: pkNames) else { return nil } + let value = row.indices.contains(index) ? row[index] : nil + return (columns[index].name, value ?? "NULL") } private var detailPairs: [(name: String, value: String)] { diff --git a/apple/TableProMobile/TableProMobile/Views/Components/RowGrid.swift b/apple/TableProMobile/TableProMobile/Views/Components/RowGrid.swift new file mode 100644 index 0000000000..90f1c2adea --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Views/Components/RowGrid.swift @@ -0,0 +1,294 @@ +import SwiftUI +import TableProModels +import UIKit + +nonisolated struct RowGridMetrics: Equatable, Sendable { + var minimumWidth: CGFloat + var maximumWidth: CGFloat + var horizontalPadding: CGFloat +} + +nonisolated struct RowGridLayout: Equatable, Sendable { + static let sampledRowCount = 50 + // Past this many characters even the narrowest glyphs overflow the widest column. + static let maximumMeasuredCharacters = 128 + + let order: [Int] + let widths: [CGFloat] + + var totalWidth: CGFloat { widths.reduce(0, +) } + + static func make( + columnNames: [String], + rows: some Collection<[String?]>, + keyColumn: Int?, + metrics: RowGridMetrics, + measure: (_ text: String, _ isHeader: Bool) -> CGFloat + ) -> RowGridLayout { + let order = displayOrder(columnCount: columnNames.count, keyColumn: keyColumn) + let sample = rows.prefix(sampledRowCount) + let widestText = metrics.maximumWidth - metrics.horizontalPadding * 2 + let widths = order.map { column in + var widest = measure(measuredText(columnNames[column]), true) + for row in sample where widest < widestText { + let value = row.indices.contains(column) ? row[column] : nil + widest = max(widest, measure(measuredText(value ?? "NULL"), false)) + } + let width = widest.rounded(.up) + metrics.horizontalPadding * 2 + return min(max(width, metrics.minimumWidth), metrics.maximumWidth) + } + return RowGridLayout(order: order, widths: widths) + } + + // A key column wider than this share of the screen would leave no room to scroll the others past it. + static let keyColumnViewportShare: CGFloat = 0.4 + + func limitingKeyColumn(toViewportWidth viewportWidth: CGFloat) -> RowGridLayout { + let limit = (viewportWidth * Self.keyColumnViewportShare).rounded(.down) + guard let keyWidth = widths.first, limit > 0, keyWidth > limit else { return self } + return RowGridLayout(order: order, widths: [limit] + widths.dropFirst()) + } + + static func displayOrder(columnCount: Int, keyColumn: Int?) -> [Int] { + let columns = Array(0.. String { + var end = text.startIndex + var count = 0 + while end < text.endIndex, count < maximumMeasuredCharacters, !text[end].isNewline { + text.formIndex(after: &end) + count += 1 + } + let measured = String(text[.. CGFloat { + (text as NSString).size(withAttributes: [.font: isHeader ? headerFont : cellFont]).width + } +} + +nonisolated struct RowGridViewport: Equatable, Sendable { + let width: CGFloat + let leadingInset: CGFloat +} + +struct RowGrid: View { + let columns: [ColumnInfo] + let rows: [[String?]] + let keyColumn: Int? + @ViewBuilder let row: (_ index: Int, _ line: RowGridLine) -> Row + + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @Environment(\.displayScale) private var displayScale + @ScaledMetric(relativeTo: .subheadline) private var minimumWidth: CGFloat = 72 + @ScaledMetric(relativeTo: .subheadline) private var maximumWidth: CGFloat = 320 + @ScaledMetric(relativeTo: .subheadline) private var horizontalPadding: CGFloat = 12 + @ScaledMetric(relativeTo: .subheadline) private var verticalPadding: CGFloat = 10 + @State private var computedLayout: (key: LayoutKey, layout: RowGridLayout)? + @State private var viewport = RowGridViewport(width: 0, leadingInset: 0) + + private var layoutKey: LayoutKey { + LayoutKey( + columnNames: columns.map(\.name), + sample: rows.prefix(RowGridLayout.sampledRowCount), + keyColumn: keyColumn, + dynamicTypeSize: dynamicTypeSize, + metrics: RowGridMetrics( + minimumWidth: minimumWidth, maximumWidth: maximumWidth, horizontalPadding: horizontalPadding + ) + ) + } + + private var style: RowGridLine.Style { + RowGridLine.Style( + horizontalPadding: horizontalPadding, + verticalPadding: verticalPadding, + hairline: HairlineThickness.points(forDisplayScale: displayScale), + pinnedInset: viewport.leadingInset + ) + } + + var body: some View { + let layout = currentLayout.limitingKeyColumn(toViewportWidth: viewport.width) + ScrollView([.horizontal, .vertical]) { + grid(layout, width: max(layout.totalWidth, viewport.width)) + } + .scrollBounceBehavior(.basedOnSize, axes: .horizontal) + .defaultScrollAnchor(.topLeading, for: .alignment) + .buttonStyle(.plain) + // Changes only on a resize or an inset change, so scrolling runs no body pass. The container is the + // visible width; the insets are where the scroll view reaches under a sidebar or into the safe area. + .onScrollGeometryChange(for: RowGridViewport.self) { geometry in + RowGridViewport(width: geometry.containerSize.width, leadingInset: geometry.contentInsets.leading) + } action: { _, newViewport in + viewport = newViewport + } + // Reads only the key: the closure's other captures belong to the previous body. + .onChange(of: layoutKey, initial: true) { _, key in + computedLayout = (key, Self.makeLayout(key)) + } + } + + // Until onChange stores the layout for these inputs, it is measured here, so no pass draws an empty grid. + private var currentLayout: RowGridLayout { + let key = layoutKey + if let computedLayout, computedLayout.key == key { return computedLayout.layout } + return Self.makeLayout(key) + } + + private static func makeLayout(_ key: LayoutKey) -> RowGridLayout { + let measure = RowGridTextMeasure(dynamicTypeSize: key.dynamicTypeSize) + return RowGridLayout.make( + columnNames: key.columnNames, + rows: key.sample, + keyColumn: key.keyColumn, + metrics: key.metrics, + measure: measure.width(of:isHeader:) + ) + } + + private func grid(_ layout: RowGridLayout, width: CGFloat) -> some View { + let style = style + return LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) { + Section { + ForEach(IndexedRow.wrap(rows)) { item in + row(item.id, RowGridLine( + columns: columns, values: item.values, layout: layout, width: width, style: style + )) + } + } header: { + header(layout, width: width, style: style) + } + } + .frame(width: width, alignment: .leading) + } + + private func header(_ layout: RowGridLayout, width: CGFloat, style: RowGridLine.Style) -> some View { + HStack(spacing: 0) { + ForEach(layout.order.indices, id: \.self) { position in + Text(verbatim: columns[layout.order[position]].name) + .font(.subheadline) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + .lineLimit(1) + .padding(.horizontal, style.horizontalPadding) + .padding(.vertical, style.verticalPadding) + .frame(width: layout.widths[position], alignment: .leading) + .modifier(RowGridCell(isPinned: position == 0, style: style)) + } + } + .frame(width: width, alignment: .leading) + .background(Color(.systemBackground)) + .overlay(alignment: .bottom) { + Rectangle().fill(Color(.separator)).frame(height: style.hairline) + } + .accessibilityElement(children: .combine) + .accessibilityAddTraits(.isHeader) + } + + private struct LayoutKey: Equatable { + let columnNames: [String] + let sample: ArraySlice<[String?]> + let keyColumn: Int? + let dynamicTypeSize: DynamicTypeSize + let metrics: RowGridMetrics + } +} + +struct RowGridLine: View { + struct Style { + let horizontalPadding: CGFloat + let verticalPadding: CGFloat + let hairline: CGFloat + let pinnedInset: CGFloat + } + + let columns: [ColumnInfo] + let values: [String?] + let layout: RowGridLayout + let width: CGFloat + let style: Style + + var body: some View { + HStack(spacing: 0) { + ForEach(layout.order.indices, id: \.self) { position in + cell(value(at: layout.order[position])) + .frame(width: layout.widths[position], alignment: .leading) + .modifier(RowGridCell(isPinned: position == 0, style: style)) + } + } + .frame(width: width, alignment: .leading) + .overlay(alignment: .bottom) { + Rectangle().fill(Color(.separator)).frame(height: style.hairline) + } + .contentShape(Rectangle()) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(verbatim: accessibilityText)) + } + + private func value(at column: Int) -> String? { + values.indices.contains(column) ? values[column] : nil + } + + private var accessibilityText: String { + layout.order + .filter { columns.indices.contains($0) } + .map { "\(columns[$0].name), \(value(at: $0) ?? "NULL")" } + .joined(separator: ", ") + } + + private func cell(_ value: String?) -> some View { + Group { + if let value { + Text(verbatim: value) + } else { + Text(verbatim: "NULL") + .foregroundStyle(.tertiary) + } + } + .font(.subheadline) + .lineLimit(1) + .padding(.horizontal, style.horizontalPadding) + .padding(.vertical, style.verticalPadding) + } +} + +private struct RowGridCell: ViewModifier { + let isPinned: Bool + let style: RowGridLine.Style + + func body(content: Content) -> some View { + if isPinned { + let inset = style.pinnedInset + // Offset in the render pass, so a sideways scroll moves no state and rebuilds no row. + content + // Reaches over the leading inset, so cells scrolled past the key never show beside it. + .background(Color(.systemBackground).padding(.leading, -inset)) + .overlay(alignment: .trailing) { + Rectangle().fill(Color(.separator)).frame(width: style.hairline) + } + .visualEffect { content, proxy in + content.offset(x: max(0, -proxy.frame(in: .scrollView).minX)) + } + .zIndex(1) + } else { + content + } + } +} diff --git a/apple/TableProMobile/TableProMobile/Views/DataBrowserView.swift b/apple/TableProMobile/TableProMobile/Views/DataBrowserView.swift index b70ab4aa42..f9e2336d32 100644 --- a/apple/TableProMobile/TableProMobile/Views/DataBrowserView.swift +++ b/apple/TableProMobile/TableProMobile/Views/DataBrowserView.swift @@ -37,6 +37,7 @@ struct DataBrowserView: View { @State private var isCompletingCells = false @State private var hapticSuccess = false @State private var hapticError = false + @AppStorage(AppPreferences.rowLayoutKey) private var preferredLayout: RowLayout? private var activeSchema: String? { coordinator.supportsSchemas ? coordinator.activeSchema : nil @@ -299,7 +300,21 @@ struct DataBrowserView: View { } } } else { - rowList + rowsView + } + } + + private var rowLayout: RowLayout { + RowLayout.resolved(preferred: preferredLayout, idiom: UIDevice.current.userInterfaceIdiom) + } + + @ViewBuilder + private var rowsView: some View { + switch rowLayout { + case .cards: + pageContainer(rowList) + case .grid: + pageContainer(rowGrid) } } @@ -311,12 +326,50 @@ struct DataBrowserView: View { } } .listStyle(.plain) - .id(viewModel.pagination.currentPage) - .opacity(viewModel.isPageLoading ? 0.5 : 1) - .allowsHitTesting(!viewModel.isPageLoading) - .overlay { if viewModel.isPageLoading { ProgressView() } } - .animation(.default, value: viewModel.isPageLoading) - .refreshable { await viewModel.load() } + } + + private var rowGrid: some View { + let primaryKeys = primaryKeyNames + return RowGrid( + columns: columns, + rows: rows, + keyColumn: RowKey.columnIndex(columns: columns, primaryKeys: primaryKeys) + ) { index, line in + NavigationLink { + rowDetail(index: index) + } label: { + line + } + .hoverEffect() + .contextMenu { + rowContextMenu(index: index) + } preview: { + // The row itself can be thousands of points wide; the lift preview shows its card. + RowCard( + columns: columns, + columnDetails: viewModel.columnDetails, + row: rows.indices.contains(index) ? rows[index] : [], + previewFieldCount: DuoLayoutResolver.regularPreviewFieldCount + ) + .padding() + .frame(maxWidth: 360, alignment: .leading) + } + .accessibilityActions { deleteAccessibilityAction(index: index) } + } + } + + private var primaryKeyNames: Set { + Set(viewModel.columnDetails.filter(\.isPrimaryKey).map(\.name)) + } + + private func pageContainer(_ rows: some View) -> some View { + rows + .id(viewModel.pagination.currentPage) + .opacity(viewModel.isPageLoading ? 0.5 : 1) + .allowsHitTesting(!viewModel.isPageLoading) + .overlay { if viewModel.isPageLoading { ProgressView() } } + .animation(.default, value: viewModel.isPageLoading) + .refreshable { await viewModel.load() } } private func rowDetail(index: Int) -> some View { @@ -351,8 +404,7 @@ struct DataBrowserView: View { } private func rowLink(index: Int, row: [String?]) -> some View { - let cells = viewModel.cells(at: index) - let deleteKeys = canDeleteRows ? viewModel.rowLocator(forRowAt: index) : nil + let deleteKeys = deletableRow(at: index) return NavigationLink { rowDetail(index: index) } label: { @@ -364,7 +416,7 @@ struct DataBrowserView: View { ) } .hoverEffect() - .contextMenu { rowContextMenu(cells: cells) } + .contextMenu { rowContextMenu(index: index) } .swipeActions(edge: .trailing, allowsFullSwipe: false) { if let deleteKeys { Button { @@ -375,10 +427,17 @@ struct DataBrowserView: View { .tint(.red) } } - .accessibilityActions { - if let deleteKeys { - Button("Delete row") { confirmDelete(deleteKeys) } - } + .accessibilityActions { deleteAccessibilityAction(index: index) } + } + + private func deletableRow(at index: Int) -> RowLocator? { + canDeleteRows ? viewModel.rowLocator(forRowAt: index) : nil + } + + @ViewBuilder + private func deleteAccessibilityAction(index: Int) -> some View { + if let locator = deletableRow(at: index) { + Button("Delete row") { confirmDelete(locator) } } } @@ -388,7 +447,8 @@ struct DataBrowserView: View { } @ViewBuilder - private func rowContextMenu(cells: [Cell]) -> some View { + private func rowContextMenu(index: Int) -> some View { + let cells = viewModel.cells(at: index) if DataBrowserViewModel.canComplete(cells) { Menu("Share Row") { ForEach(ExportFormat.offered(for: connection.type)) { format in @@ -435,6 +495,10 @@ struct DataBrowserView: View { } } } + if let locator = deletableRow(at: index) { + Divider() + Button("Delete Row", systemImage: "trash", role: .destructive) { confirmDelete(locator) } + } } @ToolbarContentBuilder @@ -450,6 +514,8 @@ struct DataBrowserView: View { } Divider() } + RowLayoutPicker() + Divider() Section("Export") { ForEach(ExportFormat.offered(for: connection.type)) { format in Button { diff --git a/apple/TableProMobile/TableProMobile/Views/QueryEditorView.swift b/apple/TableProMobile/TableProMobile/Views/QueryEditorView.swift index c63ee96275..c3c8841ab8 100644 --- a/apple/TableProMobile/TableProMobile/Views/QueryEditorView.swift +++ b/apple/TableProMobile/TableProMobile/Views/QueryEditorView.swift @@ -50,6 +50,7 @@ struct QueryEditorView: View { @State private var containerSize: CGSize = .zero @State private var savedQueriesRoute: SavedQueriesSheetRoute? @State private var savedQueryFolderExpansion: Set = [] + @AppStorage(AppPreferences.rowLayoutKey) private var preferredLayout: RowLayout? private var session: ConnectionSession? { coordinator.session } private var tables: [TableInfo] { coordinator.tables } @@ -427,7 +428,7 @@ struct QueryEditorView: View { } else { VStack(spacing: 0) { resultNotices(includesTruncation: true) - resultList + resultRows } } } @@ -541,6 +542,42 @@ struct QueryEditorView: View { .background(.thinMaterial) } + private var rowLayout: RowLayout { + RowLayout.resolved(preferred: preferredLayout, idiom: UIDevice.current.userInterfaceIdiom) + } + + @ViewBuilder + private var resultRows: some View { + switch rowLayout { + case .cards: resultList + case .grid: resultGrid + } + } + + private var resultGrid: some View { + let columns = viewModel.columns + let rows = viewModel.legacyRows + return RowGrid( + columns: columns, + rows: rows, + keyColumn: RowKey.columnIndex(columns: columns, primaryKeys: []) + ) { index, line in + NavigationLink { + RowDetailView(columns: columns, rows: viewModel.window.rows, initialIndex: index) + } label: { + line + } + .hoverEffect() + .contextMenu { + resultRowContextMenu(columns: columns, rowIndex: index) + } preview: { + resultRowCard(columns: columns, row: rows.indices.contains(index) ? rows[index] : []) + .padding() + .frame(maxWidth: 360, alignment: .leading) + } + } + } + private var resultList: some View { let indexed = IndexedRow.wrap(viewModel.legacyRows) return List { @@ -665,6 +702,7 @@ struct QueryEditorView: View { } if !viewModel.legacyRows.isEmpty { + RowLayoutPicker() Section("Share Results") { ForEach(ExportFormat.offered(for: databaseType)) { format in Button { diff --git a/apple/TableProMobile/TableProMobile/Views/RowLayout.swift b/apple/TableProMobile/TableProMobile/Views/RowLayout.swift new file mode 100644 index 0000000000..a6df08d533 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Views/RowLayout.swift @@ -0,0 +1,45 @@ +import SwiftUI +import UIKit + +nonisolated enum RowLayout: String, CaseIterable, Identifiable, Sendable { + case cards + case grid + + var id: String { rawValue } + + // The idiom, not the size class: a resize or rotation must not swap the layout under a pushed row. + static func resolved(preferred: RowLayout?, idiom: UIUserInterfaceIdiom) -> RowLayout { + if let preferred { return preferred } + return idiom == .phone ? .cards : .grid + } + + var title: String { + switch self { + case .cards: return String(localized: "Cards") + case .grid: return String(localized: "Grid") + } + } + + var systemImage: String { + switch self { + case .cards: return "rectangle.grid.1x2" + case .grid: return "tablecells" + } + } +} + +struct RowLayoutPicker: View { + @AppStorage(AppPreferences.rowLayoutKey) private var preferredLayout: RowLayout? + + var body: some View { + let current = RowLayout.resolved(preferred: preferredLayout, idiom: UIDevice.current.userInterfaceIdiom) + Picker(selection: Binding(get: { current }, set: { preferredLayout = $0 })) { + ForEach(RowLayout.allCases) { layout in + Label(layout.title, systemImage: layout.systemImage).tag(layout) + } + } label: { + Label("Layout", systemImage: current.systemImage) + } + .pickerStyle(.inline) + } +} diff --git a/apple/TableProMobile/TableProMobileTests/Views/RowGridTests.swift b/apple/TableProMobile/TableProMobileTests/Views/RowGridTests.swift new file mode 100644 index 0000000000..7d514acbea --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/Views/RowGridTests.swift @@ -0,0 +1,153 @@ +import CoreGraphics +import SwiftUI +@testable import TableProMobile +import Testing +import UIKit + +struct RowGridLayoutTests { + private static let metrics = RowGridMetrics(minimumWidth: 72, maximumWidth: 320, horizontalPadding: 12) + + private static func layout( + _ names: [String], + _ rows: [[String?]], + keyColumn: Int? = 0, + measure: (String, Bool) -> CGFloat = { text, _ in CGFloat(text.count) * 8 } + ) -> RowGridLayout { + RowGridLayout.make(columnNames: names, rows: rows, keyColumn: keyColumn, metrics: metrics, measure: measure) + } + + @Test("The key column leads and the rest keep table order") + func keyColumnLeads() { + #expect(RowGridLayout.displayOrder(columnCount: 4, keyColumn: 2) == [2, 0, 1, 3]) + #expect(RowGridLayout.displayOrder(columnCount: 4, keyColumn: 0) == [0, 1, 2, 3]) + } + + @Test("A missing or out of range key column keeps table order") + func noKeyKeepsOrder() { + #expect(RowGridLayout.displayOrder(columnCount: 3, keyColumn: nil) == [0, 1, 2]) + #expect(RowGridLayout.displayOrder(columnCount: 3, keyColumn: 5) == [0, 1, 2]) + #expect(RowGridLayout.displayOrder(columnCount: 0, keyColumn: 0).isEmpty) + } + + @Test("Widths follow the display order, one per column, and a short row counts as NULL") + func widthPerDisplayedColumn() { + let layout = Self.layout(["a", "bbbbbbbbbbbbbbbbbbbb"], [["1"]], keyColumn: 1) + #expect(layout.order == [1, 0]) + #expect(layout.widths == [20 * 8 + 24, 72]) + #expect(layout.totalWidth == CGFloat(20 * 8 + 24 + 72)) + } + + @Test("Widths stay between the minimum and the maximum") + func widthsAreClamped() { + let layout = Self.layout(["id", "body"], [["1", String(repeating: "x", count: 500)]]) + #expect(layout.widths == [72, 320]) + } + + @Test("The header is measured as a header and values as cells") + func headerMeasuredAsHeader() { + var measured: [String: Bool] = [:] + _ = Self.layout(["name"], [["value"]]) { text, isHeader in + measured[text] = isHeader + return 0 + } + #expect(measured == ["name": true, "value": false]) + } + + @Test("A wider measurement widens the column even with fewer characters") + func widthFollowsMeasurement() { + let layout = Self.layout(["c"], [["abcdefghij"], ["日本語"]]) { text, _ in + text == "日本語" ? 200 : CGFloat(text.count) * 8 + } + #expect(layout.widths == [200 + 24]) + } + + @Test("Only the first rows are sampled") + func onlyFirstRowsSampled() { + var rows: [[String?]] = Array(repeating: ["x"], count: RowGridLayout.sampledRowCount) + rows.append([String(repeating: "y", count: 30)]) + #expect(Self.layout(["c"], rows).widths == [72]) + } + + @Test("A column already at the maximum stops measuring") + func stopsAtMaximum() { + var calls = 0 + let rows: [[String?]] = Array(repeating: [String(repeating: "z", count: 100)], count: 10) + _ = Self.layout(["c"], rows) { text, _ in + calls += 1 + return CGFloat(text.count) * 8 + } + #expect(calls == 2) + } + + @Test("The key column takes at most its share of the viewport, and only the key column shrinks") + func keyColumnLimitedByViewport() { + let layout = RowGridLayout(order: [0, 1], widths: [300, 200]) + #expect(layout.limitingKeyColumn(toViewportWidth: 400).widths == [160, 200]) + #expect(layout.limitingKeyColumn(toViewportWidth: 1_000).widths == [300, 200]) + #expect(layout.limitingKeyColumn(toViewportWidth: 0).widths == [300, 200]) + #expect(RowGridLayout(order: [], widths: []).limitingKeyColumn(toViewportWidth: 400).widths.isEmpty) + } + + @Test("Only the first line counts, with room for the ellipsis that marks the rest") + func firstLineWithEllipsis() { + #expect(RowGridLayout.measuredText("first line\nsecond") == "first line…") + #expect(RowGridLayout.measuredText("one line") == "one line") + #expect(RowGridLayout.measuredText("") == "") + } + + @Test("A long value is measured up to the cap, not to its end") + func longValueCapped() { + let text = String(repeating: "a", count: 4_096) + let measured = RowGridLayout.measuredText(text) + #expect(measured.count == RowGridLayout.maximumMeasuredCharacters + 1) + #expect(measured.hasSuffix("…")) + } +} + +@MainActor +struct RowGridTextMeasureTests { + private static let samples: [(text: String, isHeader: Bool)] = [ + ("11170334", false), + ("2026-11-01 09:41:00", false), + ("WWWWWWWWWW", false), + ("For Those About To Rock (We Salute You)", false), + ("日本語のテキストと中文", false), + ("Ünïcødé ẞtraße", false), + ("NULL", false), + ("TrackId", true), + ("PlaylistId", true), + ("InvoiceLineId", true), + ("AlbumId", true), + ("UnitPrice", true), + ("MediaTypeId", true), + ] + + private static func renderedWidth(_ text: String, isHeader: Bool, size: DynamicTypeSize) -> CGFloat { + let view = Text(verbatim: text) + .font(.subheadline) + .fontWeight(isHeader ? .semibold : nil) + .lineLimit(1) + .fixedSize() + .environment(\.dynamicTypeSize, size) + return UIHostingController(rootView: view) + .sizeThatFits(in: CGSize(width: CGFloat.greatestFiniteMagnitude, height: .greatestFiniteMagnitude)) + .width + } + + @Test("A measured width holds the text as SwiftUI renders it", arguments: [DynamicTypeSize.large, .xxxLarge, .accessibility3]) + func measuredWidthHoldsRenderedText(size: DynamicTypeSize) { + let measure = RowGridTextMeasure(dynamicTypeSize: size) + for sample in Self.samples { + let measured = measure.width(of: sample.text, isHeader: sample.isHeader).rounded(.up) + let rendered = Self.renderedWidth(sample.text, isHeader: sample.isHeader, size: size) + #expect(measured >= rendered, "\(sample.text) at \(size): measured \(measured), rendered \(rendered)") + } + } + + @Test("Larger text measures wider") + func scalesWithDynamicType() { + let regular = RowGridTextMeasure(dynamicTypeSize: .large).width(of: "AlbumId", isHeader: false) + let large = RowGridTextMeasure(dynamicTypeSize: .accessibility3).width(of: "AlbumId", isHeader: false) + #expect(large > regular) + } +} diff --git a/apple/TableProMobile/TableProMobileTests/Views/RowLayoutTests.swift b/apple/TableProMobile/TableProMobileTests/Views/RowLayoutTests.swift new file mode 100644 index 0000000000..d855ff4d13 --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/Views/RowLayoutTests.swift @@ -0,0 +1,65 @@ +import Foundation +import SwiftUI +import TableProModels +@testable import TableProMobile +import Testing +import UIKit + +struct RowLayoutTests { + @Test("With no choice made, iPhone keeps the cards and every other device opens the grid") + func defaultFollowsIdiom() { + #expect(RowLayout.resolved(preferred: nil, idiom: .phone) == .cards) + #expect(RowLayout.resolved(preferred: nil, idiom: .pad) == .grid) + #expect(RowLayout.resolved(preferred: nil, idiom: .mac) == .grid) + } + + @Test("A choice the user made wins on every device", arguments: RowLayout.allCases) + func choiceWinsOnEveryDevice(layout: RowLayout) { + for idiom in [UIUserInterfaceIdiom.phone, .pad, .mac] { + #expect(RowLayout.resolved(preferred: layout, idiom: idiom) == layout) + } + } + + @Test("A stored choice is read back by a later browser") + func storedChoiceIsRemembered() throws { + let suite = "RowLayoutTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + + let writer = AppStorage(AppPreferences.rowLayoutKey, store: defaults) + #expect(writer.wrappedValue == nil) + writer.wrappedValue = .grid + + let reader = AppStorage(AppPreferences.rowLayoutKey, store: defaults) + #expect(reader.wrappedValue == .grid) + #expect(defaults.string(forKey: AppPreferences.rowLayoutKey) == "grid") + } + + @Test("An unknown stored value falls back to the device default") + func unknownStoredValueIsIgnored() throws { + let suite = "RowLayoutTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + defaults.set("table", forKey: AppPreferences.rowLayoutKey) + + let stored = AppStorage(AppPreferences.rowLayoutKey, store: defaults).wrappedValue + #expect(stored == nil) + #expect(RowLayout.resolved(preferred: stored, idiom: .phone) == .cards) + } +} + +struct RowKeyTests { + private static let columns = ["created_at", "name", "id", "code"].map { ColumnInfo(name: $0, typeName: "text") } + + @Test("The first primary key column in table order names the row") + func primaryKeyNamesTheRow() { + #expect(RowKey.columnIndex(columns: Self.columns, primaryKeys: ["id"]) == 2) + #expect(RowKey.columnIndex(columns: Self.columns, primaryKeys: ["code", "id"]) == 2) + } + + @Test("Without a primary key the first column names the row, and no columns name nothing") + func firstColumnWithoutKey() { + #expect(RowKey.columnIndex(columns: Self.columns, primaryKeys: []) == 0) + #expect(RowKey.columnIndex(columns: [], primaryKeys: ["id"]) == nil) + } +} diff --git a/apple/changelog.d/3963.added.md b/apple/changelog.d/3963.added.md new file mode 100644 index 0000000000..552d0db539 --- /dev/null +++ b/apple/changelog.d/3963.added.md @@ -0,0 +1 @@ +iPhone and iPad show table rows and query results in a grid with a pinned header and key column, beside the cards.