Skip to content
Merged
26 changes: 16 additions & 10 deletions Sources/Auth/StackAccountAvatarImageLoader.swift
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import AppKit
import Foundation

/// Loads a Stack profile picture and normalizes it into a square bitmap that
/// the AppKit-hosted icon renderer draws as an aspect-fill avatar.
/// Loads a Stack profile picture and normalizes it into a circular bitmap that
/// the AppKit-hosted icon renderer draws as-is.
///
/// `AsyncImage` hands its result to SwiftUI as a raster `Image`, which draws
/// nothing on Intel Macs running macOS 15. Decoding here and drawing through
/// `CmuxResolvedIconImage` keeps the real profile picture visible everywhere.
/// nothing on Intel Macs running macOS 15, and a SwiftUI `clipShape` over the
/// hosted image view goes blank after a while on the same machines. Decoding
/// here, clipping to the circle inside the bitmap, and drawing through
/// `CmuxResolvedIconImage` keeps the real profile picture visible everywhere
/// without SwiftUI owning any pixel of it.
@MainActor
enum StackAccountAvatarImageLoader {
/// Fetches `url` through the shared URL cache and returns a square avatar
/// bitmap sized for `pointSize`, or `nil` when the download or decode fails.
/// Fetches `url` through the shared URL cache and returns a circular
/// avatar bitmap sized for `pointSize`, or `nil` when the download or
/// decode fails.
static func load(
from url: URL,
pointSize: CGFloat,
Expand All @@ -19,12 +23,13 @@ enum StackAccountAvatarImageLoader {
guard let (data, _) = try? await session.data(from: url) else {
return nil
}
return squareImage(from: data, pointSize: pointSize)
return circularImage(from: data, pointSize: pointSize)
}

/// Decodes `data`, center-crops it to a square, and rasterizes it at
/// `scale` pixels per point so a square image view fills the avatar circle.
static func squareImage(from data: Data, pointSize: CGFloat, scale: CGFloat = 2) -> NSImage? {
/// Decodes `data`, center-crops it to a square, clips it to the inscribed
/// circle, and rasterizes it at `scale` pixels per point so the hosted
/// image view shows the finished avatar without any SwiftUI clipping.
static func circularImage(from data: Data, pointSize: CGFloat, scale: CGFloat = 2) -> NSImage? {
guard pointSize.isFinite, pointSize > 0,
scale.isFinite, scale > 0,
let source = NSImage(data: data),
Expand Down Expand Up @@ -66,6 +71,7 @@ enum StackAccountAvatarImageLoader {
graphicsContext.imageInterpolation = .high
NSColor.clear.setFill()
NSRect(origin: .zero, size: targetSize).fill()
NSBezierPath(ovalIn: NSRect(origin: .zero, size: targetSize)).addClip()
source.draw(
in: NSRect(origin: .zero, size: targetSize),
from: cropRect,
Expand Down
43 changes: 32 additions & 11 deletions Sources/Auth/StackAccountAvatarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ import CmuxAppKitSupportUI
import SwiftUI

/// Displays the Stack profile image with an initial-based fallback.
///
/// The picture is drawn by the AppKit-hosted icon renderer with the same
/// contract as the Vault agent icons: the decoded (already circular) bitmap is
/// the primary source and a tinted person symbol is the fallback for a
/// transient blank draw. No SwiftUI raster image, mask, or clip shape touches
/// the hosted view; those go blank after a while on Intel Macs running
/// macOS 15.
struct StackAccountAvatarView: View {
let avatarURL: URL?
let displayName: String
Expand All @@ -12,6 +19,23 @@ struct StackAccountAvatarView: View {

@State private var loadedAvatar: LoadedAvatar?

/// Symbol drawn when the decoded picture renders blank, mirroring
/// `SessionIndexAgentIconImage`.
static let fallbackSymbolName = "person.crop.circle.fill"

/// Builds the renderer request for a decoded profile picture.
static func hostedRequest(image: NSImage, size: CGFloat) -> CmuxResolvedIconRequest {
CmuxResolvedIconRequest(
source: .image(image),
size: NSSize(width: size, height: size),
fallbackSource: .systemSymbol(
name: fallbackSymbolName,
accessibilityDescription: nil
),
fallbackTintColor: .secondaryLabelColor
)
}

/// The last completed load, keyed by URL so a changed URL shows the
/// loading state again instead of a stale picture.
private struct LoadedAvatar {
Expand All @@ -24,22 +48,18 @@ struct StackAccountAvatarView: View {
if let avatarURL {
if let loadedAvatar, loadedAvatar.url == avatarURL {
if let image = loadedAvatar.image {
// The hosted AppKit renderer draws the picture; SwiftUI
// raster images are blank on Intel Macs running macOS 15.
CmuxResolvedIconImage(request: CmuxResolvedIconRequest(
source: .image(image),
size: NSSize(width: size, height: size)
))
CmuxResolvedIconImage(request: Self.hostedRequest(image: image, size: size))
.frame(width: size, height: size)
} else {
fallback
}
} else if let loadingSystemName {
CmuxSystemSymbolImage(
systemName: loadingSystemName,
pointSize: size,
weight: .regular
weight: .regular,
tint: Color(nsColor: .secondaryLabelColor)
)
.foregroundStyle(Color(nsColor: .secondaryLabelColor))
} else {
fallback
}
Expand All @@ -48,7 +68,8 @@ struct StackAccountAvatarView: View {
}
}
.frame(width: size, height: size)
.clipShape(Circle())
// The picture is circular inside its bitmap and the fallbacks draw a
// `Circle()` themselves, so no `clipShape` wraps the hosted view.
.overlay(Circle().stroke(Color.primary.opacity(0.12), lineWidth: 0.5))
.accessibilityHidden(true)
.task(id: avatarURL) {
Expand All @@ -70,9 +91,9 @@ struct StackAccountAvatarView: View {
CmuxSystemSymbolImage(
systemName: "person.fill",
pointSize: max(8, size * 0.45),
weight: .medium
weight: .medium,
tint: fallbackForegroundColor
)
.foregroundStyle(fallbackForegroundColor)
}
}
}
Expand Down
47 changes: 24 additions & 23 deletions Sources/CmuxHostedSystemSymbolImage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,24 @@ import AppKit
import CmuxAppKitSupportUI
import SwiftUI

/// Draws an SF Symbol through the AppKit-hosted icon renderer while keeping
/// SwiftUI foreground-style tinting.
/// Draws an SF Symbol through the AppKit-hosted icon renderer with the tint
/// baked into the bitmap, the same path the Vault (`SessionIndex`) icons use.
///
/// SwiftUI raster images (`Image(nsImage:)`, `Image(decorative:)`,
/// `AsyncImage`) draw nothing on Intel Macs running macOS 15, while AppKit
/// image views hosted in SwiftUI draw normally. Masking a `.foreground`
/// filled rectangle with the hosted symbol keeps every caller's
/// `.foregroundStyle` / `.foregroundColor` semantics and the exact glyph
/// geometry of `NSImage.SymbolConfiguration(pointSize:weight:)`.
/// SwiftUI must not own any pixel of the glyph. On Intel Macs running macOS
/// 15, SwiftUI raster images draw nothing, and a SwiftUI `.mask` over a hosted
/// AppKit view paints at first but goes blank after a while; an `NSImageView`
/// showing a pre-tinted bitmap stays visible. The shared renderer resolves the
/// dynamic tint under the view's effective appearance and retries the same
/// symbol through its fallback slot when a draw comes back blank.
struct CmuxHostedSystemSymbolImage: View {
let systemName: String
/// SF Symbol configuration point size.
let pointSize: CGFloat
/// Layout size of the configured symbol; the glyph draws 1:1 inside it.
let imageSize: NSSize
let weight: NSFont.Weight
/// Dynamic AppKit color the renderer bakes into the bitmap.
let tintColor: NSColor
/// Size of the slot the glyph is centered in, matching the SwiftUI
/// `Image(nsImage:)` frame this view replaces.
let slotSize: CGFloat
Expand All @@ -28,30 +30,29 @@ struct CmuxHostedSystemSymbolImage: View {
systemName: String,
pointSize: CGFloat,
imageSize: NSSize,
weight: NSFont.Weight
weight: NSFont.Weight,
tintColor: NSColor
) -> CmuxResolvedIconRequest {
CmuxResolvedIconRequest(
source: .systemSymbol(name: systemName, accessibilityDescription: nil),
size: imageSize,
tintColor: tintColor,
symbolWeight: weight,
fallbackSource: .systemSymbol(name: systemName, accessibilityDescription: nil),
symbolPointSize: pointSize
)
}

var body: some View {
Rectangle()
.fill(.foreground)
.frame(width: imageSize.width, height: imageSize.height)
.mask(
CmuxResolvedIconImage(request: Self.iconRequest(
systemName: systemName,
pointSize: pointSize,
imageSize: imageSize,
weight: weight
))
.frame(width: imageSize.width, height: imageSize.height)
)
.frame(width: slotSize, height: slotSize, alignment: alignment)
.accessibilityHidden(true)
CmuxResolvedIconImage(request: Self.iconRequest(
systemName: systemName,
pointSize: pointSize,
imageSize: imageSize,
weight: weight,
tintColor: tintColor
))
.frame(width: imageSize.width, height: imageSize.height)
.frame(width: slotSize, height: slotSize, alignment: alignment)
.accessibilityHidden(true)
}
}
Loading
Loading