Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,10 @@ struct SuggestionContentView: View {
}
.padding(.vertical, SuggestionController.windowPadding)
.frame(height: listMaxHeight)
// Scrolls only as far as the row needs, so a clicked row stays under the pointer for the second click.
.onChange(of: model.selectedIndex) { newIndex in
withAnimation(.easeInOut(duration: 0.1)) {
proxy.scrollTo(newIndex, anchor: .center)
proxy.scrollTo(newIndex)
}
}
}
Expand All @@ -82,18 +83,18 @@ struct SuggestionContentView: View {
.padding(.horizontal, SuggestionController.windowPadding)
)
.contentShape(Rectangle())
.onTapGesture(count: 1) {
model.selectedIndex = index
}
.onTapGesture(count: 2) {
// A count-2 tap gesture holds every single click for the double-click interval.
.onTapGesture {
let event = NSApp.currentEvent
let completesDoubleClick = event?.type == .leftMouseUp && event?.clickCount == 2
&& model.selectedIndex == index
model.selectedIndex = index
if let selectedItem = model.selectedItem {
if completesDoubleClick, let selectedItem = model.selectedItem {
model.applySelectedItem(item: selectedItem)
}
}
.accessibilityElement(children: .combine)
.accessibilityAddTraits(index == model.selectedIndex ? .isSelected : [])
.id(index)
}

private var contentWidth: CGFloat {
Expand Down
3 changes: 3 additions & 0 deletions apple/TablePro/ViewModels/ColumnJumpViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ final class ColumnJumpViewModel: ObservableObject {
let entries: [GridColumnEntry]
@Published private(set) var matches: [Match] = []
@Published var selectedId: String?
/// Bumped for each new query, which can keep the selected column while the list scrolled away from it.
@Published private(set) var searchGeneration = 0
@Published var searchText: String {
didSet { refilter() }
}
Expand Down Expand Up @@ -67,6 +69,7 @@ final class ColumnJumpViewModel: ObservableObject {
rankedQuery = query
matches = Self.rank(entries, query: query)
selectedId = matches.first?.id
searchGeneration += 1
}

private static func rank(_ entries: [GridColumnEntry], query: String) -> [Match] {
Expand Down
40 changes: 40 additions & 0 deletions apple/TablePro/ViewModels/QuickSwitcherViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,15 @@ internal final class QuickSwitcherViewModel: ObservableObject {
let items: [QuickSwitcherItem]
}

/// One list row per item. A group's header is drawn with its first item, so revealing that
/// item also reveals the header.
struct Row: Identifiable {
let item: QuickSwitcherItem
let sectionTitle: String?

var id: String { item.id }
}

/// What the panel's own connection is browsing, which is what its table rows are built for.
struct TableSource {
let database: String?
Expand Down Expand Up @@ -122,10 +131,15 @@ internal final class QuickSwitcherViewModel: ObservableObject {
@Published private(set) var isLoadingCrossConnectionQueries = false
@Published private(set) var crossConnectionQueryContentRevision = 0
@Published var selectedItemId: String?
/// Bumped when a refilter answers a new query or scope, so the list scrolls to the selection
/// even when the new search kept the same row selected.
@Published private(set) var searchGeneration = 0
private var pendingSelectionMoves: [Int] = []

@Published var searchText = "" {
didSet {
guard oldValue != searchText else { return }
pendingSelectionMoves = []
scheduleFilter(debounced: true)
refreshListingDemand()
}
Expand All @@ -134,6 +148,7 @@ internal final class QuickSwitcherViewModel: ObservableObject {
@Published var scope: QuickSwitcherScope = .all {
didSet {
guard oldValue != scope else { return }
pendingSelectionMoves = []
scheduleFilter(debounced: false)
refreshListingDemand()
}
Expand All @@ -143,6 +158,14 @@ internal final class QuickSwitcherViewModel: ObservableObject {
groups.flatMap(\.items)
}

var rows: [Row] {
groups.flatMap { group in
group.items.enumerated().map { index, item in
Row(item: item, sectionTitle: index == 0 ? group.header : nil)
}
}
}

/// Whether the panel is still fetching the results it is being asked to show.
///
/// The All scope with an empty search lists nothing but Recent, and the user has not asked for
Expand Down Expand Up @@ -850,6 +873,11 @@ internal final class QuickSwitcherViewModel: ObservableObject {
}

func moveSelection(by delta: Int) {
// The pending refilter resets the selection when it lands, so the key belongs to its results.
if isFiltering, trimmedSearchText != selectionQuery || scope != selectionScope {
pendingSelectionMoves.append(delta)
return
}
let items = flatItems
guard !items.isEmpty else {
selectedItemId = nil
Expand Down Expand Up @@ -903,6 +931,15 @@ internal final class QuickSwitcherViewModel: ObservableObject {
self.groups = groups
self.isFiltering = false
self.reconcileSelection(query: query, scope: scope)
self.applyPendingSelectionMoves()
}
}

private func applyPendingSelectionMoves() {
let moves = pendingSelectionMoves
pendingSelectionMoves = []
for delta in moves {
moveSelection(by: delta)
}
}

Expand All @@ -929,6 +966,7 @@ internal final class QuickSwitcherViewModel: ObservableObject {
if isSameSearch, let current = selectedItemId, items.contains(where: { $0.id == current }) {
return
}
if !isSameSearch { searchGeneration += 1 }
selectedItemId = items.first?.id
}

Expand All @@ -945,6 +983,7 @@ internal final class QuickSwitcherViewModel: ObservableObject {
return source.filter { includedKinds.contains($0.kind) }
}

@concurrent
nonisolated private static func emptyQueryGroups(
items: [QuickSwitcherItem],
scope: QuickSwitcherScope,
Expand Down Expand Up @@ -1040,6 +1079,7 @@ internal final class QuickSwitcherViewModel: ObservableObject {
}
}

@concurrent
nonisolated private static func filteredGroups(
items: [QuickSwitcherItem],
query: String,
Expand Down
48 changes: 48 additions & 0 deletions apple/TablePro/Views/QuickSwitcher/QuickSwitcherList.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
//
// QuickSwitcherList.swift
// TablePro
//

import SwiftUI

/// The result list under a floating panel's field: Open Quickly, Jump to Column and the recent-tab
/// switcher. One flat `ForEach` and no `.id` on a row: a lazy stack showed a row realized under a
/// removed outer `ForEach` element at its new place and never updated it again (#3785).
internal struct QuickSwitcherList<Row: Identifiable, Content: View>: View {
let rows: [Row]
let selection: Row.ID?
/// Bumped by the owner for each new search. A new search can keep the selected id while the
/// list scrolled away from it, and a refilter of the same search must not undo a mouse scroll.
var search = 0
let height: CGFloat
@ViewBuilder let content: (Row) -> Content

private struct RevealTarget: Equatable {
let selection: Row.ID?
let search: Int
}

var body: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 0) {
ForEach(rows) { row in
content(row)
}
}
.padding(.vertical, QuickSwitcherMetrics.listVerticalPadding)
}
.frame(height: height)
.onAppear { reveal(selection, in: proxy) }
// The deprecated `onChange` behind this runs with the old view, so only the new value is current.
.onValueChange(of: RevealTarget(selection: selection, search: search)) { _, target in
reveal(target.selection, in: proxy)
}
}
}

private func reveal(_ id: Row.ID?, in proxy: ScrollViewProxy) {
guard let id else { return }
proxy.scrollTo(id)
}
}
29 changes: 10 additions & 19 deletions apple/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -184,25 +184,17 @@ struct QuickSwitcherPanelContent: View {
}

private var resultsList: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 0) {
ForEach(viewModel.groups) { group in
if let header = group.header {
sectionHeader(header)
}
ForEach(group.items) { item in
itemRow(item)
}
}
}
.padding(.vertical, QuickSwitcherMetrics.listVerticalPadding)
}
.frame(height: listHeight)
.onChange(of: viewModel.selectedItemId) { newValue in
if let id = newValue {
proxy.scrollTo(id)
QuickSwitcherList(
rows: viewModel.rows,
selection: viewModel.selectedItemId,
search: viewModel.searchGeneration,
height: listHeight
) { row in
VStack(spacing: 0) {
if let title = row.sectionTitle {
sectionHeader(title)
}
itemRow(row.item)
}
}
}
Expand Down Expand Up @@ -295,7 +287,6 @@ struct QuickSwitcherPanelContent: View {
.accessibilityValue(Text(namesLocation(item) ? item.subtitle : ""))
.accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton)
.accessibilityAction { onCommit(item, .open) }
.id(item.id)
}

private func iconView(for item: QuickSwitcherItem, isSelected: Bool) -> some View {
Expand Down
27 changes: 9 additions & 18 deletions apple/TablePro/Views/RecentTabSwitcher/RecentTabSwitcherView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,24 +18,15 @@ internal struct RecentTabSwitcherView: View {

var body: some View {
QuickSwitcherGlassGroup {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 0) {
ForEach(Array(model.session.candidates.enumerated()), id: \.element.id) { index, candidate in
RecentTabSwitcherRow(
candidate: candidate,
isHighlighted: index == model.session.highlightedIndex
)
.id(candidate.id)
}
}
.padding(.vertical, QuickSwitcherMetrics.listVerticalPadding)
}
.frame(height: listHeight)
.onAppear { proxy.scrollTo(model.session.highlighted.id) }
.onValueChange(of: model.session.highlightedIndex) { _, _ in
proxy.scrollTo(model.session.highlighted.id)
}
QuickSwitcherList(
rows: model.session.candidates,
selection: model.session.highlighted.id,
height: listHeight
) { candidate in
RecentTabSwitcherRow(
candidate: candidate,
isHighlighted: candidate.id == model.session.highlighted.id
)
}
.frame(width: RecentTabSwitcherMetrics.width)
.quickSwitcherSurface(cornerRadius: QuickSwitcherMetrics.cornerRadius)
Expand Down
28 changes: 7 additions & 21 deletions apple/TablePro/Views/Results/ColumnJumpPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,26 +108,13 @@ struct ColumnJumpPanelContent: View {
}

private var resultsList: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 0) {
ForEach(viewModel.matches) { match in
columnRow(match)
}
}
.padding(.vertical, QuickSwitcherMetrics.listVerticalPadding)
}
.frame(height: listHeight)
.onAppear {
if let id = viewModel.selectedId {
proxy.scrollTo(id)
}
}
.onChange(of: viewModel.selectedId) { newValue in
if let id = newValue {
proxy.scrollTo(id)
}
}
QuickSwitcherList(
rows: viewModel.matches,
selection: viewModel.selectedId,
search: viewModel.searchGeneration,
height: listHeight
) { match in
columnRow(match)
}
}

Expand Down Expand Up @@ -210,7 +197,6 @@ struct ColumnJumpPanelContent: View {
.accessibilityValue(Text(accessibilityValue(for: entry)))
.accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton)
.accessibilityAction { onCommit(entry) }
.id(match.id)
}

private func iconView(isSelected: Bool) -> some View {
Expand Down
20 changes: 20 additions & 0 deletions apple/TableProTests/ViewModels/ColumnJumpViewModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,26 @@ struct ColumnJumpViewModelTests {
#expect(viewModel.selectedId == viewModel.matches.first?.id)
}

@Test("A new query bumps the search generation even when its best match is the selected column")
func newQueryBumpsSearchGeneration() {
let viewModel = ColumnJumpViewModel(
entries: [
entry("id", dataIndex: 0, position: 1),
entry("email", dataIndex: 1, position: 2),
entry("name", dataIndex: 2, position: 3)
],
cursorColumnIndex: 1
)
let generation = viewModel.searchGeneration

viewModel.searchText = "email"
#expect(viewModel.selectedId == "column-1")
#expect(viewModel.searchGeneration == generation + 1)

viewModel.searchText = "email "
#expect(viewModel.searchGeneration == generation + 1, "the same ranked query is not a new search")
}

@Test("Moving the selection clamps to the list")
func moveSelectionClamps() {
let viewModel = ColumnJumpViewModel(entries: [
Expand Down
Loading
Loading