| 1 | import CoreGraphics |
| 2 | |
| 3 | // π€ The feed's scroll-position arithmetic, pulled out of the view so it can be |
| 4 | // tested without mounting one. Two callers on the Mac feed |
| 5 | // (LChatMac/NativeFeedView.swift) share it: the per-thread scroll memory, and |
| 6 | // the re-anchor that keeps the viewport still while older history splices in |
| 7 | // above it. Both need the SAME reading β "which row is the reader looking at, |
| 8 | // and where in the viewport does it sit" β so it lives in one place. |
| 9 | public enum FeedGeometry { |
| 10 | |
| 11 | // The row nearest the viewport's centre, plus its top's 0β¦1 travel within |
| 12 | // the viewport: scrollTo(rowID, anchor: .init(x: 0, y: fraction)) puts it |
| 13 | // back exactly where it was. |
| 14 | // |
| 15 | // `rows` are the materialised rows' frames in the scroll content's |
| 16 | // coordinate space (the feed's named space, so minY is measured from the |
| 17 | // viewport's top: a row on screen has 0 <= minY <= viewportHeight). |
| 18 | // |
| 19 | // Prefers a FULLY visible row β a partially-visible one's fraction is |
| 20 | // outside 0β¦1 and can't be expressed as a UnitPoint. When none is (one |
| 21 | // bubble taller than the viewport β a long code block β fills the screen) |
| 22 | // it anchors that giant row's top, which is the closest honest reading: |
| 23 | // scrollTo can't say "this row's top, minus 300pt". |
| 24 | public static func centreRow(rows: [Int64: CGRect], |
| 25 | viewportHeight: CGFloat) -> NativeFeedModelCache.FeedScrollAnchor? { |
| 26 | guard viewportHeight > 0, !rows.isEmpty else { return nil } |
| 27 | let mid = viewportHeight / 2 |
| 28 | guard let ref = rows |
| 29 | .filter({ $0.value.minY >= 0 && $0.value.maxY <= viewportHeight }) |
| 30 | .min(by: { abs($0.value.midY - mid) < abs($1.value.midY - mid) }) |
| 31 | else { |
| 32 | // Deterministic pick: several rows can straddle the centre line |
| 33 | // only if they overlap, but `first(where:)` over a dictionary is |
| 34 | // order-dependent, so choose the topmost. |
| 35 | if let giant = rows |
| 36 | .filter({ $0.value.minY < mid && $0.value.maxY > mid }) |
| 37 | .min(by: { $0.value.minY < $1.value.minY }) { |
| 38 | return .init(rowID: giant.key, fraction: 0) |
| 39 | } |
| 40 | return nil |
| 41 | } |
| 42 | let denom = viewportHeight - ref.value.height |
| 43 | let fraction = denom > 0 ? max(0, min(1, ref.value.minY / denom)) : 0 |
| 44 | return .init(rowID: ref.key, fraction: fraction) |
| 45 | } |
| 46 | |
| 47 | // Whether the newest row is genuinely resting at the viewport's bottom (a |
| 48 | // tall chat pinned) or, for a chat too short to fill it, fully visible from |
| 49 | // the top. The pin verify's question β and the one that decides whether a |
| 50 | // cold feed is revealed or kept hidden for another round. |
| 51 | // |
| 52 | // `viewportHeight` is the SCROLL VIEW's own height, which on the Mac feed |
| 53 | // is already the composer-reduced one: a bottom `safeAreaInset` shrinks the |
| 54 | // scroll view's frame, so a pinned last row sits at β viewportHeight and |
| 55 | // nothing has to be subtracted for the band. (Measured off-screen, 2026-08: |
| 56 | // a 500pt pane with a 120pt inset reports 380 and pins its last row at |
| 57 | // 379.) Do not "correct" for the inset here β that double-count is what |
| 58 | // made the verify never settle. |
| 59 | // |
| 60 | // Both rects are in the scroll content's space (a row on screen has |
| 61 | // 0 <= minY <= viewportHeight). The rule's job is to tell a genuine pin |
| 62 | // apart from the #900 wedge: a layout race can leave the offset past the |
| 63 | // content's end with every row ABOVE the viewport at a NEGATIVE maxY, and |
| 64 | // the bare `maxY <= height` test called that flush and revealed a blank |
| 65 | // pane. A pinned last row has a POSITIVE maxY near the bottom; the lower |
| 66 | // bound stays loose because a row still materialising at an estimated |
| 67 | // height (and the typing bubble sitting below the newest row) must not flap |
| 68 | // the verify. |
| 69 | public static func lastRowIsFlush(last: CGRect?, first: CGRect?, |
| 70 | viewportHeight: CGFloat) -> Bool { |
| 71 | guard let last, viewportHeight > 0 else { return false } |
| 72 | // Short chat: the whole log fits (content height = last.maxY - |
| 73 | // first.minY, independent of the offset), top-aligned β flush means the |
| 74 | // first row starts at/after the top and the last row's bottom isn't |
| 75 | // shoved past the viewport bottom. |
| 76 | if let first, last.maxY - first.minY <= viewportHeight { |
| 77 | return first.minY >= -2 && last.maxY <= viewportHeight + 2 |
| 78 | } |
| 79 | return last.maxY > viewportHeight * 0.5 && last.maxY <= viewportHeight + 2 |
| 80 | } |
| 81 | |
| 82 | // Whether the newest row is close enough to the bottom to count as "the |
| 83 | // reader is at the bottom" β the gate on following new arrivals, and (in |
| 84 | // the scroll memory) on storing no anchor at all. |
| 85 | public static func isNearBottom(lastRow: CGRect?, viewportHeight: CGFloat, |
| 86 | slack: CGFloat = 150) -> Bool { |
| 87 | guard let lastRow else { return false } |
| 88 | return lastRow.maxY <= viewportHeight + slack |
| 89 | } |
| 90 | } |
| 91 | |