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
Original file line number Diff line number Diff line change
Expand Up @@ -9,33 +9,34 @@
set { fallbackRenderScale = newValue }
}
/// The process value for code with no view: set from the first window's screen at launch and by every
@@ -56,12 +56,22 @@
@@ -56,12 +56,23 @@
/// Light system appearance (the host sets it from its effective appearance).
/// Link cards follow it: Messages' light card is the light incoming grey
/// (#E9E9EB) with dark text. A change bumps `paletteGeneration`.
- static var lightAppearance = false { didSet { if lightAppearance != oldValue { paletteGeneration += 1 } } }
+ static var lightAppearance = false { didSet { if lightAppearance != oldValue { baseGeneration += 1 } } }
+ static var lightAppearance = false { didSet { if lightAppearance != oldValue { PaletteScope.bumpBaseGeneration() } } }
/// Bumped by an appearance or a colour space change: cached bitmaps are stale (not by a window's key state).
- static fileprivate(set) var paletteGeneration = 0
+ // cmux: written on the main thread only; off-main renders compare a generation they were given.
+ nonisolated(unsafe) static fileprivate(set) var baseGeneration = 0
+ // cmux: written on the main thread and read by off-main renders (their cache keys, `generationIsLive`), so it
+ // sits behind PaletteScope's lock.
+ static var baseGeneration: Int { PaletteScope.baseGeneration }
+ /// cmux: the palette generation of the drawing that runs now: the base generation and the palette's id (the
+ /// scope's, a window view's own Chat Colors, else the process palette, PaletteScope). Every palette-dependent
+ /// cache is keyed by it, so two chats with different colours keep separate bitmaps and tiles.
+ static var paletteGeneration: Int { generation(of: PaletteScope.current) }
+ /// cmux: the generation drawing in a scope of `box` has now (nil: the process palette's).
+ static func generation(of box: PaletteBox?) -> Int { baseGeneration &* 1_000_003 &+ ((box ?? PaletteScope.process)?.id ?? 0) }
+ static func generation(of box: PaletteBox?) -> Int { PaletteScope.generation(of: box) }
+ /// cmux: `generation` was made since the last appearance or colour space change (its palette may be another
+ /// view's): a result drawn for it is still good under its own key.
+ static func generationIsLive(_ generation: Int) -> Bool { generation / 1_000_003 == baseGeneration }
/// The bitmaps' colour space changed (a window moved to a display of another colour space): every cache keyed
/// on the palette generation draws again (MessagesWindowView.displayColorSpaceChanged).
- static func colorSpaceChanged() { paletteGeneration += 1 }
+ static func colorSpaceChanged() { baseGeneration += 1 }
+ static func colorSpaceChanged() { PaletteScope.bumpBaseGeneration() }

