Files
punktfunk/clients/apple/Sources/PunktfunkClient/Support/MenuIcon.swift
T
enricobuehlerandClaude Opus 5 d2523a3b90 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>
2026-07-29 12:06:41 +02:00

57 lines
2.6 KiB
Swift

// 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
}
}