fix(menubar): restore menu icons hidden by macOS 27#60
Conversation
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 <bh@bugs.cc>
Summary by CodeRabbit
Walkthrough
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✨ Finishing Touches✨ Simplify code
Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
Tests/LockIMEKitTests/MenuBarGuardTests.swift (1)
17-55: 📐 Maintainability & Code Quality | 🔵 TrivialMinor duplication: extract the comment-stripping logic shared by both tests.
Lines 30 and 46 both re-implement "strip everything from the first
//onward" independently. Since these two tests are guarding the same regression, keeping the stripping rule in one helper avoids drift if the heuristic ever needs to change (e.g., to handle block comments or string literals containing//).♻️ Proposed extraction
struct MenuBarGuardTests { private static let menuBarView = URL(fileURLWithPath: `#filePath`) .deletingLastPathComponent() // LockIMEKitTests/ .deletingLastPathComponent() // Tests/ .deletingLastPathComponent() .appending(path: "Sources/LockIME/UI/MenuBarView.swift") + + /// Strips a trailing `//` comment so only code is scanned. + private static func codeOnly(_ line: Substring) -> Substring { + line.prefix(upTo: line.firstRange(of: "//")?.lowerBound ?? line.endIndex) + }Then reuse
Self.codeOnly(line)in bothmenuIconsAreBitmapBackedandmenuIconsShareOneBoxinstead of duplicating the expression.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Tests/LockIMEKitTests/MenuBarGuardTests.swift` around lines 17 - 55, Extract the shared comment-stripping expression into a private `Self.codeOnly(line)` helper in `MenuBarGuardTests`. Update both `menuIconsAreBitmapBacked` and `menuIconsShareOneBox` to call this helper, preserving the existing behavior of removing text from the first `//` onward.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@Tests/LockIMEKitTests/MenuBarGuardTests.swift`:
- Around line 17-55: Extract the shared comment-stripping expression into a
private `Self.codeOnly(line)` helper in `MenuBarGuardTests`. Update both
`menuIconsAreBitmapBacked` and `menuIconsShareOneBox` to call this helper,
preserving the existing behavior of removing text from the first `//` onward.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 45620785-558a-4f1e-b616-362cbec53246
📒 Files selected for processing (2)
Sources/LockIME/UI/MenuBarView.swiftTests/LockIMEKitTests/MenuBarGuardTests.swift
macOS 27 walks back macOS 26's icon-heavy menus:
NSMenunow 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, notisTemplate.Every glyph in the tray menu came from
NSImage(systemSymbolName:)viaLabel(_: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-symbolNSImage(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. On the current beta (26A5378j) the symptom is exactly what a user reported after upgrading.The fix routes every glyph through a new
MenuIconenum that bakes the symbol into anNSCustomImageRepof 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.Toggle/Pickerdriving NSMenu's native state column was rejected — that column still collapses to zero width when nothing is checked, which is the width-jump the fixed slot was introduced to kill, and clearing the lock target is a legitimately-unchecked state here.NSMenuItem.preferredImageVisibilityis Apple's sanctioned way to force.visible, but it isn't reachable yet: it's absent from the SDK Xcode 26.6 builds against, andMenuBarExtraexposes no underlyingNSMenuItemto set it on. A code comment marks it for revisiting once both land.Verified against the real running app on 26A5378j by driving the status item via AX and measuring per-row ink columns: before, input-source titles started at 36.5pt vs 16pt elsewhere with no icons drawn at all; after, every title starts at 38.5pt with icons back, and the menu is 166pt wide whether or not a source is locked.
MenuBarGuardTestskeeps SF Symbols out of the menu and every glyph routed throughMenuIcon.