/// Palette, measured with `screencapture -l` on macOS 27 Messages
/// (cmux-lawrence-2, 2026-10-05): key and non-key windows are identical
@@ -74,11 +84,24 @@
@@ -74,11 +85,24 @@
static func p3(_ r: CGFloat, _ g: CGFloat, _ b: CGFloat) -> UIColor {
UIColor(displayP3Red: r / 255, green: g / 255, blue: b / 255, alpha: 1)
}
Expand All @@ -61,7 +62,7 @@
/// The compose field's placeholder and audio waveform are see-through in Messages: the same ink over the
/// key field (59, light 249) and the inactive one (50, light 240) is a constant step from it (lossless
/// deactivate-activate and appearance-light takes, macOS 27, cx-3cb compose-parity): placeholder +63 in dark
@@ -88,7 +111,7 @@
@@ -88,7 +112,7 @@
/// 10 levels too light on the inactive dark field, 60 levels too dark in light.
static var placeholderInk: UIColor { lightAppearance ? UIColor(white: 0, alpha: 0.27) : UIColor(white: 1, alpha: 0.316) }
static var waveformInk: UIColor { lightAppearance ? UIColor(white: 0, alpha: 0.39) : UIColor(white: 212 / 255, alpha: 0.556) }
Expand All @@ -70,7 +71,7 @@
/// The typing indicator (bubble, tail discs, dots) is not the incoming grey: in Messages' key window it is
/// one level darker in red and green (bubble and discs 58, 58, 61; dots dim 91, 91, 94, lit 133, 133, 135:
/// the 12 settled lossless stills typing-dots-02..13 and send-typed-take1 +5.5 s), in its inactive window it
@@ -98,16 +121,31 @@
@@ -98,16 +122,31 @@
/// `inactive`: the drawing view's own state (MessagesWindowView.inactive, through RowCell.inactive).
static func typingColors(inactive: Bool) -> (fill: UIColor, dotDim: UIColor, dotLit: UIColor) {
let dim = inactive && !lightAppearance && !elevated
Expand Down Expand Up @@ -105,7 +106,7 @@
/// The capture caret after a send (cx-3cb parity26; Messages' macOS 26 original recording 4.65-5.30 s and
/// macOS 27 send-typed-take1, lossless): it stays on the field's first line while the field collapses (the
/// compose view moves it with the placeholder), starts in the accent colour and turns grey 0.04-0.22 s after
@@ -126,8 +164,8 @@
@@ -126,8 +165,8 @@
return (s < 0.02 || s >= offFrom ? 0 : 1, c)
}
}
Expand All @@ -116,7 +117,7 @@
/// Outgoing bubbles shade with their position in the window (measured:
/// lighter near the top, deeper blue near the compose field).
/// (window y in 2x px, red, green), Display P3.
@@ -152,10 +190,68 @@
@@ -152,10 +191,68 @@
// optional, no force unwrap ; a nil gradient draws nothing.
private static let darkGradient = gradient { p3($0, $1, 247) }
private static let lightGradient = gradient(lightGradientColor)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@
fillContainer.addSublayer(fillGradient)
fillContainer.mask = fillMask
connectorLine.backgroundColor = connector.strokeColor
@@ -694,10 +723,21 @@
@@ -694,12 +723,23 @@
key = ""
// A bitmap kept in a live resize is not the row's own: the next configure draws it, even for the same row.
if liveKept { liveKept = false; spec = nil }
Expand All @@ -102,8 +102,8 @@
markdownOverlay?.detach()
CustomRows.host?.detach(self)
dropWant()
+ }
+
}
+ /// cmux: draws the selection highlight (`selectionHighlight`), nil for none. The path is in this cell's
+ /// coordinates.
+ func setSelectionHighlight(_ path: CGPath?) {
Expand All @@ -112,9 +112,11 @@
+ if path != nil, selectionHighlight.superlayer !== contentView.layer { contentView.layer.addSublayer(selectionHighlight) }
+ selectionHighlight.path = path
+ CATransaction.commit()
}
+ }
+
func clearAnimations() {
layer.removeAllAnimations()
contentView.layer.removeAllAnimations()
@@ -719,10 +759,11 @@
guard let spec else { return nil }
switch spec.kind {
Expand Down Expand Up @@ -199,13 +201,14 @@
var images = [CGImage?](repeating: nil, count: specs.count)
// Per row: its drawing time on its worker (ms), for the display change's evidence.
var rowMs = [Double](repeating: 0, count: specs.count)
@@ -1004,13 +1060,17 @@
@@ -1004,13 +1060,18 @@
DispatchQueue.concurrentPerform(iterations: specs.count) { i in
// checked reads and writes (crash ratchet)
let r0 = CACurrentMediaTime()
- if let slot = out.checkedIndex(i), let spec = specs[checked: i], let sc = scales[checked: i] { out[slot] = RowBitmaps.render(spec, scale: sc) }
+ if let slot = out.checkedIndex(i), let spec = specs[checked: i], let sc = scales[checked: i] {
+ out[slot] = PaletteScope.with(palettes[checked: i] ?? nil) { RowBitmaps.render(spec, scale: sc) }
+ if let spec = specs[checked: i], let sc = scales[checked: i] {
+ let image = PaletteScope.with(palettes[checked: i] ?? nil) { RowBitmaps.render(spec, scale: sc) }
+ if let slot = out.checkedIndex(i) { out[slot] = image }
+ }
if let slot = ms.checkedIndex(i) { ms[slot] = (CACurrentMediaTime() - r0) * 1000 }
}
Expand All @@ -219,7 +222,7 @@
let t2 = CACurrentMediaTime()
if displayPass || DisplayTiming.flushOpen {
DisplayTiming.note("flush.draw", (t1 - t0) * 1000)
@@ -1133,7 +1193,7 @@
@@ -1133,7 +1194,7 @@
private func snapTyping() {
#if !os(iOS) || targetEnvironment(macCatalyst)
guard case .typing? = spec?.kind else { return }
Expand All @@ -228,7 +231,7 @@
// In the row's content, which snapRow already moved by rowSnap.
let top = windowY + rowSnap.y + RowDraw.typingBubble.minY, left = layer.frame.minX + rowSnap.x + RowDraw.typingBubble.minX
guard top.isFinite, left.isFinite else { return }
@@ -1197,8 +1257,11 @@
@@ -1197,8 +1258,11 @@
box.position = CGPoint(x: c.x - 20 + box.anchorPoint.x * size.width, y: c.y - 20 + box.anchorPoint.y * size.height)
box.contentsScale = renderScale
badgeTop = c.y - 20
Expand All @@ -242,15 +245,15 @@
RowCell.placeFill(fill, windowTop: contentWindowY + badgeTop, width: size.width, span: fillSpan, reachBelow: fillReachBelow)
if let holder = fill.superlayer, let mask = holder.mask as? CAShapeLayer {
holder.frame = CGRect(origin: .zero, size: size)
@@ -1357,6 +1420,7 @@
@@ -1357,6 +1421,7 @@
/// layer tree (o: the window view's origin there, k: the window's length of one view point along the axis, +1 or
/// -1 when not scaled), rounded at `scale`. A tie (exactly half a pixel off) rounds up in the window, the same in
/// every pass.
+ /// `scale` is the window's device pixels per window point (cmux: `pixelScale`, not a zoomed chat's `renderScale`).
static func snap(_ v: CGFloat, _ o: CGFloat, _ k: CGFloat, scale s: CGFloat) -> CGFloat {
guard abs(k) > 1e-6 else { return 0 }
let r = o + k * v
@@ -1386,7 +1450,7 @@
@@ -1386,7 +1451,7 @@
case let .label(_, outgoing, _), let .replies(_, _, outgoing): return outgoing
case .receipt: return true
case let .threadPreview(pv): return pv.outgoing
Expand All @@ -259,7 +262,7 @@
}
}
/// The x (view pt) of the row's anchored side for a cell at `frame` (snapRow, the send morph's landing place): the
@@ -1402,7 +1466,7 @@
@@ -1402,7 +1467,7 @@
/// Moves the content to the snapped place; true when the snap changed.
@discardableResult private func snapRow() -> Bool {
#if !os(iOS) || targetEnvironment(macCatalyst)
Expand All @@ -268,7 +271,7 @@
let x = RowCell.anchorX(spec, layer.frame)
guard windowY.isFinite, x.isFinite else { return false }
let d = CGPoint(x: RowCell.snap(x, windowPlace.origin.x, windowPlace.axis.x, scale: s),
@@ -1451,6 +1515,16 @@
@@ -1451,6 +1516,16 @@
clipTypingBody(Springs.typingBreathBody, about: Springs.typingBreathAnchor, begin: c.beginTime - Springs.typingBreathBody.start)
}

Expand All @@ -285,7 +288,7 @@
/// Window y of the cell's top: the outgoing fill shades with it.
var windowY: CGFloat = 0 {
didSet {
@@ -1471,10 +1545,12 @@
@@ -1471,10 +1546,12 @@
/// down to this bubble: an arc that hangs from the root and a vertical
/// stroke whose bottom stays 8.5 pt above this bubble. When the two rows
/// move apart, the arc and the stroke's height animate (no path animation).
Expand All @@ -301,7 +304,7 @@
connectorState = state
CATransaction.begin(); CATransaction.setDisableActions(true)
defer { CATransaction.commit() }
@@ -1487,7 +1563,7 @@
@@ -1487,7 +1564,7 @@
contentView.layer.insertSublayer(connector, at: 0)
contentView.layer.insertSublayer(connectorLine, at: 1)
}
Expand All @@ -310,7 +313,7 @@
let cx = x + r, cy = top + r
let w = spec?.width ?? 628
let p = UIBezierPath()
@@ -1566,6 +1642,8 @@
@@ -1566,6 +1643,8 @@
a.isRemovedOnCompletion = false
a.calculationMode = .linear
if let p = Springs.typingStillPhase { a.speed = 0; a.timeOffset = p }
Expand All @@ -319,7 +322,7 @@
hi.add(a, forKey: "dots")
}
}
@@ -1576,6 +1654,8 @@
@@ -1576,6 +1655,8 @@
func startTypingBreath(insert: CFTimeInterval) {
#if !os(iOS) || targetEnvironment(macCatalyst)
guard Springs.typingStillPhase == nil else { return }
Expand All @@ -328,7 +331,7 @@
let a = RowDraw.typingAnchor, b = Springs.typingBreathAnchor
// The container scales about RowDraw.typingAnchor; the opposite offset of a scale e about it moves the
// fixed point to b: e x (a - b).
@@ -1610,8 +1690,10 @@
@@ -1610,8 +1691,10 @@
// 0 after snapTyping), read from the window's layer tree.
let o = typingContainer.convert(CGPoint(x: r.minX, y: r.minY), to: nil)
let o1 = typingContainer.convert(CGPoint(x: r.minX + 1, y: r.minY + 1), to: nil)
Expand All @@ -340,7 +343,7 @@
guard g.isFinite, k.isFinite, abs(k) > 1e-6 else { return 0 }
let f = (g - (g + 1e-6).rounded()) * (k < 0 ? -1 : 1)
return abs(f) > 1e-6 ? f : 0
@@ -1674,7 +1756,8 @@
@@ -1674,7 +1757,8 @@
/// The typing row should breathe but does not (macOS, not a still scene): its breathing was stopped or never started.
var needsTypingBreath: Bool {
#if !os(iOS) || targetEnvironment(macCatalyst)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,11 +217,10 @@
CATransaction.commit()
}

@@ -900,6 +995,18 @@
// (RowCell.crispEdge). `.sending` and `.sent` (the send itself) keep the crisp edge.
@@ -901,6 +996,18 @@
if case let .status(id, st) = action, st != .sending, st != .sent, crispSends.values.contains(id) {
releaseCrisp { $0 == id }
+ }
}
+ // cmux: also every crisp send whose message is past sent in the new state (delivered, read or failed), whatever
+ // the action: Home sends one status per update (HomeDiff.keepNewestStatus: two sends delivered together give
+ // only the newer one a status) and a rebuild's replaceWindow carries statuses with no status action. Sends are
Expand All @@ -233,9 +232,10 @@
+ switch m.status { case .delivered?, .read?, .failed?: past.insert(m.id); default: break }
+ }
+ if !past.isEmpty { releaseCrisp { past.contains($0) } }
}
+ }
// this view's width (several Home tabs), not the process-wide `Metrics.current`.
if case let .setDraft(text) = action { MorphBubble.prepare(draft: text, width: bounds.width, scale: renderScale) }
// Rows configured in this transaction draw now (RowCell.transitionDepth).
@@ -1007,7 +1114,8 @@
// The same for an action whose rows came out unchanged.
let draft: Bool = { if case .setDraft = action { return true }; return false }()
Expand Down Expand Up @@ -298,21 +298,22 @@
case let .react(ref, _, _):
guard let i = index(ref.messageId) else { return nil }
idx.append(i)
@@ -1511,7 +1636,14 @@
@@ -1511,8 +1636,15 @@
for case let cell as RowCell in collection.visibleCells {
if let b = map[cell.key] { cell.renameRow(to: b) }
}
+ onRowsRenamed(map)
}
+
+ /// cmux: rows renamed in place (a stream's final message took its rows), old key -> new key; the
+ /// transcript selection follows (TranscriptSelection.renameRows).
+ var onRowsRenamed: ([String: String]) -> Void = { _ in }
+ /// cmux: a cell was configured for a row (decorate); the transcript selection draws its part in it.
+ var onCellDecorated: (RowCell) -> Void = { _ in }
+
/// Live container motions: (displacement, spring, begin, end, rows it covered). A row that
/// shows up while one runs (inserted later, or outside its band) gets the opposite motion,
/// so it moves as it did with per-row springs.
@@ -1689,22 +1821,41 @@
}

