fix(client/apple): a tinted icon in a menu is a stencil, so the colour never arrived

Both the profile chips and the red trash failed for one reason: SwiftUI hands a menu
row's icon to UIKit/AppKit as a TEMPLATE image, and a template is a stencil — the
tint is discarded and the system fills in its own colour. `.foregroundStyle` on the
label's icon was never going to survive, and the destructive role only colours the
ROW (on iOS; macOS menus have no destructive styling at all), never the symbol.

`MenuIcon` rasterises the icon first and marks the bitmap `.original`, which is not a
stencil. The appearance goes into the render: `Color.brand` and the system reds are
dynamic, and a renderer with no appearance resolves them to the light variant
whatever the app is showing. Results are cached per icon and appearance — a menu
rebuilds its rows on every open, and re-rendering a bitmap to draw a 12pt dot each
time would be silly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-29 12:06:41 +02:00
co-authored by Claude Opus 5
parent 8233722e9e
commit d2523a3b90
3 changed files with 77 additions and 3 deletions
@@ -305,8 +305,13 @@ extension SettingsView {
Label {
Text(profile.name)
} icon: {
Image(systemName: "circle.fill")
.foregroundStyle(profile.accentColor)
// Rasterised, not tinted see MenuIcon: a tinted symbol in a menu is a
// stencil, and the colour never arrives.
if let chip = MenuIcon.swatch(profile.accentColor, scheme: colorScheme) {
chip
} else {
Image(systemName: "circle.fill")
}
}
.tag(SettingsScope.profile(profile.id))
}
@@ -336,7 +341,17 @@ extension SettingsView {
Button(role: .destructive) {
profilePendingDelete = active
} label: {
Label("Delete “\(active.name)”…", systemImage: "trash")
Label {
Text("Delete “\(active.name)”…")
} icon: {
// The destructive ROLE colours the row on iOS and nothing at all on macOS,
// and either way it leaves the icon as a stencil so the red is rendered in.
if let trash = MenuIcon.symbol("trash", color: .red, scheme: colorScheme) {
trash
} else {
Image(systemName: "trash")
}
}
}
}
}
@@ -30,6 +30,9 @@ struct SettingsView: View {
/// is being confirmed for.
@State var profileDraft: ProfileDraft?
@State var profilePendingDelete: StreamProfile?
/// The menu's icons are rasterised per appearance (see `MenuIcon`), so the surface has to know
/// which one it is drawing in.
@Environment(\.colorScheme) var colorScheme
#if os(macOS)
@State private var macTab: MacTab = .general
#endif
@@ -0,0 +1,56 @@
// Coloured icons inside a `Menu`.
//
// A tinted `Label` icon comes out monochrome in a menu on every Apple platform: SwiftUI hands the
// row's icon to UIKit/AppKit as a TEMPLATE image, and a template image is a stencil the tint is
// thrown away and the system's own colour is filled in. `.foregroundStyle(.red)` on a trash symbol
// and `.foregroundStyle(profile.accentColor)` on a swatch both vanish the same way.
//
// So the icon is rasterised first. A bitmap marked `.renderingMode(.original)` is not a stencil,
// and the colour survives into the menu. This is the only place in the app that needs the trick
// everywhere else a tinted symbol just works.
import SwiftUI
@MainActor
enum MenuIcon {
/// A filled dot in `color` a profile's chip, at menu-icon size.
static func swatch(_ color: Color, scheme: ColorScheme) -> Image? {
render(key: "dot-\(scheme)-\(color)", scheme: scheme) {
Circle().fill(color).frame(width: 12, height: 12)
}
}
/// An SF Symbol that keeps `color` the red trash the destructive role doesn't give us
/// (iOS colours the row itself, macOS menus have no destructive styling at all).
static func symbol(_ name: String, color: Color, scheme: ColorScheme) -> Image? {
render(key: "sym-\(name)-\(scheme)-\(color)", scheme: scheme) {
Image(systemName: name)
.font(.system(size: 14, weight: .regular))
.foregroundStyle(color)
}
}
/// Rasterised once per (icon, appearance). A menu rebuilds its rows on every open and on
/// every catalog change, and re-rendering a bitmap each time to draw a 12pt dot would be
/// silly; the set of icons is tiny and bounded by the palette.
private static var cache: [String: Image] = [:]
private static func render<Content: View>(
key: String, scheme: ColorScheme, @ViewBuilder content: () -> Content
) -> Image? {
if let cached = cache[key] { return cached }
// The scheme goes INTO the rendered content: `Color.brand` (and the system reds) are
// dynamic, and a renderer with no appearance resolves them to the light variant whatever
// the app is showing.
let renderer = ImageRenderer(content: content().environment(\.colorScheme, scheme))
renderer.scale = 3
let image: Image?
#if canImport(UIKit)
image = renderer.uiImage.map { Image(uiImage: $0).renderingMode(.original) }
#else
image = renderer.nsImage.map { Image(nsImage: $0).renderingMode(.original) }
#endif
if let image { cache[key] = image }
return image
}
}