From 918a1e7e1932dc0a7c919d8cdff578849a345bdd Mon Sep 17 00:00:00 2001 From: StuBehan Date: Fri, 25 Sep 2026 10:32:24 +0100 Subject: [PATCH] fix(panel): scroll the outcomes overview with the arrow keys --- .../VerticalScrollPaneTests.swift | 39 +++++++++++++++++++ panel/Components.swift | 18 +++++++++ panel/Panel.swift | 18 ++------- 3 files changed, 61 insertions(+), 14 deletions(-) create mode 100644 Tests/StackNudgePanelCoreTests/VerticalScrollPaneTests.swift diff --git a/Tests/StackNudgePanelCoreTests/VerticalScrollPaneTests.swift b/Tests/StackNudgePanelCoreTests/VerticalScrollPaneTests.swift new file mode 100644 index 0000000..fbfe987 --- /dev/null +++ b/Tests/StackNudgePanelCoreTests/VerticalScrollPaneTests.swift @@ -0,0 +1,39 @@ +import AppKit +import XCTest + +@testable import StackNudgePanelCore + +// ↑/↓ on the Outcomes overview and Usage detail scroll whatever this finds; the +// tab strip used to be the match, which left both panes deaf to the arrow keys. +final class VerticalScrollPaneTests: XCTestCase { + + private func scrollView(viewport: CGSize, content: CGSize) -> NSScrollView { + let scrollView = NSScrollView(frame: NSRect(origin: .zero, size: viewport)) + scrollView.documentView = NSView(frame: NSRect(origin: .zero, size: content)) + return scrollView + } + + func test_skipsASidewaysStripAboveTheOverflowingPane() { + let root = NSView(frame: NSRect(x: 0, y: 0, width: 560, height: 600)) + let tabStrip = scrollView(viewport: CGSize(width: 400, height: 28), + content: CGSize(width: 900, height: 28)) + let pane = scrollView(viewport: CGSize(width: 560, height: 500), + content: CGSize(width: 560, height: 1400)) + root.addSubview(tabStrip) + root.addSubview(pane) + + let actual = VerticalScrollPane.find(in: root) + + XCTAssertTrue(actual === pane) + } + + func test_nothingOverflows_findsNothing() { + let root = NSView(frame: NSRect(x: 0, y: 0, width: 560, height: 600)) + root.addSubview(scrollView(viewport: CGSize(width: 400, height: 28), + content: CGSize(width: 900, height: 28))) + root.addSubview(scrollView(viewport: CGSize(width: 560, height: 500), + content: CGSize(width: 560, height: 300))) + + XCTAssertNil(VerticalScrollPane.find(in: root)) + } +} diff --git a/panel/Components.swift b/panel/Components.swift index c5fe66d..5703498 100644 --- a/panel/Components.swift +++ b/panel/Components.swift @@ -292,6 +292,24 @@ struct ThinScrollers: NSViewRepresentable { } } +// The scroll view ↑/↓ move on a page with no selection to follow. Not simply +// the first NSScrollView: the sideways tab strip sits above every page and +// would match first, so take the first whose content overflows vertically. +enum VerticalScrollPane { + static func find(in view: NSView?) -> NSScrollView? { + guard let view else { return nil } + if let scrollView = view as? NSScrollView, + let document = scrollView.documentView, + document.frame.height > scrollView.contentView.bounds.height { + return scrollView + } + for subview in view.subviews { + if let found = find(in: subview) { return found } + } + return nil + } +} + // Take the horizontal scroller off a ScrollView entirely. // // `.scrollIndicators(.hidden)` is not enough: with "Show scroll bars: Always" diff --git a/panel/Panel.swift b/panel/Panel.swift index f2d739c..d54f0d1 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -2131,11 +2131,10 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, // SwiftUI's ScrollView has no programmatic delta-scroll API, so walk the // AppKit hierarchy to the underlying NSScrollView and nudge its clip view. - // Only one ScrollView is rendered at a time (mode-gated), so the first - // match is whichever detail pane is showing — the Usage tiers or the - // Tickets rollup. + // The pane is whichever of the Usage detail or the Outcomes overview is + // showing; see VerticalScrollPane for why it isn't simply the first match. private func scrollDetailBy(_ dy: CGFloat) { - guard let scrollView = findScrollView(in: panel.contentView), + guard let scrollView = VerticalScrollPane.find(in: panel.contentView), let doc = scrollView.documentView else { return } let clip = scrollView.contentView let maxY = max(0, doc.frame.height - clip.bounds.height) @@ -2148,7 +2147,7 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, // ⌘↑/↓ in a pure-scroll detail pane (no selection to move): jump the clip // view to the very top or bottom. private func scrollDetailToEdge(top: Bool) { - guard let scrollView = findScrollView(in: panel.contentView), + guard let scrollView = VerticalScrollPane.find(in: panel.contentView), let doc = scrollView.documentView else { return } let clip = scrollView.contentView let maxY = max(0, doc.frame.height - clip.bounds.height) @@ -2158,15 +2157,6 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, scrollView.reflectScrolledClipView(clip) } - private func findScrollView(in view: NSView?) -> NSScrollView? { - guard let view else { return nil } - if let sv = view as? NSScrollView { return sv } - for sub in view.subviews { - if let found = findScrollView(in: sub) { return found } - } - return nil - } - // User-triggered update check with transient row feedback. Sets // .checking immediately, swaps to .upToDate / .failed on response // (the .updateAvailable path doesn't need transient feedback — the