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
46 changes: 42 additions & 4 deletions Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2822,14 +2822,52 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS
}

private func scrollSelectedRowToVisibleIfNeeded() {
guard let table = containerView?.tableView,
guard let container = containerView,
let selectedScrollTargetWorkspaceId,
let row = rows.firstIndex(where: { $0.workspaceId == selectedScrollTargetWorkspaceId }) else {
return
}
let visibleRect = table.visibleRect
guard !visibleRect.contains(table.rect(ofRow: row)) else { return }
table.scrollRowToVisible(row)
let table = container.tableView
let scrollView = container.scrollView
let clipView = scrollView.contentView
// `visibleRect` and `scrollRowToVisible` count the strips under the
// content insets (the titlebar scrim and the footer) as visible, so a
// selected row pushed down by a reorder could stay behind the footer.
guard let origin = Self.selectedRowScrollOrigin(
rowRect: table.convert(table.rect(ofRow: row), to: clipView),
clipBounds: clipView.bounds,
insets: scrollView.contentInsets,
documentHeight: table.frame.height
) else { return }
clipView.scroll(to: NSPoint(x: clipView.bounds.origin.x, y: origin))
scrollView.reflectScrolledClipView(clipView)
}

/// The clip view origin that brings `rowRect` fully between the top and
/// bottom insets, moving as little as possible, or nil when it already is.
/// All rects are in the (flipped) clip view's coordinates.
nonisolated static func selectedRowScrollOrigin(
rowRect: NSRect,
clipBounds: NSRect,
insets: NSEdgeInsets,
documentHeight: CGFloat
) -> CGFloat? {
let unobscuredMinY = clipBounds.minY + insets.top
let unobscuredMaxY = clipBounds.maxY - insets.bottom
let target: CGFloat
// A row taller than the clear area aligns its top, like a short row
// scrolled down to.
if rowRect.minY < unobscuredMinY || rowRect.height > unobscuredMaxY - unobscuredMinY {
target = rowRect.minY - insets.top
} else if rowRect.maxY > unobscuredMaxY {
target = rowRect.maxY + insets.bottom - clipBounds.height
} else {
return nil
}
let lowest = -insets.top
let highest = max(lowest, documentHeight + insets.bottom - clipBounds.height)
let clamped = min(max(target, lowest), highest)
return clamped == clipBounds.origin.y ? nil : clamped
}

