|
| 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