eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1import 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.
9public 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