Expand Down Expand Up @@ -566,7 +567,7 @@
}

var threadOpen: Bool { store.state.ui.openThread != nil }
+
+ /// cmux: the space an open thread view's rows may take, between the header and the field (as a sheet's).
+ var threadArea: CGRect {
+ let top = Fixture.headerHeight + 12, bottom = max(top + 40, fieldTop - 12)
Expand All @@ -592,7 +593,7 @@
+ CATransaction.commit()
+ return true
+ }
+
+ /// cmux: Reduce Motion (the host's pin or the system setting): the thread view and a reply sent in it change at once.
+ private var reducesMotion: Bool { MessagesLabHomeView.reducesMotion }
+
Expand All @@ -615,7 +616,7 @@
func softMaskOwner(_ h: Hit) -> CALayer? {
guard !threadOpen else { return nil }
for case let cell as RowCell in collection.visibleCells where cell.spec?.key == h.key { return cell.fillContainer }
@@ -2252,7 +2479,7 @@
@@ -2252,12 +2479,29 @@
let r = cell.convert(cell.bounds, to: self)
if pv.outgoing {
if p.x >= r.minX + spec.metrics.rightEdge - max(pv.box.width, 90) { return pv.root }
Expand All @@ -624,7 +625,29 @@
}
}
return nil
@@ -2317,6 +2544,9 @@
}

