Skip to content
Open
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
84 changes: 84 additions & 0 deletions Sources/CodexBar/StatusItemController+MenuBarLayout.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ extension StatusItemController {
} else {
.noImage
}
if !SettingsStore.isRunningTests {
return self.setRasterizedButtonLayoutContent(rendered, for: button, statusItem: statusItem)
}
let wasCached = button.image === rendered.leadingIcon
&& button.imagePosition == expectedImagePosition
&& button.attributedTitle.isEqual(to: rendered.attributedTitle)
Expand Down Expand Up @@ -293,4 +296,85 @@ extension StatusItemController {
let horizontalPadding: CGFloat = self.settings.menuBarLayoutGap == .tight ? 3 : 10
statusItem.length = max(18, ceil(bounds.width) + horizontalPadding)
}

/// Rasterize custom text layouts into one cached status image. macOS replicates status-item titles for
/// every display and Space; keeping the title empty prevents unrelated menu-bar invalidations from
/// repeatedly laying out and rasterizing the same attributed string in each replicant.
private func setRasterizedButtonLayoutContent(
_ rendered: MenuBarLayoutRenderedTitle,
for button: NSStatusBarButton,
statusItem: NSStatusItem)
-> Bool
{
let buttonID = ObjectIdentifier(button)
let iconIdentity = rendered.leadingIcon.map { ObjectIdentifier($0).hashValue } ?? 0
let signature = [
String(ObjectIdentifier(rendered.attributedTitle).hashValue),
String(iconIdentity),
String(rendered.attributedTitle.hash),
self.settings.menuBarLayoutGap.rawValue,
].joined(separator: "|")
let horizontalPadding: CGFloat = self.settings.menuBarLayoutGap == .tight ? 3 : 10

if self.rasterizedMenuBarLayoutCache.signatures[buttonID] == signature,
let image = self.rasterizedMenuBarLayoutCache.images[buttonID]
{
if button.image !== image { button.image = image }
if button.imagePosition != .imageOnly { button.imagePosition = .imageOnly }
if button.attributedTitle.length > 0 { button.attributedTitle = NSAttributedString() }
statusItem.length = max(18, ceil(image.size.width) + horizontalPadding)
return true
}

let image = Self.rasterizedMenuBarLayoutImage(rendered)
self.rasterizedMenuBarLayoutCache.signatures[buttonID] = signature
self.rasterizedMenuBarLayoutCache.images[buttonID] = image
Comment on lines +330 to +331

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove raster cache entries with destroyed status items

When a provider is disabled or icons are switched into merged mode, removeProviderStatusItem destroys its status item but leaves these ObjectIdentifier entries behind; switching back creates a new button and inserts another image. Repeated configuration changes therefore grow both dictionaries for the lifetime of the controller, and the existing memory-pressure trim also does not clear this new image cache. Remove the button's entries during status-item teardown and include this cache in rebuildable-cache trimming.

Useful? React with 👍 / 👎.

button.image = image
button.imagePosition = .imageOnly
button.attributedTitle = NSAttributedString()
if button.accessibilityTitle() != rendered.accessibilityLabel {
button.setAccessibilityTitle(rendered.accessibilityLabel)
}
statusItem.length = max(18, ceil(image.size.width) + horizontalPadding)
return false
}

private static func rasterizedMenuBarLayoutImage(_ rendered: MenuBarLayoutRenderedTitle) -> NSImage {
let titleBounds = rendered.attributedTitle.boundingRect(
with: NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude),
options: [.usesLineFragmentOrigin, .usesFontLeading])
let iconWidth = rendered.leadingIcon?.size.width ?? 0
let spacing: CGFloat = rendered.leadingIcon != nil && rendered.attributedTitle.length > 0 ? 3 : 0
let contentWidth = max(1, ceil(iconWidth + spacing + titleBounds.width))
let contentHeight = max(22, ceil(max(rendered.leadingIcon?.size.height ?? 0, titleBounds.height)))
let imageSize = NSSize(width: contentWidth, height: contentHeight)
let image = NSImage(size: imageSize)
image.lockFocus()
defer { image.unlockFocus() }

var titleX: CGFloat = 0
if let icon = rendered.leadingIcon {
let iconRect = NSRect(
x: 0,
y: floor((imageSize.height - icon.size.height) / 2),
width: icon.size.width,
height: icon.size.height)
icon.draw(in: iconRect)
if icon.isTemplate {
NSColor.labelColor.setFill()
iconRect.fill(using: .sourceAtop)
}
titleX = iconWidth + spacing
}
let titleRect = NSRect(
x: titleX - titleBounds.minX,
y: floor((imageSize.height - titleBounds.height) / 2) - titleBounds.minY,
width: titleBounds.width,
height: titleBounds.height)
rendered.attributedTitle.draw(
with: titleRect,
options: [.usesLineFragmentOrigin, .usesFontLeading])
image.isTemplate = false

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve inactive-display tinting for rasterized layouts

When inactive-display contrast is disabled and a custom layout contains a normal template icon, forcing the combined bitmap to be non-template prevents AppKit from dimming it on inactive displays. MenuBarLayoutRenderedTitle explicitly surfaces the leading icon separately because only template status images receive this tinting; after this change the entire custom layout remains at full contrast on every monitor, making the inactive-display contrast preference ineffective for these layouts. Preserve the native/template path when inactive tinting is requested, or otherwise account for each replicant's active state.

Useful? React with 👍 / 👎.

return image
}
}
12 changes: 12 additions & 0 deletions Sources/CodexBar/StatusItemController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,16 @@ struct NativeHighlightDeferredMenuRebuild {
let provider: UsageProvider?
}

struct RasterizedMenuBarLayoutCache {
var signatures: [ObjectIdentifier: String] = [:]
var images: [ObjectIdentifier: NSImage] = [:]

mutating func removeAll() {
self.signatures.removeAll()
self.images.removeAll()
}
}

@MainActor
final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControlling {
// Disable SwiftUI menu cards + menu refresh work in tests to avoid swiftpm-testing-helper crashes.
Expand Down Expand Up @@ -306,6 +316,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin
var lastAppliedMergedIconRenderSignature: String?
var lastAppliedProviderIconRenderSignatures: [ProviderInstanceID: String] = [:]
let menuBarLayoutRenderer = MenuBarLayoutRenderer()
var rasterizedMenuBarLayoutCache = RasterizedMenuBarLayoutCache()
var lastObservedStoreIconWorkSignature: String?
var iconPerfRefreshCycleMetrics: IconPerfRefreshCycleMetrics?
var iconPerfUpdatePassActive = false
Expand Down Expand Up @@ -770,6 +781,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin
}
self.lastAppliedMergedIconRenderSignature = nil
self.lastAppliedProviderIconRenderSignatures.removeAll()
self.rasterizedMenuBarLayoutCache.removeAll()
self.updateVisibility()
self.updateIcons()
}
Expand Down