diff --git a/apple/Packages/TableProEditor/Sources/TableProEditorKit/CodeSuggestion/View/SuggestionContentView.swift b/apple/Packages/TableProEditor/Sources/TableProEditorKit/CodeSuggestion/View/SuggestionContentView.swift index 9a2f74b50a..59a93f318a 100644 --- a/apple/Packages/TableProEditor/Sources/TableProEditorKit/CodeSuggestion/View/SuggestionContentView.swift +++ b/apple/Packages/TableProEditor/Sources/TableProEditorKit/CodeSuggestion/View/SuggestionContentView.swift @@ -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) } } } @@ -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 { diff --git a/apple/TablePro/ViewModels/ColumnJumpViewModel.swift b/apple/TablePro/ViewModels/ColumnJumpViewModel.swift index b93950ea27..cb3a35f66e 100644 --- a/apple/TablePro/ViewModels/ColumnJumpViewModel.swift +++ b/apple/TablePro/ViewModels/ColumnJumpViewModel.swift @@ -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() } } @@ -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] { diff --git a/apple/TablePro/ViewModels/QuickSwitcherViewModel.swift b/apple/TablePro/ViewModels/QuickSwitcherViewModel.swift index d0290a00a1..816776578f 100644 --- a/apple/TablePro/ViewModels/QuickSwitcherViewModel.swift +++ b/apple/TablePro/ViewModels/QuickSwitcherViewModel.swift @@ -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? @@ -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() } @@ -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() } @@ -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 @@ -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 @@ -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) } } @@ -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 } @@ -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, @@ -1040,6 +1079,7 @@ internal final class QuickSwitcherViewModel: ObservableObject { } } + @concurrent nonisolated private static func filteredGroups( items: [QuickSwitcherItem], query: String, diff --git a/apple/TablePro/Views/QuickSwitcher/QuickSwitcherList.swift b/apple/TablePro/Views/QuickSwitcher/QuickSwitcherList.swift new file mode 100644 index 0000000000..6faddae394 --- /dev/null +++ b/apple/TablePro/Views/QuickSwitcher/QuickSwitcherList.swift @@ -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: 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) + } +} diff --git a/apple/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift b/apple/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift index a127b57533..3310f0a7d9 100644 --- a/apple/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift +++ b/apple/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift @@ -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) } } } @@ -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 { diff --git a/apple/TablePro/Views/RecentTabSwitcher/RecentTabSwitcherView.swift b/apple/TablePro/Views/RecentTabSwitcher/RecentTabSwitcherView.swift index 0926a8e8e6..44260541a9 100644 --- a/apple/TablePro/Views/RecentTabSwitcher/RecentTabSwitcherView.swift +++ b/apple/TablePro/Views/RecentTabSwitcher/RecentTabSwitcherView.swift @@ -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) diff --git a/apple/TablePro/Views/Results/ColumnJumpPanelView.swift b/apple/TablePro/Views/Results/ColumnJumpPanelView.swift index 7274595ad8..5692a7ea79 100644 --- a/apple/TablePro/Views/Results/ColumnJumpPanelView.swift +++ b/apple/TablePro/Views/Results/ColumnJumpPanelView.swift @@ -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) } } @@ -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 { diff --git a/apple/TableProTests/ViewModels/ColumnJumpViewModelTests.swift b/apple/TableProTests/ViewModels/ColumnJumpViewModelTests.swift index 57f9d49c19..4eae078fa4 100644 --- a/apple/TableProTests/ViewModels/ColumnJumpViewModelTests.swift +++ b/apple/TableProTests/ViewModels/ColumnJumpViewModelTests.swift @@ -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: [ diff --git a/apple/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift b/apple/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift index 2159aef692..0757fb8bc1 100644 --- a/apple/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift +++ b/apple/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift @@ -1227,4 +1227,96 @@ struct QuickSwitcherViewModelTests { #expect(localStore.recentItemIds().isEmpty) #expect(remoteStore.recentItemIds() == ["history_remote"]) } + + private func tableItems(_ names: [String]) -> [QuickSwitcherItem] { + names.map { QuickSwitcherItem(frecencyKey: "table:\($0)", name: $0, kind: .table, subtitle: "") } + } + + @Test("Rows follow the groups and carry each header on its group's first item only") + func rowsCarryHeadersOnFirstItems() async { + let vm = makeViewModel(items: sampleItems()) + vm.scope = .tables + await vm.flushPendingFilter() + + #expect(vm.rows.map(\.id) == vm.flatItems.map(\.id)) + let titled = vm.rows.filter { $0.sectionTitle != nil } + #expect(titled.map(\.sectionTitle) == vm.groups.map(\.header)) + #expect(titled.map(\.id) == vm.groups.compactMap { $0.items.first?.id }) + #expect(Set(vm.rows.map(\.id)).count == vm.rows.count) + } + + @Test("An arrow pressed before a new query's results land moves within those results") + func arrowBeforeRefilterLandsInNewResults() async { + let vm = makeViewModel(items: tableItems(["Album", "Artist", "Employee", "Genre", "Invoice", "MediaType"])) + vm.scope = .tables + await vm.flushPendingFilter() + + vm.searchText = "e" + vm.moveSelection(by: 1) + await vm.flushPendingFilter() + + #expect(vm.flatItems.count > 2) + #expect(vm.selectedItemId == vm.flatItems[1].id) + } + + @Test("Arrows pressed before a new query's results land replay in order, each clamped to the list") + func pendingArrowsReplayWithClamping() async { + let vm = makeViewModel(items: tableItems(["Album", "Artist", "Employee", "Genre", "Invoice", "MediaType"])) + vm.scope = .tables + await vm.flushPendingFilter() + + vm.searchText = "e" + vm.moveSelection(by: -1) + vm.moveSelection(by: 1) + await vm.flushPendingFilter() + + #expect(vm.flatItems.count > 2) + #expect(vm.selectedItemId == vm.flatItems[1].id) + } + + @Test("A keystroke drops an arrow that was waiting for the previous query's results") + func keystrokeDropsPendingArrow() async { + let vm = makeViewModel(items: tableItems(["Album", "Artist", "Employee", "Genre", "Invoice", "MediaType"])) + vm.scope = .tables + await vm.flushPendingFilter() + + vm.searchText = "e" + vm.moveSelection(by: 1) + vm.searchText = "em" + await vm.flushPendingFilter() + + #expect(vm.selectedItemId == vm.flatItems.first?.id) + } + + @Test("A refilter of the same search moves the selection at once and keeps the search generation") + func sameSearchRefilterDoesNotDeferArrows() async { + let vm = makeViewModel(items: tableItems(["Album", "Artist", "Employee", "Genre"])) + vm.scope = .tables + await vm.flushPendingFilter() + let generation = vm.searchGeneration + + vm.allItems = tableItems(["Album", "Artist", "Employee", "Genre", "Invoice"]) + #expect(vm.isFiltering) + vm.moveSelection(by: 1) + #expect(vm.selectedItemId == "table:Artist") + await vm.flushPendingFilter() + + #expect(vm.selectedItemId == "table:Artist") + #expect(vm.searchGeneration == generation) + } + + @Test("A new query bumps the search generation even when it keeps the selected row") + func newQueryBumpsSearchGeneration() async { + let vm = makeViewModel(items: tableItems(["orders", "order_items", "customers"])) + vm.searchText = "orders" + await vm.flushPendingFilter() + let generation = vm.searchGeneration + #expect(vm.selectedItem()?.name == "orders") + + vm.searchText = "order" + await vm.flushPendingFilter() + + #expect(vm.selectedItem()?.name == "orders") + #expect(vm.searchGeneration == generation + 1) + } } diff --git a/apple/TableProTests/Views/QuickSwitcherSelectionRenderingTests.swift b/apple/TableProTests/Views/QuickSwitcherSelectionRenderingTests.swift new file mode 100644 index 0000000000..b992b04c67 --- /dev/null +++ b/apple/TableProTests/Views/QuickSwitcherSelectionRenderingTests.swift @@ -0,0 +1,188 @@ +// +// QuickSwitcherSelectionRenderingTests.swift +// TableProTests +// + +import AppKit +import SwiftUI +@testable import TablePro +import TableProTestSupport +import Testing + +/// Reads the drawn Open Quickly list: the scroll view SwiftUI backs its `ScrollView` with, and the +/// selection fill under the panel's glass. Both are private rendering, so the cases run only where +/// the probe sees one fill for one selected row. `QuickSwitcherViewModelTests` covers the model. +@MainActor +struct QuickSwitcherSelectionRenderingTests { + private let suite: EphemeralDefaults + + init() throws { + suite = try EphemeralDefaults() + } + + static func hostDrawsTheSelection() async -> Bool { + guard let suite = try? EphemeralDefaults() else { return false } + let viewModel = await makeViewModel(tables: ["alpha", "beta", "gamma"], defaults: suite.defaults) + let panel = DrawnPanel(viewModel: viewModel) + return (try? await waitUntil(timeout: .seconds(5)) { panel.read()?.bands.count == 1 }) != nil + } + + private static func makeViewModel(tables: [String], defaults: UserDefaults) async -> QuickSwitcherViewModel { + let viewModel = QuickSwitcherViewModel(connectionId: UUID(), services: .live, defaults: defaults) + viewModel.allItems = tables.map { name in + QuickSwitcherItem(frecencyKey: "table:\(name)", name: name, kind: .table, subtitle: "") + } + viewModel.scope = .tables + await viewModel.flushPendingFilter() + return viewModel + } + + private func resultTop(_ index: Int) -> CGFloat { + QuickSwitcherMetrics.listVerticalPadding + CGFloat(index) * QuickSwitcherMetrics.rowHeight + } + + @Test( + "A query that moves rows out of their section draws the highlight on the selected row (#3785)", + .enabled("The host draws no selection fill in the panel") { await Self.hostDrawsTheSelection() } + ) + func highlightFollowsSelectionAfterQuery() async throws { + let viewModel = await Self.makeViewModel( + tables: ["Album", "Artist", "Customer", "Employee", "Genre", "Invoice", "InvoiceLine", "MediaType", "Track"], + defaults: suite.defaults + ) + let panel = DrawnPanel(viewModel: viewModel) + try await waitUntil(timeout: .seconds(5)) { panel.read()?.bands.count == 1 } + + viewModel.searchText = "e" + await viewModel.flushPendingFilter() + try #require(viewModel.flatItems.count > 3) + + for index in 0 ..< 3 { + if index > 0 { viewModel.moveSelection(by: 1) } + #expect(viewModel.selectedItemId == viewModel.flatItems[index].id) + let top = resultTop(index) + try await waitUntil(timeout: .seconds(3)) { + guard let bands = panel.read()?.bands, bands.count == 1 else { return false } + return abs(bands[0].lowerBound - top) <= 1 + } + } + } + + @Test( + "A new query whose best match is the scrolled-away selection scrolls back to it", + .enabled("The host draws no selection fill in the panel") { await Self.hostDrawsTheSelection() } + ) + func newQueryKeepingTheSelectionRevealsIt() async throws { + let viewModel = await Self.makeViewModel( + tables: (0 ..< 40).map { String(format: "book_%02d", $0) } + ["orders"], + defaults: suite.defaults + ) + let panel = DrawnPanel(viewModel: viewModel) + try await waitUntil(timeout: .seconds(5)) { panel.read()?.bands.count == 1 } + viewModel.moveSelection(by: 40) + try #require(viewModel.selectedItem()?.name == "orders") + try await waitUntil(timeout: .seconds(3)) { (panel.read()?.offset ?? 0) > 1_000 } + + viewModel.searchText = "o" + await viewModel.flushPendingFilter() + try #require(viewModel.flatItems.first?.name == "orders") + try #require(viewModel.flatItems.count > QuickSwitcherMetrics.maxVisibleRows) + + let top = resultTop(0) + try await waitUntil(timeout: .seconds(3)) { + guard let bands = panel.read()?.bands, bands.count == 1 else { return false } + return abs(bands[0].lowerBound - top) <= 1 + } + } + + @Test( + "Arrowing back to a section's first row shows the section header again", + .enabled("The host draws no selection fill in the panel") { await Self.hostDrawsTheSelection() } + ) + func arrowingUpRevealsTheSectionHeader() async throws { + let viewModel = await Self.makeViewModel( + tables: (0 ..< 30).map { String(format: "table_%02d", $0) }, + defaults: suite.defaults + ) + let panel = DrawnPanel(viewModel: viewModel) + try await waitUntil(timeout: .seconds(5)) { panel.read()?.bands.count == 1 } + + viewModel.moveSelection(by: 20) + try await waitUntil(timeout: .seconds(3)) { (panel.read()?.offset ?? 0) > 400 } + viewModel.moveSelection(by: -20) + + try await waitUntil(timeout: .seconds(3)) { + guard let offset = panel.read()?.offset else { return false } + return offset <= QuickSwitcherMetrics.listVerticalPadding + 1 + } + } +} + +/// The real panel content in a window, read back as the selection bands its list draws. +@MainActor +private final class DrawnPanel { + struct Reading { + /// Each run of selection fill, as a span of the list's document in points. + let bands: [ClosedRange] + let offset: CGFloat + } + + private let window: NSWindow + private let selectionFill: NSColor? + + init(viewModel: QuickSwitcherViewModel) { + window = ThemeTestFixture.host( + QuickSwitcherPanelContent(viewModel: viewModel) { _, _ in }, + size: NSSize(width: QuickSwitcherMetrics.width, height: 640) + ) + selectionFill = Self.hostedFill() + } + + /// Taken through the same window and snapshot path, so color management cancels out. + private static func hostedFill() -> NSColor? { + let size = NSSize(width: 40, height: 40) + let window = ThemeTestFixture.host(Color(nsColor: .selectedContentBackgroundColor), size: size) + guard let bitmap = ThemeTestFixture.snapshot(window) else { return nil } + return ThemeTestFixture.pixel(bitmap, x: bitmap.pixelsWide / 2, y: bitmap.pixelsHigh / 2) + } + + func read() -> Reading? { + guard let selectionFill, let root = window.contentView else { return nil } + root.layoutSubtreeIfNeeded() + guard let scrollView = Self.listScrollView(in: root), + let documentView = scrollView.documentView, + let bitmap = ThemeTestFixture.snapshot(window) else { return nil } + + let scale = CGFloat(bitmap.pixelsHigh) / root.bounds.height + let visible = scrollView.convert(scrollView.bounds, to: root) + let top = root.isFlipped ? visible.minY : root.bounds.height - visible.maxY + let clip = scrollView.contentView.bounds + let offset = documentView.isFlipped ? clip.minY : documentView.bounds.height - clip.maxY + let column = Int((visible.minX + visible.width * 0.6) * scale) + + var bands: [ClosedRange] = [] + var start: CGFloat? + let firstRow = Int((top * scale).rounded(.up)) + let lastRow = Int(((top + visible.height) * scale).rounded(.down)) + for pixelRow in firstRow ... lastRow { + let documentY = CGFloat(pixelRow) / scale - top + offset + let isFill = pixelRow < lastRow + && ThemeTestFixture.pixel(bitmap, x: column, y: pixelRow) + .map { ThemeTestFixture.matches($0, selectionFill, tolerance: 0.03) } == true + if isFill, start == nil { + start = documentY + } else if !isFill, let bandStart = start { + bands.append(bandStart ... documentY) + start = nil + } + } + return Reading(bands: bands, offset: offset) + } + + private static func listScrollView(in view: NSView) -> NSScrollView? { + if let scrollView = view as? NSScrollView, !(scrollView.documentView is NSTextView) { + return scrollView + } + return view.subviews.lazy.compactMap { listScrollView(in: $0) }.first + } +} diff --git a/apple/changelog.d/3789.fixed.1.md b/apple/changelog.d/3789.fixed.1.md new file mode 100644 index 0000000000..91a0618936 --- /dev/null +++ b/apple/changelog.d/3789.fixed.1.md @@ -0,0 +1 @@ +Open Quickly and Jump to Column could leave the selected row or its section header scrolled out of view. diff --git a/apple/changelog.d/3789.fixed.2.md b/apple/changelog.d/3789.fixed.2.md new file mode 100644 index 0000000000..b5097ecabe --- /dev/null +++ b/apple/changelog.d/3789.fixed.2.md @@ -0,0 +1 @@ +Open Quickly lost an arrow key pressed right after typing. diff --git a/apple/changelog.d/3789.fixed.3.md b/apple/changelog.d/3789.fixed.3.md new file mode 100644 index 0000000000..0db777d9e7 --- /dev/null +++ b/apple/changelog.d/3789.fixed.3.md @@ -0,0 +1 @@ +Open Quickly paused on every keystroke when searching tables across several large connections. diff --git a/apple/changelog.d/3789.fixed.4.md b/apple/changelog.d/3789.fixed.4.md new file mode 100644 index 0000000000..9e0bbdd0fb --- /dev/null +++ b/apple/changelog.d/3789.fixed.4.md @@ -0,0 +1 @@ +Clicking an autocomplete suggestion highlighted it a third of a second late. diff --git a/apple/changelog.d/3789.fixed.md b/apple/changelog.d/3789.fixed.md new file mode 100644 index 0000000000..19bb9d675c --- /dev/null +++ b/apple/changelog.d/3789.fixed.md @@ -0,0 +1 @@ +Open Quickly did not highlight the selected row after typing a search, or highlighted two rows at once.