+ /// cmux: the key of the transcript row under `p` (a visible cell), or nil.
+ func rowKey(at p: CGPoint) -> String? {
+ for case let cell as RowCell in collection.visibleCells where cell.convert(cell.bounds, to: self).contains(p) {
+ return cell.spec?.key
+ }
+ return nil
+ }
+
+ /// cmux: where the transcript row `key` is in this view (a visible cell, under an open thread view's
+ /// blur too), or nil when it is not on screen.
+ func rowFrame(key: String) -> CGRect? {
+ for case let cell as RowCell in collection.visibleCells where cell.spec?.key == key {
+ return cell.convert(cell.bounds, to: self)
+ }
+ return nil
+ }
+
func threadContentContains(_ p: CGPoint) -> Bool {
threadViews.contains { $0.frame.insetBy(dx: 0, dy: RowDraw.margin).contains(p) }
|| threadHeaderView?.frame.contains(p) == true
@@ -2317,6 +2561,9 @@

/// The host's thread header changed (title, status, owner): the open thread view asks
/// `HostHooks.threadHeader` again.
Expand All @@ -634,7 +657,7 @@
func threadHeaderDidChange() {
guard store.state.ui.threadMode == .tree, store.state.ui.openThread != nil, threadClosing == 0 else { return }
threadForceRebuild = true
@@ -2515,12 +2745,21 @@
@@ -2515,12 +2762,21 @@
return l
}

Expand All @@ -657,15 +680,15 @@
let delay = open ? ThreadBackdrop.openDelay : 0
let t = CABasicAnimation(keyPath: "opacity"); t.fromValue = a0; t.toValue = a1; t.duration = d; t.timingFunction = fn
// Without a wait, start at the commit as the row flights do (`fly`, `fade`): a begin
@@ -2529,7 +2768,6 @@
@@ -2529,7 +2785,6 @@
if delay > 0 { t.beginTime = CACurrentMediaTime() + delay; t.fillMode = .backwards }
tint.add(t, forKey: "thread")
if let blur {
- blur.setValue(r1, forKeyPath: "filters.gaussianBlur.inputRadius")
let b = CABasicAnimation(keyPath: "filters.gaussianBlur.inputRadius"); b.fromValue = r0; b.toValue = r1; b.duration = d; b.timingFunction = fn
if delay > 0 { b.beginTime = CACurrentMediaTime() + delay; b.fillMode = .backwards }
blur.add(b, forKey: "thread")
@@ -2727,7 +2965,7 @@
@@ -2727,7 +2982,7 @@
let textTop = Fixture.bubblePadY
if case let .part(p) = spec0.kind, case let .text(t, _) = p.part, p.text == nil {
let l = LongTextStore.shared.layout(t, width: spec0.width, message: p.ref.messageId, markdown: p.markdownFormat)
Expand Down
Loading
Loading