Skip to content

Commit 7f2969c

Browse files
authored
fix(connections): settle the connections strip on whole entries and reveal the one you switch to (#2456)
Claude-Session: https://claude.ai/code/session_01Qk1xfY3vnneRifC22eV2r7 Signed-off-by: Ngô Quốc Đạt <datlechin@gmail.com>
1 parent 1b2cf42 commit 7f2969c

6 files changed

Lines changed: 550 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2020

2121
### Fixed
2222

23+
- Stale cells after fitting, hiding or reordering a data grid column on a result narrower than the window. (#2446)
2324
- Autocomplete keeping an earlier prefix's ordering after the typed word becomes an exact match. (#2444)
2425
- Whole MySQL and MariaDB result set fetched before a capped query returned its first rows. (#2427)
2526
- KILL sent to a different server when a MySQL or MariaDB connection's host is spelled `localhost`.
@@ -42,6 +43,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4243
- Active editor tab drawn darker than its track on macOS 27, and inverting when the window lost focus.
4344
- Editor tab selection changing with the desktop picture behind the window.
4445
- Editor tab strip tests reporting four appearances while running plain Aqua and Dark Aqua twice.
46+
- Icon cut off the entry at the top of a scrolled connections strip. (#2452)
47+
- Connections strip not scrolling to the entry you switch to.
4548
- Grid cells left at the old column positions until the next click, after a resize, an auto-fit, a reorder, hiding a column, or a row-number width change. (#2449)
4649
- The row-number column draggable out of first place, which walked it to the far right on the next refresh.
4750

TablePro/Core/Services/Infrastructure/WorkspaceRailMetrics.swift

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,30 @@ internal enum WorkspaceRailMetrics {
1111
internal let rowHeight: CGFloat
1212
internal let iconSize: CGFloat
1313
internal let fontSize: CGFloat
14+
internal let rowSpacing: CGFloat
15+
16+
/// What one entry costs down the strip, and the only spacing `WorkspaceRailScrollGeometry`
17+
/// works in. The table's `intercellSpacing` is set from `rowSpacing` for the same reason:
18+
/// a pitch assembled from two places drifts the moment one of them changes.
19+
internal var rowPitch: CGFloat {
20+
rowHeight + rowSpacing
21+
}
1422
}
1523

1624
/// An icon above a label, the shape Finder's icon view and Reminders' smart lists use,
1725
/// rather than a source-list row. The width is set by the label: the source-list style
1826
/// spends 32pt on insets, so the rail has to be wide enough that what is left still
1927
/// holds a database name at a legible size. `smallSystemFontSize` is the smallest system
2028
/// size and stays above the 10pt macOS minimum.
21-
internal static let small = Layout(width: 80, rowHeight: 52, iconSize: 20, fontSize: 10)
29+
internal static let small = Layout(
30+
width: 80, rowHeight: 52, iconSize: 20, fontSize: 10, rowSpacing: 2
31+
)
2232
internal static let medium = Layout(
23-
width: 90, rowHeight: 58, iconSize: 24, fontSize: NSFont.smallSystemFontSize
33+
width: 90, rowHeight: 58, iconSize: 24, fontSize: NSFont.smallSystemFontSize, rowSpacing: 2
34+
)
35+
internal static let large = Layout(
36+
width: 100, rowHeight: 66, iconSize: 28, fontSize: 12, rowSpacing: 2
2437
)
25-
internal static let large = Layout(width: 100, rowHeight: 66, iconSize: 28, fontSize: 12)
2638

2739
/// Mirrors System Settings > Appearance > Sidebar icon size, which AppKit exposes
2840
/// through `NSTableView.effectiveRowSizeStyle`.
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
//
2+
// WorkspaceRailScrollGeometry.swift
3+
// TablePro
4+
//
5+
6+
import AppKit
7+
8+
/// Where the rail is allowed to come to rest.
9+
///
10+
/// Every entry is a tile whose meaning is its glyph, laid out across the top of the row, so a
11+
/// viewport edge that falls inside a tile does not read as a partly scrolled row the way a line of
12+
/// text does: it deletes the glyph and leaves the label behind with nothing above it. The rail
13+
/// autohides its scroller, so at rest there is no affordance saying the strip scrolls at all, and
14+
/// that orphaned label reads as a drawing bug. It was reported as one (#2452).
15+
///
16+
/// `NSClipView.constrainBoundsRect` allows any offset between zero and the document's end, so a
17+
/// mid-tile offset is a legal resting position that nothing corrects. These are the offsets the
18+
/// rail settles onto instead, all of them multiples of the row pitch.
19+
///
20+
/// The document's own end is the case that forces `bottomInset`. A viewport is almost never a whole
21+
/// number of tiles, so the last tile can be reached only from an offset that is not a multiple of
22+
/// the pitch; without the inset the rail either stops short of its final entry or slices the tile at
23+
/// the top to reach it. The inset is the empty strip that makes that final offset land on a boundary
24+
/// like every other one.
25+
internal enum WorkspaceRailScrollGeometry {
26+
/// The furthest the rail may rest while still keeping the last entry whole and a tile edge at
27+
/// the top of the viewport.
28+
///
29+
/// Measured against the last entry's own bottom rather than by counting whole pitches. The
30+
/// spacing under the final entry is not part of it, so a viewport that ends between the last
31+
/// entry and the next boundary already holds every entry: counting pitches called that a scroll
32+
/// of one whole row and let the first entry be hidden under a strip that fits.
33+
internal static func maximumRestingOrigin(
34+
rowCount: Int,
35+
rowPitch: CGFloat,
36+
rowHeight: CGFloat,
37+
viewportHeight: CGFloat
38+
) -> CGFloat {
39+
guard rowPitch > 0, rowCount > 0, viewportHeight > 0 else { return 0 }
40+
let lastRowBottom = CGFloat(rowCount - 1) * rowPitch + rowHeight
41+
guard lastRowBottom > viewportHeight else { return 0 }
42+
return ((lastRowBottom - viewportHeight) / rowPitch).rounded(.up) * rowPitch
43+
}
44+
45+
/// The empty strip below the last tile that brings `maximumRestingOrigin` within reach.
46+
///
47+
/// `documentHeight` is asked for rather than derived, because `NSTableView` sizes its document
48+
/// to fill a viewport the rows do not, and pads it past the rows when they overflow.
49+
internal static func bottomInset(
50+
rowCount: Int,
51+
rowPitch: CGFloat,
52+
rowHeight: CGFloat,
53+
documentHeight: CGFloat,
54+
viewportHeight: CGFloat
55+
) -> CGFloat {
56+
guard rowPitch > 0, rowCount > 0, viewportHeight > 0 else { return 0 }
57+
let maximum = maximumRestingOrigin(
58+
rowCount: rowCount, rowPitch: rowPitch, rowHeight: rowHeight, viewportHeight: viewportHeight
59+
)
60+
return max(0, maximum + viewportHeight - documentHeight)
61+
}
62+
63+
/// The boundary a settled scroll lands on.
64+
internal static func settledOrigin(
65+
proposed: CGFloat,
66+
rowPitch: CGFloat,
67+
maximumOrigin: CGFloat
68+
) -> CGFloat {
69+
guard rowPitch > 0, maximumOrigin > 0 else { return 0 }
70+
let snapped = (proposed / rowPitch).rounded() * rowPitch
71+
return min(max(0, snapped), maximumOrigin)
72+
}
73+
74+
/// The boundary a settled scroll lands on, without cutting an entry the highlight was already
75+
/// showing whole.
76+
///
77+
/// `NSTableView` reveals the row the arrow keys reach by scrolling the least it can, which stops
78+
/// between boundaries; rounding that to the nearest one would cut the entry the keyboard just
79+
/// moved to. Only an entry that was whole before the snap is protected, so scrolling away from
80+
/// the highlighted entry on purpose still settles wherever the scroll ended.
81+
internal static func settledOrigin(
82+
proposed: CGFloat,
83+
selectedRow: Int?,
84+
rowCount: Int,
85+
rowPitch: CGFloat,
86+
rowHeight: CGFloat,
87+
viewportHeight: CGFloat
88+
) -> CGFloat {
89+
let maximum = maximumRestingOrigin(
90+
rowCount: rowCount, rowPitch: rowPitch, rowHeight: rowHeight, viewportHeight: viewportHeight
91+
)
92+
let snapped = settledOrigin(proposed: proposed, rowPitch: rowPitch, maximumOrigin: maximum)
93+
guard rowPitch > 0, viewportHeight > 0, rowCount > 0 else { return snapped }
94+
guard let selectedRow, selectedRow >= 0, selectedRow < rowCount else { return snapped }
95+
96+
let top = CGFloat(selectedRow) * rowPitch
97+
let bottom = top + rowHeight
98+
func showsSelectionWhole(_ origin: CGFloat) -> Bool {
99+
top >= origin - 0.5 && bottom <= origin + viewportHeight + 0.5
100+
}
101+
guard showsSelectionWhole(proposed), !showsSelectionWhole(snapped) else { return snapped }
102+
103+
let nearest = ((bottom - viewportHeight) / rowPitch).rounded(.up) * rowPitch
104+
let furthest = (top / rowPitch).rounded(.down) * rowPitch
105+
return min(max(min(max(snapped, nearest), furthest), 0), maximum)
106+
}
107+
108+
/// The offset that brings `row` fully into view, or nil while it already is.
109+
///
110+
/// Nil is the answer that lets every caller ask unconditionally. The rail's entries are the same
111+
/// list in every window, so a change in one window reloads the rail in all of them; a reveal
112+
/// that moved a rail whose entry was already on screen would drag another window's strip away
113+
/// from wherever its owner had scrolled it.
114+
internal static func revealOrigin(
115+
row: Int,
116+
rowCount: Int,
117+
rowPitch: CGFloat,
118+
rowHeight: CGFloat,
119+
viewportHeight: CGFloat,
120+
currentOrigin: CGFloat
121+
) -> CGFloat? {
122+
guard rowPitch > 0, rowCount > 0, viewportHeight > 0 else { return nil }
123+
guard row >= 0, row < rowCount else { return nil }
124+
125+
let top = CGFloat(row) * rowPitch
126+
let bottom = top + rowHeight
127+
guard top < currentOrigin || bottom > currentOrigin + viewportHeight else { return nil }
128+
129+
let maximum = maximumRestingOrigin(
130+
rowCount: rowCount, rowPitch: rowPitch, rowHeight: rowHeight, viewportHeight: viewportHeight
131+
)
132+
let target = top < currentOrigin
133+
? top
134+
: ((bottom - viewportHeight) / rowPitch).rounded(.up) * rowPitch
135+
let clamped = min(max(0, target), maximum)
136+
guard abs(clamped - currentOrigin) > 0.5 else { return nil }
137+
return clamped
138+
}
139+
}

0 commit comments

Comments
 (0)