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:
@@ -305,8 +305,13 @@ extension SettingsView {
|
||||
Label {
|
||||
Text(profile.name)
|
||||
} icon: {
|
||||
// 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")
|
||||
.foregroundStyle(profile.accentColor)
|
||||
}
|
||||
}
|
||||
.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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user