diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Scope.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Scope.swift index ce3ec5a3..61950dce 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Scope.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Scope.swift @@ -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") + } + } } } } diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift index 749705b8..303fb618 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift @@ -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 diff --git a/clients/apple/Sources/PunktfunkClient/Support/MenuIcon.swift b/clients/apple/Sources/PunktfunkClient/Support/MenuIcon.swift new file mode 100644 index 00000000..c6965825 --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Support/MenuIcon.swift @@ -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( + 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 + } +}