Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 88 additions & 33 deletions Sources/LockIME/UI/MenuBarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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)
}
}
}
Expand All @@ -86,17 +86,17 @@ struct MenuBarView: View {
NSApp.activate(ignoringOtherApps: true)
openSettings()
} label: {
Label("Settings…", systemImage: "gearshape")
Label { Text("Settings…") } icon: { Image(nsImage: MenuIcon.gear) }
}
.keyboardShortcut(",", modifiers: .command)

Button {
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)
Expand All @@ -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()
Expand All @@ -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)
}
Expand Down Expand Up @@ -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
}
}
56 changes: 56 additions & 0 deletions Tests/LockIMEKitTests/MenuBarGuardTests.swift
Original file line number Diff line number Diff line change
@@ -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))"
)
}
}
}
Loading