diff --git a/Sources/Auth/StackAccountAvatarImageLoader.swift b/Sources/Auth/StackAccountAvatarImageLoader.swift index ffdda8755d87..9cdad78a0f6f 100644 --- a/Sources/Auth/StackAccountAvatarImageLoader.swift +++ b/Sources/Auth/StackAccountAvatarImageLoader.swift @@ -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, @@ -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), @@ -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, diff --git a/Sources/Auth/StackAccountAvatarView.swift b/Sources/Auth/StackAccountAvatarView.swift index 18f777743a32..bc76cd180e7c 100644 --- a/Sources/Auth/StackAccountAvatarView.swift +++ b/Sources/Auth/StackAccountAvatarView.swift @@ -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 @@ -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 { @@ -24,12 +48,8 @@ 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 } @@ -37,9 +57,9 @@ struct StackAccountAvatarView: View { CmuxSystemSymbolImage( systemName: loadingSystemName, pointSize: size, - weight: .regular + weight: .regular, + tint: Color(nsColor: .secondaryLabelColor) ) - .foregroundStyle(Color(nsColor: .secondaryLabelColor)) } else { fallback } @@ -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) { @@ -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) } } } diff --git a/Sources/CmuxHostedSystemSymbolImage.swift b/Sources/CmuxHostedSystemSymbolImage.swift index f3d5f21f8655..5223637b3dfb 100644 --- a/Sources/CmuxHostedSystemSymbolImage.swift +++ b/Sources/CmuxHostedSystemSymbolImage.swift @@ -2,15 +2,15 @@ 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. @@ -18,6 +18,8 @@ struct CmuxHostedSystemSymbolImage: View { /// 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 @@ -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) } } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index c539b4537d58..c3c9a12291a9 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -13370,7 +13370,7 @@ struct VerticalTabsSidebar: View, Equatable { Button(action: onNewTab) { HStack(spacing: 9) { - CmuxSystemSymbolImage(magnified: "plus", pointSize: 15, weight: .regular) + CmuxSystemSymbolImage(magnified: "plus", pointSize: 15, weight: .regular, tint: .secondary) .frame(width: 22, height: 22) Text(String(localized: "sidebar.browserStack.newTab", defaultValue: "New Tab")) .cmuxFont(size: 13, weight: .regular) @@ -13406,14 +13406,12 @@ struct VerticalTabsSidebar: View, Equatable { ) -> some View { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 8) { - CmuxSystemSymbolImage(magnified: "folder.fill", pointSize: 14, weight: .regular) - .foregroundColor(.secondary) + CmuxSystemSymbolImage(magnified: "folder.fill", pointSize: 14, weight: .regular, tint: .secondary) Text(extensionSidebarTreeSectionTitle(section.treeSection)) .cmuxFont(size: 13, weight: .semibold) .foregroundColor(.primary.opacity(0.86)) .lineLimit(1) - CmuxSystemSymbolImage(magnified: "chevron.down", pointSize: 11, weight: .medium) - .foregroundColor(.secondary) + CmuxSystemSymbolImage(magnified: "chevron.down", pointSize: 11, weight: .medium, tint: .secondary) Spacer(minLength: 0) } .padding(.horizontal, 10) @@ -13718,8 +13716,7 @@ struct VerticalTabsSidebar: View, Equatable { RoundedRectangle(cornerRadius: size * 0.24, style: .continuous).fill(background) } if let systemImageName = icon?.systemImageName { - CmuxSystemSymbolImage(magnified: systemImageName, pointSize: size * 0.58, weight: .semibold) - .foregroundColor(foreground) + CmuxSystemSymbolImage(magnified: systemImageName, pointSize: size * 0.58, weight: .semibold, tint: foreground) } else { Text(icon?.text ?? ".") .cmuxFont(size: size * 0.58, weight: .bold) @@ -13776,7 +13773,7 @@ struct VerticalTabsSidebar: View, Equatable { } } } label: { - CmuxSystemSymbolImage(magnified: isCollapsed ? "folder" : "folder.fill", pointSize: 13, weight: .regular) + CmuxSystemSymbolImage(magnified: isCollapsed ? "folder" : "folder.fill", pointSize: 13, weight: .regular, tint: .primary) .offset(y: -0.5) } .buttonStyle(.plain) @@ -13797,7 +13794,7 @@ struct VerticalTabsSidebar: View, Equatable { Button { createExtensionWorktreeWorkspace(for: section.treeSection) } label: { - CmuxSystemSymbolImage(magnified: worktreeButtonSymbol, pointSize: 11, weight: .regular) + CmuxSystemSymbolImage(magnified: worktreeButtonSymbol, pointSize: 11, weight: .regular, tint: .primary) .frame(width: 18, height: 18) } .buttonStyle(.plain) @@ -15360,8 +15357,7 @@ struct SidebarFooterButtons: View { title: String(localized: "sidebar.extensions.browser.title", defaultValue: "Sidebar Extensions") ) } label: { - CmuxSystemSymbolImage(magnified: "puzzlepiece.extension", pointSize: 12, weight: .medium) - .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + CmuxSystemSymbolImage(magnified: "puzzlepiece.extension", pointSize: 12, weight: .medium, tint: Color(nsColor: .secondaryLabelColor)) .frame(width: 22, height: 22, alignment: .center) } .buttonStyle(SidebarFooterIconButtonStyle()) @@ -15589,8 +15585,7 @@ private struct SidebarHelpMenuButton: View { } private func helpOptionTrailingIcon(systemName: String, size: CGFloat = 13) -> some View { - CmuxSystemSymbolImage(systemName: systemName, pointSize: size) - .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + CmuxSystemSymbolImage(systemName: systemName, pointSize: size, tint: Color(nsColor: .secondaryLabelColor)) } private func perform(_ action: SidebarHelpMenuAction) { @@ -16014,14 +16009,12 @@ struct TabItemView: View, Equatable { } if workspaceSnapshot.isPinned { - CmuxSystemSymbolImage(magnified: "pin.fill", pointSize: scaledFontSize(9), weight: .semibold) - .foregroundColor(activeSecondaryColor(0.8)) + CmuxSystemSymbolImage(magnified: "pin.fill", pointSize: scaledFontSize(9), weight: .semibold, tint: activeSecondaryColor(0.8)) .safeHelp(protectedWorkspaceTooltip) } if workspaceSnapshot.isMuted { - CmuxSystemSymbolImage(magnified: "bell.slash.fill", pointSize: scaledFontSize(9), weight: .semibold) - .foregroundColor(activeSecondaryColor(0.8)) + CmuxSystemSymbolImage(magnified: "bell.slash.fill", pointSize: scaledFontSize(9), weight: .semibold, tint: activeSecondaryColor(0.8)) .safeHelp(String(localized: "sidebar.mutedWorkspace.tooltip", defaultValue: "Notifications muted for this workspace")) } @@ -16160,8 +16153,7 @@ struct TabItemView: View, Equatable { if detailVisibility.showsLog, let latestLog = workspaceSnapshot.latestLog { HStack(alignment: .center, spacing: 4) { - CmuxSystemSymbolImage(magnified: logLevelIcon(latestLog.level), pointSize: scaledFontSize(8)) - .foregroundColor(logLevelColor(latestLog.level, isActive: usesInvertedActiveForeground)) + CmuxSystemSymbolImage(magnified: logLevelIcon(latestLog.level), pointSize: scaledFontSize(8), tint: logLevelColor(latestLog.level, isActive: usesInvertedActiveForeground)) Text(latestLog.message) .font(magnifiedFont(scaledFontSize(10))) .foregroundColor(activeSecondaryColor(0.8)) @@ -16200,8 +16192,7 @@ struct TabItemView: View, Equatable { if !workspaceSnapshot.branchDirectoryLines.isEmpty { HStack(alignment: .top, spacing: 3) { if sidebarShowGitBranchIcon, workspaceSnapshot.branchLinesContainBranch { - CmuxSystemSymbolImage(magnified: "arrow.triangle.branch", pointSize: scaledFontSize(9)) - .foregroundColor(activeSecondaryColor(0.6)) + CmuxSystemSymbolImage(magnified: "arrow.triangle.branch", pointSize: scaledFontSize(9), tint: activeSecondaryColor(0.6)) } VStack(alignment: .leading, spacing: 1) { ForEach(Array(workspaceSnapshot.branchDirectoryLines.enumerated()), id: \.offset) { _, line in @@ -16230,8 +16221,7 @@ struct TabItemView: View, Equatable { .truncationMode(.tail) } if line.branch != nil, !line.directoryCandidates.isEmpty { - CmuxSystemSymbolImage(magnified: "circle.fill", pointSize: scaledFontSize(3)) - .foregroundColor(activeSecondaryColor(0.6)) + CmuxSystemSymbolImage(magnified: "circle.fill", pointSize: scaledFontSize(3), tint: activeSecondaryColor(0.6)) .padding(.horizontal, 1) } if !line.directoryCandidates.isEmpty { @@ -16252,8 +16242,7 @@ struct TabItemView: View, Equatable { || !workspaceSnapshot.compactDirectoryCandidates.isEmpty) { HStack(alignment: .top, spacing: 3) { if sidebarShowGitBranchIcon, workspaceSnapshot.compactGitBranchSummaryText != nil { - CmuxSystemSymbolImage(magnified: "arrow.triangle.branch", pointSize: scaledFontSize(9)) - .foregroundColor(activeSecondaryColor(0.6)) + CmuxSystemSymbolImage(magnified: "arrow.triangle.branch", pointSize: scaledFontSize(9), tint: activeSecondaryColor(0.6)) } VStack(alignment: .leading, spacing: 1) { if let branchRow = workspaceSnapshot.compactGitBranchSummaryText { @@ -16275,8 +16264,7 @@ struct TabItemView: View, Equatable { } else if !workspaceSnapshot.compactBranchDirectoryCandidates.isEmpty { HStack(spacing: 3) { if sidebarShowGitBranchIcon, workspaceSnapshot.compactGitBranchSummaryText != nil { - CmuxSystemSymbolImage(magnified: "arrow.triangle.branch", pointSize: scaledFontSize(9)) - .foregroundColor(activeSecondaryColor(0.6)) + CmuxSystemSymbolImage(magnified: "arrow.triangle.branch", pointSize: scaledFontSize(9), tint: activeSecondaryColor(0.6)) } SidebarDirectoryText( candidates: workspaceSnapshot.compactBranchDirectoryCandidates, @@ -16626,8 +16614,7 @@ struct TabItemView: View, Equatable { .scaleEffect(fontScale) .frame(width: customFrameSize, height: customFrameSize) case .closed: - CmuxSystemSymbolImage(magnified: "xmark.circle", pointSize: 7 * fontScale, weight: .regular) - .foregroundColor(color) + CmuxSystemSymbolImage(magnified: "xmark.circle", pointSize: 7 * fontScale, weight: .regular, tint: color) .frame(width: closedFrameSize, height: closedFrameSize) } } @@ -16898,6 +16885,8 @@ private struct SidebarMetadataEntryRow: View { private func rowContent(underlined: Bool) -> some View { HStack(alignment: .center, spacing: 4) { if let icon = iconView { + // `emoji:` / `text:` icons are SwiftUI text and take the row + // color from here; the SF Symbol branch bakes it as `tint`. icon .foregroundColor(foregroundColor.opacity(0.95)) } @@ -16944,7 +16933,12 @@ private struct SidebarMetadataEntryRow: View { symbolName = iconRaw } guard !symbolName.isEmpty else { return nil } - return AnyView(CmuxSystemSymbolImage(magnified: symbolName, pointSize: 8 * fontScale, weight: .medium)) + return AnyView(CmuxSystemSymbolImage( + magnified: symbolName, + pointSize: 8 * fontScale, + weight: .medium, + tint: foregroundColor.opacity(0.95) + )) } @ViewBuilder diff --git a/Sources/NotificationsPage.swift b/Sources/NotificationsPage.swift index 0cae0a967c57..1292cf3b13c9 100644 --- a/Sources/NotificationsPage.swift +++ b/Sources/NotificationsPage.swift @@ -218,8 +218,7 @@ struct NotificationsPage: View { private var emptyState: some View { VStack(spacing: 8) { - CmuxSystemSymbolImage(magnified: "bell.slash", pointSize: 32) - .foregroundColor(.secondary) + CmuxSystemSymbolImage(magnified: "bell.slash", pointSize: 32, tint: .secondary) Text(String(localized: "notifications.empty.title", defaultValue: "No notifications yet")) .cmuxFont(.headline) Text(String(localized: "notifications.empty.description", defaultValue: "Desktop notifications will appear here for quick review.")) @@ -231,8 +230,7 @@ struct NotificationsPage: View { private var workspaceUnreadIndicatorState: some View { VStack(spacing: 8) { - CmuxSystemSymbolImage(magnified: "bell.badge", pointSize: 32) - .foregroundColor(.secondary) + CmuxSystemSymbolImage(magnified: "bell.badge", pointSize: 32, tint: .secondary) Text(notificationStore.notificationMenuSnapshot.stateHintTitle) .cmuxFont(.headline) } @@ -381,8 +379,7 @@ struct NotificationRow: View, Equatable { .modifier(DefaultActionModifier(isActive: isFocused)) Button(action: onClear) { - CmuxSystemSymbolImage(systemName: "xmark.circle.fill", pointSize: 14) - .foregroundColor(.secondary) + CmuxSystemSymbolImage(systemName: "xmark.circle.fill", pointSize: 14, tint: .secondary) } .buttonStyle(.plain) // CmuxSystemSymbolImage renders an AppKit NSImage with no accessibility diff --git a/Sources/Panels/BrowserDesignModeToolbarButton.swift b/Sources/Panels/BrowserDesignModeToolbarButton.swift index e31578c8b075..c047c85852f5 100644 --- a/Sources/Panels/BrowserDesignModeToolbarButton.swift +++ b/Sources/Panels/BrowserDesignModeToolbarButton.swift @@ -16,9 +16,9 @@ struct BrowserDesignModeToolbarButton: View { CmuxSystemSymbolImage( systemName: controller.isActive ? "paintbrush.pointed.fill" : "paintbrush.pointed", pointSize: iconPointSize, - weight: .medium + weight: .medium, + tint: controller.isActive ? Color.accentColor : inactiveColor ) - .foregroundStyle(controller.isActive ? Color.accentColor : inactiveColor) .frame(width: hitSize, height: hitSize, alignment: .center) } .buttonStyle(OmnibarAddressButtonStyle()) diff --git a/Sources/Panels/BrowserPDFDocumentToolbarButtons.swift b/Sources/Panels/BrowserPDFDocumentToolbarButtons.swift index 1d88df4eb43a..f2ff5ab1145a 100644 --- a/Sources/Panels/BrowserPDFDocumentToolbarButtons.swift +++ b/Sources/Panels/BrowserPDFDocumentToolbarButtons.swift @@ -10,7 +10,7 @@ struct BrowserPDFDocumentToolbarButtons: View { Button(action: { panel.downloadRenderedPDFDocument() }) { - CmuxSystemSymbolImage(systemName: "square.and.arrow.down", pointSize: iconPointSize, weight: .medium) + CmuxSystemSymbolImage(systemName: "square.and.arrow.down", pointSize: iconPointSize, weight: .medium, tint: .primary) .frame(width: hitSize, height: hitSize, alignment: .center) .contentShape(Rectangle()) } @@ -21,7 +21,7 @@ struct BrowserPDFDocumentToolbarButtons: View { Button(action: { panel.printRenderedPDFDocument() }) { - CmuxSystemSymbolImage(systemName: "printer", pointSize: iconPointSize, weight: .medium) + CmuxSystemSymbolImage(systemName: "printer", pointSize: iconPointSize, weight: .medium, tint: .primary) .frame(width: hitSize, height: hitSize, alignment: .center) .contentShape(Rectangle()) } diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index a9b59b42cca9..a782711a1d6c 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -1244,7 +1244,7 @@ struct BrowserPanelView: View { #endif panel.goBack() }) { - CmuxSystemSymbolImage(systemName: "chevron.left", pointSize: chromeMetrics.navigationIconFontSize, weight: .medium) + CmuxSystemSymbolImage(systemName: "chevron.left", pointSize: chromeMetrics.navigationIconFontSize, weight: .medium, tint: .primary) .frame(width: addressBarButtonHitSize, height: addressBarButtonHitSize, alignment: .center) .contentShape(Rectangle()) } @@ -1259,7 +1259,7 @@ struct BrowserPanelView: View { #endif panel.goForward() }) { - CmuxSystemSymbolImage(systemName: "chevron.right", pointSize: chromeMetrics.navigationIconFontSize, weight: .medium) + CmuxSystemSymbolImage(systemName: "chevron.right", pointSize: chromeMetrics.navigationIconFontSize, weight: .medium, tint: .primary) .frame(width: addressBarButtonHitSize, height: addressBarButtonHitSize, alignment: .center) .contentShape(Rectangle()) } @@ -1269,7 +1269,7 @@ struct BrowserPanelView: View { .safeHelp(String(localized: "browser.goForward", defaultValue: "Go Forward")) Button(action: handleReloadOrStopButtonAction) { - CmuxSystemSymbolImage(systemName: panel.isLoading ? "xmark" : "arrow.clockwise", pointSize: chromeMetrics.navigationIconFontSize, weight: .medium) + CmuxSystemSymbolImage(systemName: panel.isLoading ? "xmark" : "arrow.clockwise", pointSize: chromeMetrics.navigationIconFontSize, weight: .medium, tint: .primary) .frame(width: addressBarButtonHitSize, height: addressBarButtonHitSize, alignment: .center) .contentShape(Rectangle()) } @@ -1365,9 +1365,9 @@ struct BrowserPanelView: View { CmuxSystemSymbolImage( systemName: activeToolbarModeIconName, pointSize: devToolsButtonIconSize, - weight: .medium + weight: .medium, + tint: activeToolbarModeColor ) - .foregroundStyle(activeToolbarModeColor) .accessibilityHidden(true) } Text(activeToolbarModeTitle) @@ -1492,8 +1492,7 @@ struct BrowserPanelView: View { Button(action: { openDevTools() }) { - CmuxSystemSymbolImage(systemName: devToolsIconOption.rawValue, pointSize: devToolsButtonIconSize, weight: .medium) - .foregroundStyle(devToolsColorOption.color) + CmuxSystemSymbolImage(systemName: devToolsIconOption.rawValue, pointSize: devToolsButtonIconSize, weight: .medium, tint: devToolsColorOption.color) .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) } .buttonStyle(OmnibarAddressButtonStyle()) @@ -1506,8 +1505,7 @@ struct BrowserPanelView: View { Button(action: { isBrowserProfileMenuPresented.toggle() }) { - CmuxSystemSymbolImage(systemName: "person.crop.circle", pointSize: devToolsButtonIconSize, weight: .medium) - .foregroundStyle(devToolsColorOption.color) + CmuxSystemSymbolImage(systemName: "person.crop.circle", pointSize: devToolsButtonIconSize, weight: .medium, tint: devToolsColorOption.color) .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) } .buttonStyle(OmnibarAddressButtonStyle()) @@ -1593,8 +1591,7 @@ struct BrowserPanelView: View { Button(action: { isBrowserThemeMenuPresented.toggle() }) { - CmuxSystemSymbolImage(systemName: browserThemeMode.iconName, pointSize: devToolsButtonIconSize, weight: .medium) - .foregroundStyle(browserThemeModeIconColor) + CmuxSystemSymbolImage(systemName: browserThemeMode.iconName, pointSize: devToolsButtonIconSize, weight: .medium, tint: browserThemeModeIconColor) .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) } .buttonStyle(OmnibarAddressButtonStyle()) @@ -1619,7 +1616,7 @@ struct BrowserPanelView: View { isBrowserImportHintPopoverPresented.toggle() }) { HStack(spacing: 4) { - CmuxSystemSymbolImage(systemName: "square.and.arrow.down.on.square", pointSize: 10, weight: .medium) + CmuxSystemSymbolImage(systemName: "square.and.arrow.down.on.square", pointSize: 10, weight: .medium, tint: devToolsColorOption.color) Text(String(localized: "browser.import.hint.toolbar", defaultValue: "Import")) .cmuxFont(size: 11, weight: .medium) .lineLimit(1) @@ -1648,7 +1645,7 @@ struct BrowserPanelView: View { applyBrowserProfileSelection(profile.id) } label: { HStack(spacing: 8) { - CmuxSystemSymbolImage(systemName: profile.id == panel.profileID ? "checkmark" : "circle", pointSize: 10, weight: .semibold) + CmuxSystemSymbolImage(systemName: profile.id == panel.profileID ? "checkmark" : "circle", pointSize: 10, weight: .semibold, tint: .primary) .opacity(profile.id == panel.profileID ? 1.0 : 0.0) .frame(width: 12, alignment: .center) Text(profile.displayName) @@ -1710,7 +1707,7 @@ struct BrowserPanelView: View { isBrowserThemeMenuPresented = false } label: { HStack(spacing: 8) { - CmuxSystemSymbolImage(systemName: mode == browserThemeMode ? "checkmark" : "circle", pointSize: 10, weight: .semibold) + CmuxSystemSymbolImage(systemName: mode == browserThemeMode ? "checkmark" : "circle", pointSize: 10, weight: .semibold, tint: .primary) .opacity(mode == browserThemeMode ? 1.0 : 0.0) .frame(width: 12, alignment: .center) Text(mode.displayName) @@ -1742,8 +1739,7 @@ struct BrowserPanelView: View { return HStack(spacing: 4) { if showSecureBadge { - CmuxSystemSymbolImage(systemName: "lock.fill", pointSize: chromeMetrics.secureBadgeFontSize) - .foregroundColor(.secondary) + CmuxSystemSymbolImage(systemName: "lock.fill", pointSize: chromeMetrics.secureBadgeFontSize, tint: .secondary) } OmnibarTextFieldRepresentable( diff --git a/Sources/Panels/PanelContentView.swift b/Sources/Panels/PanelContentView.swift index f2f6937a125b..1a15e3173bc6 100644 --- a/Sources/Panels/PanelContentView.swift +++ b/Sources/Panels/PanelContentView.swift @@ -271,8 +271,7 @@ private struct CloudVMLoadingPanelView: View { .foregroundStyle(.primary) CloudVMLoadingStatusView(elapsedSeconds: elapsedSeconds) case .failed(let message, let failedElapsedSeconds): - CmuxSystemSymbolImage(systemName: "exclamationmark.triangle.fill", pointSize: 18) - .foregroundStyle(.orange) + CmuxSystemSymbolImage(systemName: "exclamationmark.triangle.fill", pointSize: 18, tint: .orange) Text(String(localized: "panel.cloudVM.loading.failed.headline", defaultValue: "Base unavailable")) .cmuxFont(size: 14, weight: .semibold) .foregroundStyle(.primary) @@ -379,8 +378,7 @@ private struct CloudVMLoadingStatusRow: View { var body: some View { HStack(spacing: 8) { - CmuxSystemSymbolImage(systemName: icon, pointSize: 12) - .foregroundStyle(isActive ? .secondary : .tertiary) + CmuxSystemSymbolImage(systemName: icon, pointSize: 12, tint: isActive ? Color.secondary : Color(nsColor: .tertiaryLabelColor)) .frame(width: 14) Text(text) .cmuxFont(size: 12) @@ -398,8 +396,7 @@ struct PanelFilePathHeader: View { var body: some View { HStack(spacing: 8) { - CmuxSystemSymbolImage(systemName: iconSystemName, pointSize: 16) - .foregroundStyle(.secondary) + CmuxSystemSymbolImage(systemName: iconSystemName, pointSize: 16, tint: .secondary) .frame(width: 16) Text(filePath) .cmuxFont(size: 11, design: .monospaced) @@ -438,7 +435,7 @@ struct PanelHeaderIconGlyph: View { let systemName: String var body: some View { - CmuxSystemSymbolImage(systemName: systemName, pointSize: 13) + CmuxSystemSymbolImage(systemName: systemName, pointSize: 13, tint: .secondary) .frame(width: 20, height: 20, alignment: .center) .contentShape(Rectangle()) } diff --git a/Sources/Panels/TerminalPanelView.swift b/Sources/Panels/TerminalPanelView.swift index 95cdba97335c..ce995335111f 100644 --- a/Sources/Panels/TerminalPanelView.swift +++ b/Sources/Panels/TerminalPanelView.swift @@ -304,15 +304,14 @@ private struct AgentHibernationPlaceholderView: View { .controlSize(.small) .accessibilityIdentifier("AgentHibernationTerminationRecoveryProgress") case .hibernated: - CmuxSystemSymbolImage(magnified: "pause.circle", pointSize: 34, weight: .regular) - .foregroundStyle(.secondary) + CmuxSystemSymbolImage(magnified: "pause.circle", pointSize: 34, weight: .regular, tint: .secondary) case .failed: CmuxSystemSymbolImage( magnified: "exclamationmark.triangle", pointSize: 34, - weight: .regular + weight: .regular, + tint: .secondary ) - .foregroundStyle(.secondary) } VStack(spacing: 4) { Text(title) diff --git a/Sources/Panels/WorkspaceTodoPanelView.swift b/Sources/Panels/WorkspaceTodoPanelView.swift index 25643b953959..a8006d09df93 100644 --- a/Sources/Panels/WorkspaceTodoPanelView.swift +++ b/Sources/Panels/WorkspaceTodoPanelView.swift @@ -424,8 +424,7 @@ private struct WorkspaceTodoPaneContent: View { HStack(alignment: .center, spacing: 7) { // A `plus.circle` "add" affordance, not an empty checkbox, so the // add row never reads as a real (unchecked) item. - CmuxSystemSymbolImage(systemName: "plus.circle", pointSize: Self.checkboxPointSize) - .foregroundColor(.secondary) + CmuxSystemSymbolImage(systemName: "plus.circle", pointSize: Self.checkboxPointSize, tint: .secondary) TextField( String(localized: "sidebar.checklist.addItemPlaceholder", defaultValue: "New checklist item"), text: $pendingItemText, @@ -543,9 +542,9 @@ private struct WorkspaceTodoPaneItemRow: View { } label: { CmuxSystemSymbolImage( systemName: checkboxSymbolName(for: item.state), - pointSize: checkboxPointSize + pointSize: checkboxPointSize, + tint: isCompleted ? .secondary : .primary ) - .foregroundColor(isCompleted ? .secondary : .primary) .contentShape(Rectangle()) } .buttonStyle(.plain) diff --git a/Sources/RenderableSystemSymbol.swift b/Sources/RenderableSystemSymbol.swift index 4ba1c0a789ef..e7c4cb4b43cf 100644 --- a/Sources/RenderableSystemSymbol.swift +++ b/Sources/RenderableSystemSymbol.swift @@ -412,12 +412,21 @@ enum RenderableSystemSymbol { #endif } +/// SF Symbol drawn by the AppKit-hosted icon renderer with an explicit tint. +/// +/// `tint` replaces the `.foregroundStyle` / `.foregroundColor` modifier the +/// SwiftUI symbol image used to inherit: the color is bridged to a dynamic +/// `NSColor` and baked into the bitmap, the same way the Vault icons pass +/// `tintColor` to `SessionIndexResolvedSystemSymbolImage`. SwiftUI never +/// composites the glyph (no raster image, no mask), which is what keeps it +/// visible on Intel Macs running macOS 15. struct CmuxSystemSymbolImage: View { @Environment(\.cmuxGlobalFontMagnificationPercent) private var globalFontPercent let systemName: String let pointSize: CGFloat var weight: Font.Weight? + let tint: Color var alignment: Alignment = .center var appliesGlobalFontMagnification = false @@ -425,12 +434,14 @@ struct CmuxSystemSymbolImage: View { systemName: String, pointSize: CGFloat, weight: Font.Weight? = nil, + tint: Color, alignment: Alignment = .center, appliesGlobalFontMagnification: Bool = false ) { self.systemName = systemName self.pointSize = pointSize self.weight = weight + self.tint = tint self.alignment = alignment self.appliesGlobalFontMagnification = appliesGlobalFontMagnification } @@ -439,25 +450,35 @@ struct CmuxSystemSymbolImage: View { magnified systemName: String, pointSize: CGFloat, weight: Font.Weight? = nil, + tint: Color, alignment: Alignment = .center ) { self.init( systemName: systemName, pointSize: pointSize, weight: weight, + tint: tint, alignment: alignment, appliesGlobalFontMagnification: true ) } + /// Bridges a SwiftUI color (including `.primary` / `.secondary` and + /// `.opacity` variants) to a dynamic `NSColor` that resolves under the + /// renderer's drawing appearance. Equal inputs bridge to equal colors, so + /// the hosted image view's render key stays stable across updates. + nonisolated static func hostedTintColor(for tint: Color) -> NSColor { + NSColor(tint) + } + var body: some View { let rasterSize = RenderableSystemSymbol.resolvedRasterPointSize( pointSize, globalFontPercent: globalFontPercent, appliesGlobalFontMagnification: appliesGlobalFontMagnification ) - // SwiftUI raster images draw blank on Intel Macs running macOS 15, so - // the AppKit-hosted renderer owns every symbol draw. The materialized + let tintColor = Self.hostedTintColor(for: tint) + // The AppKit-hosted renderer owns every symbol draw. The materialized // image only supplies the configured symbol's natural layout size. if let image = RenderableSystemSymbol.configuredAppKitImage( systemName: systemName, @@ -469,6 +490,7 @@ struct CmuxSystemSymbolImage: View { pointSize: rasterSize, imageSize: image.size, weight: RenderableSystemSymbol.nsFontWeight(for: weight), + tintColor: tintColor, slotSize: rasterSize, alignment: alignment ) @@ -480,6 +502,7 @@ struct CmuxSystemSymbolImage: View { pointSize: rasterSize, imageSize: NSSize(width: rasterSize, height: rasterSize), weight: RenderableSystemSymbol.nsFontWeight(for: weight), + tintColor: tintColor, slotSize: rasterSize, alignment: alignment ) diff --git a/Sources/RightSidebarChromeStyle.swift b/Sources/RightSidebarChromeStyle.swift index 09c16bc90346..a3bdf1cf6498 100644 --- a/Sources/RightSidebarChromeStyle.swift +++ b/Sources/RightSidebarChromeStyle.swift @@ -29,7 +29,8 @@ enum HeaderChromeIconStyle { CmuxSystemSymbolImage( systemName: systemName, pointSize: RightSidebarChromeMetrics.headerIconSize, - weight: weight + weight: weight, + tint: foregroundColor ) } @@ -85,6 +86,17 @@ enum RightSidebarChromeControlStyle { static let labelWeight = HeaderChromeIconStyle.weight static let foregroundColor = HeaderChromeIconStyle.foregroundColor + /// Pill tint for a mode/grouping control, shared by the pill modifier's + + /// text foreground and the hosted symbol's baked-in tint. + + static func pillForegroundColor(isSelected: Bool, isHovered: Bool) -> Color { + + foregroundColor.opacity(foregroundOpacity(isSelected: isSelected, isHovered: isHovered)) + + } + + static func foregroundOpacity(isSelected: Bool, isHovered: Bool, isEnabled: Bool = true) -> Double { guard isEnabled else { return HeaderChromeIconStyle.disabledOpacity } if isSelected { @@ -128,7 +140,7 @@ struct RightSidebarChromePillModifier: ViewModifier { func body(content: Content) -> some View { content .foregroundStyle( - RightSidebarChromeControlStyle.foregroundColor.opacity(foregroundOpacity) + RightSidebarChromeControlStyle.pillForegroundColor(isSelected: isSelected, isHovered: isHovered) ) .padding(.horizontal, horizontalPadding) .frame(height: controlHeight) @@ -214,7 +226,10 @@ private struct RightSidebarHeaderIconButtonStyleBody: View { width: RightSidebarChromeMetrics.headerControlSize, height: RightSidebarChromeMetrics.headerControlSize ) - .foregroundStyle(HeaderChromeIconStyle.foregroundColor.opacity(foregroundOpacity)) + // The hosted symbol bakes `HeaderChromeIconStyle.foregroundColor` + // into its bitmap; hover/pressed dimming applies as view opacity. + .foregroundStyle(HeaderChromeIconStyle.foregroundColor) + .opacity(foregroundOpacity) .background { if backgroundOpacity > 0 { RoundedRectangle(cornerRadius: RightSidebarChromeMetrics.headerControlCornerRadius, style: .continuous) @@ -354,6 +369,7 @@ struct ModeBarButton: View { systemName: item.symbolName, pointSize: RightSidebarChromeControlStyle.modeIconSize, weight: RightSidebarChromeControlStyle.iconWeight, + tint: RightSidebarChromeControlStyle.pillForegroundColor(isSelected: isSelected, isHovered: isHovered), appliesGlobalFontMagnification: true ) .reportRightSidebarChromeNamedGeometryForBonsplitUITest( diff --git a/Sources/SessionIndexView.swift b/Sources/SessionIndexView.swift index f0219bf598b2..c38ecb0ee7fa 100644 --- a/Sources/SessionIndexView.swift +++ b/Sources/SessionIndexView.swift @@ -612,7 +612,8 @@ private struct GroupingButton: View { CmuxSystemSymbolImage( magnified: mode.symbolName, pointSize: RightSidebarChromeControlStyle.secondaryIconSize, - weight: RightSidebarChromeControlStyle.iconWeight + weight: RightSidebarChromeControlStyle.iconWeight, + tint: RightSidebarChromeControlStyle.pillForegroundColor(isSelected: isSelected, isHovered: isHovered) ) .frame(width: RightSidebarChromeMetrics.contentIconFrameSize) Text(mode.label) @@ -847,8 +848,7 @@ struct IndexSectionView: View, Equatable { .cmuxFont(size: 11, weight: .medium, monospacedDigit: true) .foregroundStyle(.tertiary) .fixedSize() - CmuxSystemSymbolImage(magnified: "chevron.down", pointSize: 9, weight: .semibold) - .foregroundColor(.secondary.opacity(0.6)) + CmuxSystemSymbolImage(magnified: "chevron.down", pointSize: 9, weight: .semibold, tint: .secondary.opacity(0.6)) .rotationEffect(.degrees(isCollapsed ? -90 : 0)) Spacer(minLength: 0) } @@ -1231,8 +1231,7 @@ struct SessionTranscriptPreviewView: View { } } Spacer(minLength: 8) - CmuxSystemSymbolImage(magnified: "xmark", pointSize: 11, weight: .semibold) - .foregroundColor(closeIsHovered ? .primary : .secondary) + CmuxSystemSymbolImage(magnified: "xmark", pointSize: 11, weight: .semibold, tint: closeIsHovered ? .primary : .secondary) .frame(width: 20, height: 20) .background( RoundedRectangle(cornerRadius: 4, style: .continuous) @@ -1308,8 +1307,7 @@ struct SessionTranscriptPreviewView: View { private func statusRow(systemImage: String, text: String) -> some View { HStack(spacing: 8) { - CmuxSystemSymbolImage(magnified: systemImage, pointSize: 12, weight: .medium) - .foregroundColor(.secondary) + CmuxSystemSymbolImage(magnified: systemImage, pointSize: 12, weight: .medium, tint: .secondary) Text(text) .cmuxFont(size: 12) .foregroundColor(.secondary) @@ -2481,8 +2479,7 @@ struct SectionPopoverView: View { .padding(.bottom, 6) HStack(spacing: 6) { - CmuxSystemSymbolImage(magnified: "magnifyingglass", pointSize: 11, weight: .medium) - .foregroundColor(.secondary) + CmuxSystemSymbolImage(magnified: "magnifyingglass", pointSize: 11, weight: .medium, tint: .secondary) TextField( String(localized: "sessionIndex.popover.searchPlaceholder", defaultValue: "Search Vault"), @@ -2495,8 +2492,7 @@ struct SectionPopoverView: View { Button { query = "" } label: { - CmuxSystemSymbolImage(magnified: "xmark.circle.fill", pointSize: 11) - .foregroundColor(.secondary) + CmuxSystemSymbolImage(magnified: "xmark.circle.fill", pointSize: 11, tint: .secondary) } .buttonStyle(.plain) .accessibilityLabel(String(localized: "historyPane.search.clear", defaultValue: "Clear search")) @@ -2517,8 +2513,7 @@ struct SectionPopoverView: View { VStack(alignment: .leading, spacing: 2) { ForEach(errorMessages, id: \.self) { msg in HStack(alignment: .top, spacing: 6) { - CmuxSystemSymbolImage(magnified: "exclamationmark.triangle.fill", pointSize: 10) - .foregroundColor(.orange) + CmuxSystemSymbolImage(magnified: "exclamationmark.triangle.fill", pointSize: 10, tint: .orange) Text(msg) .cmuxFont(size: 11) .foregroundColor(.primary.opacity(0.85)) diff --git a/Sources/ShortcutDiscoveryButton.swift b/Sources/ShortcutDiscoveryButton.swift index c551d5642ae4..c6017c730c39 100644 --- a/Sources/ShortcutDiscoveryButton.swift +++ b/Sources/ShortcutDiscoveryButton.swift @@ -25,8 +25,7 @@ struct ShortcutDiscoveryButton: View { Button { isPopoverPresented.toggle() } label: { - CmuxSystemSymbolImage(systemName: "keyboard", pointSize: iconSize, weight: .medium) - .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + CmuxSystemSymbolImage(systemName: "keyboard", pointSize: iconSize, weight: .medium, tint: Color(nsColor: .secondaryLabelColor)) .frame(width: buttonSize, height: buttonSize, alignment: .center) } .buttonStyle(SidebarFooterIconButtonStyle()) diff --git a/Sources/Sidebar/SidebarMediaActivityIndicators.swift b/Sources/Sidebar/SidebarMediaActivityIndicators.swift index 7f17338c8ad9..419f5b92c477 100644 --- a/Sources/Sidebar/SidebarMediaActivityIndicators.swift +++ b/Sources/Sidebar/SidebarMediaActivityIndicators.swift @@ -11,8 +11,7 @@ struct SidebarMediaActivityIndicators: View { localized: "sidebar.mediaActivity.audio.tooltip", defaultValue: "Playing audio" ) - CmuxSystemSymbolImage(magnified: "speaker.wave.2.fill", pointSize: symbolPointSize, weight: .semibold) - .foregroundColor(audioColor) + CmuxSystemSymbolImage(magnified: "speaker.wave.2.fill", pointSize: symbolPointSize, weight: .semibold, tint: audioColor) .safeHelp(audioPlayingTooltip) .accessibilityLabel(audioPlayingTooltip) } @@ -22,8 +21,7 @@ struct SidebarMediaActivityIndicators: View { localized: "sidebar.mediaActivity.microphone.tooltip", defaultValue: "Microphone in use" ) - CmuxSystemSymbolImage(magnified: "mic.fill", pointSize: symbolPointSize, weight: .semibold) - .foregroundColor(.orange) + CmuxSystemSymbolImage(magnified: "mic.fill", pointSize: symbolPointSize, weight: .semibold, tint: .orange) .safeHelp(microphoneInUseTooltip) .accessibilityLabel(microphoneInUseTooltip) } @@ -33,8 +31,7 @@ struct SidebarMediaActivityIndicators: View { localized: "sidebar.mediaActivity.camera.tooltip", defaultValue: "Camera in use" ) - CmuxSystemSymbolImage(magnified: "video.fill", pointSize: symbolPointSize, weight: .semibold) - .foregroundColor(.green) + CmuxSystemSymbolImage(magnified: "video.fill", pointSize: symbolPointSize, weight: .semibold, tint: .green) .safeHelp(cameraInUseTooltip) .accessibilityLabel(cameraInUseTooltip) } diff --git a/Sources/Sidebar/SidebarWorkspaceTrailingStatusSlot.swift b/Sources/Sidebar/SidebarWorkspaceTrailingStatusSlot.swift index 17577acda5a2..68b4b0925f7a 100644 --- a/Sources/Sidebar/SidebarWorkspaceTrailingStatusSlot.swift +++ b/Sources/Sidebar/SidebarWorkspaceTrailingStatusSlot.swift @@ -39,8 +39,7 @@ struct SidebarWorkspaceTrailingStatusSlot: View { } if canCloseWorkspace { Button(action: closeAction) { - CmuxSystemSymbolImage(magnified: "xmark", pointSize: closeButtonFontSize, weight: .medium) - .foregroundColor(closeButtonColor) + CmuxSystemSymbolImage(magnified: "xmark", pointSize: closeButtonFontSize, weight: .medium, tint: closeButtonColor) .frame(width: width, height: height, alignment: .center) .contentShape(Rectangle()) } diff --git a/Sources/SidebarWorkspaceChecklistPopover.swift b/Sources/SidebarWorkspaceChecklistPopover.swift index 45e98effb79e..4b3664b3d523 100644 --- a/Sources/SidebarWorkspaceChecklistPopover.swift +++ b/Sources/SidebarWorkspaceChecklistPopover.swift @@ -247,9 +247,9 @@ struct SidebarWorkspaceChecklistPopover: View { } label: { CmuxSystemSymbolImage( systemName: checkboxSymbolName(for: item.state), - pointSize: Self.checkboxPointSize + pointSize: Self.checkboxPointSize, + tint: isCompleted ? .secondary : .primary ) - .foregroundColor(isCompleted ? .secondary : .primary) .contentShape(Rectangle()) } .buttonStyle(.plain) @@ -371,8 +371,7 @@ struct SidebarWorkspaceChecklistPopover: View { return Button { actions.removeItem(item.id) } label: { - CmuxSystemSymbolImage(systemName: "xmark.circle.fill", pointSize: Self.checkboxPointSize - 2) - .foregroundColor(.secondary) + CmuxSystemSymbolImage(systemName: "xmark.circle.fill", pointSize: Self.checkboxPointSize - 2, tint: .secondary) .frame(width: Self.checkboxPointSize + 6, height: Self.checkboxPointSize + 6, alignment: .center) .contentShape(Rectangle()) } @@ -394,8 +393,7 @@ struct SidebarWorkspaceChecklistPopover: View { return HStack(alignment: .center, spacing: 6) { // A `plus.circle` "add" affordance, not an empty checkbox, so the // add row never reads as a real (unchecked) item. - CmuxSystemSymbolImage(systemName: "plus.circle", pointSize: Self.checkboxPointSize) - .foregroundColor(.secondary) + CmuxSystemSymbolImage(systemName: "plus.circle", pointSize: Self.checkboxPointSize, tint: .secondary) TextField( placeholder, text: $pendingItemText, @@ -565,7 +563,7 @@ struct SidebarWorkspaceChecklistPopover: View { actions.openPane() } label: { HStack(spacing: 6) { - CmuxSystemSymbolImage(systemName: "rectangle.split.2x1", pointSize: 11) + CmuxSystemSymbolImage(systemName: "rectangle.split.2x1", pointSize: 11, tint: .secondary) Text(String(localized: "sidebar.checklist.openAsPane", defaultValue: "Open as Pane")) .font(.system(size: 12)) } diff --git a/Sources/SidebarWorkspaceChecklistView.swift b/Sources/SidebarWorkspaceChecklistView.swift index dda6ac5652b2..88bc10f5b3a9 100644 --- a/Sources/SidebarWorkspaceChecklistView.swift +++ b/Sources/SidebarWorkspaceChecklistView.swift @@ -106,7 +106,7 @@ struct WorkspaceChecklistAttachmentMenu: View { } } label: { HStack(spacing: 2) { - CmuxSystemSymbolImage(systemName: "paperclip", pointSize: iconPointSize) + CmuxSystemSymbolImage(systemName: "paperclip", pointSize: iconPointSize, tint: foregroundColor) if item.attachmentCount > 0 { Text(verbatim: "\(item.attachmentCount)") .font(countFont) @@ -272,9 +272,9 @@ struct SidebarWorkspaceChecklistSection: View { HStack(spacing: 4) { CmuxSystemSymbolImage( magnified: completedCount == totalCount ? "checkmark.circle.fill" : "checklist", - pointSize: 8 * fontScale + pointSize: 8 * fontScale, + tint: secondaryColor ) - .foregroundColor(secondaryColor) Text(verbatim: "\(completedCount)/\(totalCount)") .font(summaryFont) .foregroundColor(primaryColor) @@ -352,9 +352,9 @@ struct SidebarWorkspaceChecklistSection: View { } label: { CmuxSystemSymbolImage( magnified: checkboxSymbolName(for: item.state), - pointSize: 8 * fontScale + pointSize: 8 * fontScale, + tint: isCompleted ? secondaryColor : primaryColor ) - .foregroundColor(isCompleted ? secondaryColor : primaryColor) .contentShape(Rectangle()) } .buttonStyle(.plain) @@ -471,8 +471,7 @@ struct SidebarWorkspaceChecklistSection: View { return Button { actions.removeItem(item.id) } label: { - CmuxSystemSymbolImage(magnified: "xmark.circle.fill", pointSize: 9 * fontScale) - .foregroundColor(secondaryColor) + CmuxSystemSymbolImage(magnified: "xmark.circle.fill", pointSize: 9 * fontScale, tint: secondaryColor) .frame(width: 9 * fontScale + 8, height: 9 * fontScale + 8, alignment: .center) .contentShape(Rectangle()) } @@ -494,8 +493,7 @@ struct SidebarWorkspaceChecklistSection: View { // the add row never reads as a real (unchecked) item. Uses the // row's secondary color (which inverts on the selected row) so // it never clashes as accent-blue on a blue selected row. - CmuxSystemSymbolImage(magnified: "plus.circle", pointSize: 8 * fontScale) - .foregroundColor(secondaryColor) + CmuxSystemSymbolImage(magnified: "plus.circle", pointSize: 8 * fontScale, tint: secondaryColor) // AppKit field (like the sidebar rename field): takes first // responder in the main window on appear, so typing works // reliably (a SwiftUI TextField / floating popover does not win @@ -521,7 +519,7 @@ struct SidebarWorkspaceChecklistSection: View { } } label: { HStack(spacing: 4) { - CmuxSystemSymbolImage(magnified: "plus", pointSize: 7 * fontScale) + CmuxSystemSymbolImage(magnified: "plus", pointSize: 7 * fontScale, tint: secondaryColor) Text(String(localized: "sidebar.checklist.addItem", defaultValue: "Add item")) .font(itemFont) } diff --git a/Sources/SidebarWorkspaceGroupHeaderView.swift b/Sources/SidebarWorkspaceGroupHeaderView.swift index 65e24be1b4cc..da4a203341de 100644 --- a/Sources/SidebarWorkspaceGroupHeaderView.swift +++ b/Sources/SidebarWorkspaceGroupHeaderView.swift @@ -131,9 +131,9 @@ struct SidebarWorkspaceGroupHeaderView: View, Equatable { CmuxSystemSymbolImage( magnified: "pin.fill", pointSize: metrics.pinnedIconFontSize, - weight: .semibold + weight: .semibold, + tint: .secondary ) - .foregroundStyle(.secondary) .frame(width: metrics.iconFrame, height: metrics.iconFrame) .safeHelp(pinnedGroupTooltip) .accessibilityLabel(Text(pinnedGroupTooltip)) @@ -142,9 +142,8 @@ struct SidebarWorkspaceGroupHeaderView: View, Equatable { systemName: isCollapsed ? "chevron.right" : "chevron.down", pointSize: metrics.chevronFontSize, weight: .semibold, - appliesGlobalFontMagnification: true - ) - .foregroundStyle(.secondary) + tint: .secondary, + appliesGlobalFontMagnification: true) .frame(width: metrics.chevronFrame, height: metrics.chevronFrame) .contentShape(Rectangle()) .onTapGesture { actions.onToggleCollapsed() } @@ -162,9 +161,8 @@ struct SidebarWorkspaceGroupHeaderView: View, Equatable { systemName: displayedIconSymbol, pointSize: metrics.iconFontSize, weight: .semibold, - appliesGlobalFontMagnification: true - ) - .foregroundStyle(iconColor) + tint: iconColor, + appliesGlobalFontMagnification: true) .frame(width: metrics.iconFrame, height: metrics.iconFrame) .accessibilityHidden(true) Text(name) @@ -203,9 +201,8 @@ struct SidebarWorkspaceGroupHeaderView: View, Equatable { systemName: "plus", pointSize: metrics.plusFontSize, weight: .medium, - appliesGlobalFontMagnification: true - ) - .foregroundStyle(.secondary) + tint: .secondary, + appliesGlobalFontMagnification: true) .frame(width: metrics.plusFrame, height: metrics.plusFrame) .contentShape(Rectangle()) .opacity(plusVisible ? 1 : 0) diff --git a/Sources/SidebarWorkspaceStatusPopover.swift b/Sources/SidebarWorkspaceStatusPopover.swift index 798d2724b916..88a6abf08fc4 100644 --- a/Sources/SidebarWorkspaceStatusPopover.swift +++ b/Sources/SidebarWorkspaceStatusPopover.swift @@ -233,8 +233,7 @@ struct SidebarWorkspaceStatusPopover: View { .lineLimit(1) Spacer(minLength: 8) if lane.isSelected { - CmuxSystemSymbolImage(systemName: "checkmark", pointSize: 10, weight: .semibold) - .foregroundColor(.secondary) + CmuxSystemSymbolImage(systemName: "checkmark", pointSize: 10, weight: .semibold, tint: .secondary) } } .padding(.horizontal, 6) diff --git a/Sources/TaskManagerView.swift b/Sources/TaskManagerView.swift index c02d7216f1da..1834f925e222 100644 --- a/Sources/TaskManagerView.swift +++ b/Sources/TaskManagerView.swift @@ -175,7 +175,7 @@ struct CmuxTaskManagerView: View { private func sortIndicator(for column: CmuxTaskManagerSortOrder.Column) -> some View { let isActive = model.sortOrder.column == column let imageName = model.sortOrder.direction == .ascending ? "chevron.up" : "chevron.down" - return CmuxSystemSymbolImage(magnified: imageName, pointSize: 8, weight: .bold) + return CmuxSystemSymbolImage(magnified: imageName, pointSize: 8, weight: .bold, tint: isActive ? .primary : .secondary) .opacity(isActive ? 1 : 0) .frame(width: 8) .accessibilityHidden(true) @@ -472,8 +472,7 @@ struct CmuxTaskManagerRowView: View, Equatable { )) .frame(width: 14, height: 14) } else { - CmuxSystemSymbolImage(magnified: row.kind.systemImage, pointSize: 12) - .foregroundStyle(row.kind.tint) + CmuxSystemSymbolImage(magnified: row.kind.systemImage, pointSize: 12, tint: row.kind.tint) .frame(width: 14) } } diff --git a/Sources/TextBoxInput.swift b/Sources/TextBoxInput.swift index 3da835197282..79996d5d45da 100644 --- a/Sources/TextBoxInput.swift +++ b/Sources/TextBoxInput.swift @@ -869,7 +869,7 @@ private struct TextBoxAttachmentPreviewPopoverView: View { .background(Color.black.opacity(0.82)) } else { VStack(spacing: 10) { - CmuxSystemSymbolImage(magnified: "doc", pointSize: 42, weight: .regular) + CmuxSystemSymbolImage(magnified: "doc", pointSize: 42, weight: .regular, tint: .primary.opacity(0.86)) Text(attachment.displayName) .cmuxFont(size: 13, weight: .medium) .lineLimit(2) @@ -950,7 +950,7 @@ private struct TextBoxAttachmentChip: View { ) .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) } else { - CmuxSystemSymbolImage(magnified: "doc", pointSize: 12, weight: .medium) + CmuxSystemSymbolImage(magnified: "doc", pointSize: 12, weight: .medium, tint: foreground.opacity(0.88)) .frame( width: TextBoxLayout.attachmentImageSize, height: TextBoxLayout.attachmentImageSize @@ -964,7 +964,7 @@ private struct TextBoxAttachmentChip: View { .frame(maxWidth: 118, alignment: .leading) Button(action: onRemove) { - CmuxSystemSymbolImage(magnified: "xmark", pointSize: 8, weight: .bold) + CmuxSystemSymbolImage(magnified: "xmark", pointSize: 8, weight: .bold, tint: foreground.opacity(0.62)) .frame(width: 14, height: 14) } .buttonStyle(.plain) @@ -2414,7 +2414,7 @@ struct TextBoxInputContainer: View { private func addFilesButton(foreground: Color) -> some View { Button(action: chooseFiles) { - CmuxSystemSymbolImage(magnified: "plus", pointSize: TextBoxLayout.iconSymbolSize, weight: .semibold) + CmuxSystemSymbolImage(magnified: "plus", pointSize: TextBoxLayout.iconSymbolSize, weight: .semibold, tint: foreground.opacity(0.82)) .frame(width: TextBoxLayout.iconButtonSize, height: TextBoxLayout.iconButtonSize) .background( Circle() @@ -2458,7 +2458,7 @@ struct TextBoxInputContainer: View { showPendingCommentsPreview.toggle() } label: { HStack(spacing: 5) { - CmuxSystemSymbolImage(magnified: "text.bubble", pointSize: 11, weight: .medium) + CmuxSystemSymbolImage(magnified: "text.bubble", pointSize: 11, weight: .medium, tint: foreground.opacity(0.92)) Text(pendingCommentsLabel(count)) .cmuxFont(size: 12, weight: .medium) .lineLimit(1) @@ -2472,7 +2472,7 @@ struct TextBoxInputContainer: View { Button { dismissPendingComments() } label: { - CmuxSystemSymbolImage(magnified: "xmark", pointSize: 9, weight: .bold) + CmuxSystemSymbolImage(magnified: "xmark", pointSize: 9, weight: .bold, tint: foreground.opacity(0.92)) .frame(width: 16, height: 16) .background(Circle().fill(foreground.opacity(0.12))) } diff --git a/Sources/Update/NotificationPopoverRow.swift b/Sources/Update/NotificationPopoverRow.swift index f47bfbd864eb..a5fdcaeca8d6 100644 --- a/Sources/Update/NotificationPopoverRow.swift +++ b/Sources/Update/NotificationPopoverRow.swift @@ -154,8 +154,7 @@ struct NotificationPopoverRow: View, Equatable { ZStack { Circle() .fill(Color.primary.opacity(0.1)) - CmuxSystemSymbolImage(systemName: "xmark", pointSize: 9, weight: .bold) - .foregroundColor(.primary.opacity(0.7)) + CmuxSystemSymbolImage(systemName: "xmark", pointSize: 9, weight: .bold, tint: .primary.opacity(0.7)) } .frame(width: 20, height: 20) } diff --git a/Sources/Update/TitlebarNewWorkspaceSplitButton.swift b/Sources/Update/TitlebarNewWorkspaceSplitButton.swift index 0ef3b40e843a..b9d6b59cbd79 100644 --- a/Sources/Update/TitlebarNewWorkspaceSplitButton.swift +++ b/Sources/Update/TitlebarNewWorkspaceSplitButton.swift @@ -49,7 +49,12 @@ struct TitlebarNewWorkspaceSplitButton: View { ZStack { Rectangle() .fill(Color.clear) - CmuxSystemSymbolImage(systemName: "plus", pointSize: config.iconSize, weight: .medium) + CmuxSystemSymbolImage( + systemName: "plus", + pointSize: config.iconSize, + weight: .medium, + tint: foregroundColor.opacity(foregroundOpacity) + ) } .frame(width: primaryWidth, height: config.buttonSize) .contentShape(Rectangle()) @@ -86,7 +91,8 @@ struct TitlebarNewWorkspaceSplitButton: View { CmuxSystemSymbolImage( systemName: "chevron.down", pointSize: TitlebarNewWorkspaceSplitButtonMetrics.dropdownIconSize(config: config), - weight: .bold + weight: .bold, + tint: foregroundColor.opacity(foregroundOpacity) ) } .frame(width: dropdownWidth, height: config.buttonSize) diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index 072eb595bf22..d508425a92a3 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -890,6 +890,11 @@ private struct TitlebarControlButtonStyleBody: View { configuration.label .frame(width: config.buttonSize, height: config.buttonSize) .foregroundStyle(foregroundColor.opacity(foregroundOpacity)) + // Hosted symbols bake their tint into the bitmap, so they read the + // same dimming from the environment (`TitlebarControlSymbol`) + // while other label content (the notification badge) keeps its + // own colors. + .environment(\.titlebarControlForegroundOpacity, foregroundOpacity) .background { if backgroundOpacity > 0 { RoundedRectangle(cornerRadius: config.buttonCornerRadius, style: .continuous) @@ -1164,6 +1169,7 @@ struct TitlebarControlsView: View { iconLabel( systemName: "bell", config: config, + foregroundColor: foregroundColor, iconGeometryKeyPrefix: "titlebarControl_showNotificationsIcon" ) @@ -1196,7 +1202,7 @@ struct TitlebarControlsView: View { _ = AppDelegate.shared?.showFocusHistoryContextMenu(anchorView: anchorView, event: event, direction: .back) } ) { - iconLabel(systemName: "arrow.left", config: config, iconGeometryKeyPrefix: "titlebarControl_focusHistoryBackIcon") + iconLabel(systemName: "arrow.left", config: config, foregroundColor: foregroundColor, iconGeometryKeyPrefix: "titlebarControl_focusHistoryBackIcon") } .safeHelp(KeyboardShortcutSettings.Action.focusHistoryBack.tooltip(String(localized: "menu.history.focusBack", defaultValue: "Focus Back"))) @@ -1211,7 +1217,7 @@ struct TitlebarControlsView: View { _ = AppDelegate.shared?.showFocusHistoryContextMenu(anchorView: anchorView, event: event, direction: .forward) } ) { - iconLabel(systemName: "arrow.right", config: config, iconGeometryKeyPrefix: "titlebarControl_focusHistoryForwardIcon") + iconLabel(systemName: "arrow.right", config: config, foregroundColor: foregroundColor, iconGeometryKeyPrefix: "titlebarControl_focusHistoryForwardIcon") } .safeHelp(KeyboardShortcutSettings.Action.focusHistoryForward.tooltip(String(localized: "menu.history.focusForward", defaultValue: "Focus Forward"))) @@ -1345,10 +1351,15 @@ struct TitlebarControlsView: View { private func iconLabel( systemName: String, config: TitlebarControlsStyleConfig, + foregroundColor: Color, iconGeometryKeyPrefix: String? = nil ) -> some View { titlebarIconChrome(config: config, iconGeometryKeyPrefix: iconGeometryKeyPrefix) { - CmuxSystemSymbolImage(systemName: systemName, pointSize: config.iconSize, weight: TitlebarControlIconStyle.weight) + TitlebarControlSymbol( + systemName: systemName, + config: config, + foregroundColor: foregroundColor + ) } } @@ -1377,6 +1388,39 @@ struct TitlebarControlsView: View { } } +/// Hover/pressed dimming for hosted symbols inside `TitlebarControlButtonStyle`. +/// +/// The style fades its label with `.foregroundStyle(color.opacity(...))`, +/// which SwiftUI text and shapes pick up but a hosted AppKit bitmap cannot. +/// The style publishes the same opacity here so the symbol bakes it into its +/// tint, and non-symbol label content (the notification badge) is untouched. +private struct TitlebarControlForegroundOpacityKey: EnvironmentKey { + static let defaultValue: Double = 1 +} + +extension EnvironmentValues { + fileprivate var titlebarControlForegroundOpacity: Double { + get { self[TitlebarControlForegroundOpacityKey.self] } + set { self[TitlebarControlForegroundOpacityKey.self] = newValue } + } +} + +private struct TitlebarControlSymbol: View { + let systemName: String + let config: TitlebarControlsStyleConfig + let foregroundColor: Color + @Environment(\.titlebarControlForegroundOpacity) private var foregroundOpacity + + var body: some View { + CmuxSystemSymbolImage( + systemName: systemName, + pointSize: config.iconSize, + weight: TitlebarControlIconStyle.weight, + tint: foregroundColor.opacity(foregroundOpacity) + ) + } +} + private struct TitlebarSidebarGlyph: View { let iconSize: CGFloat @@ -2447,7 +2491,12 @@ private struct NotificationsPopoverView: View { Spacer() Button(action: jumpToLatestUnread) { HStack(spacing: 5) { - CmuxSystemSymbolImage(systemName: "arrow.down.to.line", pointSize: 10, weight: .semibold) + CmuxSystemSymbolImage( + systemName: "arrow.down.to.line", + pointSize: 10, + weight: .semibold, + tint: hasUnreadNotifications ? .primary : .secondary + ) Text(String(localized: "notifications.jumpToLatest", defaultValue: "Jump to Latest")) .cmuxFont(size: 11) if !jumpToUnreadShortcut.displayString.isEmpty { @@ -2505,8 +2554,7 @@ private struct NotificationsPopoverView: View { private var phoneForwardingEntry: some View { Button(action: onOpenPhoneForwarding) { HStack(spacing: 8) { - CmuxSystemSymbolImage(systemName: "iphone", pointSize: 12, weight: .medium) - .foregroundColor(.secondary) + CmuxSystemSymbolImage(systemName: "iphone", pointSize: 12, weight: .medium, tint: .secondary) Text( String( localized: "notifications.forwardToPhone.title", @@ -2515,8 +2563,7 @@ private struct NotificationsPopoverView: View { ) .cmuxFont(size: 12, weight: .medium) Spacer() - CmuxSystemSymbolImage(systemName: "chevron.right", pointSize: 9, weight: .semibold) - .foregroundColor(.secondary) + CmuxSystemSymbolImage(systemName: "chevron.right", pointSize: 9, weight: .semibold, tint: .secondary) } .contentShape(Rectangle()) .padding(.horizontal, 14) @@ -2652,8 +2699,7 @@ private struct NotificationsPopoverView: View { private func emptyState(systemImage: String, title: String, subtitle: String?) -> some View { VStack(spacing: 10) { - CmuxSystemSymbolImage(systemName: systemImage, pointSize: 30, weight: .light) - .foregroundColor(.secondary.opacity(0.7)) + CmuxSystemSymbolImage(systemName: systemImage, pointSize: 30, weight: .light, tint: .secondary.opacity(0.7)) Text(title) .cmuxFont(size: 14, weight: .medium) .foregroundColor(.primary) diff --git a/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift b/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift index f560feb62ce4..98643a5ad44f 100644 --- a/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift +++ b/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift @@ -197,9 +197,9 @@ struct SidebarFooterCircularIcon: View { CmuxSystemSymbolImage( systemName: systemName, pointSize: style.pointSize, - weight: style.weight + weight: style.weight, + tint: .secondary ) - .foregroundStyle(.secondary) } } @@ -471,8 +471,7 @@ struct SidebarMobileConnectButton: View { debugSource: "sidebar.mobileConnect" ) } label: { - CmuxSystemSymbolImage(systemName: "iphone", pointSize: iconSize, weight: .medium) - .foregroundStyle(.secondary) + CmuxSystemSymbolImage(systemName: "iphone", pointSize: iconSize, weight: .medium, tint: .secondary) .frame( width: SidebarFooterButtonMetrics.buttonSize, height: SidebarFooterButtonMetrics.buttonSize diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index 34842e87e1c8..4ff96e7cb108 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -800,7 +800,14 @@ struct EmptyPanelView: View { let button = Button(action: action) { HStack(spacing: 10) { HStack(spacing: 6) { - CmuxSystemSymbolImage(systemName: systemImage, pointSize: 13) + // `.borderedProminent` paints its label in the system's + // on-accent text color, so bake that semantic color rather + // than a literal white. + CmuxSystemSymbolImage( + systemName: systemImage, + pointSize: 13, + tint: Color(nsColor: .alternateSelectedControlTextColor) + ) Text(title) } ShortcutHint(text: shortcut.displayString) @@ -817,8 +824,7 @@ struct EmptyPanelView: View { var body: some View { VStack(spacing: 16) { - CmuxSystemSymbolImage(magnified: "terminal.fill", pointSize: 48) - .foregroundStyle(.tertiary) + CmuxSystemSymbolImage(magnified: "terminal.fill", pointSize: 48, tint: Color(nsColor: .tertiaryLabelColor)) Text(String(localized: "emptyPanel.title", defaultValue: "Empty Panel")) .cmuxFont(.headline) diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index c06b5206bb57..e49f74db9c1f 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -3207,8 +3207,7 @@ private struct SidebarFooterHoverIntensityPreview: View { .accessibilityLabel(accessibilityLabel) Button(action: {}) { - CmuxSystemSymbolImage(systemName: "iphone", pointSize: CGFloat(mobileSize), weight: .medium) - .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + CmuxSystemSymbolImage(systemName: "iphone", pointSize: CGFloat(mobileSize), weight: .medium, tint: Color(nsColor: .secondaryLabelColor)) .frame(width: 22, height: 22) } .buttonStyle(SidebarFooterIconButtonStyle()) @@ -3358,8 +3357,7 @@ private struct SidebarFooterMobileIconReference: View { let size: Double var body: some View { - CmuxSystemSymbolImage(systemName: "iphone", pointSize: CGFloat(size), weight: .medium) - .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + CmuxSystemSymbolImage(systemName: "iphone", pointSize: CGFloat(size), weight: .medium, tint: Color(nsColor: .secondaryLabelColor)) .frame(width: 22, height: 22) } } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index bf50e45bb7ee..ce87bfcedcb1 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -962,6 +962,7 @@ C0DE34020000000000000002 /* CmuxHelpResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE34020000000000000004 /* CmuxHelpResource.swift */; }; A11C00010000000000000001 /* CmuxHostedSystemSymbolImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11C00010000000000000002 /* CmuxHostedSystemSymbolImage.swift */; }; A11C00040000000000000001 /* CmuxHostedSystemSymbolImageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11C00040000000000000002 /* CmuxHostedSystemSymbolImageTests.swift */; }; + A11C00050000000000000001 /* StackAccountAvatarViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11C00050000000000000002 /* StackAccountAvatarViewTests.swift */; }; C0DE46010000000000000001 /* CMUXInstalledExtensionSidebarHostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE46010000000000000002 /* CMUXInstalledExtensionSidebarHostView.swift */; }; 1A0B0C0D0E0F101112132013 /* CmuxIrohTransport in Frameworks */ = {isa = PBXBuildFile; productRef = 1A0B0C0D0E0F101112132012 /* CmuxIrohTransport */; }; 1A0B0C0D0E0F101112132014 /* CmuxIrohTransport in Frameworks */ = {isa = PBXBuildFile; productRef = 1A0B0C0D0E0F101112132012 /* CmuxIrohTransport */; }; @@ -4326,6 +4327,7 @@ C0DE34020000000000000004 /* CmuxHelpResource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/CmuxHelpResource.swift; sourceTree = ""; }; A11C00010000000000000002 /* CmuxHostedSystemSymbolImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxHostedSystemSymbolImage.swift; sourceTree = ""; }; A11C00040000000000000002 /* CmuxHostedSystemSymbolImageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxHostedSystemSymbolImageTests.swift; sourceTree = ""; }; + A11C00050000000000000002 /* StackAccountAvatarViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StackAccountAvatarViewTests.swift; sourceTree = ""; }; C0DE46010000000000000002 /* CMUXInstalledExtensionSidebarHostView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CMUXInstalledExtensionSidebarHostView.swift; sourceTree = ""; }; E7E00000000000000000000C /* CmuxLifecycleEventPublishing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxLifecycleEventPublishing.swift; sourceTree = ""; }; 2F0C07000000000000000001 /* CmuxMainWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/CmuxMainWindow.swift; sourceTree = ""; }; @@ -9329,6 +9331,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef D36A00050000000000000002 /* RendererRealizationPlannerTests.swift */, C58410010000000000000002 /* RenderableSystemSymbolTests.swift */, A11C00040000000000000002 /* CmuxHostedSystemSymbolImageTests.swift */, + A11C00050000000000000002 /* StackAccountAvatarViewTests.swift */, A11C00030000000000000002 /* StackAccountAvatarImageLoaderTests.swift */, DCDC0000000000000000B002 /* DockControlDefinitionDecodingTests.swift */, 859100000000000000000002 /* TerminalLinkOpenCoordinatorTests.swift */, @@ -13287,6 +13290,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef C54860040000000000000001 /* CmuxDurableDeepLinkRestoreTests.swift in Sources */, E7E000000000000000000003 /* CmuxEventBusTests.swift in Sources */, A11C00040000000000000001 /* CmuxHostedSystemSymbolImageTests.swift in Sources */, + A11C00050000000000000001 /* StackAccountAvatarViewTests.swift in Sources */, D36090010000000000000005 /* CmuxMainWindowConstrainFrameTests.swift in Sources */, D36090020000000000000005 /* CmuxMainWindowFullScreenCapabilityTests.swift in Sources */, C54860030000000000000001 /* CmuxNavigationTargetResolverTests.swift in Sources */, diff --git a/cmuxTests/CmuxHostedSystemSymbolImageTests.swift b/cmuxTests/CmuxHostedSystemSymbolImageTests.swift index 125dc3989896..3caa64e62489 100644 --- a/cmuxTests/CmuxHostedSystemSymbolImageTests.swift +++ b/cmuxTests/CmuxHostedSystemSymbolImageTests.swift @@ -9,12 +9,15 @@ import Testing @testable import cmux #endif -/// Hosted symbols must request the configured symbol's natural layout size -/// with an explicit point size, so the AppKit renderer draws the same glyph -/// geometry the SwiftUI symbol image used before. +/// Hosted symbols must go through the same path as the Vault icons: an +/// explicit tint baked into the AppKit bitmap by the shared renderer, plus a +/// same-symbol fallback for a transient blank draw. SwiftUI must not own any +/// pixel of the glyph (no `.foreground` mask over the hosted view), because +/// SwiftUI masks over hosted AppKit views go blank after a while on Intel +/// Macs running macOS 15. @Suite("Hosted system symbol image") struct CmuxHostedSystemSymbolImageTests { - @Test @MainActor func iconRequestPreservesConfiguredSymbolGeometry() throws { + @Test @MainActor func iconRequestPreservesConfiguredSymbolGeometryAndTint() throws { RenderableSystemSymbol.resetRenderabilityCacheForTesting() let materialized = try #require(RenderableSystemSymbol.configuredAppKitImage( systemName: "person.crop.circle", @@ -25,21 +28,30 @@ struct CmuxHostedSystemSymbolImageTests { systemName: "person.crop.circle", pointSize: 14, imageSize: materialized.size, - weight: .regular + weight: .regular, + tintColor: .systemRed ) #expect(request.size == materialized.size) #expect(request.symbolPointSize == 14) #expect(request.symbolWeight == .regular) - #expect(request.tintColor == nil) + #expect(request.tintColor == NSColor.systemRed) guard case .systemSymbol(let name, _) = request.source else { Issue.record("expected a system symbol source") return } #expect(name == "person.crop.circle") + // The Vault icons retry the same symbol through the renderer's + // fallback slot when the first draw comes back blank. + guard case .systemSymbol(let fallbackName, _)? = request.fallbackSource else { + Issue.record("expected a same-symbol fallback source") + return + } + #expect(fallbackName == "person.crop.circle") + #expect(request.fallbackTintColor == nil) } - @Test @MainActor func hostedRendererDrawsRequestedSymbolAtNaturalSize() throws { + @Test @MainActor func hostedRendererDrawsTintedSymbolAtNaturalSize() throws { let materialized = try #require(RenderableSystemSymbol.configuredAppKitImage( systemName: "questionmark.circle", pointSize: 14, @@ -49,7 +61,8 @@ struct CmuxHostedSystemSymbolImageTests { systemName: "questionmark.circle", pointSize: 14, imageSize: materialized.size, - weight: .regular + weight: .regular, + tintColor: .systemRed ) let appearance = try #require(NSAppearance(named: .darkAqua)) let rendered = try #require(CmuxResolvedIconRenderer().image(for: request, appearance: appearance)) @@ -59,21 +72,52 @@ struct CmuxHostedSystemSymbolImageTests { let materializedBitmap = try #require(materialized.representations .compactMap { $0 as? NSBitmapImageRep } .first { $0.pixelsWide == renderedBitmap.pixelsWide }) - let renderedPixels = Self.visiblePixelCount(in: renderedBitmap) - let materializedPixels = Self.visiblePixelCount(in: materializedBitmap) - #expect(renderedPixels > 0) - #expect(abs(renderedPixels - materializedPixels) <= materializedPixels / 10) + let renderedPixels = Self.visiblePixels(in: renderedBitmap) + let materializedPixels = Self.visiblePixels(in: materializedBitmap) + #expect(renderedPixels.count > 0) + #expect(abs(renderedPixels.count - materializedPixels.count) <= materializedPixels.count / 10) + + // Every opaque pixel carries the requested tint, so the NSImageView + // shows the final color without any SwiftUI compositing. (Edge pixels + // are anti-aliased; un-premultiplying them loses precision.) + let expected = try #require(NSColor.systemRed.usingColorSpace(.deviceRGB)) + let opaquePixels = renderedPixels.filter { $0.alphaComponent > 0.9 } + #expect(opaquePixels.count > 0) + for color in opaquePixels { + #expect(abs(color.redComponent - expected.redComponent) < 0.05) + #expect(abs(color.greenComponent - expected.greenComponent) < 0.05) + #expect(abs(color.blueComponent - expected.blueComponent) < 0.05) + } + } + + @Test @MainActor func systemSymbolImageBridgesSwiftUITintToDynamicAppKitColor() throws { + let tint = CmuxSystemSymbolImage.hostedTintColor(for: .secondary) + let expected = NSColor.secondaryLabelColor + for name in [NSAppearance.Name.aqua, .darkAqua] { + let appearance = try #require(NSAppearance(named: name)) + var resolved: NSColor? + var resolvedExpected: NSColor? + appearance.performAsCurrentDrawingAppearance { + resolved = tint.usingColorSpace(.deviceRGB) + resolvedExpected = expected.usingColorSpace(.deviceRGB) + } + let actual = try #require(resolved) + let wanted = try #require(resolvedExpected) + #expect(abs(actual.redComponent - wanted.redComponent) < 0.05, "\(name.rawValue)") + #expect(abs(actual.alphaComponent - wanted.alphaComponent) < 0.05, "\(name.rawValue)") + } } - private static func visiblePixelCount(in bitmap: NSBitmapImageRep) -> Int { - var count = 0 + private static func visiblePixels(in bitmap: NSBitmapImageRep) -> [NSColor] { + var pixels: [NSColor] = [] for y in 0.. 0.01 { - count += 1 + if let color = bitmap.colorAt(x: x, y: y)?.usingColorSpace(.deviceRGB), + color.alphaComponent > 0.01 { + pixels.append(color) } } } - return count + return pixels } } diff --git a/cmuxTests/StackAccountAvatarImageLoaderTests.swift b/cmuxTests/StackAccountAvatarImageLoaderTests.swift index d6cd2d6e8200..31ee92dd7f58 100644 --- a/cmuxTests/StackAccountAvatarImageLoaderTests.swift +++ b/cmuxTests/StackAccountAvatarImageLoaderTests.swift @@ -9,16 +9,18 @@ import Testing #endif /// The avatar loader must hand the hosted renderer a square bitmap that -/// keeps the center of the profile picture, since the renderer draws its -/// sources aspect-fit and the avatar is clipped to a circle. +/// keeps the center of the profile picture and is already clipped to a +/// circle. The circle lives in the bitmap so the hosted `NSImageView` needs +/// no SwiftUI `clipShape`: SwiftUI masks over hosted AppKit views go blank +/// after a while on Intel Macs running macOS 15. @Suite("Stack account avatar image loader") struct StackAccountAvatarImageLoaderTests { - @Test @MainActor func squareImageCenterCropsWideSourceAtRequestedScale() throws { + @Test @MainActor func circularImageCenterCropsWideSourceAtRequestedScale() throws { // 40x20 source: red | green | blue columns, 10/20/10 wide. let data = try #require(Self.pngData(width: 40, height: 20) { x, _ in x < 10 ? .red : (x < 30 ? .green : .blue) }) - let image = try #require(StackAccountAvatarImageLoader.squareImage(from: data, pointSize: 10, scale: 2)) + let image = try #require(StackAccountAvatarImageLoader.circularImage(from: data, pointSize: 10, scale: 2)) let bitmap = try #require(image.representations.first as? NSBitmapImageRep) #expect(image.size == NSSize(width: 10, height: 10)) @@ -33,12 +35,12 @@ struct StackAccountAvatarImageLoaderTests { } } - @Test @MainActor func squareImageCenterCropsTallSource() throws { + @Test @MainActor func circularImageCenterCropsTallSource() throws { // 20x40 source: red top band, green middle, blue bottom band. let data = try #require(Self.pngData(width: 20, height: 40) { _, y in y < 10 ? .red : (y < 30 ? .green : .blue) }) - let image = try #require(StackAccountAvatarImageLoader.squareImage(from: data, pointSize: 8, scale: 1)) + let image = try #require(StackAccountAvatarImageLoader.circularImage(from: data, pointSize: 8, scale: 1)) let bitmap = try #require(image.representations.first as? NSBitmapImageRep) #expect(bitmap.pixelsWide == 8) @@ -49,13 +51,36 @@ struct StackAccountAvatarImageLoaderTests { } } - @Test @MainActor func squareImageRejectsUndecodableDataAndInvalidSizes() throws { + @Test @MainActor func circularImageClipsCornersInsideTheBitmap() throws { + let data = try #require(Self.pngData(width: 30, height: 30) { _, _ in .green }) + let image = try #require(StackAccountAvatarImageLoader.circularImage(from: data, pointSize: 20, scale: 2)) + let bitmap = try #require(image.representations.first as? NSBitmapImageRep) + + #expect(bitmap.pixelsWide == 40) + #expect(bitmap.pixelsHigh == 40) + let center = try #require(bitmap.colorAt(x: 20, y: 20)?.usingColorSpace(.deviceRGB)) + #expect(center.alphaComponent > 0.99) + #expect(center.greenComponent > 0.8) + // The square source is fully opaque; only the circular clip can make + // the corners transparent. + for (x, y) in [(0, 0), (39, 0), (0, 39), (39, 39), (2, 2), (37, 37)] { + let corner = try #require(bitmap.colorAt(x: x, y: y)) + #expect(corner.alphaComponent < 0.01, "corner (\(x), \(y))") + } + // Points just inside the circle stay opaque. + for (x, y) in [(20, 1), (1, 20), (38, 20), (20, 38)] { + let edge = try #require(bitmap.colorAt(x: x, y: y)) + #expect(edge.alphaComponent > 0.5, "edge (\(x), \(y))") + } + } + + @Test @MainActor func circularImageRejectsUndecodableDataAndInvalidSizes() throws { let garbage = Data([0x00, 0x01, 0x02, 0x03]) - #expect(StackAccountAvatarImageLoader.squareImage(from: garbage, pointSize: 10) == nil) + #expect(StackAccountAvatarImageLoader.circularImage(from: garbage, pointSize: 10) == nil) let data = try #require(Self.pngData(width: 4, height: 4) { _, _ in .green }) - #expect(StackAccountAvatarImageLoader.squareImage(from: data, pointSize: 0) == nil) - #expect(StackAccountAvatarImageLoader.squareImage(from: data, pointSize: .nan) == nil) + #expect(StackAccountAvatarImageLoader.circularImage(from: data, pointSize: 0) == nil) + #expect(StackAccountAvatarImageLoader.circularImage(from: data, pointSize: .nan) == nil) } private static func pngData(width: Int, height: Int, color: (Int, Int) -> NSColor) -> Data? { diff --git a/cmuxTests/StackAccountAvatarViewTests.swift b/cmuxTests/StackAccountAvatarViewTests.swift new file mode 100644 index 000000000000..c2e1d045ba94 --- /dev/null +++ b/cmuxTests/StackAccountAvatarViewTests.swift @@ -0,0 +1,74 @@ +import AppKit +import CmuxAppKitSupportUI +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// The signed-in avatar must use the same hosted-renderer contract as the +/// Vault agent icons: the decoded picture as the primary source and a +/// tinted system symbol as the fallback for a transient blank draw, so the +/// sidebar account button never ends up empty. +@Suite("Stack account avatar view") +struct StackAccountAvatarViewTests { + @Test @MainActor func hostedRequestUsesPictureWithTintedSymbolFallback() throws { + let picture = NSImage(size: NSSize(width: 22, height: 22)) + let request = StackAccountAvatarView.hostedRequest(image: picture, size: 22) + + #expect(request.size == NSSize(width: 22, height: 22)) + #expect(request.tintColor == nil) + guard case .image(let source) = request.source else { + Issue.record("expected the decoded picture as the primary source") + return + } + #expect(source === picture) + guard case .systemSymbol(let fallbackName, _)? = request.fallbackSource else { + Issue.record("expected a system symbol fallback source") + return + } + #expect(fallbackName == StackAccountAvatarView.fallbackSymbolName) + #expect(request.fallbackTintColor == NSColor.secondaryLabelColor) + } + + @Test @MainActor func hostedRequestRendersPictureThroughSharedRenderer() throws { + let data = try #require(Self.pngData(width: 30, height: 30) { _, _ in .green }) + let picture = try #require(StackAccountAvatarImageLoader.circularImage(from: data, pointSize: 22, scale: 2)) + let request = StackAccountAvatarView.hostedRequest(image: picture, size: 22) + let appearance = try #require(NSAppearance(named: .aqua)) + let rendered = try #require(CmuxResolvedIconRenderer().image(for: request, appearance: appearance)) + let bitmap = try #require(rendered.representations.first as? NSBitmapImageRep) + + let center = try #require(bitmap.colorAt(x: bitmap.pixelsWide / 2, y: bitmap.pixelsHigh / 2)?.usingColorSpace(.deviceRGB)) + #expect(center.greenComponent > 0.8) + #expect(center.alphaComponent > 0.99) + let corner = try #require(bitmap.colorAt(x: 0, y: 0)) + #expect(corner.alphaComponent < 0.01) + } + + private static func pngData(width: Int, height: Int, color: (Int, Int) -> NSColor) -> Data? { + guard let bitmap = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: width, + pixelsHigh: height, + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0 + ) else { + return nil + } + for y in 0..