From 7125eb4775dc2d43fb9f4bd7a87b8aac646420e1 Mon Sep 17 00:00:00 2001 From: Kevin Cui Date: Mon, 20 Jul 2026 10:42:02 -0400 Subject: [PATCH] fix(menubar): restore menu icons hidden by macOS 27 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS 27 walks back macOS 26's icon-heavy menus: `NSMenu` now hides all menu-item SF Symbol images by default (for any app linked against the macOS 26 SDK or newer), leaving only non-symbol images visible. The discriminator is the image's representation, not `isTemplate`. Every glyph in the tray menu came from `NSImage(systemSymbolName:)` via `Label(_:systemImage:)`, so all of them stopped drawing — including the checkmark that marks the locked source. The one exception was the unlocked rows' blank placeholder, a non-symbol `NSImage(size:)` that stayed visible and kept claiming its column width, which is why the input-source rows ended up indented ~20pt past every other row while showing nothing there. Route every glyph through a new `MenuIcon` enum that bakes the symbol into an `NSCustomImageRep` of one fixed 16pt box. A non-symbol rep opts back into drawing, the shared box keeps NSMenu's image column at a constant width (so titles align and the menu doesn't jump as the lock moves between sources), and the rep re-renders at the destination scale to stay crisp on Retina. Verified against the real app on 26A5378j: every title now starts at 38.5pt and the menu is 166pt wide whether or not a source is locked. `NSMenuItem.preferredImageVisibility` is the sanctioned fix but isn't reachable yet — absent from the SDK Xcode 26.6 builds against, and `MenuBarExtra` exposes no underlying `NSMenuItem` to set it on. Add `MenuBarGuardTests` to keep SF Symbols out of the menu and every glyph routed through `MenuIcon`. Signed-off-by: Kevin Cui --- Sources/LockIME/UI/MenuBarView.swift | 121 +++++++++++++----- Tests/LockIMEKitTests/MenuBarGuardTests.swift | 56 ++++++++ 2 files changed, 144 insertions(+), 33 deletions(-) create mode 100644 Tests/LockIMEKitTests/MenuBarGuardTests.swift diff --git a/Sources/LockIME/UI/MenuBarView.swift b/Sources/LockIME/UI/MenuBarView.swift index 63106b0..c7556f1 100644 --- a/Sources/LockIME/UI/MenuBarView.swift +++ b/Sources/LockIME/UI/MenuBarView.swift @@ -30,7 +30,7 @@ struct MenuBarView: View { Label { Text(verbatim: status) } icon: { - Image(systemName: state.isAppEnabled ? "lock.fill" : "lock.open.fill") + Image(nsImage: state.isAppEnabled ? MenuIcon.lockClosed : MenuIcon.lockOpen) } } .keyboardShortcut(toggleShortcut) @@ -41,15 +41,16 @@ struct MenuBarView: View { // The system input sources, flattened directly into the menu. Each is a // Button carrying a leading checkmark in the menu-item *image* column — // visible on the locked source (LockIME on AND this is the global - // target), kept as a transparent placeholder otherwise. That reserves - // the gutter at a constant width, so the menu doesn't grow/shrink as the - // lock toggles. (A `Toggle`'s native checkmark lives in NSMenu's *state* - // column, which collapses to zero width when nothing is checked — that - // is what made the menu jump.) Clicking an unchecked source targets it - // (sets the global source + turns LockIME on, one commit, preserving the - // configured lock/switch default behavior); clicking the - // checked source clears the global target (app and switch rules stay - // live). No separate on/off toggle, no submenu. Source names are + // target), kept as an empty slot of the same size otherwise. That + // reserves the gutter at a constant width, so the menu doesn't + // grow/shrink as the lock toggles. (A `Toggle`'s native checkmark lives + // in NSMenu's *state* column, which collapses to zero width when nothing + // is checked — that is what made the menu jump, and it still does on + // macOS 27: 144pt unlocked vs 158pt locked.) Clicking an unchecked + // source targets it (sets the global source + turns LockIME on, one + // commit, preserving the configured lock/switch default behavior); + // clicking the checked source clears the global target (app and switch + // rules stay live). No separate on/off toggle, no submenu. Source names are // verbatim system strings, not catalog keys. The global toggle-lock // shortcut (Settings ▸ Shortcuts) flips LockIME on/off. ForEach(state.availableSources) { source in @@ -67,13 +68,12 @@ struct MenuBarView: View { Label { Text(verbatim: source.localizedName) } icon: { - // NSMenu draws a Label's system-image icon as a raw template - // symbol and drops SwiftUI's `.opacity`, so a hidden-via- - // opacity checkmark would show on every row. Swap the image - // itself instead: the real checkmark when locked, a same-size - // transparent slot otherwise — keeping the gutter reserved at - // a constant width either way. - Image(nsImage: isLockedTo ? CheckmarkSlot.on : CheckmarkSlot.off) + // NSMenu drops SwiftUI's `.opacity`, so a hidden-via-opacity + // checkmark would show on every row. Swap the image itself + // instead: the real checkmark when locked, a same-size empty + // slot otherwise — keeping the gutter reserved at a constant + // width either way. + Image(nsImage: isLockedTo ? MenuIcon.checkmark : MenuIcon.blank) } } } @@ -86,7 +86,7 @@ struct MenuBarView: View { NSApp.activate(ignoringOtherApps: true) openSettings() } label: { - Label("Settings…", systemImage: "gearshape") + Label { Text("Settings…") } icon: { Image(nsImage: MenuIcon.gear) } } .keyboardShortcut(",", modifiers: .command) @@ -94,9 +94,9 @@ struct MenuBarView: View { state.checkForUpdates() } label: { if pendingUpdate != nil { - Label("Install Update…", systemImage: "arrow.down.circle.fill") + Label { Text("Install Update…") } icon: { Image(nsImage: MenuIcon.updateReady) } } else { - Label("Check for Updates…", systemImage: "arrow.down.circle") + Label { Text("Check for Updates…") } icon: { Image(nsImage: MenuIcon.update) } } } .keyboardShortcut("u", modifiers: .command) @@ -105,7 +105,7 @@ struct MenuBarView: View { Button { state.showAbout() } label: { - Label("About", systemImage: "info.circle") + Label { Text("About") } icon: { Image(nsImage: MenuIcon.about) } } Divider() @@ -116,7 +116,7 @@ struct MenuBarView: View { // (it can't tell a real quit from AppKit hiding our status item). state.quit() } label: { - Label("Quit", systemImage: "power") + Label { Text("Quit") } icon: { Image(nsImage: MenuIcon.quit) } } .keyboardShortcut("q", modifiers: .command) } @@ -147,19 +147,74 @@ private extension KeyboardShortcuts.Shortcut { } } -/// Leading-gutter images for the input-source rows. Using fixed NSImages (not a -/// `Toggle`'s native state checkmark, nor an opacity-hidden symbol) keeps the -/// menu's image column reserved at a constant width whether or not anything is -/// locked, so the menu never grows or shrinks as the lock toggles. -private enum CheckmarkSlot { - /// Shown on the locked source. Template so NSMenu tints it like native chrome. - static let on: NSImage = { - let image = NSImage(systemSymbolName: "checkmark", accessibilityDescription: nil) - ?? NSImage(size: NSSize(width: 12, height: 12)) +/// Every glyph this menu draws, baked into a bitmap-backed template `NSImage` of +/// one fixed box. +/// +/// Two things force that shape, and neither is optional: +/// +/// 1. **`NSMenu` hides symbol-backed images.** macOS 27 walks back macOS 26's +/// icon-heavy menus: `NSMenu` now hides *all* menu-item symbol images by +/// default, leaving non-symbol images visible, for every app linked against +/// the macOS 26 SDK or newer. (macOS 27 release notes, AppKit.) So an item +/// whose `image` came out of `NSImage(systemSymbolName:)` — which is what +/// `Label(_:systemImage:)` bridges to — draws *no glyph and reserves no +/// width*. That emptied this menu's whole icon column, checkmark included, +/// while the one leftover non-symbol image (the blank placeholder) kept +/// claiming width: hence input-source rows indented past every other row. +/// The discriminator is the image's representation, not `isTemplate`, so +/// drawing the symbol into an `NSCustomImageRep` opts back in — and the rep +/// re-renders at the destination scale, so it stays crisp on Retina. +/// +/// macOS 27 adds `NSMenuItem.preferredImageVisibility` as the sanctioned way +/// to ask for `.visible`. It isn't reachable yet: it's absent from the SDK +/// Xcode 26.6 builds against, and `MenuBarExtra` hands out no `NSMenuItem` +/// to set it on. Revisit once both land. +/// 2. **The column has to stay put.** A single box size for every row keeps +/// NSMenu's image column at a constant width, so titles line up and the menu +/// neither grows nor shrinks as the lock moves between sources. NSMenu's +/// native *state* column can't stand in: it still collapses to zero width +/// when no item is checked. +private enum MenuIcon { + /// Sized to sit comfortably next to the 13pt menu font. + private static let box = NSSize(width: 16, height: 16) + + static let lockClosed = symbol("lock.fill") + static let lockOpen = symbol("lock.open.fill") + static let checkmark = symbol("checkmark") + static let gear = symbol("gearshape") + static let update = symbol("arrow.down.circle") + static let updateReady = symbol("arrow.down.circle.fill") + static let about = symbol("info.circle") + static let quit = symbol("power") + + /// An empty slot that holds the gutter open on rows without a glyph. + static let blank: NSImage = { + let image = NSImage(size: box, flipped: false) { _ in true } image.isTemplate = true return image }() - /// A transparent placeholder of the same size for unlocked rows. - static let off = NSImage(size: CheckmarkSlot.on.size) + /// `name` centred in the box, flagged template so NSMenu tints it like + /// native chrome (including white-on-highlight). + private static func symbol(_ name: String) -> NSImage { + guard let source = NSImage(systemSymbolName: name, accessibilityDescription: nil) else { + return blank + } + source.isTemplate = true + let image = NSImage(size: box, flipped: false) { rect in + let size = source.size + guard size.width > 0, size.height > 0 else { return true } + let scale = min(rect.width / size.width, rect.height / size.height) + let drawn = NSSize(width: size.width * scale, height: size.height * scale) + source.draw(in: NSRect( + x: rect.midX - drawn.width / 2, + y: rect.midY - drawn.height / 2, + width: drawn.width, + height: drawn.height + )) + return true + } + image.isTemplate = true + return image + } } diff --git a/Tests/LockIMEKitTests/MenuBarGuardTests.swift b/Tests/LockIMEKitTests/MenuBarGuardTests.swift new file mode 100644 index 0000000..a98a430 --- /dev/null +++ b/Tests/LockIMEKitTests/MenuBarGuardTests.swift @@ -0,0 +1,56 @@ +import Foundation +import Testing + +@testable import LockIMEKit + +/// Guards for the menu-bar menu, which is a real `NSMenu` +/// (`.menuBarExtraStyle(.menu)`) and therefore plays by AppKit's rules rather +/// than SwiftUI's. +@Suite("Menu bar guards") +struct MenuBarGuardTests { + private static let menuBarView = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // LockIMEKitTests/ + .deletingLastPathComponent() // Tests/ + .deletingLastPathComponent() + .appending(path: "Sources/LockIME/UI/MenuBarView.swift") + + @Test("menu rows never carry an SF Symbol image directly") + func menuIconsAreBitmapBacked() throws { + // macOS 27 hides *all* menu-item symbol images by default (for apps + // linked against the macOS 26 SDK or newer). So an item whose `image` + // came out of `NSImage(systemSymbolName:)` — which is what + // `Label(_:systemImage:)` and `Image(systemName:)` bridge to — draws no + // glyph *and* reserves no width. The whole icon column disappears, and + // any row still holding a non-symbol image is left indented past the + // rest. Route every glyph through `MenuIcon`, which bakes the symbol + // into a bitmap-backed template image of one fixed box. + let text = try String(contentsOf: Self.menuBarView, encoding: .utf8) + for (index, line) in text.split(separator: "\n", omittingEmptySubsequences: false).enumerated() { + // Comments explain the trap by name; only flag code. + let code = line.prefix(upTo: line.firstRange(of: "//")?.lowerBound ?? line.endIndex) + guard code.contains("systemImage:") || code.contains("Image(systemName:") else { continue } + Issue.record( + "MenuBarView.swift:\(index + 1) puts an SF Symbol straight into an NSMenu row — NSMenu won't draw it; use MenuIcon" + ) + } + } + + @Test("every MenuIcon glyph is drawn into the shared fixed box") + func menuIconsShareOneBox() throws { + // A single box size across every row is what keeps NSMenu's image + // column at a constant width — so titles line up and the menu neither + // grows nor shrinks as the lock moves between input sources. A glyph + // declared outside `MenuIcon` would escape that. + let text = try String(contentsOf: Self.menuBarView, encoding: .utf8) + let iconUses = text.split(separator: "\n", omittingEmptySubsequences: false) + .map { $0.prefix(upTo: $0.firstRange(of: "//")?.lowerBound ?? $0.endIndex) } + .filter { $0.contains("Image(nsImage:") } + #expect(!iconUses.isEmpty, "MenuBarView draws no menu glyphs at all — did the menu lose its icons?") + for use in iconUses { + #expect( + use.contains("MenuIcon."), + "menu glyph bypasses MenuIcon, so it won't share the fixed icon box: \(use.trimmingCharacters(in: .whitespaces))" + ) + } + } +}