diff --git a/CHANGELOG.md b/CHANGELOG.md index 446193966..70c962b51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- The data view bottom bar groups the row count with the pagination controls, and the rows-per-page menu shows plain numbers instead of locale-grouped values like "1.000". Click the row range to change rows per page. - Selecting a Redis namespace in the sidebar key tree now filters the open database view to that prefix, with paging, instead of opening a separate tab limited to one batch of keys. (#1701) ### Fixed diff --git a/TablePro/Models/Query/PaginationState+Display.swift b/TablePro/Models/Query/PaginationState+Display.swift new file mode 100644 index 000000000..f2bfa56b3 --- /dev/null +++ b/TablePro/Models/Query/PaginationState+Display.swift @@ -0,0 +1,25 @@ +// +// PaginationState+Display.swift +// TablePro +// + +import Foundation + +extension PaginationState { + func rangeText(loadedRowCount: Int) -> String? { + if let total = totalRowCount, total > 0 { + let formattedTotal = total.formatted(.number.grouping(.automatic)) + let prefix = isApproximateRowCount ? "~" : "" + return String(format: String(localized: "%d-%d of %@%@ rows"), rangeStart, rangeEnd, prefix, formattedTotal) + } + if currentPage > 1 || loadedRowCount >= pageSize { + let end = currentOffset + loadedRowCount + return String(format: String(localized: "%d-%d of ? rows"), rangeStart, end) + } + return nil + } + + static func pageSizeLabel(_ size: Int) -> String { + size.formatted(.number.grouping(.never)) + } +} diff --git a/TablePro/Views/Components/PaginationControlsView.swift b/TablePro/Views/Components/PaginationControlsView.swift index de975bf86..4144cbc8f 100644 --- a/TablePro/Views/Components/PaginationControlsView.swift +++ b/TablePro/Views/Components/PaginationControlsView.swift @@ -27,18 +27,20 @@ struct PaginationControlsView: View { var body: some View { HStack(spacing: 8) { - pageSizeMenu + rangeMenu navigationCluster + .fixedSize() + .layoutPriority(1) } } - // MARK: - Page Size Menu + // MARK: - Range / Page Size Menu - private var pageSizeMenu: some View { + private var rangeMenu: some View { Menu { Picker(String(localized: "Rows per page"), selection: pageSizeBinding) { ForEach(Self.pageSizePresets, id: \.self) { size in - Text(size.formatted()).tag(size) + Text(PaginationState.pageSizeLabel(size)).tag(size) } } .pickerStyle(.inline) @@ -52,13 +54,16 @@ struct PaginationControlsView: View { showCustomPopover = true } } label: { - Text(pageSizeLabel) + Text(rangeLabel) + .lineLimit(1) + .truncationMode(.middle) + .frame(minWidth: 60, alignment: .leading) } .menuStyle(.borderlessButton) - .fixedSize() .controlSize(.small) .help(String(localized: "Rows per page")) - .accessibilityLabel(String(localized: "Rows per page")) + .accessibilityLabel(rangeLabel) + .accessibilityHint(String(localized: "Rows per page")) .overlay(alignment: .bottom) { Color.clear .frame(width: 0, height: 0) @@ -72,8 +77,8 @@ struct PaginationControlsView: View { Binding(get: { pagination.pageSize }, set: { onPageSizeChange($0) }) } - private var pageSizeLabel: String { - pagination.pageSize.formatted() + private var rangeLabel: String { + pagination.rangeText(loadedRowCount: loadedRowCount) ?? PaginationState.pageSizeLabel(pagination.pageSize) } // MARK: - Navigation diff --git a/TablePro/Views/Main/Child/MainStatusBarView.swift b/TablePro/Views/Main/Child/MainStatusBarView.swift index b6e17f14c..9f62d739b 100644 --- a/TablePro/Views/Main/Child/MainStatusBarView.swift +++ b/TablePro/Views/Main/Child/MainStatusBarView.swift @@ -52,191 +52,244 @@ struct MainStatusBarView: View { viewMode == .data && canAddRow } - private var filterToggleHelp: String { - helpText(String(localized: "Toggle Filters"), shortcut: .toggleFilters) + private var showsAddButton: Bool { + Self.showsAddRow(viewMode: viewMode, canAddRow: onAddRow != nil) } - private var addRowHelp: String { - helpText(String(localized: "Add Row"), shortcut: .addRow) + private var showsFiltersToggle: Bool { + snapshot.tabType == .table && snapshot.hasTableName } - private func helpText(_ label: String, shortcut action: ShortcutAction) -> String { - AppSettingsManager.shared.keyboard.shortcutHint(label, for: action) + private var showsActionButtons: Bool { + showsDataChrome && (showsAddButton || snapshot.hasColumns || showsFiltersToggle) } - private var columnsAccessibilityLabel: String { - guard !columnState.hidden.isEmpty else { - return String(localized: "Columns") - } - let visible = columnState.all.count - columnState.hidden.count - return String(format: String(localized: "%d of %d columns visible"), visible, columnState.all.count) + private var showsPagination: Bool { + snapshot.tabType == .table && snapshot.hasTableName && snapshot.showsPaginationControls + } + + private var showsTruncation: Bool { + snapshot.tabType == .query && snapshot.pagination.hasMoreRows && !snapshot.pagination.isLoadingMore + } + + private func hasStatusText(_ status: String?) -> Bool { + snapshot.pagination.isLoadingMore + || status != nil + || showsTruncation + || snapshot.statusMessage != nil + } + + private func hasPrimaryStatus(_ status: String?) -> Bool { + snapshot.pagination.isLoadingMore || status != nil + } + + private func showsDataNavigation(_ status: String?) -> Bool { + showsDataChrome && (hasStatusText(status) || showsPagination) } var body: some View { - HStack { - if snapshot.tabId != nil { - if snapshot.tabType == .table, snapshot.hasTableName { - Picker(String(localized: "View Mode"), selection: $viewMode) { - Label("Data", systemImage: "tablecells").tag(ResultsViewMode.data) - Label("Structure", systemImage: "list.bullet.rectangle").tag(ResultsViewMode.structure) - Label("JSON", systemImage: "curlybraces").tag(ResultsViewMode.json) - } - .labelsHidden() - .pickerStyle(.segmented) - .frame(width: 260) - .controlSize(.small) - } else if snapshot.hasColumns { - Picker(String(localized: "View Mode"), selection: $viewMode) { - Label("Data", systemImage: "tablecells").tag(ResultsViewMode.data) - Label("JSON", systemImage: "curlybraces").tag(ResultsViewMode.json) - } - .labelsHidden() - .pickerStyle(.segmented) - .frame(width: 140) - .controlSize(.small) + HStack(spacing: 8) { + viewModePicker + Spacer(minLength: 8) + trailingControls + } + .padding(.leading, 8) + .padding(.trailing, 20) + .padding(.vertical, 4) + .background(Color(nsColor: .controlBackgroundColor)) + .onChange(of: snapshot.tabId) { _, _ in + showColumnPopover = false + } + } + + @ViewBuilder + private var viewModePicker: some View { + if snapshot.tabId != nil { + if snapshot.tabType == .table, snapshot.hasTableName { + Picker(String(localized: "View Mode"), selection: $viewMode) { + Label("Data", systemImage: "tablecells").tag(ResultsViewMode.data) + Label("Structure", systemImage: "list.bullet.rectangle").tag(ResultsViewMode.structure) + Label("JSON", systemImage: "curlybraces").tag(ResultsViewMode.json) + } + .labelsHidden() + .pickerStyle(.segmented) + .frame(width: 260) + .controlSize(.small) + } else if snapshot.hasColumns { + Picker(String(localized: "View Mode"), selection: $viewMode) { + Label("Data", systemImage: "tablecells").tag(ResultsViewMode.data) + Label("JSON", systemImage: "curlybraces").tag(ResultsViewMode.json) } + .labelsHidden() + .pickerStyle(.segmented) + .frame(width: 140) + .controlSize(.small) } + } + } - Spacer() + @ViewBuilder + private var trailingControls: some View { + let status = snapshot.statusText(selectedCount: selectedRowIndices.count) + HStack(spacing: 8) { + if isStructureMode { + if structureState.footer.isActive { + structureFooterControls(state: structureState.footer) + } + } else { + actionButtons + if showsActionButtons, showsDataNavigation(status) { + Divider().frame(height: 16) + } + dataNavigationGroup(status: status) + } + } + } - if showsDataChrome, snapshot.hasRows { + @ViewBuilder + private var actionButtons: some View { + if showsAddButton, let onAddRow { + Button { + onAddRow() + } label: { HStack(spacing: 4) { - if snapshot.pagination.isLoadingMore { - ProgressView() - .controlSize(.small) - .accessibilityHidden(true) - Text("Loading…") - .font(.caption) - .foregroundStyle(.secondary) - .accessibilityLabel(String(localized: "Loading more rows")) - } else { - Text(snapshot.rowInfoText(selectedCount: selectedRowIndices.count)) - .font(.caption) - .foregroundStyle(.secondary) - } - - if snapshot.tabType == .query && snapshot.pagination.hasMoreRows && !snapshot.pagination.isLoadingMore { - Text("·") - .font(.caption) - .foregroundStyle(.quaternary) - Text("truncated") - .font(.caption) - .foregroundStyle(.secondary) - Button { - onFetchAll?() - } label: { - Text("Fetch All") - .font(.caption) - } - .buttonStyle(.link) - } - - if let statusMessage = snapshot.statusMessage { - Text("·") - .foregroundStyle(.tertiary) - Text(statusMessage) - .font(.caption) - .foregroundStyle(.secondary) - } + Image(systemName: "plus") + Text("Add") } } + .controlSize(.small) + .help(addRowHelp) + .accessibilityLabel(String(localized: "Add Row")) + } - Spacer() + if snapshot.hasColumns { + columnsButton + } - HStack(spacing: 8) { - if isStructureMode, structureState.footer.isActive { - structureFooterControls(state: structureState.footer) + if showsFiltersToggle { + filtersToggle + } + } + + private var columnsButton: some View { + Button { + showColumnPopover.toggle() + } label: { + HStack(spacing: 4) { + Image(systemName: !columnState.hidden.isEmpty ? "eye.slash.circle.fill" : "eye.circle") + Text("Columns") + if !columnState.hidden.isEmpty { + let visible = columnState.all.count - columnState.hidden.count + Text("(\(visible)/\(columnState.all.count))") + .foregroundStyle(.secondary) + } + } + } + .controlSize(.small) + .accessibilityLabel(columnsAccessibilityLabel) + .popover(isPresented: $showColumnPopover, arrowEdge: .top) { + ColumnVisibilityPopover( + columns: columnState.all, + hiddenColumns: columnState.hidden, + onToggleColumn: columnState.onToggle, + onShowAll: columnState.onShowAll, + onHideAll: columnState.onHideAll + ) + } + } + + private var filtersToggle: some View { + Toggle(isOn: Binding( + get: { filterState.isVisible }, + set: { _ in onToggleFilters() } + )) { + HStack(spacing: 4) { + Image(systemName: filterState.hasAppliedFilters + ? "line.3.horizontal.decrease.circle.fill" + : "line.3.horizontal.decrease.circle") + Text("Filters") + if filterState.hasAppliedFilters { + Text("(\(filterState.appliedFilters.count))") + .foregroundStyle(.secondary) } + } + } + .toggleStyle(.button) + .controlSize(.small) + .help(filterToggleHelp) + .accessibilityLabel(String(localized: "Filters")) + .accessibilityAddTraits(filterState.isVisible ? .isSelected : []) + } - if showsDataChrome { - if Self.showsAddRow(viewMode: viewMode, canAddRow: onAddRow != nil), let onAddRow { - Button { - onAddRow() - } label: { - HStack(spacing: 4) { - Image(systemName: "plus") - Text("Add") - } - } - .controlSize(.small) - .help(addRowHelp) - .accessibilityLabel(String(localized: "Add Row")) - } - - if snapshot.hasColumns { - Button { - showColumnPopover.toggle() - } label: { - HStack(spacing: 4) { - Image(systemName: !columnState.hidden.isEmpty - ? "eye.slash.circle.fill" - : "eye.circle") - Text("Columns") - if !columnState.hidden.isEmpty { - let visible = columnState.all.count - columnState.hidden.count - Text("(\(visible)/\(columnState.all.count))") - .foregroundStyle(.secondary) - } - } - } - .controlSize(.small) - .accessibilityLabel(columnsAccessibilityLabel) - .popover(isPresented: $showColumnPopover, arrowEdge: .top) { - ColumnVisibilityPopover( - columns: columnState.all, - hiddenColumns: columnState.hidden, - onToggleColumn: columnState.onToggle, - onShowAll: columnState.onShowAll, - onHideAll: columnState.onHideAll - ) - } - } - - if snapshot.tabType == .table, snapshot.hasTableName { - Toggle(isOn: Binding( - get: { filterState.isVisible }, - set: { _ in onToggleFilters() } - )) { - HStack(spacing: 4) { - Image(systemName: filterState.hasAppliedFilters - ? "line.3.horizontal.decrease.circle.fill" - : "line.3.horizontal.decrease.circle") - Text("Filters") - if filterState.hasAppliedFilters { - Text("(\(filterState.appliedFilters.count))") - .foregroundStyle(.secondary) - } - } - } - .toggleStyle(.button) - .controlSize(.small) - .help(filterToggleHelp) - .accessibilityLabel(String(localized: "Filters")) - .accessibilityAddTraits(filterState.isVisible ? .isSelected : []) - } - - if snapshot.tabType == .table, snapshot.hasTableName, snapshot.showsPaginationControls { - PaginationControlsView( - pagination: snapshot.pagination, - loadedRowCount: snapshot.rowCount, - onFirst: paginationCallbacks.onFirst, - onPrevious: paginationCallbacks.onPrevious, - onNext: paginationCallbacks.onNext, - onLast: paginationCallbacks.onLast, - onPageSizeChange: paginationCallbacks.onPageSizeChange, - onShowAll: paginationCallbacks.onShowAll, - onGoToPage: paginationCallbacks.onGoToPage - ) - } + @ViewBuilder + private func dataNavigationGroup(status: String?) -> some View { + if showsDataNavigation(status) { + HStack(spacing: 4) { + statusCluster(status: status) + if showsPagination { + PaginationControlsView( + pagination: snapshot.pagination, + loadedRowCount: snapshot.rowCount, + onFirst: paginationCallbacks.onFirst, + onPrevious: paginationCallbacks.onPrevious, + onNext: paginationCallbacks.onNext, + onLast: paginationCallbacks.onLast, + onPageSizeChange: paginationCallbacks.onPageSizeChange, + onShowAll: paginationCallbacks.onShowAll, + onGoToPage: paginationCallbacks.onGoToPage + ) } } } - .padding(.leading, 8) - .padding(.trailing, 20) - .padding(.vertical, 4) - .background(Color(nsColor: .controlBackgroundColor)) - .onChange(of: snapshot.tabId) { _, _ in - showColumnPopover = false + } + + private var statusSeparator: some View { + Text("·") + .font(.caption) + .foregroundStyle(.tertiary) + } + + @ViewBuilder + private func statusCluster(status: String?) -> some View { + if snapshot.pagination.isLoadingMore { + ProgressView() + .controlSize(.small) + .accessibilityHidden(true) + Text("Loading…") + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityLabel(String(localized: "Loading more rows")) + } else if let status { + Text(status) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + if showsTruncation { + if hasPrimaryStatus(status) { + statusSeparator + } + Text("truncated") + .font(.caption) + .foregroundStyle(.secondary) + Button { + onFetchAll?() + } label: { + Text("Fetch All") + .font(.caption) + } + .buttonStyle(.link) + } + + if let statusMessage = snapshot.statusMessage { + if hasPrimaryStatus(status) || showsTruncation { + statusSeparator + } + Text(statusMessage) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) } } @@ -267,4 +320,24 @@ struct MainStatusBarView: View { .controlSize(.small) .fixedSize() } + + private var filterToggleHelp: String { + helpText(String(localized: "Toggle Filters"), shortcut: .toggleFilters) + } + + private var addRowHelp: String { + helpText(String(localized: "Add Row"), shortcut: .addRow) + } + + private func helpText(_ label: String, shortcut action: ShortcutAction) -> String { + AppSettingsManager.shared.keyboard.shortcutHint(label, for: action) + } + + private var columnsAccessibilityLabel: String { + guard !columnState.hidden.isEmpty else { + return String(localized: "Columns") + } + let visible = columnState.all.count - columnState.hidden.count + return String(format: String(localized: "%d of %d columns visible"), visible, columnState.all.count) + } } diff --git a/TablePro/Views/Main/Child/StatusBarSnapshot+RowInfo.swift b/TablePro/Views/Main/Child/StatusBarSnapshot+RowInfo.swift index 158683756..693d930e0 100644 --- a/TablePro/Views/Main/Child/StatusBarSnapshot+RowInfo.swift +++ b/TablePro/Views/Main/Child/StatusBarSnapshot+RowInfo.swift @@ -6,9 +6,8 @@ import Foundation extension StatusBarSnapshot { - func rowInfoText(selectedCount: Int) -> String { + func statusText(selectedCount: Int) -> String? { let loadedCount = rowCount - let total = pagination.totalRowCount if selectedCount > 0 { if selectedCount == loadedCount { @@ -20,14 +19,8 @@ extension StatusBarSnapshot { let formattedCount = loadedCount.formatted(.number.grouping(.automatic)) return String(format: String(localized: "Showing %@ rows"), formattedCount) } - if tabType == .table, let total, total > 0 { - let formattedTotal = total.formatted(.number.grouping(.automatic)) - let prefix = pagination.isApproximateRowCount ? "~" : "" - return String(format: String(localized: "%d-%d of %@%@ rows"), pagination.rangeStart, pagination.rangeEnd, prefix, formattedTotal) - } - if tabType == .table, isPagedWithUnknownTotal { - let rangeEnd = pagination.currentOffset + loadedCount - return String(format: String(localized: "%d-%d of ? rows"), pagination.rangeStart, rangeEnd) + if tabType == .table, showsPaginationControls { + return nil } if loadedCount > 0 { let formattedCount = loadedCount.formatted(.number.grouping(.automatic)) diff --git a/TableProTests/Models/PaginationStateDisplayTests.swift b/TableProTests/Models/PaginationStateDisplayTests.swift new file mode 100644 index 000000000..aee3f6e7c --- /dev/null +++ b/TableProTests/Models/PaginationStateDisplayTests.swift @@ -0,0 +1,59 @@ +// +// PaginationStateDisplayTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("PaginationState Display") +struct PaginationStateDisplayTests { + @Test("A known total reports the offset range") + func rangeWithKnownTotal() { + let pagination = PaginationState(totalRowCount: 5_000, pageSize: 1_000, currentPage: 3, currentOffset: 2_000) + let text = pagination.rangeText(loadedRowCount: 1_000) + #expect(text?.contains("2001-3000") == true) + #expect(text?.contains("5,000") == true || text?.contains("5.000") == true) + } + + @Test("An approximate total is prefixed with a tilde") + func rangeWithApproximateTotal() { + var pagination = PaginationState(totalRowCount: 111_559, pageSize: 1_000, currentPage: 1, currentOffset: 0) + pagination.isApproximateRowCount = true + let text = pagination.rangeText(loadedRowCount: 1_000) + #expect(text?.contains("~") == true) + } + + @Test("An exact total has no tilde") + func rangeWithExactTotal() { + let pagination = PaginationState(totalRowCount: 111_559, pageSize: 1_000, currentPage: 1, currentOffset: 0) + let text = pagination.rangeText(loadedRowCount: 1_000) + #expect(text?.contains("~") == false) + } + + @Test("A paged table with unknown total reports a question mark") + func rangeWithUnknownTotal() { + let pagination = PaginationState(totalRowCount: nil, pageSize: 50, currentPage: 2, currentOffset: 50) + let text = pagination.rangeText(loadedRowCount: 50) + #expect(text?.contains("51-100") == true) + #expect(text?.contains("?") == true) + } + + @Test("A small single page has no range") + func rangeOnSinglePage() { + let pagination = PaginationState(totalRowCount: nil, pageSize: 50, currentPage: 1, currentOffset: 0) + #expect(pagination.rangeText(loadedRowCount: 5) == nil) + } + + @Test("Page size labels never use a grouping separator") + func pageSizeLabelHasNoGrouping() { + for size in [5, 100, 1_000, 100_000] { + let label = PaginationState.pageSizeLabel(size) + #expect(!label.contains(",")) + #expect(!label.contains(".")) + #expect(!label.contains(" ")) + } + } +} diff --git a/TableProTests/Models/StatusBarSnapshotTests.swift b/TableProTests/Models/StatusBarSnapshotTests.swift index 922c0c6b8..fbffe5b2d 100644 --- a/TableProTests/Models/StatusBarSnapshotTests.swift +++ b/TableProTests/Models/StatusBarSnapshotTests.swift @@ -57,40 +57,39 @@ struct StatusBarSnapshotTests { @Test("No rows reports an empty state") func rowInfoNoRows() { let snapshot = makeSnapshot(rowCount: 0) - #expect(snapshot.rowInfoText(selectedCount: 0) == String(localized: "No rows")) + #expect(snapshot.statusText(selectedCount: 0) == String(localized: "No rows")) } @Test("Selecting every loaded row reports the all-selected text") func rowInfoAllSelected() { let snapshot = makeSnapshot(rowCount: 5) - #expect(snapshot.rowInfoText(selectedCount: 5) == String(format: String(localized: "All %d rows selected"), 5)) + #expect(snapshot.statusText(selectedCount: 5) == String(format: String(localized: "All %d rows selected"), 5)) } @Test("Selecting some rows reports the partial-selection text") func rowInfoPartialSelection() { let snapshot = makeSnapshot(rowCount: 5) - #expect(snapshot.rowInfoText(selectedCount: 2) == String(format: String(localized: "%d of %d rows selected"), 2, 5)) + #expect(snapshot.statusText(selectedCount: 2) == String(format: String(localized: "%d of %d rows selected"), 2, 5)) } - @Test("A table with a known total reports the offset range") - func rowInfoTableRange() { + @Test("A paginated table defers its range to the pagination control") + func tableRangeDefersToPagination() { let snapshot = makeSnapshot( rowCount: 1_000, pagination: PaginationState(totalRowCount: 5_000, pageSize: 1_000, currentPage: 3, currentOffset: 2_000) ) - let text = snapshot.rowInfoText(selectedCount: 0) - #expect(text.contains("2001-3000")) + #expect(snapshot.showsPaginationControls) + #expect(snapshot.statusText(selectedCount: 0) == nil) } - @Test("A paged table with unknown total reports a question mark for the total") - func rowInfoUnknownTotalRange() { + @Test("A small single-page table reports a plain row count") + func tableSinglePageRowCount() { let snapshot = makeSnapshot( - rowCount: 50, - pagination: PaginationState(totalRowCount: nil, pageSize: 50, currentPage: 2, currentOffset: 50) + rowCount: 5, + pagination: PaginationState(totalRowCount: nil, pageSize: 50, currentPage: 1) ) - let text = snapshot.rowInfoText(selectedCount: 0) - #expect(text.contains("51-100")) - #expect(text.contains("?")) + #expect(!snapshot.showsPaginationControls) + #expect(snapshot.statusText(selectedCount: 0) == String(format: String(localized: "%@ rows"), 5.formatted(.number.grouping(.automatic)))) } @Test("A truncated query reports the showing-rows text") @@ -98,7 +97,7 @@ struct StatusBarSnapshotTests { var pagination = PaginationState(pageSize: 1_000) pagination.hasMoreRows = true let snapshot = makeSnapshot(tabType: .query, rowCount: 1_000, pagination: pagination) - let text = snapshot.rowInfoText(selectedCount: 0) - #expect(text.contains("1,000") || text.contains("1000")) + let text = snapshot.statusText(selectedCount: 0) + #expect(text?.contains("1,000") == true || text?.contains("1000") == true) } } diff --git a/docs/features/data-grid.mdx b/docs/features/data-grid.mdx index ad9680062..506482c84 100644 --- a/docs/features/data-grid.mdx +++ b/docs/features/data-grid.mdx @@ -375,9 +375,9 @@ Copying rows reflects the grid as shown: hidden columns are left out and the col ## Pagination -Table tabs paginate large result sets. The status bar shows a rows-per-page menu and First / Previous / Next / Last buttons, with a page indicator between them. +Table tabs paginate large result sets. The bottom bar shows the row range (e.g. `1-1,000 of ~111,559`) next to the First / Previous / Next / Last buttons, with a page indicator between them. -- **Rows per page**: pick a preset (5, 10, 20, 100, 500, 1,000), enter a custom size, or choose **All rows** to load the whole table on one page. Loading all rows asks for confirmation first, since large tables use a lot of memory. +- **Rows per page**: click the row range to pick a preset (5, 10, 20, 100, 500, 1,000), enter a custom size, or choose **All rows** to load the whole table on one page. Loading all rows asks for confirmation first, since large tables use a lot of memory. - **Page navigation**: jump to the first or last page, step one page at a time, or click the page indicator to go to a specific page. - The bar also appears for filtered tables whose total row count is unknown. There the Next button stays available while a full page keeps loading, and the indicator shows the page number without a total.