private func configureDropViews(
Expand Down
4 changes: 4 additions & 0 deletions cmux.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -2975,6 +2975,7 @@
951635C63E1D44A9BF7E61D4 /* SidebarSearchFieldCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A01600A90E464973B8D8122C /* SidebarSearchFieldCoordinator.swift */; };
F6528A82B7124269968F9A99 /* SidebarSearchFieldTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C270D11DD3C408DBB9A0B09 /* SidebarSearchFieldTests.swift */; };
5A1180B9BE6B4395A220318A /* SidebarSearchFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C31DD7A6E10E483D915E7F2B /* SidebarSearchFieldView.swift */; };
4A435919EC29E25A2E139D7A /* SidebarSelectedRowScrollTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E924EBE2A4B7D8798B993C1 /* SidebarSelectedRowScrollTests.swift */; };
B804A0280000000000000028 /* SidebarSelectionCoalescer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B0280000000000000028 /* SidebarSelectionCoalescer.swift */; };
B804C0020000000000000002 /* SidebarSelectionCoalescerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804D0020000000000000002 /* SidebarSelectionCoalescerTests.swift */; };
E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */; };
Expand Down Expand Up @@ -6961,6 +6962,7 @@
A01600A90E464973B8D8122C /* SidebarSearchFieldCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarSearchFieldCoordinator.swift; sourceTree = "<group>"; };
7C270D11DD3C408DBB9A0B09 /* SidebarSearchFieldTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarSearchFieldTests.swift; sourceTree = "<group>"; };
C31DD7A6E10E483D915E7F2B /* SidebarSearchFieldView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarSearchFieldView.swift; sourceTree = "<group>"; };
8E924EBE2A4B7D8798B993C1 /* SidebarSelectedRowScrollTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SidebarSelectedRowScrollTests.swift"; sourceTree = "<group>"; };
B804B0280000000000000028 /* SidebarSelectionCoalescer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/SidebarSelectionCoalescer.swift; sourceTree = "<group>"; };
B804D0020000000000000002 /* SidebarSelectionCoalescerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarSelectionCoalescerTests.swift; sourceTree = "<group>"; };
9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarSelectionState.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -12190,6 +12192,7 @@
A634CA3FCA39C6F02FA78DC7 /* UpdateRelaunchAgentResumeTests.swift */,
D87FDCA31298C3B71D50F9E7 /* PasteLastScreenshotShortcutTests.swift */,
AFD49B60E1A7F637C0ADFEF4 /* TerminalPasteFailureNoticeTests.swift */,
8E924EBE2A4B7D8798B993C1 /* SidebarSelectedRowScrollTests.swift */,
);
path = cmuxTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -16574,6 +16577,7 @@
C12791000000000000000005 /* SidebarRowSnapshotCacheTests.swift in Sources */,
C9A57513C9A57513C9A57513 /* SidebarScrollViewConfiguratorTests.swift in Sources */,
F6528A82B7124269968F9A99 /* SidebarSearchFieldTests.swift in Sources */,
4A435919EC29E25A2E139D7A /* SidebarSelectedRowScrollTests.swift in Sources */,
B804C0020000000000000002 /* SidebarSelectionCoalescerTests.swift in Sources */,
C12791000000000000000007 /* SidebarSnapshotOwnerTests.swift in Sources */,
D73440010000000000000001 /* SidebarTabDragPayloadProviderTests.swift in Sources */,
Expand Down
52 changes: 52 additions & 0 deletions cmuxTests/SidebarSelectedRowScrollTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import AppKit
import Testing
@testable import cmux_DEV

/// The AppKit workspace list scrolls the selected row fully between its
/// content insets: the top titlebar scrim and the bottom footer overlay.
/// A row behind the footer used to count as visible.
@Suite
struct SidebarSelectedRowScrollTests {
private let insets = NSEdgeInsets(top: 30, left: 0, bottom: 58, right: 0)
private let documentHeight: CGFloat = 1000

private func origin(row minY: CGFloat, height: CGFloat = 50, clipOriginY: CGFloat) -> CGFloat? {
SidebarWorkspaceTableController.selectedRowScrollOrigin(
rowRect: NSRect(x: 0, y: minY, width: 240, height: height),
clipBounds: NSRect(x: 0, y: clipOriginY, width: 240, height: 600),
insets: insets,
documentHeight: documentHeight
)
}

@Test
func aRowBetweenTheInsetsDoesNotScroll() {
#expect(origin(row: 100, clipOriginY: 0) == nil)
}

@Test
func aRowBehindTheFooterScrollsUpUntilItClearsIt() {
// Visible by the clip bounds (ends at 570 < 600), hidden by the
// footer (unobscured area ends at 600 - 58 = 542).
#expect(origin(row: 520, clipOriginY: 0) == 28)
}

@Test
func aRowUnderTheTitlebarScrimScrollsDown() {
#expect(origin(row: 210, clipOriginY: 200) == 180)
}

@Test
func aRowTallerThanTheClearAreaAlignsItsTop() {
// Clear area is 512 tall; this row starts in view and runs past the footer.
#expect(origin(row: 100, height: 600, clipOriginY: 0) == 70)
}

@Test
func scrollingStopsAtTheEndsOfTheList() {
// The last row can go no further than the bottom inset allows.
#expect(origin(row: 960, height: 40, clipOriginY: 0) == documentHeight + insets.bottom - 600)
// The first row can go no higher than the top inset allows.
#expect(origin(row: 0, clipOriginY: 100) == -insets.top)
}
}
Loading