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
39 changes: 39 additions & 0 deletions Tests/StackNudgePanelCoreTests/VerticalScrollPaneTests.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
18 changes: 18 additions & 0 deletions panel/Components.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
18 changes: 4 additions & 14 deletions panel/Panel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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
Expand Down
Loading